#4667 Phase 2 — read-path LoadProjectedAsync bypasses session-shared trackers#4670
Merged
jeremydmiller merged 1 commit intoJun 5, 2026
Conversation
…trackers Closes the remaining read sites that ProjectionStorage's LoadAsync / LoadManyAsync still routed through the session-aware closed-shape selectors, which per-row write into _session.Versions / _session.ItemMap / _session.ChangeTrackers (the same race shape #4658 + Phase 1 closed for the write path). Surface change: IDocumentStorage<T, TId>.LoadProjectedAsync(TId id, IMartenDatabase database, string tenantId, CancellationToken token) IDocumentStorage<T, TId>.LoadManyProjectedAsync(TId[] ids, IMartenDatabase database, string tenantId, CancellationToken token) These take IMartenDatabase + tenantId, not a session — the issue's stated goal that storage read methods never accept an IMartenSession argument. Implementation: a new ClosedShapeProjectionLoader<TDoc, TId> static helper opens a fresh connection from the database, executes the existing BuildLoad{,Many}Command SQL, and deserializes the data column directly via ISerializer.FromJsonAsync. Hierarchical storages dispatch through DocumentMapping.TypeFor like HierarchicalClosedShapeQueryOnlySelector. No metadata binders are applied — projections care about aggregate state, not per-row CreatedAt / Headers / etc.; if a future projection scenario needs metadata it can be added here as a focused follow-up. Column layout matches the writeable closed-shape selectors (Lightweight / IdentityMap / DirtyChecked): id at col 0, data at col 1, metadata at 2+. This was the v1 bug — I'd started with the QueryOnly layout (data at col 0) and the first DaemonTests run failed 19 tests with JsonReaderException because the loader was deserializing the id column bytes as JSON. Storage wiring: * LightweightClosedShapeStorage / IdentityMapClosedShapeStorage / DirtyCheckedClosedShapeStorage (the 3 writeable closed-shape mid-tiers) override with the helper. * QueryOnlyClosedShapeStorage throws NotSupported — it isn't used by the projection read path; ProjectionStorage holds a writeable storage for the projected document type. * SubClassDocumentStorage delegates to parent + downcast like its other Load delegations. * ValueTypeIdentifiedDocumentStorage delegates to inner with the unwrapped id. * DocumentStorage<T, TId> base default impl throws NotImplementedException for non-closed-shape paths (legacy Roslyn-generated storages) — those aren't projection-eligible by construction. * IDocumentStorage<T> (single-T) doesn't get the new methods — they're on the <T, TId> interface only, so EventDocumentStorage / EventMapping<T> don't need to implement them (events aren't projected documents). ProjectionStorage rewrites: * LoadAsync(id, ct) — gated on UseIdentityMapForAggregates. Default (false) routes through LoadProjectedAsync. The opt-in (true) case falls through to the session-aware LoadAsync to preserve the inline-projection identity-map semantics that fetching_inline_aggregates_for_writing. silently_turns_on_identity_map_for_inline_aggregates depends on — the inline projection needs to mutate the same instance the IdentitySession holds. Matches the same gating Phase 1 applied to Store(snapshot, id, ...) for GH-3850. * LoadManyAsync(identities, ct) — same gating. Verified locally on net9.0: * DaemonTests — 187 / 187 ✅ * EventSourcingTests — 1361 passed / 7 pre-existing skips ✅ (including the GH-3850 regression and the silently_turns_on_identity_map_for_inline_aggregates regression that caught the v1 gating gap) * CoreTests — 421 passed / 1 pre-existing skip ✅ 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 2 of #4667 — eliminate
session-shared dictionary access from the projection read path. Builds on
#4669 which closed the write
sites.
Background
ProjectionStorage'sLoadAsync/LoadManyAsyncrouted through thesession-aware closed-shape selectors (Lightweight / IdentityMap /
DirtyChecked), which per-row write into
_session.Versions/_session.ItemMap/_session.ChangeTrackers— the same race shape #4658Block<EventSliceExecution>parallel workers sharing oneIMartenSession,each row materialization was a race on those dictionaries.
What changes
New
IDocumentStorage<T, TId>surface:These take
IMartenDatabase+tenantIdinstead of a session — the issue'sstated goal that storage read methods never accept an
IMartenSessionargument.
Implementation: a new
ClosedShapeProjectionLoader<TDoc, TId>statichelper opens a fresh connection from the database, executes the existing
BuildLoad{,Many}CommandSQL, and deserializes the data column directlyvia
ISerializer.FromJsonAsync. Hierarchical storages dispatch throughDocumentMapping.TypeForlikeHierarchicalClosedShapeQueryOnlySelector.No metadata binders are applied — projections care about aggregate state,
not per-row CreatedAt / Headers / etc.; if a future projection scenario
needs metadata, it can be added here as a focused follow-up.
Column layout matches the writeable closed-shape selectors (
idat col 0,dataat col 1, metadata at 2+). This was the v1 bug — I'd started withthe QueryOnly layout (
dataat col 0) and the first DaemonTests run failed19 tests with
JsonReaderExceptionbecause the loader was deserializingthe id-column bytes as JSON.
Storage wiring:
LightweightClosedShapeStorage/IdentityMapClosedShapeStorage/DirtyCheckedClosedShapeStorage(3 writeable closed-shape mid-tiers)QueryOnlyClosedShapeStorageNotSupported— not used by the projection read pathSubClassDocumentStorageValueTypeIdentifiedDocumentStorageDocumentStorage<T, TId>base defaultNotImplementedExceptionfor non-closed-shape paths (legacy Roslyn-generated storages); those aren't projection-eligibleIDocumentStorage<T>(single-T)<T, TId>interface only, soEventDocumentStorage/EventMapping<T>don't need them (events aren't projected documents)ProjectionStoragerewrites:LoadAsync(id, ct)— gated onUseIdentityMapForAggregates. Default(false) routes through
LoadProjectedAsync. The opt-in (true) casefalls through to the session-aware
LoadAsyncto preserve theinline-projection identity-map semantics that
silently_turns_on_identity_map_for_inline_aggregatesdepends on —the inline projection needs to mutate the same instance the
IdentitySessionholds. Mirrors the same gating Phase 1 applied toStore(snapshot, id, ...)for FetchLatest doesnt return the latest version of the aggregate after appending an event when working with record-types #3850.LoadManyAsync(identities, ct)— same gating.Acceptance criteria
ProjectionStorage.LoadAsync/LoadManyAsyncperform zero writes tosession.Versions,session.ItemMap, orsession.ChangeTrackersin thedefault (race-prone) configuration. ✅ (The opt-in
UseIdentityMapForAggregates = truepath intentionally retains the session-aware route.)
UseIdentityMapForAggregates = trueis set. ✅Verification
Local on net9.0:
Specifically green: the GH-3850 regression
(
fetch_latest_immutable_aggregate_running_inline_and_identity_map) and thesilently_turns_on_identity_map_for_inline_aggregatesregression thatcaught the v1 gating gap.
Follow-up
Phase 3 (#4667) —
ProjectionDocumentSessionoverrides for user-code reads from insideEvolveAsync(closes the open surface where user-suppliedoperations.LoadAsync<X>(...)from a projection still routes through thesession-aware path).
🤖 Generated with Claude Code