Repository navigation
GH-3796 follow-up: make the prefetch drain safe for a non-terminal stop - #3960
Merged
Merged
Conversation
DrainWaitForPrefetch (#3796) completes the consumer's BatchingChannel inside RabbitMqListener.StopAsync. StopAsync is not always a terminal shutdown: RequeueContinuation stops the listener inline from the handler pipeline and the background PauseAsync then stops the SAME consumer again before disposing it, so a durable listener genuinely sees two StopAsync calls against one channel. Measured what a JasperFx BatchingChannel actually does after Complete(), rather than assuming: TriggerBatch, Complete, WaitForCompletionAsync and PostAsync all quietly do nothing. Nothing throws. That makes the second stop harmless but not free, and it makes the window between Complete() and Dispose's latch the real problem -- a delivery landing there is posted into a completed channel and SILENTLY DISCARDED. It never reaches the receiver and is never acked, so the broker redelivers it on channel close: exactly the redelivery DrainWaitForPrefetch exists to prevent, arrived at through a silent path. DrainBatchedDeliveriesAsync now latches BEFORE completing, so such a delivery is rejected-with-requeue through the existing guard in HandleBasicDeliverAsync instead of vanishing. Safe because the drain only runs after cancel-ok, and the broker sends no further deliveries on a cancelled consumer. The drain is also idempotent now, so the second stop does not re-run the cancel-ok wait against an already drained channel, and Dispose skips a TriggerBatch that would do nothing. SqsListener sidesteps this ordering entirely -- StopAsync only calls TriggerBatch, and Complete lives in the dispose-time flush, with a comment naming the paused-listener case. The Rabbit drain has to complete earlier than dispose to beat LatchReceiver, so the state is tracked instead. The new test pins the double-stop sequence end to end. It is a regression guard, not a red-first repro -- verified passing both with and without the fix, because the channel tolerates the duplicate calls. What it protects is that everything still drains when StopAsync runs twice, which a naive "guard the second Complete" fix would break. Also fixes an unrelated chronic flake in the same suite, folded in because it failed two consecutive full-suite runs and would otherwise redden this PR's own CI: multi_tenancy_through_virtual_hosts.send_message_to_a_specific_tenant waited only for the REQUEST to reach node two, then asserted on the RESPONSE arriving back at main. The trace showed the response Sent in the same millisecond the completion condition was satisfied. It now waits for the response too. Local: Wolverine.RabbitMQ.Tests 502/502 -- the first fully clean run of this suite in this session, where the two runs before the flake fix were 501/502 and 498/499 with that same test failing. wolverine.slnx -c Release -f net9.0 clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JG8Un6iNeyXECKJk3jo5uC
The class note in multi_tenancy_through_virtual_hosts records a prior inference from CI run 30856898284 that "the request never produced its response". The local full-suite trace behind the previous commit shows the opposite: node two DID send MultiTenantResponse, in the same millisecond the tracked session's completion condition was satisfied. Leaving the two accounts side by side without comment would have the file arguing with itself. Also walks back the "chronic flake fixed" framing. One contributing race is identified and fixed; the fixed-queue-name interference the note has been tracking since GH-3763 is untouched, and two failures plus one clean run is evidence rather than proof. If that interference bites now, the symptom changes from "no messages of type MultiTenantResponse" to a 15s timeout, which the note now says. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JG8Un6iNeyXECKJk3jo5uC
This was referenced Aug 16, 2026
This was referenced Aug 16, 2026
esond
pushed a commit
to esond/wolverine
that referenced
this pull request
Aug 18, 2026
Covers JasperFxGH-3956 (store agnostic document contracts), JasperFxGH-3954 (durability agents no longer assigned to incapable nodes), JasperFxGH-3953 (persistence provider owns the chain's transaction), JasperFxGH-3955 (idle reaper latching durable endpoints), and the three RabbitMQ changes: the community DrainWaitForPrefetch opt-in (JasperFx#3796), its non-terminal-stop follow-up (JasperFx#3960), and the JasperFxGH-3950 pair (JasperFx#3961 coverage, JasperFx#3964 channel quiesce). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JG8Un6iNeyXECKJk3jo5uC
This was referenced Aug 20, 2026
This was referenced Aug 26, 2026
Closed
This was referenced Sep 3, 2026
This was referenced Sep 10, 2026
This was referenced Sep 17, 2026
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.
Follow-up to #3796, as discussed on merge.
The problem
DrainWaitForPrefetchcompletes the consumer'sBatchingChannelinsideRabbitMqListener.StopAsync. ButStopAsyncis not always a terminal shutdown —RequeueContinuation.cs:47-49stops the listener inline from the handler pipeline, and the backgroundPauseAsyncthen stops the same consumer again before disposing it. So a durable listener genuinely sees twoStopAsynccalls against one channel.I measured what a JasperFx
BatchingChannelactually does afterComplete()rather than assuming:Complete()TriggerBatch()Complete()WaitForCompletionAsync()PostAsync(item)Nothing throws — so the duplicate stop is harmless but not free. The real problem is the last row. A delivery landing between
Complete()andDispose's latch is posted into a completed channel and silently swallowed: it never reaches the receiver and is never acked, so the broker redelivers it on channel close. That is precisely the redeliveryDrainWaitForPrefetchexists to prevent, reached through a silent path.The fix
DrainBatchedDeliveriesAsyncnow latches before completing, so such a delivery is rejected-with-requeue through the guard already at the top ofHandleBasicDeliverAsyncinstead of vanishing. Safe because the drain only runs aftercancel-ok, and the broker sends no further deliveries on a cancelled consumer.The drain is also idempotent now, so the second stop doesn't re-run the
cancel-okwait against an already-drained channel, andDisposeskips aTriggerBatchthat would do nothing.For reference,
SqsListenersidesteps this ordering entirely — itsStopAsynccalls onlyTriggerBatch, withCompletereserved for the dispose-timeflushPendingDeletesAsync, and a comment naming the paused-listener case. The Rabbit drain has to complete earlier than dispose in order to beatLatchReceiver, so it tracks the state instead.On the test
drain_wait_for_prefetch_repeated_stoppins the double-stop sequence end to end. Being straight about it: it is a regression guard, not a red-first repro — I verified it passes both with and without the fix, because the channel tolerates the duplicate calls. What it protects is that everything still drains whenStopAsyncruns twice, which a naive "just guard the secondComplete()" fix would break. The silent-swallow window itself needs a delivery to land inside a sub-millisecond race, which I didn't think was worth building a flaky test around.Unrelated flake, partially addressed — folded in
multi_tenancy_through_virtual_hosts.send_message_to_a_specific_tenantwaited only for the request to reach node two, then asserted on the response arriving back at main. A local full-suite trace caught the session completing in the same millisecond node two Sent the response, soSingleRecordhad nothing to find. It now waits for the response too.Two caveats, because this file carries a standing GH-3763 note I should have read before calling it fixed:
Queue1,multi_response,global_response,multi_incoming) in the shared default vhost — is untouched by this. If it bites, the symptom changes from "no messages of type MultiTenantResponse" to a 15s timeout.So: one contributing race identified and removed, not a chronic flake eliminated.
dotnet build wolverine.slnx -c Release -f net9.0Included here rather than split out because it failed two consecutive full-suite runs and would otherwise redden this PR's own CI.
Not addressed here
RequeueContinuation's inlineStopAsync()can now block a handler for up toDurabilitySettings.DrainTimeout(default 30s) when the flag is on, waiting forcancel-ok. It is not a deadlock — the batch flush persists and enqueues rather than waiting on the handler pipeline — but it is latency on a path whose own comments explain it was carefully built not to wait. Worth a separate look at whether that call site should opt out of the drain.🤖 Generated with Claude Code
https://claude.ai/code/session_01JG8Un6iNeyXECKJk3jo5uC