Skip to content

fix(core): run the outbox handler before dispatching other event handlers - #905

Merged
samtrion merged 6 commits into
mainfrom
fix/812-parallel-dispatch-dbcontext
Sep 29, 2026
Merged

samtrion merged 6 commits into
mainfrom
fix/812-parallel-dispatch-dbcontext

Conversation

@samtrion

@samtrion samtrion commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Closes #812

Summary

The default ParallelEventDispatcher ran the framework's OutboxEventHandler<TEvent> at the same time as user handlers resolved from the caller's scope. With the EF outbox, both used the same scoped DbContext. PublishAsync then failed intermittently with "A second operation was started on this context instance".

With this change, PulseMediator always runs the outbox handler first and on its own. Only the remaining handlers go to the configured dispatcher.

Changes

  • PulseMediator.ExecuteAsync splits the resolved handlers into outbox handlers and all other handlers.
    • The split runs inside the innermost step of the interceptor chain, so event filters and other interceptors still wrap the outbox write and can suppress it.
    • The outbox handlers run in order via InvokeHandlerAsync. Only the remaining handlers go to the resolved dispatcher (keyed, global or the parallel default). The dispatcher is skipped when no handlers remain.
    • The error contract is unchanged: an outbox failure does not stop the other handlers, and all failures are thrown as one flat AggregateException.
    • Events without an outbox handler take the unchanged code path.
  • ParallelEventDispatcher stays the default and stays available as an opt-in.
  • XML docs updated:
    • PublishAsync remarks
    • AddOutbox
    • AddEntityFrameworkOutbox
    • ParallelEventDispatcher caution
    • IEvent
    • IEventDispatcher, SequentialEventDispatcher, PrioritizedEventDispatcher and RateLimitedEventDispatcher: the outbox handler is never passed to a dispatcher, and registration or priority order applies only to the remaining handlers
  • README sections added in NetEvolve.Pulse ("Outbox and Handlers Sharing the Caller's Scope") and NetEvolve.Pulse.EntityFramework ("Event Handlers Sharing the DbContext"). They cover:
    • the per-event UseEventDispatcherFor<MyEvent, SequentialEventDispatcher>() workaround for several user handlers on one DbContext, with UseDefaultEventDispatcher<SequentialEventDispatcher>() as the app-wide alternative
    • the secondary SaveChangesAsync-on-shared-context effect
  • ADR decisions/2026-09-29-outbox-handler-runs-before-event-dispatcher.md (state: proposed). It records why the fix runs the outbox first and does not make the sequential dispatcher the default. Its applyTo uses OutboxEventHandler*.cs, because {TEvent} in a glob is brace alternation and would not match the file.

Bug hunt

Suspicion Confirmed? Test
Default parallel dispatch runs the outbox handler concurrently with a scoped user handler that shares the same dependency Yes (red before fix: max concurrency 2) PulseMediatorOutboxDispatchTests.PublishAsync_WithOutboxAndScopedHandler_DefaultDispatcher_NeverUsesSharedDependencyConcurrently
EF outbox plus a handler using the same DbContext throws EF Core's concurrent-use InvalidOperationException Yes (red before fix on SQLite, InMemory, SqlServer, PostgreSql, MySql) *EntityFrameworkOutboxSharedContextTests.PublishAsync_WithSharedDbContextHandler_PersistsBoth (asserts the outbox rows and the handler rows are both persisted)
Outbox + one user handler never reaches the single-handler fast path (the dispatcher gets 2 handlers) Yes PublishAsync_WithOutboxAndSingleHandler_PassesOnlyUserHandlerToDispatcher
An outbox-only event still goes through the dispatcher Yes (now bypassed) PublishAsync_WithOnlyOutboxHandler_StoresEventWithoutDispatcher
Running the outbox separately could break the "all handlers run, one AggregateException" contract No (guard test, green before and after) PublishAsync_WithFailingOutbox_StillRunsOtherHandlersAndThrowsFlatAggregate
Running the outbox separately could bypass interceptors (e.g. event filters) No (guard test, green before and after) PublishAsync_WithSuppressingInterceptor_DoesNotStoreOutboxMessage
EntityFrameworkOutboxRepository.AddAsync calls SaveChangesAsync on the shared context and flushes the caller's pending changes Known and documented, not changed (see ADR and EF README). Running the outbox first keeps it from flushing half-built state of other handlers none

