fix(efcore): suppress duplicate FlushOutgoingMessages in Eager-mode HTTP chains#2663
Merged
jeremydmiller merged 1 commit intomainfrom May 2, 2026
Merged
Conversation
…n Eager mode Reported via the sample at https://github.com/dmytro-pryvedeniuk/outbox. An HTTP endpoint that takes a DbContext and publishes a cascading message generated code that calls `messageContext.FlushOutgoingMessagesAsync()` BEFORE `efCoreEnvelopeTransaction.CommitAsync(...)`: // Added by EF Core Transaction Middleware var result_of_SaveChangesAsync = await _tasksContext.SaveChangesAsync(...); // Have to flush outgoing messages just in case Marten did nothing because of #536 await messageContext.FlushOutgoingMessagesAsync().ConfigureAwait(false); // <-- early flush await efCoreEnvelopeTransaction.CommitAsync(...).ConfigureAwait(false); // <-- this commits + re-flushes Sequence at runtime: SaveChangesAsync writes the wolverine_outgoing row inside the open EF Core transaction (uncommitted). The early flush sends the cascading envelope through the transport sender, which then asks IMessageOutbox.DeleteOutgoingAsync (running on a separate connection) to remove the row — but the INSERT is still uncommitted and invisible to that connection, so the DELETE no-ops. The EF Core commit then makes the INSERT visible and the row is left stranded for the durability agent to re-send (at-least-once instead of the exactly-once semantics the outbox is supposed to provide). Cause: EFCorePersistenceFrameProvider.ApplyTransactionSupport adds the FlushOutgoingMessages postprocessor whenever `chain.RequiresOutbox() && chain.ShouldFlushOutgoingMessages()` — the second condition is true for HttpChain but false for HandlerChain, which is why the bug was HTTP-only. In Eager mode the chain ALSO has EnrollDbContextInTransaction wrapping the body in a try block ending with `efCoreEnvelopeTransaction.CommitAsync(...)`, and CommitAsync already calls `_messaging.FlushOutgoingMessagesAsync()` AFTER the DB commit. So in Eager mode the postprocessor is both redundant and unsafe. Fix: gate the postprocessor add on `mode != TransactionMiddlewareMode.Eager`. Lightweight mode keeps it (no EnrollDbContextInTransaction wrap, so the postprocessor is the only flush trigger after SaveChangesAsync). Tests: - src/Http/Wolverine.Http.Tests/Bug_efcore_outbox_flush_before_commit.cs: failing-then-passing test that asserts the /ef/publish HttpChain has zero FlushOutgoingMessages postprocessors. Inspects the postprocessor collection directly rather than the generated source string so the assertion works whether codegen ran in Dynamic or Static mode. - src/Persistence/EfCoreTests/Bug_efcore_outbox_flush_before_commit.cs: parameterised over TransactionMiddlewareMode.{Eager, Lightweight}: 1. Codegen check — handler chain never gets a FlushOutgoingMessages postprocessor in either mode (today protected by HandlerChain .ShouldFlushOutgoingMessages=false; locked down so a future change there can't silently introduce a double flush). 2. Round-trip cleanup invariant — handler with [AutoApplyTransactions] publishes a cascading message to a UseDurableLocalQueues destination, and after TrackActivity completes the wolverine_outgoing_envelopes table is empty. Fills the gap that none of the existing EFCoreTests (per-search) verified end-to-end outbox-row deletion after a durable send under the EF Core transactional middleware. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jeremydmiller
added a commit
that referenced
this pull request
May 2, 2026
Patch release. Single fix: - WolverineFx.EntityFrameworkCore: suppress the duplicate FlushOutgoingMessages postprocessor in Eager-mode HTTP chains so cascading messages aren't sent before the EF Core transaction commits. Reported via https://github.com/dmytro-pryvedeniuk/outbox. See PR #2663. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 4, 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.
Summary
Fixes a code-generation bug in the EF Core transactional middleware reported via the sample at https://github.com/dmytro-pryvedeniuk/outbox.
For an HTTP endpoint that takes a
DbContextand publishes a cascading message, the generated handler emittedmessageContext.FlushOutgoingMessagesAsync()BEFOREefCoreEnvelopeTransaction.CommitAsync(...):Runtime sequence:
SaveChangesAsyncwrites the wolverine_outgoing row inside the open EF Core transaction (uncommitted). The early flush sends the cascading envelope through the transport sender, which then asksIMessageOutbox.DeleteOutgoingAsync(running on a separate connection) to remove the row — but the INSERT is still uncommitted and invisible to that connection, so the DELETE no-ops. The EF Core commit then makes the INSERT visible and the row is left stranded for the durability agent to re-send (at-least-once instead of the exactly-once semantics the outbox is supposed to provide).Cause:
EFCorePersistenceFrameProvider.ApplyTransactionSupportadds theFlushOutgoingMessagespostprocessor wheneverchain.RequiresOutbox() && chain.ShouldFlushOutgoingMessages(). The second condition is true forHttpChainbut false forHandlerChain, which is why the bug was HTTP-only. In Eager mode the chain ALSO hasEnrollDbContextInTransactionwrapping the body in atryblock ending withefCoreEnvelopeTransaction.CommitAsync(...), andCommitAsyncalready calls_messaging.FlushOutgoingMessagesAsync()AFTER the DB commit. So in Eager mode the postprocessor is both redundant and unsafe.Fix: gate the postprocessor add on
mode != TransactionMiddlewareMode.Eager. Lightweight mode keeps it (noEnrollDbContextInTransactionwrap, so the postprocessor is the only flush trigger afterSaveChangesAsync).Tests
src/Http/Wolverine.Http.Tests/Bug_efcore_outbox_flush_before_commit.cs— failing-then-passing test that asserts the/ef/publishHttpChainhas zeroFlushOutgoingMessagespostprocessors. Inspects the postprocessor collection directly so the assertion works in both Dynamic and Static codegen modes. Confirmed to fail onmainand pass after the fix.src/Persistence/EfCoreTests/Bug_efcore_outbox_flush_before_commit.cs— parameterised overTransactionMiddlewareMode.{Eager, Lightweight}, runs:FlushOutgoingMessagespostprocessor in either mode (today protected byHandlerChain.ShouldFlushOutgoingMessages=false; locked down so a future change there can't silently introduce a double flush).[AutoApplyTransactions]publishes a cascading message to aUseDurableLocalQueuesdestination, and afterTrackActivitycompletes thewolverine_outgoing_envelopestable is empty. Fills the gap that none of the existingEFCoreTestsverified end-to-end outbox-row deletion after a durable send under the EF Core transactional middleware.Test plan
.NETworkflow greenefcoreworkflow greenhttpworkflow greenBug_efcore_outbox_flush_before_commitpass (2 codegen × 2 modes, 2 cleanup × 2 modes)Wolverine.Http.Tests/using_efcore.cs+Bug_efcore_outbox_flush_before_commitpass🤖 Generated with Claude Code