Skip to content

In-Storage & WAL Coupling - #4

Closed
dashpole wants to merge 3 commits into
mainfrom
prototype/opt-a-in-storage-coupling
Closed

In-Storage & WAL Coupling#4
dashpole wants to merge 3 commits into
mainfrom
prototype/opt-a-in-storage-coupling

Conversation

@dashpole

@dashpole dashpole commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Why is this change necessary?

Possible fix for prometheus#17857

In Remote Write 2.0 (PRW 2.0), the specification mandates:

"At least one element in samples or in histograms MUST be provided. A TimeSeries MUST NOT include both samples and histograms."

Historically, Prometheus has treated exemplars as a series-level attribute rather than a sample-level attribute. headAppenderV2.Commit() writes float/histogram samples and exemplars to separate WAL records (record.Samples vs. record.Exemplars). When the WAL Watcher tails the WAL and pushes them into Remote Write shard queues, they are processed as independent series items. For PRW 2.0, this causes standalone exemplar records to be emitted as writev2.TimeSeries containing exemplars with 0 samples and 0 histograms, directly violating the PRW 2.0 specification and causing failures in PRW 2.0 receivers (e.g. OpenTelemetry Collector, Mimir, Google Cloud Managed Prometheus).

What does this PR do?

This PR implements Option A: End-to-End Per-Sample Association (In-Storage & WAL Coupling):

  1. Compound WAL Records (tsdb/record):

    • Introduces compound record types: RefSampleV2, RefHistogramSampleV2, RefFloatHistogramSampleV2, and RefCustomBucketsHistogramSampleV2.
    • Adds binary encoders and full-fidelity decoders for record.SamplesV2 (10), record.HistogramSamplesV2 (11), record.FloatHistogramSamplesV2 (12), and record.CustomBucketsHistogramSamplesV2 (13).
    • Implements zero-allocation exemplar skipping decoders (skipExemplars) used during recovery/fast replay when exemplars are not needed.
  2. Head Appender & TSDB Dual-Write (tsdb/head_append_v2.go, tsdb/head_wal.go):

    • Extends headAppenderV2.Append to package attached exemplars into compound batches and commit compound WAL records.
    • Preserves PromQL /api/v1/query_exemplars via dual-write to in-memory h.exemplars (CircularExemplarStorage).
    • Supports mixed-version WAL replay (interleaved V1 and V2 records across restarts/upgrades).
    • Restores attached exemplars into ExemplarStorage during Head WAL replay recovery.
  3. WAL Watcher & Remote Write Pipeline (tsdb/wlog/watcher.go, storage/remote/queue_manager.go):

    • WAL Watcher streams compound sample+exemplar units to QueueManager.
    • Supports zero-allocation exemplar stripping when sendExemplars=false or during non-tailing replay.
    • populateV2TimeSeries maps attached exemplars directly to the metric's writev2.TimeSeries object, guaranteeing 0 empty/standalone exemplar series.
    • Preserves 100% backward compatibility for PRW 1.0 endpoints (prompb.WriteRequest).

Verification & Testing

  • go test -v -race ./tsdb/record/... ./tsdb/... ./tsdb/wlog/... ./storage/remote/... (All PASS)
  • BenchmarkRecord/DecodeSamplesV2_ZeroAllocStripping: 0 allocs/op
  • Verified mixed-version WAL replay and PromQL ExemplarQuerier assertions after replay.

Implement compound WAL records and full pipeline coupling between samples
and exemplars for issue prometheus#17857 (Option A):
- Define compound record types (RefSampleV2, RefHistogramSampleV2, RefFloatHistogramSampleV2, RefCustomBucketsHistogramSampleV2) in tsdb/record.
- Implement zero-allocation exemplar skipping decoders for replay efficiency.
- Update HeadAppenderV2 to commit compound WAL records while dual-writing to in-memory ExemplarStorage to preserve PromQL query invariants.
- Update WAL Watcher to stream compound records directly to QueueManager, populating PRW 2.0 TimeSeries with attached exemplars and PRW 1.0 conversions.
- Add comprehensive test coverage verifying mixed WAL replay, rollback semantics, PRW 2.0/1.0 exemplar attachment, and zero-allocation decoding.
@dashpole dashpole changed the title Prototype/opt a in storage coupling In-Storage & WAL Coupling Aug 27, 2026
Comment thread tsdb/record/record.go

