Skip to content

Flush EF Core outbox before writing the HTTP response (GH-2917) - #2920

Merged
jeremydmiller merged 3 commits into
mainfrom
fix-2917-http-outbox-tracking
May 27, 2026
Merged

jeremydmiller merged 3 commits into
mainfrom
fix-2917-http-outbox-tracking

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #2917.

Problem

With Wolverine.Http + EF Core's transactional outbox, a transactional endpoint that returns cascading messages would leave an IHost.TrackActivity().ExecuteAndWaitAsync(...) session empty (no Sent/MessageSucceeded records) unless the caller added an explicit WaitForMessageToBeReceivedAt condition.

Root cause (deterministic ordering, not a flaky race): EF Core's Eager transaction middleware EnrollDbContextInTransaction wraps the rest of the chain in try { … } and emits efCoreEnvelopeTransaction.CommitAsync() at the end of the try block. CommitAsync commits the DB transaction and then flushes the outbox (sends the cascading messages). But the response-writing postprocessor (WriteJsonAsync) is inside that try block, before CommitAsync. So the generated endpoint wrote the HTTP response → the Alba client received it and ExecuteAndWaitAsync saw no in-flight activity and returned → then the outbox was flushed. Tracking missed the send. (Marten is unaffected because it flushes via a SaveChanges session listener, before the response.)

Fix

Reorder so the transaction commit + outbox flush run before the response is written:

  • EnrollDbContextInTransaction no longer emits CommitAsync() at the end of its wrap — it just begins the transaction and provides the try/catch rollback wrapper.

  • A new CommitEfCoreEnvelopeTransaction postprocessor performs the commit + flush. EFCorePersistenceFrameProvider adds it right after SaveChanges, which is before the response writer Wolverine.Http appends during codegen. The generated try block now reads:

    try {
        handler;
        SaveChanges;
        commit + flush;     // CommitEfCoreEnvelopeTransaction (was at end of wrap)
        WriteResponse;       // now after commit + flush
    } catch { rollback; throw; }
    

This preserves existing guarantees:

  • The flush still runs after the EF Core commit → does not reintroduce the flush-before-commit row-stranding bug (Bug_efcore_outbox_flush_before_commit stays green).
  • A failed commit now rolls back and never writes a success response — strictly more correct than before (previously the client got 200 before the transaction committed). Rollback-after-commit is a no-op because EF Core clears CurrentTransaction on commit.

Scope: the non-multi-tenant Eager path (EnrollDbContextInTransaction). Lightweight mode (standalone FlushOutgoingMessages) and message-handler chains are unaffected. The multi-tenant StartDatabaseTransactionForDbContext path still has the old ordering — a follow-up commit will extend the same reordering there.

Validation

  • New regression test issue_2917_trackactivity_transactional_outbox passes deterministically (no explicit wait condition).
  • Bug_efcore_outbox_flush_before_commit still passes (no flush-before-commit regression).
  • Full Wolverine.Http.Tests: 771 passed, 10 skipped, 0 failed (EF Core + Marten HTTP).
  • EfCoreTests, EfCoreTests.MultiTenancy, PersistenceTests compile clean.

The SQL Server-backed EF Core message-handler suites couldn't be run locally (emulated SQL Server too slow) — relying on CI here. The change is functionally equivalent for handlers (no response writer, so the commit postprocessor remains the last frame).

🤖 Generated with Claude Code

jeremydmiller and others added 3 commits May 27, 2026 09:42
… after the response

Adds a failing reproduction for #2917. /issue2917 is an EF Core [Transactional]
endpoint that returns (IResult, OutgoingMessages). EF Core's Eager transaction
middleware (EnrollDbContextInTransaction) wraps the rest of the chain — including
the WriteJsonAsync response postprocessor — in a try block ending with
efCoreEnvelopeTransaction.CommitAsync(), and CommitAsync flushes the outbox AFTER
the DB commit. So the cascading message is only sent after the HTTP response is
written; an Alba TrackActivity().ExecuteAndWaitAsync(...) session (no explicit wait
condition) completes when the client receives the response and finishes empty.

- tracked_session_should_capture_the_transactional_outbox_message: FAILS today
  ("No activity detected"), demonstrating the bug.
- workaround_with_explicit_wait_condition: PASSES, proving the message is in fact
  sent + received — just after the response — when an explicit wait condition keeps
  the session open.

The fix (response written after the transaction commit + outbox flush) follows in
the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ponse (GH-2917)

EF Core's Eager transaction middleware previously committed the transaction and
flushed the outbox inside EnrollDbContextInTransaction.CommitAsync emitted at the
END of its try block, i.e. AFTER the response-writing postprocessor it wraps. So a
transactional HTTP endpoint wrote its response (and the Alba client received it)
before the cascading outbox messages were sent, leaving an
IHost.TrackActivity().ExecuteAndWaitAsync(...) session empty unless the caller added
an explicit WaitForMessageToBeReceivedAt condition.

Reorder so the commit + flush runs before the response is written:

- EnrollDbContextInTransaction no longer emits CommitAsync at the end of its wrap;
  it just begins the transaction and provides the try/catch (rollback) wrapper.
- A new CommitEfCoreEnvelopeTransaction postprocessor performs the commit + flush.
  EFCorePersistenceFrameProvider adds it right after the SaveChanges postprocessor,
  which is before the response writer that Wolverine.Http appends during codegen, so
  the generated try block now reads: handler; SaveChanges; commit+flush; WriteResponse.

This keeps the existing ordering guarantees: the flush still runs AFTER the EF Core
commit (so it does not reintroduce the flush-before-commit row-stranding bug guarded
by Bug_efcore_outbox_flush_before_commit), and a failed commit now rolls back and
never produces a success response (the response is no longer written before commit).
Rollback-after-commit is a no-op because EF Core clears CurrentTransaction on commit.

Scope: the non-multi-tenant Eager path (EnrollDbContextInTransaction). Lightweight
mode (standalone FlushOutgoingMessages) and the multi-tenant
StartDatabaseTransactionForDbContext path are unchanged. Message-handler chains using
EnrollDbContextInTransaction are unaffected in practice (no response writer; the
commit postprocessor remains the last frame).

Validated: full Wolverine.Http.Tests green (771 passed), including
Bug_efcore_outbox_flush_before_commit and the GH-2917 regression test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The multi-tenant path (StartDatabaseTransactionForDbContext + CreateTenantedDbContext)
had the same response-before-flush ordering as the single-DbContext path, plus a
flush-before-commit: StartDatabaseTransactionForDbContext committed the DbContext
transaction at the END of its try block (after the response writer it wraps), and
because it was not IFlushesMessages the chain added a standalone FlushOutgoingMessages
postprocessor that ran AFTER the response writer and BEFORE that raw commit.

Apply the same reordering rule as the single-DbContext fix:

- StartDatabaseTransactionForDbContext no longer commits at the end of its wrap; it
  just begins the transaction and provides the try/catch rollback wrapper.
- A new CommitTenantedDbContextTransaction postprocessor commits the DbContext
  transaction and then flushes the MessageContext, added right after SaveChanges so it
  runs before the response writer. It implements IFlushesMessages so the chain does not
  also add a standalone FlushOutgoingMessages (avoiding a double flush and the
  flush-before-commit ordering).

Net generated order for multi-tenant chains is now: handler; SaveChanges;
commit; flush; WriteResponse - matching the single-DbContext path. This also fixes the
multi-tenant flush-before-commit ordering as a side effect.

Validated: Wolverine.EntityFrameworkCore + Wolverine.Http.Tests build clean; the
single-DbContext GH-2917 regression test and Bug_efcore_outbox_flush_before_commit
still pass. The multi-tenant runtime paths (tenant databases) are validated by the
EfCoreTests.MultiTenancy suite in CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jeremydmiller

Copy link
Copy Markdown
Member Author

Follow-up commit 66da0ce7 extends the same reordering rule to the multi-tenant EF Core path (StartDatabaseTransactionForDbContext), which had the same response-before-flush ordering plus a latent flush-before-commit (its raw CommitTransactionAsync ran at the end of the wrap, after the response writer, and the standalone FlushOutgoingMessages ran after the response and before that commit).

  • StartDatabaseTransactionForDbContext no longer commits at the end of its wrap.
  • New CommitTenantedDbContextTransaction postprocessor commits the DbContext transaction then flushes the MessageContext, added right after SaveChanges (before the response writer). It implements IFlushesMessages so the chain doesn't also add a standalone FlushOutgoingMessages (no double flush, no flush-before-commit).

Net generated order for multi-tenant chains is now handler; SaveChanges; commit; flush; WriteResponse — matching the single-DbContext path. Relying on the EfCoreTests.MultiTenancy CI suite to validate the multi-tenant runtime paths (tenant databases aren't easily provisioned locally).

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.

TrackActivity with Transactional Outbox doesn't work with Wolverine.Http

1 participant