Materialize EF domain-event scraper before publishing (#2585)#2594
Merged
jeremydmiller merged 2 commits intomainfrom Apr 26, 2026
Merged
Materialize EF domain-event scraper before publishing (#2585)#2594jeremydmiller merged 2 commits intomainfrom
jeremydmiller merged 2 commits intomainfrom
Conversation
DomainEventScraper<T,TEvent>.ScrapeEvents enumerated this LINQ pipeline
lazily:
dbContext.ChangeTracker.Entries()
.Select(x => x.Entity)
.OfType<T>()
.SelectMany(_source);
then `await bus.PublishAsync(...)` per event inside the foreach. With the
EF-backed outbox, PublishAsync flows through
EfCoreEnvelopeTransaction.PersistIncomingAsync, which adds an
IncomingMessage entity to the SAME DbContext. That mutates the
ChangeTracker mid-enumeration and throws:
InvalidOperationException: Collection was modified;
enumeration operation may not execute.
Fix: materialize with .ToArray() before the publish loop. Single
end-of-pipeline materialization is sufficient because all per-entity
event lists get enumerated by SelectMany during ToArray, before any
PublishAsync runs. Cost: one small array allocation per scrape.
Tests: regression test in DomainEventScraperStateFilterTests adapts the
approach from @jf2s's closed PR #2586 — uses an ISendMyself domain event
that mutates the DbContext during ApplyAsync to deterministically
trigger the bug without needing live SQL Server / PostgreSQL. Verified
the test fails on main and passes with this change. All other 9
DomainEvents tests in the folder still pass.
Sweep of remaining foreach loops across Wolverine.EntityFrameworkCore
turned up no other risky patterns — every other foreach iterates an
already-materialized array (_scrapers), an IReadOnlyList<>
(_store.Source.AllActiveByTenant()), or codegen-time data with no
side-effecting body.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pop_off_buffered (and the other tests in this fixture that assert queue/scheduled counts after a manual TryPop / DeleteExpired / MoveScheduled call) were flaky on slow CI: the host's auto-started PostgreSQL queue listener polls every 5s by default, and tests that take longer than that under load can have the host listener consume messages out from under the test's manual pop. Observed on PR #2594's CIPersistence run (test wallclock 9.28s) where pop_off_buffered failed the `(await theQueue.CountAsync()).ShouldBe(5)` assertion after TryPopAsync(5). Locally the test takes ~50ms and the issue never surfaces. None of the tests in this fixture rely on the host's auto-started listener — every test that exercises receiving creates its own PostgresqlQueueListener and calls TryPopAsync / TryPopDurablyAsync / DeleteExpiredAsync directly. Setting the host listener's PollingInterval to 1.Hours() keeps the queue registered (theTransport.Queues["one"] still resolves) while ensuring the host listener won't fire during any test in this fixture. Verified: full basic_functionality class passes (15/15) under the fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced Apr 28, 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.
Fixes #2585.
The bug
DomainEventScraper<T,TEvent>.ScrapeEventsenumerated this LINQ pipeline lazily and thenawait bus.PublishAsync(...)per event inside the foreach:With the EF-backed outbox,
PublishAsyncflows throughEfCoreEnvelopeTransaction.PersistIncomingAsync, which adds anIncomingMessageentity to the sameDbContext. That mutates theChangeTrackermid-enumeration and throws:The fix
.ToArray()at the end of the LINQ pipeline. Single end-of-pipeline materialization is sufficient becauseSelectManyflattens all per-entity event lists during theToArrayevaluation — every list is enumerated to completion before anyPublishAsynccall runs. Cost: one small array allocation per scrape.Tests
DomainEventScraperStateFilterTests.domain_event_scraper_materializes_events_before_publishingadapts the approach from @jf2s's closed PR #2586. Uses anISendMyselfdomain event that mutates the DbContext duringApplyAsyncto deterministically trigger the bug without needing live SQL Server / PostgreSQL. Verified the test fails onmainand passes with this change.All 10 tests in
EfCoreTests/DomainEvents/still pass.Aggressive sweep — Wolverine.EntityFrameworkCore only
Per discussion in the issue thread, scope is
Wolverine.EntityFrameworkCore. Audited everyforeachin the package; no other risky lazy-enumeration patterns found:DbContextOutbox._scrapers—IDomainEventScraper[], materialized in constructor.EfCoreEnvelopeTransaction._scrapers— same, materialized.TenantedDbContextBuilder*— iterates_store.Source.AllActiveByTenant(), which returnsIReadOnlyList<>. TheBuildAllAsyncbody adds DbContexts to a localList<T>only — does not mutate the source.Codegen/*— runs at code-generation time, not on hot paths; no publishing side effects.This was the only site that exhibited the publish-while-iterating-the-source pattern.
Credit
Same approach as the closed PR #2586 by @jf2s — credited in the test docstring and this PR.
Test plan
mainforeachin Wolverine.EntityFrameworkCore — no other lazy-enumeration risks🤖 Generated with Claude Code