numEx := dec.Uvarint()
if numEx > 0 {
skipExemplars(dec, numEx)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This appends a numEx uvarint to record types 11/12/13 without changing the type number, so the same byte now means two different layouts. I encoded 4 samples with main's samplesV2 and decoded them here, and vice versa: both directions misparse. Usually that surfaces as invalid sizeCorruptionErrwal.Repair truncating the WAL, which is already data loss; in a minority of byte alignments it returns fewer samples with wrong refs/timestamps and err == nil. Please allocate a new record type for the compound layout instead of redefining 11/12/13.

Comment thread tsdb/record/record.go

numEx := dec.Uvarint()
if numEx > 0 {
s.Exemplars = make([]RefExemplar, 0, numEx)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

numEx comes straight off disk and is passed to make unbounded. Flipping this record's last byte to a 5-byte uvarint (~2³⁵) produces fatal error: runtime: out of memory — a runtime throw, so CorruptionErr/wal.Repair never gets a chance and Prometheus crash-loops on startup. The stripping path (dec.Samples) took 61.7s to return invalid size on the same input. Please bound numEx against dec.Len() before allocating and check dec.Err() inside skipExemplars's loop. (DecodeHistogram has the same shape on main, so a shared bound helper would be worth it.)

Comment thread tsdb/head_append.go
// to end up with the correct order.
if len(b.floats) > 0 {
if len(b.floatsV2) > 0 {
if a.storeST || hasExemplarsFloats(b.floatsV2) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tsdb/wlog/checkpoint.go:210 still round-trips these records through dec.Samples/enc.Samples, and RefSample has no exemplar field — so every checkpoint strips the coupled exemplars. Since head_append.go:1268 now suppresses the standalone Exemplars record whenever a V2 batch exists, the coupled copy is the only one, and checkpoint.go:343's preservation of record.Exemplars no longer helps. Checkpointing needs a compound branch, plus a test.

Comment thread tsdb/head_append_v2.go
s = a.bestEffortAppendSTZeroSample(s, ls, st, t, h, fh)
}

var attachedExemplars []record.RefExemplar

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

attachedExemplars is built from opts.Exemplars with no validation, but the in-memory write at line 220 still goes through ValidateExemplar. So duplicates (which validateExemplar explicitly calls "expected"), out-of-order exemplars and over-length label sets now reach the WAL and remote write while being dropped in memory — query_exemplars gives different answers before and after a replay of the same WAL. Validate first, then attach the survivors.

Comment thread tsdb/head_append.go
// to end up with the correct order.
if len(b.floats) > 0 {
if len(b.floatsV2) > 0 {
if a.storeST || hasExemplarsFloats(b.floatsV2) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because AppenderV2 is the default scrape path here and scrape_append_v2.go fills AOptions.Exemplars unconditionally, this condition puts compound records into the WAL of a default-configured Prometheus, where main only ever wrote type 11/12/13 under --enable-feature=st-storage. That makes F2 and F3 reachable with no opt-in, and it writes exemplar bytes that replay discards when exemplar-storage is off (head_wal.go:177) while remote write still sends them. This needs a feature gate.

Comment thread tsdb/head_wal.go
Offset: r.Offset(),
rec := r.Record()
if h.opts.EnableExemplarStorage && dec.Type(rec) == record.SamplesV2 {
samplesV2, err := dec.SamplesV2(rec, nil)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dec.SamplesV2(rec, nil) allocates a fresh slice per WAL record, bypasses wlReplaySamplesPool, and then copies every sample into a second pooled slice — and the decoder does a make([]RefExemplar, 0, numEx) per sample. The PR's own BenchmarkRecord/DecodeRefSamplesV2 measures 2000 allocs/op and 96 kB/op for 1000 samples. Worth pooling the V2 slice and carving exemplars out of one per-record backing array before this lands.

return true
}

func (t *QueueManager) AppendSamplesV2(samples []record.RefSampleV2) bool {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With send_exemplars on, everything now goes through AppendSamplesV2 and AppendExemplars is never called — so prometheus_remote_storage_exemplars_dropped_total stops incrementing entirely, and dataDropped.incr(1) under-counts a coupled unit. Worth incrementing the exemplar counters by len(s.Exemplars) on the drop paths here.

@dashpole dashpole closed this Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant