Skip to content

QueueManager Shard-Level Coalescing - #5

Closed
dashpole wants to merge 2 commits into
mainfrom
prototype/opt-b-queuemanager-coalescing
Closed

QueueManager Shard-Level Coalescing#5
dashpole wants to merge 2 commits into
mainfrom
prototype/opt-b-queuemanager-coalescing

Conversation

@dashpole

Copy link
Copy Markdown
Owner

Why is this change necessary?

Possible fix for prometheus#17857

The Prometheus Remote Write 2.0 (PRW 2.0) specification states:

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

Currently, Prometheus TSDB writes samples and exemplars into separate WAL records. Remote Write QueueManager ingests them as independent queue items (tSample vs tExemplar). When serializing for PRW 2.0 in populateV2TimeSeries, tExemplar emits a writev2.TimeSeries message with attached exemplars but 0 samples and 0 histograms. This violates the PRW 2.0 specification, causing downstream PRW 2.0 receivers (OTel Collector, Mimir, Google Cloud Managed Prometheus) to reject payloads or fail parsing.

Previous attempts (such as PR prometheus#18014) attempted to match exemplars across a single popped batch slice, which dropped exemplars whenever samples and exemplars landed in adjacent batches.

What does this PR do?

This PR implements a QueueManager Shard-Level Coalescing Window, resolving the specification violation completely within storage/remote/ with zero TSDB or WAL on-disk modifications:

  1. Lock-Free Shard Coalescing Ring (storage/remote/shard_coalescer.go):

    • Embeds a shardCoalescer into each shard queue worker with a pre-allocated circular ring buffer (default 2,048 slots) and 64-bit slot generation counters (uint64) to eliminate dangling index risks on buffer wrap-around.
    • Uses an integer-keyed series map index (map[chunks.HeadSeriesRef]coalescerIndexEntry) guaranteeing zero heap string allocations on the hot ingestion path.
  2. Exact Scrape Timestamp Matching ($\le 50\text{ms}$):

    • Enforces an exact timestamp correlation window: an exemplar matches a sample/histogram if and only if $\text{sample.SeriesRef} == \text{exemplar.SeriesRef} \text{ and } |\text{sample.T} - \text{exemplar.T}| \le 50\text{ms}$.
    • Forbids cross-scrape matching, preventing trace attribution corruption across scrape intervals.
  3. Strict PRW 2.0 Invariants & Drop Accounting:

    • populateV2TimeSeries populates writev2.TimeSeries containing $\ge 1$ Sample or Histogram with attached exemplars. Standalone exemplar series are eliminated.
    • If an exemplar exceeds the lookback deadline or series terminates without a matching sample, it is evicted and tracked in prometheus_remote_write_unmatched_exemplars_dropped_total (never emitting an invalid 0-sample series and never retransmitting duplicate samples).
  4. Resharding Synchronization:

    • QueueManager.reshardLoop() flushes active batches and drains shard coalescers during dynamic shard scaling, preventing orphaned buffered exemplars.

Verification & Testing

  • go test -v -race ./storage/remote/... (All PASS)
  • TestQueueManager_PRW2_Coalescing: Verifies all sample arrival orders, native histograms, and float histograms.
  • TestQueueManager_100kSeriesChurn_HeapStability: Ingested 100,000 distinct series under continuous churn; confirmed stable bounded heap memory (~13.6 MB).
  • BenchmarkQueueManager_PRW2: Validated minimal allocation footprint on PRW 2.0 sending path.

Implement Prototype Option B for Prometheus Issue prometheus#17857 to correlate, buffer,
and coalesce samples and exemplars in QueueManager shard worker queues prior to
PRW 2.0 protobuf serialization.

- Introduce shardCoalescer with a fixed-size circular ring buffer (2,048 entries),
  slot generation tracking, and exact scrape timestamp matching (<= 50ms).
- Support sample-first and exemplar-first ingestion for float samples, native
  histograms, and float histograms with zero string allocations on the hot path.
- Enforce PRW 2.0 specification invariants (zero empty TimeSeries, zero duplicate
  samples, drop unmatched exemplars on deadline with metric instrumentation).
- Synchronize lifecycle and drainage during dynamic resharding.
- Add unit tests, resharding concurrency tests with -race, and 100k churn benchmarks.
@dashpole dashpole changed the title Prototype/opt b queuemanager coalescing QueueManager Shard-Level Coalescing Aug 27, 2026
HasTs: true,
}
// Try attaching to an existing un-flushed sample/histogram in the current batch.
if q.coalescer.TryAttachToBatch(q.batch, datum.seriesRef, ex) {

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.

The WAL emits a whole samples record and then a whole exemplars record (head_append.go:1141 then :1187, dispatched at watcher.go:571 then :589), so by the time an exemplar reaches Append its sample has usually already been flushed out of q.batch — and the ring only helps the exemplar-first order, which never happens. Driving queue.Append with default max_samples_per_send: 2000 and one shard: 500 series → 100% of exemplars delivered, 5,000 → 20%, 20,000 → 0%; the ring delivered zero in all three. The correlation buffer needs to hold samples across the batch flush, not exemplars.


// TODO(cstyan): Check if metadata now means we've reduced the total # of samples
// we can batch together here, and if so find a way to not include metadata
// in the batch size calculation.

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.

TryAttachMatchingExemplars at line 1504 has already tombstoned and removed the matched exemplars from the ring by the time this branch un-appends the datum and returns false. datum is a local copy, so those exemplars are gone — not sent, and not counted by onDrop, so unmatched_exemplars_dropped_total misses them while pendingExemplars stays permanently elevated. I reproduced it with a 2/2 queue: pending goes 1 → 0, onDrop calls = 0, and the retried sample carries 0 exemplars. It is latent today because the ring path is itself unreachable (F1), but it would become the main leak as soon as F1 is fixed — please only commit the ring removal once the append has succeeded.

}

