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:
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.
- 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:
- 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.
- 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.
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 fromStorageTypes:A
CompositeProjectionnever populates its ownOptions.StorageTypes(norOptions.CleanUps) from its member projections. For a store whose entire read side is one composite over ~10 members, the full registry is:So the loop iterates nothing.
Two things compound it:
StorageTypesis 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 throughAsyncOptions.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.Reproduction
Store: one
CompositeProjectionFor("invoices", …), stage-1 snapshot + stage-2 read models,StreamIdentity.AsString, conjoined tenancy. Two scenarios in sequence:Plus the direct assertion:
Control group — the same harness against a store of 17 ordinary (non-composite) projections. Every one reports both lists, e.g.
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 compositeStorageTypesare 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>()insideAssertAgainstProjectedDatasees prior scenarios' rows — which is how I hit it: an orderedQuery<InvoiceOverviewItem>()asserting[100m, 200m]returned[90m, 100m, 100m, 200m, 250m].Suggested fix
Two independent parts, either of which fixes the symptom:
StorageTypes.Options.CleanUpsis 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.CompositeProjectionsurface 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 thatStorageTypesexists for.Happy to open a PR for either if you have a preference on which.