Repository navigation
GH-5107 PostgreSQL queue sender moves the outbox row atomically for every durable send - #5109
Merged
Merged
Conversation
…very durable send An envelope whose outbox row was written by the Marten or EF Core outbox, or read back by outbox recovery, does not carry WasPersistedInOutbox, so the durable PostgreSQL queue sender inserted it into the queue in one transaction and left DurableSendingAgent to delete the outbox row in a second. A node dying in between left a row that recovery sent again; once the receiver's handled inbox row had expired the copy was handled a second time. Durable sends without the flag now go through a single statement that deletes the outbox row (if any) and inserts it into the queue or scheduled table, falling back to the envelope's own body when there is no outbox row. Sends with the flag keep the existing MoveFromOutgoingTo* path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DCggXgxnG3EBXoR9rrq37U
This was referenced Oct 21, 2026
This was referenced Oct 23, 2026
This was referenced Oct 27, 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.
Closes #5107
Mechanism
PostgresqlQueueSender.SendAsync(Envelope)only took the atomicMoveFromOutgoingToQueueAsyncpath (queue insert + outbox delete in one statement batch) whenEnvelope.WasPersistedInOutboxwas true. Only the RDBMSMessageDatabasestore paths set that flag. Envelopes whose outbox row came from the Marten outbox (StoreOutgoingEnvelopequeued on the session), the EF Core outbox, or outbox recovery (ReadOutgoingAsync) arrive without it. Those went through the direct insert, andDurableSendingAgentdeleted the outbox row afterwards, in a separate transaction.If a node died between those two commits, it left the outbox row behind. Orphan release and outbox recovery then sent it again. The durable pop's GH-4316 inbox check drops that copy only while the receiver's
Handledrow still exists (KeepAfterMessageHandling, 5 minutes by default). After that, the copy was handled a second time.Fix
For a durable endpoint, when the envelope is not flagged, the sender now uses a single statement:
The scheduled table has the same shape, with
ON CONFLICT DO UPDATEto match the existing direct scheduled upsert. A crash now leaves either the outbox row with nothing on the queue, or the queued row with no outbox row. When there is no outbox row, the statement is just the old insert. The laterDurableSendingAgentdelete becomes a no-op.MoveFromOutgoingTo*path. ItsNo matching outgoing envelopeguard stays as it is: it is what exposed the wrong-schema bug in Durable PostgreSQL queue + DbContext enrolled in an ancillary store: incoming envelope stays Incoming in the main store and is replayed on every restart #5040, and turning it into a silent success could lose a message.MessageStorageSchemaNamebeing in the queue's database (Durability recovery poll and handled-message cleanup cannot use any index #4316 dedup), so this adds no new co-location assumption.Tests
New file:
PostgresqlTests/Transport/Bug_5107_outbox_row_survives_the_queue_insert.csan_envelope_is_handled_once_when_the_sender_dies_after_the_queue_insert: an end-to-end version of the issue's scenario. It stores the outbox row under a "dead" owner and sends it unflagged, as Marten or recovery would. It waits for handling, deletes the handled inbox row to simulate expiry, then setsowner_id = 0to simulate the orphan sweep and lets recovery run. Before the fix it failsexpected 1 but was 2, with the sameRecovered 1 messages from outboxlog lines as the issue.the_queue_insert_takes_the_outbox_row_with_it: before the fix, the outbox row was still there after the send (1 instead of 0).a_scheduled_send_takes_the_outbox_row_with_itandan_envelope_with_no_outbox_row_is_still_written_to_the_queue: cover the scheduled table and the fallback insert.Local results (net9.0):
PostgresqlTests.Transportnamespace: 171 passed, 2 skipped, 0 failed.PostgresqlTests: 870 passed, 2 skipped, 3 failed.inbox_outbox_usage.cascaded_response_with_outboxtimed out once at 5s under full-suite load. It uses non-durable queues, so the changed path is not involved, and it passed 5 of 5 times on its own.MartenTestsPostgreSQL-queue classes (Bug_1175_schema_name_with_queues,multi_tenancy_queue_usage,using_tenant_specific_queues_and_subscriptions): 5 of 5 passed.dotnet build wolverine.slnx -c Release -f net9.0: clean.Not in this PR
WasPersistedInOutboxgate, so they very likely have the same window when used with the EF Core outbox or outbox recovery. They are not changed here.No matching outgoing envelopeon retry. That is existing behaviour and is unchanged here.🤖 Generated with Claude Code
https://claude.ai/code/session_01DCggXgxnG3EBXoR9rrq37U