Skip to content

Durable receiver settles unacked deliveries when the inbox database is unavailable (GH-3767) - #3770

Merged
jeremydmiller merged 1 commit into
mainfrom
gh-3767/settle-unacked-on-inbox-outage
Aug 1, 2026
Merged

jeremydmiller merged 1 commit into
mainfrom
gh-3767/settle-unacked-on-inbox-outage

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Summary

Fixes #3767 — a durable, buffered listener could permanently drop deliveries during a brief inbox database outage.

The reported chain is exactly right and reproduces in code review:

  1. ProcessReceivedMessagesAsync batch persist fails → SignalInboxUnavailable() + each envelope re-posted through _receivingOne (and the single-envelope ReceivedAsync path feeds the same block).
  2. receiveOneAsync (unlatched) → StoreIncomingAsync throws → SignalInboxUnavailable(); throw;
  3. RetryBlock retries 3 times over ~400ms (50/100/250ms pauses), then discards — the delivery is left unacked on a live consumer, and RabbitMQ never redelivers while the connection stays up.

The listener pause (PauseForInboxRecoveryAsync) does latch the receiver, and pending retry items that run after the latch take the latched path, which already persists-as-owner-0 + defers. The loss window is the race before the latch goes up — envelopes that exhaust their retries first are discarded unsettled.

The fix

On a persist failure for a delivery that has a listener, settle inline on the first failure: defer back to the listener (the same semantics the latched branch applies) instead of rethrowing into RetryBlock's discard. Broker redelivery plus the paused listener's InboxHealthRestarter restart carry the retry. Envelopes with no listener (nothing to settle with) keep the old retry behavior.

This eliminates the race entirely rather than tuning the retry schedule — discarding is never the terminal state for an unsettled durable delivery.

Sibling of GH-3680 (latched-path rows persisted invisibly to recovery); this closes the remaining unlatched-first-casualties path the issue identified.

Testing

New when_the_inbox_is_unavailable_for_an_unlatched_durable_receiver tests cover both the single-envelope and batch arrival paths: every delivery is deferred at the listener, none completed, none processed. Notably the tests deliberately do not drain first — draining latches the receiver, whose defer semantics would mask the bug (the first version of the test passed on the unfixed code for exactly that reason). Verified red without the product change, green with it. Full CoreTests suite green (2181 passed).

🤖 Generated with Claude Code

https://claude.ai/code/session_013eR4GL278688VhyhrGcttJ

…able (GH-3767)

When inbox persistence failed for a broker delivery on an unlatched
DurableReceiver, receiveOneAsync rethrew into its RetryBlock, which
retries 3 times over ~400ms and then discards. The delivery was left
neither acked, nacked, nor deferred -- unacknowledged on a live
consumer, which brokers like RabbitMQ never redeliver while the
connection stays up. For any inbox outage longer than the retry
window, that was permanent message loss under a durable inbox.

The latched branch already had the right semantics (persist as owner 0,
defer back to the listener). Now the unlatched persist-failure path
defers back to the listener too, settling the delivery inline on the
first failure instead of racing the listener pause: broker redelivery
plus the paused listener's inbox-recovery restart carry the retry.
Envelopes without a listener keep the old rethrow/retry behavior since
there is nothing to settle with.

This is the sibling of GH-3680, which fixed the latched path writing
recovery-invisible inbox rows; this closes the remaining unlatched-
first-casualties path.

Closes GH-3767

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013eR4GL278688VhyhrGcttJ
@jeremydmiller
jeremydmiller merged commit 34c033f into main Aug 1, 2026
32 checks passed
This was referenced Aug 1, 2026
BlackChepo pushed a commit to BlackChepo/wolverine that referenced this pull request Oct 5, 2026
… caller

PublishAsync or ScheduleAsync from outside a handler and outside any transaction,
to a local queue made durable by UseDurableLocalQueues(), while the message store
was unreachable, returned WITHOUT an error. Four attempts were made in about
400 ms, the envelope was discarded with a single line at Information, and the
message was never handled -- not even once the database came back, because no
inbox row was ever written. A caller could not tell a published message from a
lost one.

The cause is the shape of the retry, not the retry itself. RetryBlock.PostAsync
runs one attempt inline and posts the rest to its own worker, so the task the
caller awaits completes successfully the moment the first attempt fails. That is
the right shape once an envelope is durable somewhere and something else can
recover it -- it is what JasperFxGH-3770 relies on for a broker delivery. It is the wrong
shape for the write that MAKES an envelope durable, because there is no other
copy to fall back on: no broker delivery, no inbox row, nothing for recovery to
find.

So DurableWriteRetry keeps the same 50/100/250ms budget, awaits it inline, and
lets the last exception reach the caller. A transient blip still costs latency
rather than an exception; an outage now fails the publish, which is what the EF
Core and database outbox paths already did for the same outage.

Both first-write paths get it: DurableLocalQueue.StoreAndForwardAsync and
DurableSendingAgent.storeAndForwardAsync. Their RetryBlocks are gone, along with
the drain and dispose calls that went with them.

This also makes an existing comment true. SendingAgent.StoreAndForwardAsync only
logs Sent after storeAndForwardAsync returns, and says so -- "a store that throws
still reports no send" -- but nothing could throw there before, so a discarded
envelope was reported as sent.

This is a behavior change: a durable publish outside a transaction can now throw
where it previously returned silently. That is the point, and it needs a release
note.

Reverting only the production files fails all four facts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj
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.

Durable buffered listener drops unacked deliveries when the inbox database is briefly unavailable

1 participant