The existing ParallelEventDispatcherTests still cover the opt-in parallel dispatcher for independent handlers and its single-handler fast path.

Impact

  • Behavior: with an outbox registered, the outbox write now always runs first and before the other handlers, for every dispatcher. Before, SequentialEventDispatcher followed registration order. Custom dispatchers (for example rate-limited or prioritized) no longer receive the outbox handler.
  • No public API or interface changes, so nothing changes for external implementers.
  • Several user handlers that share one scoped DbContext still need SequentialEventDispatcher, per event via UseEventDispatcherFor<TEvent, SequentialEventDispatcher>() or for all events. This is now documented.

Test evidence

  • dotnet build Pulse.slnx -c Release: 0 errors, 0 warnings. csharpier check . is clean.
  • After merging origin/main and the review follow-up (docs only): build 0 errors, 0 warnings. Unit tests (net8.0, net9.0, net10.0): 7257 total, 7257 passed.
  • Integration tests, local (net10.0):
    • New *EntityFrameworkOutboxSharedContextTests on SQLite, InMemory, SqlServer, PostgreSql and MySql: red before the fix, 5/5 green after it.
    • Other suites: SQLite EF 77/77, InMemory EF 59/59, Pipeline 64/64.
    • The Docker-backed EF suites (SqlServer, PostgreSql, MySql) did not run: Docker stopped responding locally during the later full run. They are left to CI.

Proves that the default parallel dispatcher runs OutboxEventHandler concurrently with a user handler that shares the caller's scoped dependency (#812).
…nd scoped handler

A user handler that shares the caller's DbContext collides with the EF outbox write under the default parallel dispatcher (#812).
…handlers

The outbox handler writes through the caller's scoped services (e.g. the EF DbContext). PublishAsync now invokes it first and on its own, inside the interceptor chain, and passes only the remaining handlers to the configured dispatcher, so the outbox write never runs concurrently with another handler. Failures of all handlers are still collected into one flat AggregateException.
Adds a proposed ADR, XML docs on AddOutbox, AddEntityFrameworkOutbox, ParallelEventDispatcher and IEvent, and README sections covering SequentialEventDispatcher for handlers that share a scoped DbContext and the SaveChangesAsync side effect of the EF outbox.
@samtrion
samtrion requested a review from a team as a code owner September 28, 2026 23:11
@samtrion
samtrion requested a review from benwirren September 28, 2026 23:11
@codecov

codecov Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.00000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.10%. Comparing base (ed5c2dd) to head (2488963).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
src/NetEvolve.Pulse/Internals/PulseMediator.cs 90.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #905      +/-   ##
==========================================
+ Coverage   96.07%   96.10%   +0.03%     
==========================================
  Files         274      274              
  Lines       11514    11540      +26     
  Branches     1072     1076       +4     
==========================================
+ Hits        11062    11091      +29     
- Misses        232      233       +1     
+ Partials      220      216       -4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

… per-event sequential dispatch

Dispatcher XML docs now state that the outbox handler is never passed to a dispatcher. READMEs and the ParallelEventDispatcher caution show UseEventDispatcherFor<TEvent, SequentialEventDispatcher>() as the per-event option. The ADR applyTo glob now matches OutboxEventHandler{TEvent}.cs.
@samtrion
samtrion merged commit e436932 into main Sep 29, 2026
14 checks passed
@samtrion
samtrion deleted the fix/812-parallel-dispatch-dbcontext branch September 29, 2026 00:26
samtrion added a commit that referenced this pull request Sep 29, 2026
Accept the seven decisions merged in state proposed with #893, #903,
#909, #908, #902, #905 and #904.
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.

fix: Default parallel event dispatch runs the outbox handler concurrently on the caller's DbContext in NetEvolve.Pulse

1 participant