ProjectionScenario only flushes the session when the next queued step is an assertion, so any action that is the last step in a scenario is never saved. The session is disposed in the finally without a SaveChangesAsync, and Marten's dispose does not commit.
Marten/Events/TestSupport/ScenarioAction.cs:
public override async Task Execute(ProjectionScenario scenario, CancellationToken ct = default)
{
_action(scenario.Session.Events);
if (scenario.NextStep is ScenarioAssertion) // <-- null for the last step
{
await scenario.Session.SaveChangesAsync(ct).ConfigureAwait(false);
await scenario.WaitForNonStaleData().ConfigureAwait(false);
}
}
NextStep peeks the queue after the current step was dequeued, so it is null on the final step and the branch never runs.
Consequences
- A scenario ending in an append silently loses those events.
- An arrange-only scenario is a complete no-op that passes. Nothing is written, nothing is asserted, the test is green.
Reproduction
Verified against master (c89ef018e). Fails with count should be 2 but was 1:
public class scenario_trailing_action_probe : OneOffConfigurationsContext
{
[Fact]
public async Task trailing_action_is_committed()
{
StoreOptions(opts => opts.Projections.Add(new UserProjection(), ProjectionLifecycle.Inline));
var id = Guid.NewGuid();
await theStore.Advanced.EventProjectionScenario(scenario =>
{
// assertion in the middle, so the FIRST append flushes
scenario.Append(Guid.NewGuid(), new CreateUser { UserId = id, UserName = "First" });
scenario.DocumentShouldExist<User>(id);
// trailing append with nothing after it
scenario.Append(Guid.NewGuid(), new CreateUser { UserId = Guid.NewGuid(), UserName = "Trailing" });
});
await using var query = theStore.QuerySession();
var count = await query.Query<User>().CountAsync();
count.ShouldBe(2); // actual: 1
}
}
(CreateUser / UserProjection are the existing types in DaemonTests/EventProjections/event_projection_scenario_tests.cs.)
Why no existing test catches it
All 8 tests in event_projection_scenario_tests.cs happen to end with an assertion, so the final-step path is never exercised.
Suggested fix
Flush any pending work after the step loop, before the finally disposes the session — Execute already owns the session lifetime and is the natural place:
while (_steps.Any()) { ... }
// commit whatever the last action(s) queued
await Session.SaveChangesAsync(ct);
await WaitForNonStaleData();
Guarding on Session.PendingChanges.Any() (or equivalent) keeps it a no-op for scenarios that already flushed.
Adjacent design question, deliberately not folded in here
The same NextStep is ScenarioAssertion rule means consecutive actions batch into a single transaction. That is probably intended, but it does mean a scenario cannot express "two separate commits" — which is what you would want to exercise optimistic concurrency or per-batch projection behavior. Worth deciding on separately from this fix.
Found by
Reviewing ProjectionScenario while assessing the cross-store lift (marten#5118 program).
🤖 Generated with Claude Code
ProjectionScenarioonly flushes the session when the next queued step is an assertion, so any action that is the last step in a scenario is never saved. The session is disposed in thefinallywithout aSaveChangesAsync, and Marten's dispose does not commit.Marten/Events/TestSupport/ScenarioAction.cs:NextSteppeeks the queue after the current step was dequeued, so it is null on the final step and the branch never runs.Consequences
Reproduction
Verified against master (
c89ef018e). Fails withcount should be 2 but was 1:(
CreateUser/UserProjectionare the existing types inDaemonTests/EventProjections/event_projection_scenario_tests.cs.)Why no existing test catches it
All 8 tests in
event_projection_scenario_tests.cshappen to end with an assertion, so the final-step path is never exercised.Suggested fix
Flush any pending work after the step loop, before the
finallydisposes the session —Executealready owns the session lifetime and is the natural place:Guarding on
Session.PendingChanges.Any()(or equivalent) keeps it a no-op for scenarios that already flushed.Adjacent design question, deliberately not folded in here
The same
NextStep is ScenarioAssertionrule means consecutive actions batch into a single transaction. That is probably intended, but it does mean a scenario cannot express "two separate commits" — which is what you would want to exercise optimistic concurrency or per-batch projection behavior. Worth deciding on separately from this fix.Found by
Reviewing
ProjectionScenariowhile assessing the cross-store lift (marten#5118 program).🤖 Generated with Claude Code