#4667 Phase 3 — ProjectionDocumentSession routes user-code LoadAsync through projection-safe path#4671
Merged
jeremydmiller merged 1 commit intoJun 5, 2026
Conversation
…through projection-safe path
Closes the user-code escape hatch that Phases 1 and 2 left open: when a
user-supplied aggregation projection's Apply (or DetermineAction / EvolveAsync)
calls operations.LoadAsync<X>(...) from inside the daemon, that call routed
through IDocumentStorage<,>.LoadAsync(id, this, ct) → BuildSelector(session) →
per-row writes into _session.Versions / _session.ItemMap / _session.ChangeTrackers.
The async daemon shares one IMartenSession across the 10-wide Block<
EventSliceExecution> parallel workers per (range, tenant); each row was a race
on those shared dictionaries.
Surface change:
* QuerySession (the load API base) — introduces two protected internal
virtual chokepoints:
ExecuteLoadOneAsync<T, TId>(storage, id, token)
ExecuteLoadManyAsync<T, TId>(storage, ids, token)
All public LoadAsync<T> / LoadManyAsync<T> overloads (string / object /
int / long / Guid × single / params / IEnumerable × with / without
CancellationToken) now call through the chokepoints instead of
storage.LoadAsync(id, this, token) directly. Default impl preserves the
existing session-aware route — zero behavior change for non-projection
sessions.
* ProjectionDocumentSession overrides both chokepoints with
UseIdentityMapForAggregates gating that mirrors Phase 1 + Phase 2:
- default (false) routes through LoadProjectedAsync /
LoadManyProjectedAsync (fresh connection, no session-shared dict
writes)
- opt-in (true) falls through to base session-aware route to keep
GH-3850 inline-projection-immutable-aggregate semantics; documented
as not safe for parallel projection workers.
Implementation note on the [return: MaybeNull] + bare Task<T> chokepoint
return shape: the override on ProjectionDocumentSession (a partial-class
chain not uniformly in #nullable enable context) loses the
reference-type-vs-Nullable<T> disambiguation of T? at the override site
(CS0508 + CS0453). The attribute form is unambiguous in either context
and produces the same callsite annotation. All public LoadAsync<T>
return-type Task<T?> is unchanged.
Regression test:
Bug_4667_projection_load_async_parallel_no_race.cs runs 250 streams × 4
events through the async daemon's parallel Block<EventSliceExecution>
fanout against a SingleStreamProjection whose Apply does
session.LoadAsync<Bug4667Customer> per event. Pre-Phase 3 this path
per-row-wrote into the shared session's tracker dicts; post-Phase 3 it
goes through LoadProjectedAsync (fresh connection, no session state).
Verified locally on net9.0:
* DaemonTests — 188 / 188 ✅ (+1 new regression test)
* EventSourcingTests — 1361 passed / 7 pre-existing skips ✅
* CoreTests — 421 passed / 1 pre-existing skip ✅ (one flaky run observed;
re-run clean)
Out of scope (Phase 4 / follow-up):
* Query<T>() / LINQ — the issue lists Query<T> as a Phase 3 target, but
the LINQ selector swap is more invasive than the LoadAsync chokepoint
and the typical user-code escape hatch hits LoadAsync, not Query<T>.
Defer to a focused follow-up once the load-harness in #4666 exposes a
surviving Query<T> race.
* Phase 4 per-slice scoped session — only needed if a race survives
Phases 1–3 per the issue's "Skip until Phases 1–3 ship" guidance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
Phase 3 of #4667 — closes
the user-code escape hatch. Builds on #4669
(write path) and #4670
(read path on
ProjectionStorage).Background
When a user-supplied aggregation projection's
Apply(orDetermineAction/EvolveAsync) callsoperations.LoadAsync<X>(...)from inside the daemon,that call routed through
IDocumentStorage<,>.LoadAsync(id, this, ct)→BuildSelector(session)→ per-row writes into_session.Versions/_session.ItemMap/_session.ChangeTrackers. The async daemon shares oneIMartenSessionacross the 10-wideBlock<EventSliceExecution>parallelworkers per
(range, tenant); each row was a race on those shared dicts.What changes
QuerySession(the load API base): introduces twoprotected internal virtualchokepoints:
All public
LoadAsync<T>/LoadManyAsync<T>overloads (string / object /int / long / Guid × single / params / IEnumerable × with / without
CancellationToken) now call through the chokepoints instead ofstorage.LoadAsync(id, this, token)directly. Default impl preserves theexisting session-aware route — zero behavior change for non-projection
sessions.
ProjectionDocumentSessionoverrides both chokepoints withUseIdentityMapForAggregatesgating that mirrors Phase 1 + Phase 2:UseIdentityMapForAggregatesfalse(default in daemon)LoadProjectedAsync/LoadManyProjectedAsync(fresh connection, no session-shared dict writes)true(opt-in)Implementation note — the
[return: MaybeNull]+ bareTask<T>chokepointreturn shape: the override on
ProjectionDocumentSession(a partial-classchain not uniformly in
#nullable enablecontext) loses thereference-type-vs-
Nullable<T>disambiguation ofT?at the override site(CS0508 + CS0453). The attribute form is unambiguous in either context and
produces the same call-site annotation. All public
LoadAsync<T>return-type
Task<T?>is unchanged.Regression test
Bug_4667_projection_load_async_parallel_no_race.csruns 250 streams × 4events through the async daemon's parallel
Block<EventSliceExecution>fanout against a
SingleStreamProjectionwhoseApplydoessession.LoadAsync<Bug4667Customer>per event. Pre-Phase 3 this pathper-row-wrote into the shared session's tracker dicts; post-Phase 3 it goes
through
LoadProjectedAsync(fresh connection, no session state).Acceptance criteria
operations.LoadAsync<X>(...)runs through a parallel
Blockfan-out withUseIdentityMapForAggregates = false— mustnot throw and must produce correct aggregates. ✅
UseIdentityMapForAggregates = truestill runs (theFetchLatest doesnt return the latest version of the aggregate after appending an event when working with record-types #3850 inline-projection path still uses the identity-map-aware route).
✅ (covered by the existing
fetch_latest_async_aggregate,fetching_inline_aggregates_for_writingtests which all pass.)Verification
Local on net9.0:
Out of scope (Phase 4 / follow-up)
Query<T>()/ LINQ — the issue listsQuery<T>as a Phase 3 target, butthe LINQ selector swap is more invasive than the
LoadAsyncchokepointand the typical user-code escape hatch hits
LoadAsync, notQuery<T>.Defer to a focused follow-up once the load-harness in
#4666 exposes a
surviving
Query<T>race.Phases 1–3 per the issue's "Skip until Phases 1–3 ship" guidance.
🤖 Generated with Claude Code