Repository navigation
GH-4662: a durable publish that cannot be persisted fails the caller - #4675
Merged
jeremydmiller merged 2 commits intoSep 29, 2026
Merged
Conversation
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 GH-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
Three places assumed a durable store failure could never reach them, because the sending agents swallowed it. They deliberately do not any more. MessageContext's outgoing flush logged "Unable to send an outgoing message, most likely due to serialization issues". That was a fair guess when a store failure was absorbed by a RetryBlock and could never surface here; now an unreachable store is the more common of the two causes. The message names both, and names the envelope -- which it never did, so the line said nothing about which message was lost. The two acknowledgement paths both claimed their catch "should never happen". Both are reachable now, and both still swallow on purpose: a lost acknowledgement is not worth failing a message that was handled successfully. The comments say so instead of the opposite. Also pins the blast radius, which was asserted in the PR but not tested. Inside a handler the publish is buffered against the context's own transaction and does not throw; the send happens in the flush, whose loop catches, logs and records a DiscardedEnvelope. So a cascaded message whose store is down must not turn a handler that succeeded into a failure. The new fact asserts the cascade really did reach the store and fail there, so it cannot pass vacuously. With a real outbox enlisted the flush takes QuickSendAsync and never reaches this code at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj
Member
Author
|
Released in Wolverine 6.42.0. |
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.
Closes #4662.
PublishAsync/ScheduleAsyncfrom outside a handler and outside any transaction, to a local queue made durable byUseDurableLocalQueues(), while the message store was unreachable, returned without an error. Four attempts were made in ~400 ms, the envelope was discarded with a single line at Information, and the message was never handled — not even after the database came back, because no inbox row was ever written.Why
The problem is the shape of the retry, not the retry.
RetryBlock.PostAsyncruns 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. By the time the block gives up, the caller has long since returned.That is the correct shape once an envelope is durable somewhere and something else can recover it — it is exactly what #3770 relies on for a broker delivery, which can be deferred back to the broker. 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. There is no option here that is both silent and safe.
The fix
DurableWriteRetrykeeps the same 50/100/250 ms budget, awaits it inline, and lets the last exception reach the caller. A transient blip still costs latency rather than an exception; a real outage now fails the publish.Both first-write paths get it:
DurableLocalQueue.StoreAndForwardAsync(the reported one) andDurableSendingAgent.storeAndForwardAsync(the same shape, which the reporter also observed while writing the #4658 repro). TheirRetryBlocks are gone, along with the drain/dispose calls that went with them.Where this does and does not apply
Traced through
MessageBus.PersistOrSendAsyncandMessageContext.FlushOutgoingMessagesAsync. There are three cases and only one of them changes:1. A real outbox is enlisted (EF Core, Marten,
DatabaseEnvelopeTransaction) — no impact at all.Transaction != this, so the publish goes toenvelope.PersistAsync(Transaction)and never reaches this code. The post-commit flush branches onReferenceEquals(this, Transaction), which is false here, so it takesQuickSendAsyncrather thanStoreAndForwardAsync. This is the path the reporter noted already fails visibly, and it still does, by an entirely separate route.2. Inside a handler with no explicit outbox — no new exception escapes.
ReadEnvelopesetsTransaction = this, so a publish is buffered into_outstandingand cannot throw. The send happens later inFlushOutgoingMessagesAsync, whose loop catches, logs, and records aDiscardedEnvelope. A cascaded message whose store is down therefore does not turn a successful handler into a failed one. What changes is visibility: the discard moves from an Information line buried in aRetryBlockto anErrorplus a real tracking event.3. Outside a handler and outside any transaction — this is the change.
Transactionis null, so the exception reaches the caller.Release note
A durable publish or schedule made outside a transaction and outside a handler now surfaces a store failure to the caller instead of discarding the message. Applications that call
PublishAsync/ScheduleAsyncto a durable endpoint from outside a handler should be prepared to handle the exception. Publishing through an outbox, and cascading messages from inside a handler, are unchanged.Logging that this makes reachable
Three places assumed a durable store failure could never reach them. All three are now corrected:
catch"should never happen". Both are reachable now, and both still swallow on purpose — a lost acknowledgement is not worth failing a message that was handled successfully. The comments now say so rather than the opposite.This also makes an existing comment in
SendingAgent.StoreAndForwardAsynctrue. It only logsSentafterstoreAndForwardAsyncreturns, and says "a store that throws still reports no send" — but nothing could throw there before, so a discarded envelope was reported as sent.Testing
durable_publish_failure_reaches_the_caller_4662runs against a real host whose store is aNullMessageStoresubclass with only the inbox made unreachable, so the host boots exactly as it normally does and the only variable is the write. Five facts: publish throws, schedule throws, the full retry budget is still spent first (>350 ms, 4 attempts), a store that recovers inside the budget still succeeds, and a cascaded message is discarded rather than failing its handler — which pins case 2 above, and asserts the cascade really did reach the store and fail there so it cannot pass vacuously.Reverting only the production files fails all of them.
Also green:
CoreTests(3158),PostgresqlTests(615 — the suite that exercisesDurableSendingAgenthardest), anddotnet build wolverine.slnx -c Release -f net9.0at 0 warnings / 0 errors.Not in scope
#4664 (a completion that cannot be written leaves the envelope owned by a live node) and #4659 (deferred tenant-scoped envelopes redeliver with no delay) are the other two faces of this retry budget, each on its own issue and PR. #4664 in particular needs the opposite treatment — the envelope is already durable there, so the answer is holding on rather than throwing.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj