From 67644106318b1a1f5c413aa9c9cf0c89ed956271 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Sun, 2 Aug 2026 19:52:03 -0500 Subject: [PATCH] Commit the events queued by a ProjectionScenario's final step (#5126) Closes #5126 ScenarioAction only flushes the session when the step AFTER it is an assertion: _action(scenario.Session.Events); if (scenario.NextStep is ScenarioAssertion) // null for the last step { await scenario.Session.SaveChangesAsync(ct); await scenario.WaitForNonStaleData(); } NextStep peeks the queue after the current step was dequeued, so it is null on the final step and the branch never runs. Execute's finally then disposes the session, and Marten's dispose does not commit -- so a scenario ending in an append silently lost those events, and an arrange-only scenario was a complete no-op that passed. Flushes once more after the step loop. Unconditional on purpose: SaveChangesAsync returns immediately when the unit of work is empty (DocumentSessionBase checks HasOutstandingWork before doing anything), and WaitForNonStaleData is already a no-op when no daemon is running, so scenarios that already flushed pay nothing. The flush is inside the try and aggregates into ProjectionScenarioException like any other step, so a failing commit reports as a scenario failure rather than escaping raw. Two regression tests, both verified to fail without the fix: a trailing append after an assertion, and a scenario with no assertions at all. None of the eight existing tests covered this because every one of them happens to end with an assertion. net9.0: DaemonTests 262/0/0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VpDCvJcBDZerieJB4JEHde --- .../scenario_trailing_action_tests.cs | 57 +++++++++++++++++++ .../Events/TestSupport/ProjectionScenario.cs | 19 +++++++ 2 files changed, 76 insertions(+) create mode 100644 src/DaemonTests/EventProjections/scenario_trailing_action_tests.cs diff --git a/src/DaemonTests/EventProjections/scenario_trailing_action_tests.cs b/src/DaemonTests/EventProjections/scenario_trailing_action_tests.cs new file mode 100644 index 0000000000..0c72bd6ee6 --- /dev/null +++ b/src/DaemonTests/EventProjections/scenario_trailing_action_tests.cs @@ -0,0 +1,57 @@ +using System; +using System.Threading.Tasks; +using JasperFx.Events.Projections; +using Marten; +using Marten.Testing.Documents; +using Marten.Testing.Harness; +using Shouldly; +using Xunit; + +namespace DaemonTests.EventProjections; + +// #5126: a ScenarioAction only flushed when the NEXT queued step was an assertion, so anything a +// trailing action appended was still in the session when Execute's finally disposed it. The events +// were silently dropped, and an arrange-only scenario was a no-op that passed. +public class scenario_trailing_action_tests: OneOffConfigurationsContext +{ + [Fact] + public async Task trailing_append_is_committed() + { + StoreOptions(opts => opts.Projections.Add(new UserProjection(), ProjectionLifecycle.Inline)); + + var flushed = Guid.NewGuid(); + var trailing = Guid.NewGuid(); + + await theStore.Advanced.EventProjectionScenario(scenario => + { + // An assertion follows this one, so it flushes through the pre-existing path. + scenario.Append(Guid.NewGuid(), new CreateUser { UserId = flushed, UserName = "Flushed" }); + scenario.DocumentShouldExist(flushed); + + // Nothing follows this one. + scenario.Append(Guid.NewGuid(), new CreateUser { UserId = trailing, UserName = "Trailing" }); + }); + + await using var query = theStore.QuerySession(); + (await query.LoadAsync(trailing)).ShouldNotBeNull(); + } + + [Fact] + public async Task arrange_only_scenario_actually_writes() + { + StoreOptions(opts => opts.Projections.Add(new UserProjection(), ProjectionLifecycle.Inline)); + + var id = Guid.NewGuid(); + + // No assertions at all -- every step is an action, so nothing used to be committed. + await theStore.Advanced.EventProjectionScenario(scenario => + { + scenario.Append(Guid.NewGuid(), new CreateUser { UserId = id, UserName = "Arranged" }); + }); + + await using var query = theStore.QuerySession(); + var user = await query.LoadAsync(id); + user.ShouldNotBeNull(); + user.UserName.ShouldBe("Arranged"); + } +} diff --git a/src/Marten/Events/TestSupport/ProjectionScenario.cs b/src/Marten/Events/TestSupport/ProjectionScenario.cs index a38c07441b..afb6a0e202 100644 --- a/src/Marten/Events/TestSupport/ProjectionScenario.cs +++ b/src/Marten/Events/TestSupport/ProjectionScenario.cs @@ -107,6 +107,25 @@ internal async Task Execute(CancellationToken ct = default) } } + // A ScenarioAction only flushes when the step AFTER it is an assertion, so whatever a + // trailing action queued is still sitting in the session -- and the finally below disposes + // that session without committing. An append with no assertion after it is still an append, + // and an arrange-only scenario should not be a silent no-op that passes. See #5126. + // + // Unconditional on purpose: SaveChangesAsync returns immediately when the unit of work is + // empty, and WaitForNonStaleData is already a no-op when no daemon is running. + try + { + await Session.SaveChangesAsync(ct).ConfigureAwait(false); + await WaitForNonStaleData().ConfigureAwait(false); + } + catch (Exception e) + { + descriptions.Add($"FAILED: committing the events queued by the final step"); + descriptions.Add(e.ToString()); + exceptions.Add(e); + } + if (exceptions.Any()) { throw new ProjectionScenarioException(descriptions, exceptions);