Skip to content

EventProjectionScenario's up-front wipe is a no-op for composite projections — read models survive while their events are deleted #5169

Description

@erdtsieck

Found while trying the harness from the 03-08 blog post against a real CompositeProjectionFor(...). Adjacent to #5127 but not one of its 11 items, and not #5126.

Summary

EventProjectionScenario's up-front wipe is a no-op for composite projections. Event data is deleted, the composite's read models are not — so every scenario after the first starts against the previous scenario's documents, now orphaned from any events.

Why

ProjectionScenario.Execute (9.22.2) / DeleteExistingDataAsync (master) derives the wipe list from StorageTypes:

await _store.Advanced.Clean.DeleteAllEventDataAsync(ct);
foreach (var storageType in _store.Options.Projections.All.SelectMany(x => x.Options.StorageTypes))
{
    await _store.Advanced.Clean.DeleteDocumentsByTypeAsync(storageType, ct);
}

A CompositeProjection never populates its own Options.StorageTypes (nor Options.CleanUps) from its member projections. For a store whose entire read side is one composite over ~10 members, the full registry is:

invoices (Marten.Events.Projections.CompositeProjection)
    StorageTypes : []
    CleanUps     : []

So the loop iterates nothing.

Two things compound it:

  1. StorageTypes is documented as a schema-building hint — "used to help build out schema objects if the async daemon is started before the rest of the application" — not as a teardown list. Teardown-on-rebuild goes through AsyncOptions.CleanUps, which is why composite rebuilds are fine: the composite delegates teardown to its members internally, and neither list is surfaced on the composite itself. Anything reasoning about a composite from the outside sees a projection that writes nothing.
  2. Because the events are deleted, the leftover documents cannot be reconstructed or noticed by a subsequent rebuild.

Reproduction

Store: one CompositeProjectionFor("invoices", …), stage-1 snapshot + stage-2 read models, StreamIdentity.AsString, conjoined tenancy. Two scenarios in sequence:

var invoiceId = Guid.NewGuid().ToString();

await store.Advanced.EventProjectionScenario(scenario =>
{
    scenario.StartStream<Invoice>(invoiceId, Creation(invoiceId, 1_000_000m));
    scenario.DocumentShouldExist<InvoiceOverviewItem>(invoiceId);
}, ct);

// Second scenario, touching nothing related. If the wipe worked, the first
// scenario's read model would be gone by the time this one asserts.
await store.Advanced.EventProjectionScenario(scenario =>
{
    var unrelatedId = Guid.NewGuid().ToString();
    scenario.StartStream<Invoice>(unrelatedId, Creation(unrelatedId, 1m));

    scenario.DocumentShouldExist<InvoiceOverviewItem>(unrelatedId);
    scenario.DocumentShouldExist<InvoiceOverviewItem>(invoiceId, leftover =>
        leftover.ClaimedAmount.ShouldBe(1_000_000m));   // PASSES — the leak
}, ct);

// ...and its events really are gone, so events and read models are out of sync.
await using var session = store.QuerySession();
(await session.Events.FetchStreamAsync(invoiceId, token: ct)).ShouldBeEmpty();   // PASSES

Plus the direct assertion:

var storageTypes = store.Options.Projections.All.SelectMany(x => x.Options.StorageTypes).ToList();

storageTypes.ShouldNotContain(typeof(Invoice));               // PASSES
storageTypes.ShouldNotContain(typeof(InvoiceOverviewItem));   // PASSES
storageTypes.ShouldNotContain(typeof(InsurerInvoice));        // PASSES

Control group — the same harness against a store of 17 ordinary (non-composite) projections. Every one reports both lists, e.g.

EigenPrestatieMetTarieven (…EigenPrestatieMetTarievenProjection)
    StorageTypes : [EigenPrestatieMetTarieven]
    CleanUps     : [DeleteDocuments { DocumentType = …EigenPrestatieMetTarieven }]

and there a second scenario genuinely starts from an empty slate. So this is specific to CompositeProjectionFor(...), not a general failure of the wipe.

Observed on Marten 9.22.2 (JasperFx.Events 2.37.2). The StorageTypes-derived wipe and the empty composite StorageTypes are both unchanged on master, so I'd expect it to reproduce there too.

Impact

Silent, and it undercuts exactly the guarantee the docs lead with ("deletes all event data plus storage for every registered projection before it runs"). Tests stay accidentally correct as long as they use unique ids per scenario, but any unfiltered Query<T>() inside AssertAgainstProjectedData sees prior scenarios' rows — which is how I hit it: an ordered Query<InvoiceOverviewItem>() asserting [100m, 200m] returned [90m, 100m, 100m, 200m, 250m].

Suggested fix

Two independent parts, either of which fixes the symptom:

  1. Have the wipe use the teardown rules rather than StorageTypes. Options.CleanUps is the list that already means "what to delete when this projection is torn down", and running it per projection is closer to the guarantee the docs state. It also stops the harness depending on a property whose documented purpose is schema building.
  2. Have CompositeProjection surface the union of its members' StorageTypes / CleanUps. Worth doing regardless of (1): a composite currently claims to write no documents, which is wrong for any external consumer, including the schema pre-build that StorageTypes exists for.

Happy to open a PR for either if you have a preference on which.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions