Skip to content

GH-3796 follow-up: make the prefetch drain safe for a non-terminal stop - #3960

Merged
jeremydmiller merged 2 commits into
mainfrom
gh-3796-followup-batch-drain-completion
Aug 16, 2026
Merged

jeremydmiller merged 2 commits into
mainfrom
gh-3796-followup-batch-drain-completion

Conversation

@jeremydmiller

@jeremydmiller jeremydmiller commented Aug 16, 2026 •

Copy link
Copy Markdown
Member

Follow-up to #3796, as discussed on merge.

The problem

DrainWaitForPrefetch completes the consumer's BatchingChannel inside RabbitMqListener.StopAsync. But StopAsync is not always a terminal shutdown — RequeueContinuation.cs:47-49 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.

I measured what a JasperFx BatchingChannel actually does after Complete() rather than assuming:

call after Complete() behavior
TriggerBatch() no-op, no throw
Complete() no-op, no throw
WaitForCompletionAsync() no-op, no throw
PostAsync(item) accepted and silently discarded

Nothing throws — so the duplicate stop is harmless but not free. The real problem is the last row. A delivery landing between Complete() and Dispose'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 redelivery DrainWaitForPrefetch exists to prevent, reached through a silent path.

The fix

DrainBatchedDeliveriesAsync now latches before completing, so such a delivery is rejected-with-requeue through the guard already at the top of 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 doesn't re-run the cancel-ok wait against an already-drained channel, and Dispose skips a TriggerBatch that would do nothing.

For reference, SqsListener sidesteps this ordering entirely — its StopAsync calls only TriggerBatch, with Complete reserved for the dispose-time flushPendingDeletesAsync, and a comment naming the paused-listener case. The Rabbit drain has to complete earlier than dispose in order to beat LatchReceiver, so it tracks the state instead.

On the test

drain_wait_for_prefetch_repeated_stop pins 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 when StopAsync runs twice, which a naive "just guard the second Complete()" 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_tenant waited 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, so SingleRecord had 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:

  • That note records a prior inference from CI run 30856898284 that "the request never produced its response". My trace shows the opposite — the response was produced, the session just stopped before it landed. I've updated the note so the file doesn't argue with itself.
  • The note's real subject — suspected interference from the fixture's fixed queue names (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.

Run Result
Rabbit suite, before 501/502 and 498/499 — same test failing
Rabbit suite, after 502/502 — one clean run, which is evidence, not proof
dotnet build wolverine.slnx -c Release -f net9.0 clean

Included 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 inline StopAsync() can now block a handler for up to DurabilitySettings.DrainTimeout (default 30s) when the flag is on, waiting for cancel-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

jeremydmiller and others added 2 commits August 16, 2026 10:15
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
@jeremydmiller
jeremydmiller merged commit b1bc901 into main Aug 16, 2026
37 checks passed
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
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