Repository navigation
GH-4824: store a forwarded envelope as outgoing before retiring its inbox row - #4830
Merged
jeremydmiller merged 2 commits intoOct 5, 2026
Merged
Conversation
…nbox row A scheduled envelope that comes due on a node which does not listen to its destination is forwarded through a sending agent. GH-4645 (#4656) made that path delete the forwarded envelope's inbox row, which fixed the orphan. It left the send first and stored nothing as outgoing, which is still two losses in 6.46.0: - A poll by the owning node between the send and the delete. The listener's anti-duplicate probe (GH-4316) finds the inbox row beside the new queue row and deletes the queue row, with no status filter. The message is not handled and not dead lettered. - A failed first send. EnqueueOutgoingAsync posts to the agent's RetryBlock and stores nothing, and RetryBlock.PostAsync awaits the first attempt and queues the item on an exception. The inbox row is deleted anyway, so until a retry succeeds no table holds the message and a process that stops in that interval loses it. For a DURABLE agent the order is now store the outgoing row, retire the inbox row, then send: - the inbox row is gone before any queue row exists, so the probe cannot match it; - a failed send is the ordinary outbox case the agent already retries from, and outbox recovery picks it up if this node stops; - a stop between the store and the delete leaves both rows, which can deliver the message twice and cannot lose it. An agent with no outbox behind it keeps the original order: there is nothing to move the message into, so reordering would only widen the window in which no table holds it at all. ISendingAgent.TryStoreOutgoingAsync is a DEFAULT interface member, so an external implementation keeps compiling and keeps today's behaviour. Only DurableSendingAgent overrides it, reusing its own DurableWriteRetry store path (GH-4662) rather than a second copy; SendingAgent.setDefaults became protected so the outbox row carries the same Status/OwnerId/ReplyUri it would have had. Both call sites are fixed, not just the reported one: forwardToPartitionSlotAsync (GH-4700) ran the identical send-then-delete pair, and shares the new helper. Its parked-address handling is unchanged -- the delete still names the address the row was written under, because received_at is part of the identity. Reported with a three-scenario reproduction (audit triggers recording every queue insert, queue delete and inbox delete, with the statement that ran; the loss observed in 4 of 4 runs and the no-table window in 2 of 2) by @smoqmilus, who also identified the fix. #4823 landed first, as the issue said it needed to. Gates: wolverine.slnx Release -f net9.0 0 errors / 0 warnings; CoreTests 3289 green, including Bug_4700 and Bug_4822, whose stub transports are non-durable and so still exercise the original order. Co-Authored-By: smoqmilus <smoqmilus@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj
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 #4824. Reported, reproduced and diagnosed by @smoqmilus, who is credited as a co-author on the commit; #4823 landed first, as the issue said it needed to.
The two losses
A scheduled envelope that comes due on a node which does not listen to its destination is forwarded through a sending agent. #4656 made that path delete the forwarded envelope's inbox row, which fixed the orphan. It left the send first and stored nothing as outgoing:
EnqueueOutgoingAsyncposts to the agent'sRetryBlockand stores nothing, andRetryBlock.PostAsyncawaits the first attempt and queues the item on an exception. The inbox row is deleted anyway, so until a retry succeeds no table holds the message and a process that stops in that interval loses it.The reporter's
repro.csobserved the first in 4 of 4 runs and the second in 2 of 2, with audit triggers recording every queue insert, queue delete and inbox delete alongside the statement that ran — and acontrolscenario that handled the message in 3 of 3.The fix
For a durable agent: store the outgoing row, retire the inbox row, then send.
An agent with no outbox behind it keeps the original order: there is nothing to move the message into, so reordering would only widen the window in which no table holds it at all.
Shape of the change
ISendingAgent.TryStoreOutgoingAsyncis a default interface member, so an externalISendingAgentkeeps compiling and keeps today's behaviour — no breaking change.StoreAndForwardAsynccould not serve this: it stores and sends in one call, and the whole point is to get a durable home while something else is still true.Only
DurableSendingAgentoverrides it, reusing its ownDurableWriteRetrystore path (#4662) rather than a second copy.SendingAgent.setDefaultsbecameprotectedso the outbox row carries the sameStatus/OwnerId/ReplyUriit would have had; it is pure assignments, so the second application insideEnqueueOutgoingAsyncchanges nothing.Both call sites are fixed, not just the reported one.
forwardToPartitionSlotAsync(#4700) ran the identical send-then-delete pair and now shares the new helper. Its parked-address handling is unchanged — the delete still names the address the row was written under, becausereceived_atis part of the identity underMessageIdentity.IdAndDestination.Tests
CoreTests/Runtime/forwarded_envelope_hand_over_ordering.csasserts the order, because the order is the entire fix:["store", "delete", "send"]for a durable agent and["send", "delete"]for one without an outbox, plus the parked-address delete through the durable path.Two guards against the tests being vacuous, since they all drive a fake:
DurableSendingAgentreally declares the override andBufferedSendingAgentreally does not. A durable agent that silently inherited the basefalsewould leave the file green while production kept the losing order.false— that is the whole default-interface-member contract.Gates
wolverine.slnxRelease-f net9.0: 0 errors, 0 warningsBug_4700_partition_slot_retry_runs_on_a_non_ownerandBug_4822_failed_promotion_releases_the_parked_rows— their stub transports are non-durable, so they still exercise the original order and are a real negative control for this change.🤖 Generated with Claude Code
https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj