Fix #4481: FetchForWriting without appending events shouldn't block unrelated inserts (master)#4490
Merged
jeremydmiller merged 1 commit intoMay 19, 2026
Conversation
When an inline aggregate's stream was fetched via FetchForWriting
and no events were subsequently appended, an unrelated document
insert on the same session would fail with
JasperFx.ConcurrencyException on SaveChangesAsync — the empty stream
still flowed through the inline projection's Apply step and queued
a snapshot-update operation against the unchanged aggregate version,
which then failed the optimistic-concurrency check.
The symptom reproduced for projections that override Evolve directly
but not for the conventional Apply/Create method shape. Root cause
is upstream in JasperFx.Events 1.x:
JasperFxAggregationProjectionBase.AppliesTo(eventTypes)
// Have to do this because you don't know if any events catch
if (AllEventTypes.Length == 0) return true;
return eventTypes.Intersect(AllEventTypes).Any() || ...;
For Apply/Create projections AllEventTypes is populated from the
discovered handler methods, so an empty stream's empty eventTypes
collection cleanly evaluates to false and the stream is screened out.
For Evolve-based projections AllEventTypes is empty, the early-out
fires `return true`, and the empty stream slips through.
Fix lives in Marten's two inline-appender entry points
(RichEventAppender, QuickEventAppender): only pass streams that
actually have events to the inline-projection ApplyAsync calls.
Mirrors the same `x.Events.Any()` filter that RichEventAppender's
streamActions branch already uses on the storage side.
Regression test added in
src/EventSourcingTests/FetchForWriting/
fetch_for_writing_and_projection_metadata_for_inline_projections.cs
that fails without the fix (the exact symptom from the issue:
ConcurrencyException on the unchanged VersionedGuy) and passes with it.
Closes #4481.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This was referenced May 19, 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.
Summary
Closes #4481. Master companion to #4489 (the 8.0 PR).
When an inline aggregate's stream was fetched via
FetchForWriting<TAggregate>and no events were subsequently appended, an unrelated document insert on the same session was failing withJasperFx.ConcurrencyExceptiononSaveChangesAsync— the empty stream still flowed through the inline projection's apply step and queued a snapshot-update operation against the unchanged aggregate version, tripping the optimistic-concurrency check.Symptom reproduced only when the projection overrode
Evolvedirectly; the conventionalApply/Createshape wasn't affected.Root cause
Upstream in
JasperFxAggregationProjectionBase.AppliesTo(jasperfx repo, unchanged across the 1.x → 2.x rename):For
Apply/CreateprojectionsAllEventTypesis populated from the discovered handler methods, so an empty stream's emptyeventTypescollection cleanly evaluates tofalseand the stream is screened out by the intersection check inJasperFxSingleStreamProjectionBase.ApplyAsync. ForEvolve-based projectionsAllEventTypesis empty, theLength == 0early-out firesreturn true, and the empty stream slips through — the projection then writes a snapshot with the old expected version and a concurrency check fires when any other operation commits.Fix (cherry-picked from #4489, with master-side reconciliation)
Same approach as the 8.0 PR — filter empty streams at the Marten-side appender entry points. Master diverges from 8.0 on the
IInlineProjection.ApplyAsynccontract:IReadOnlyList<StreamAction>. The 8.0 fix materializes aList<>via.ToList().IEnumerable<StreamAction>. The cherry-pick reconciles to use a lazyWhere(x => x.Events.Any())instead of materializing — preserves the no-allocation property that Runtime perf: widen IInlineProjection.ApplyAsync streams parameter to IEnumerable<StreamAction> #4306 introduced.RichEventAppender.ProcessEventsAsyncandQuickEventAppender.ProcessEventsAsyncboth updated.Test plan
fetch_for_writing_without_appending_does_not_block_unrelated_insertstest cherry-picked from the 8.0 PR; passes on master with the fixFullyQualifiedName~FetchForWritingtests pass on net10.0 with the fixCompanion PR on 8.0: #4489.
🤖 Generated with Claude Code