// EvictOlderThan evicts any pending exemplars older than cutoffTimestamp - maxCoalescingTimeDeltaMs.
func (c *shardCoalescer) EvictOlderThan(cutoffTimestamp int64) int {

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.

EvictOlderThan has no non-test caller (and shardCoalescer is behind no interface), so nothing ever expires a pending exemplar on time — it leaves the ring only when the ring wraps or on shutdown/reshard. In the churn scenario from queue_manager_coalescing_benchmark_test.go I measured all four rings full (2048/2048) with 8,192 exemplars uncounted by unmatched_exemplars_dropped_total, and the same residue shows up in the single-shard runs in F1 (2,048 of the 4,000 and 20,000 unmatched). Wiring this into runShard's timer.C branch under q.batchMtx would close the gap — or drop the function and say the window is wrap-driven.

c.generations[slotIdx]++
gen := c.generations[slotIdx]

nextSlot := -1

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.

If wrap-around reuses a slot that a newer slot of the same series still links to, nextSlot closes a loop. With newShardCoalescer(8): two exemplars for ref 42 in slots 0,1 (chain 1→0), fill the ring, add a third for ref 42 — slot0.next == 1 and slot1.next == 0, and an unbounded chain walk never terminates. The visited < capacity bound you added in 964f9896 keeps it bounded (2048 iterations per lookup at the default) but does not prevent the cycle, and it survives across calls because firstActiveSlot can re-point the index into it. Refusing to set nextSlot to a slot newer than the one being written would prevent it.


// Ring buffer memory is bounded (2048 slots per shard)
for _, q := range qm.shards.queues {
require.LessOrEqual(t, q.coalescer.PendingCount(), 2048)

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.

PendingCount() iterates exactly capacity (2048) slots, so require.LessOrEqual(…, 2048) can never fail — this test has no assertion that any change could break, and the heap figure the PR body quotes is only logged (and is absolute HeapAlloc, not growth against memStart). When I instrumented this same scenario the rings were at 2048/2048 with 8,192 exemplars stranded and 41,808 dropped, and the assertion still held. Asserting delivered + dropped + pending == appended here would pin what the test is meant to be about.


writeConfig := baseRemoteWriteConfig("http://test-storage.com")
writeConfig.QueueConfig = queueConfig
writeConfig.SendExemplars = false

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.

_Baseline has SendExemplars = false and never appends exemplars while _Coalescing appends 100 per iteration, so the delta measures "exemplars at all", not coalescing. Measured here: 3 allocs/op vs 200 allocs/op for 100 exemplars — 2 allocations per exemplar (the d.exemplars append, and SymbolizeLabels(ex.Labels, nil) at lines 2115/2137/2150), which contradicts "zero heap allocations on the hot ingestion path". A same-workload baseline against origin/main with benchstat is what AGENTS.md asks for.

@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