Skip to content

Fix #4481: FetchForWriting without appending events shouldn't block unrelated inserts#4489

Merged
jeremydmiller merged 1 commit into
8.0from
fix/4481-concurrency-fetch-without-append-8.0
May 19, 2026
Merged

Fix #4481: FetchForWriting without appending events shouldn't block unrelated inserts#4489
jeremydmiller merged 1 commit into
8.0from
fix/4481-concurrency-fetch-without-append-8.0

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Summary

Closes #4481.

When an inline aggregate's stream is fetched via FetchForWriting<TAggregate> and no events are subsequently appended, an unrelated document insert on the same session was failing 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, tripping the optimistic-concurrency check.

The symptom reproduced for projections that override Evolve directly but not for the conventional Apply/Create method shape.

Root cause

Upstream in JasperFx.Events 1.x:

// JasperFxAggregationProjectionBase.AppliesTo
public bool AppliesTo(IEnumerable<Type> 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 by the early intersection check in JasperFxSingleStreamProjectionBase.ApplyAsync. For Evolve-based projections AllEventTypes is empty, the Length == 0 early-out fires return true, and the empty stream slips through — then the projection writes a snapshot with the old expected version and a concurrency check fires when any other operation commits.

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. Marten-side fix avoids needing to ship a new JasperFx.Events 1.x release.

Test plan

  • New regression test fetch_for_writing_without_appending_does_not_block_unrelated_inserts reproduces the exact ConcurrencyException from the issue without the fix.
  • The new test passes with the fix.
  • All 194 FullyQualifiedName~FetchForWriting tests pass on net10.0.
  • Full EventSourcingTests run: 5 pre-existing failures on 8.0 baseline confirmed reproducible without this PR (verified by reverting and re-running the same test set) — no regressions introduced.

A companion PR cherry-picks the fix to master.

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
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.

1 participant