Skip to content

ProjectionScenario silently drops a trailing append — the events are never committed #5126

Description

@jeremydmiller

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions