diff --git a/src/DaemonTests/EventProjections/Bug_5169_composite_scenario_wipe.cs b/src/DaemonTests/EventProjections/Bug_5169_composite_scenario_wipe.cs new file mode 100644 index 0000000000..bd565b3151 --- /dev/null +++ b/src/DaemonTests/EventProjections/Bug_5169_composite_scenario_wipe.cs @@ -0,0 +1,108 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using JasperFx.Events; +using Marten; +using Marten.Events.Aggregation; +using Marten.Testing.Harness; +using Shouldly; +using Xunit; + +namespace DaemonTests.EventProjections; + +/// +/// #5169 — EventProjectionScenario's up-front wipe derived its list of document types from +/// Options.StorageTypes. A CompositeProjection never populates its own StorageTypes +/// (its members hold theirs), so for a store whose entire read side is one composite the wipe loop +/// iterated NOTHING: event data was deleted, the composite's read models were not, and every scenario +/// after the first ran against the previous scenario's documents — now orphaned from any events. +/// Silent, and the exact opposite of what the harness leads with. +/// +public class Bug_5169_composite_scenario_wipe: OneOffConfigurationsContext +{ + private void configure() + { + StoreOptions(opts => + { + opts.Projections.CompositeProjectionFor("Wipe5169", composite => + { + // Stage 1 snapshot, stage 2 read model — the reporter's shape. + composite.Snapshot(); + composite.Add(new Wipe5169OverviewProjection(), 2); + }); + }); + } + + [Fact] + public void the_composite_reports_the_document_types_its_members_write() + { + configure(); + + var published = theStore.Options.Projections.All.SelectMany(x => x.PublishedTypes()).ToArray(); + + published.ShouldContain(typeof(Wipe5169Invoice)); + published.ShouldContain(typeof(Wipe5169Overview)); + + // ...and why the wipe could not use StorageTypes: a composite holds none of its own. That list is + // documented as a schema-building hint, and the members are where the real values live. + theStore.Options.Projections.All.SelectMany(x => x.Options.StorageTypes).ShouldBeEmpty(); + } + + [Fact] + public async Task a_second_scenario_starts_from_a_clean_slate() + { + configure(); + + var first = Guid.NewGuid(); + + await theStore.Advanced.EventProjectionScenario(scenario => + { + scenario.StartStream(first, new Wipe5169Created(1_000_000m)); + scenario.DocumentShouldExist(first); + scenario.DocumentShouldExist(first); + }); + + // A second scenario touching nothing related. Before #5169 the first scenario's read models + // survived the wipe while its events were deleted, so this saw both. + var second = Guid.NewGuid(); + + await theStore.Advanced.EventProjectionScenario(scenario => + { + scenario.StartStream(second, new Wipe5169Created(1m)); + scenario.DocumentShouldExist(second); + scenario.DocumentShouldExist(second); + }); + + await using var query = theStore.QuerySession(); + + // The leak the issue is about: an unfiltered Query() inside AssertAgainstProjectedData used to + // see every prior scenario's rows. + (await query.Query().CountAsync()).ShouldBe(1); + (await query.Query().CountAsync()).ShouldBe(1); + + (await query.LoadAsync(first)).ShouldBeNull(); + (await query.LoadAsync(first)).ShouldBeNull(); + } +} + +public record Wipe5169Created(decimal Amount); + +// Self-aggregating: CompositeProjection.Snapshot() builds a SingleStreamProjection over it. +public class Wipe5169Invoice +{ + public Guid Id { get; set; } + public decimal Amount { get; set; } + + public void Apply(Wipe5169Created e) => Amount = e.Amount; +} + +public class Wipe5169Overview +{ + public Guid Id { get; set; } + public decimal ClaimedAmount { get; set; } +} + +public partial class Wipe5169OverviewProjection: SingleStreamProjection +{ + public void Apply(Wipe5169Overview overview, Wipe5169Created e) => overview.ClaimedAmount = e.Amount; +} diff --git a/src/Marten/Events/TestSupport/ProjectionScenario.cs b/src/Marten/Events/TestSupport/ProjectionScenario.cs index 56d4fa46de..ea13196f2f 100644 --- a/src/Marten/Events/TestSupport/ProjectionScenario.cs +++ b/src/Marten/Events/TestSupport/ProjectionScenario.cs @@ -25,11 +25,20 @@ internal ProjectionScenario(DocumentStore store) _store = store; } + /// + /// #5169: the wipe list comes from , + /// not Options.StorageTypes. A CompositeProjection never populates its own + /// StorageTypes — its members hold theirs — so the loop iterated nothing for a store whose read + /// side is a composite, and every scenario after the first ran against the previous scenario's + /// documents while their events had already been deleted. PublishedTypes() is the traversal that + /// already knows to expand a composite into its members (and is a superset of StorageTypes for + /// everything else), which is why Marten's own schema build-out uses it. + /// protected override async Task DeleteExistingDataAsync(CancellationToken ct) { await _store.Advanced.Clean.DeleteAllEventDataAsync(ct).ConfigureAwait(false); foreach (var storageType in - _store.Options.Projections.All.SelectMany(x => x.Options.StorageTypes)) + _store.Options.Projections.All.SelectMany(x => x.PublishedTypes()).Distinct()) { await _store.Advanced.Clean.DeleteDocumentsByTypeAsync(storageType, ct).ConfigureAwait(false); }