Replace the peek with ramped receive dispatch - #5
Open
LukeButters wants to merge 1 commit into
Open
Conversation
The peek estimate (max - min RowVersion) counts the gaps left by other instances in-flight rows, so under competing consumers it wildly over-reports the backlog and the pump fans out that many receives at once. With high concurrency limits (Octopus: 64 x cores) that measured as ~6 empty receive round-trips per processed message at 12 nodes (152k empties + 15k peeks for 24k messages per minute), and one synchronized burst of receives per instance spikes latches on the queue head. The pump now probes with receives directly and adapts to what it finds: after idle/empty a single receive probes the queue; the wave doubles while every receive in it finds a message; a partial wave sets the next wave to the observed availability (no backoff, so a busy instance keeps up with its own arrival rate); a fully empty wave resets to one probe and backs off by the peek delay. Waves are capped at 64 per instance. The receive countdown event now carries whether each receive found a message so the pump can count wave successes; QueuePeeker is no longer used by the pump. Measured (concurrency 256, 100ms peek delay, 50ms handler, DB CPU cpu-ms/msg at 1/6/12 nodes) against the unpatched transport: steady 400 msg/s: 2.86/3.00/3.32 vs 3.49/5.88/6.69; drain 20k backlog at 12 nodes: 2.13 vs 6.28 with equal throughput, receive p99 278ms -> 16ms. Real-endpoint benchmark (16 nodes, defaults): 1.87 vs 4.31 cpu-ms/msg, throughput +20%. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
(part 2 see part 1 , some the results include part 0)
Replace the peek with ramped receive dispatch
Problem
The peek's backlog estimate —
max(RowVersion) − min(RowVersion) + 1underREADPAST— counts every gap left by other instances' in-flight rows, so under competing consumers it wildly over-reports (e.g. "hundreds" when 3 messages are actually available), and the pump fans out that many concurrent receives. Almost all come up empty, and each empty receive is a full round-trip set: connection acquire,BEGIN TRAN, query,ROLLBACK.Measured at a production-shaped configuration (12 instances, concurrency-per-instance 256, 100ms peek delay, steady 400 msg/s): 176,319 receive attempts plus 15,141 peeks for 24,000 messages per minute — ~6 wasted round trips per processed message — plus one synchronized receive burst per instance per peek, which spikes latches on the queue index head.
Change
The pump probes with real receives and adapts to what it finds — a receive costs about what a peek costs, but when the answer is "yes" you already have the message instead of a number:
QueuePeekerOptions.Delaykeeps its meaning as the empty backoff);Mechanically:
ReceiveCountdownEvent(which the pump already awaits per batch) now carries whether each receive found a message, so the pump counts wave successes; the peek query no longer runs at all. Processing concurrency semantics are untouched — each receive remains its own connection/transaction/handler, the wave only decides how many independent receives launch between feedback checks, and since the latch is signaled when the receive query resolves (before the handler), waves keep the concurrency limiter saturated under load.Results
Same benchmark rig as PR #2, on top of the anchored receive (cpu-ms of DB CPU per message at 1 / 6 / 12 instances, concurrency 256, 100ms peek delay, 50ms handler):
Wasted attempts at 12 instances drop 152k → 18.5k/min; DB cost per message becomes near-flat with instance count (+16% at 12 instances vs its own single-instance figure, against +92% for stock). In end-user production-shaped testing (Octopus Deploy, 6 small instances) the full series took total DB CPU from 38% to ~20% — below the original single-instance baseline of 22%.
Isolation vs the anchor branch on real endpoints: at modest concurrency (6 instances × 32) this change measures neutral — backlog drain 1.92 vs 1.90 cpu-ms/msg — because 32-wide fan-out was never large enough to hurt. Its win is specifically the high-concurrency configuration that motivated it (hundreds of slots per instance, e.g. 64 × cores): there the peek fan-out is the dominant waste and this change halves steady-state DB cost (table above). Real-endpoint A/B at 16 instances: 4.31 (stock) → 1.87 cpu-ms/msg, +20% throughput. One honest observation from the bursty-arrival test (due-messages moved in batches of 100 at modest concurrency): the ramp trailed the arrival window slightly (52 s vs 46 s to fully drain) as each burst re-ramps — visible only when arrivals are bursty and concurrency is small; at high concurrency the same shape wins outright.
Trade-offs / notes
QueuePeeker/peek query becomes unused by the pump (left in place in this PR to keep the diff minimal); anything monitoring peek executions will see them stop.🤖 Generated with Claude Code