diff --git a/Directory.Packages.props b/Directory.Packages.props index f5b88463bd..5d63f56ecf 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -73,13 +73,13 @@ AggregateType (TypeDescriptor), populated in the shared SubscriptionDescriptor(ISubscriptionSource, IEventStore) ctor that Marten's projections funnel through via ProjectionGraph.Describe. See marten#4772. --> - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/ContainerScopedProjectionTests/Bug_4787_di_projection_with_instance_apply.cs b/src/ContainerScopedProjectionTests/Bug_4787_di_projection_with_instance_apply.cs new file mode 100644 index 0000000000..cc2388f50e --- /dev/null +++ b/src/ContainerScopedProjectionTests/Bug_4787_di_projection_with_instance_apply.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using JasperFx.Events; +using JasperFx.Events.Projections; +using Marten; +using Marten.Events.Aggregation; +using Marten.Testing.Harness; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Npgsql; +using Shouldly; +using Weasel.Postgresql; +using Xunit; + +namespace ContainerScopedProjectionTests; + +/// +/// Repro for https://github.com/JasperFx/marten/issues/4787. A +/// registered via +/// AddProjectionWithServices with constructor-injected dependencies +/// and conventional instance methods (Create / Apply / +/// ShouldDelete) has its dependencies silently NULL inside every +/// convention method on Marten 9.11+ / JasperFx.Events 2.14.x+. The +/// reporter's original "silent zero events" symptom came from a downstream +/// null-conditional masking the NRE; these tests deref the injected +/// service directly so the failure surfaces deterministically. +/// +/// Root cause is visible in the generated Evolver (file is in +/// obj/.../OrderProjection.Evolver.g.cs): +/// +/// _projection => _projectionInstance ??= (OrderProjection) +/// RuntimeHelpers.GetUninitializedObject(typeof(OrderProjection)); +/// +/// That bypasses the constructor entirely — added in jasperfx#470 / +/// JasperFx 2.14.1 commit 1b728d1 +/// (EvolverCodeEmitter.cs:152-163) to make DI-only-ctor projections +/// compile (previously CS7036). It fixed compilation but introduced this +/// runtime regression: tryUseAssemblyRegisteredEvolver +/// (JasperFxAggregationProjectionBase.cs:222) routes every Apply / +/// Create / ShouldDelete through the uninitialized shadow, so the +/// DI-resolved projection that AddProjectionWithServices supplied +/// is never used for event dispatch. +/// +/// Coverage matrix: 3 convention methods × 3 +/// values = 9 cases, all expected to fail today the same way and to pass +/// once the dispatcher routes through the DI-built instance. +/// +/// Note that AddProjectionWithServices documents Transient as +/// "treated as Scoped" — we still parameterize over all three so any +/// future per-lifetime divergence is captured. +/// +[Collection("ioc")] +public class Bug_4787_di_projection_with_instance_apply +{ + [Theory] + [InlineData(ServiceLifetime.Singleton)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public async Task Create_convention_sees_injected_dependency(ServiceLifetime lifetime) + { + var recorder = await runScenario("create", lifetime, append => + { + // Stream-start event triggers the Create convention. + append(new OrderCreatedEvent { OrderNumber = "X", Version = 1 }); + }); + + recorder.Created.Count.ShouldBe(1); + recorder.Created[0].ShouldBeOfType(); + } + + [Theory] + [InlineData(ServiceLifetime.Singleton)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public async Task Apply_convention_sees_injected_dependency(ServiceLifetime lifetime) + { + var recorder = await runScenario("apply", lifetime, append => + { + // Create first to materialize the snapshot, then Apply runs for + // the second event. + append(new OrderCreatedEvent { OrderNumber = "X", Version = 1 }); + append(new OrderUpdatedEvent { OrderNumber = "X", Version = 2, Total = 42m }); + }); + + recorder.Applied.Count.ShouldBe(1); + recorder.Applied[0].ShouldBeOfType(); + } + + [Theory] + [InlineData(ServiceLifetime.Singleton)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public async Task ShouldDelete_convention_sees_injected_dependency(ServiceLifetime lifetime) + { + var recorder = await runScenario("delete", lifetime, append => + { + // Create + Closed sequence — ShouldDelete is the predicate that + // tombstones the aggregate when a Closed event arrives. + append(new OrderCreatedEvent { OrderNumber = "X", Version = 1 }); + append(new OrderClosedEvent { OrderNumber = "X", Version = 2 }); + }); + + recorder.ShouldDeleteChecked.Count.ShouldBe(1); + recorder.ShouldDeleteChecked[0].ShouldBeOfType(); + } + + private static async Task runScenario( + string scenario, ServiceLifetime lifetime, Action> arrange) + { + // Per-scenario, per-lifetime schema so test cases don't collide. + // Compact name to stay well under PostgreSQL's 63-char identifier limit. + var schema = $"b4787_{scenario}_{lifetime.ToString().ToLowerInvariant()}"; + + await using (var conn = new NpgsqlConnection(ConnectionSource.ConnectionString)) + { + await conn.OpenAsync(); + await conn.DropSchemaAsync(schema); + await conn.CloseAsync(); + } + + var recorder = new OrderEventRecorder(); + + using var host = await Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddSingleton(recorder); + + services.AddMarten(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = schema; + opts.Events.StreamIdentity = StreamIdentity.AsString; + opts.Schema.For().Identity(x => x.OrderNumber); + }) + .AddProjectionWithServices(ProjectionLifecycle.Inline, lifetime); + }) + .StartAsync(); + + var store = host.Services.GetRequiredService(); + + var events = new List(); + arrange(events.Add); + + await using (var session = store.LightweightSession()) + { + session.Events.StartStream("X", events.Cast().ToArray()); + await session.SaveChangesAsync(); + } + + return recorder; + } +} + +public interface IOrderEventRecorder +{ + void RecordCreate(OrderEvent @event); + void RecordApply(OrderEvent @event); + void RecordShouldDelete(OrderEvent @event); + IReadOnlyList Created { get; } + IReadOnlyList Applied { get; } + IReadOnlyList ShouldDeleteChecked { get; } +} + +public class OrderEventRecorder: IOrderEventRecorder +{ + private readonly List _created = new(); + private readonly List _applied = new(); + private readonly List _shouldDelete = new(); + + public void RecordCreate(OrderEvent @event) => _created.Add(@event); + public void RecordApply(OrderEvent @event) => _applied.Add(@event); + public void RecordShouldDelete(OrderEvent @event) => _shouldDelete.Add(@event); + + public IReadOnlyList Created => _created; + public IReadOnlyList Applied => _applied; + public IReadOnlyList ShouldDeleteChecked => _shouldDelete; +} + +public abstract class OrderEvent +{ + public required string OrderNumber { get; init; } + public required int Version { get; init; } +} + +public class OrderCreatedEvent: OrderEvent +{ +} + +public class OrderUpdatedEvent: OrderEvent +{ + public required decimal Total { get; init; } +} + +public class OrderClosedEvent: OrderEvent +{ +} + +public record OrderAggregate +{ + public string OrderNumber { get; init; } = string.Empty; + public IReadOnlyList Versions { get; init; } = new List(); +} + +public record OrderLine +{ + public string OrderNumber { get; init; } = string.Empty; + public int Version { get; init; } + public decimal Total { get; init; } +} + +/// +/// Mirrors the reporter's shape: partial projection class, instance +/// convention methods (Create, Apply, ShouldDelete), +/// constructor-injected dependency. Every convention method dereferences +/// the injected recorder so a null-injection bug surfaces deterministically +/// (rather than silently as in the reporter's original null-conditional +/// masking). +/// +public partial class OrderProjection: SingleStreamProjection +{ + private readonly IOrderEventRecorder _recorder; + + public OrderProjection(IOrderEventRecorder recorder) + { + _recorder = recorder; + } + + public OrderAggregate Create(OrderCreatedEvent @event) + { + _recorder.RecordCreate(@event); + + return new OrderAggregate + { + OrderNumber = @event.OrderNumber, + Versions = new List + { + new() { OrderNumber = @event.OrderNumber, Version = @event.Version } + } + }; + } + + public OrderAggregate Apply(OrderAggregate snapshot, OrderUpdatedEvent @event) + { + _recorder.RecordApply(@event); + + var versions = snapshot.Versions.ToList(); + versions.Add(new OrderLine + { + OrderNumber = @event.OrderNumber, + Version = @event.Version, + Total = @event.Total + }); + + return snapshot with { Versions = versions.AsReadOnly() }; + } + + public bool ShouldDelete(OrderAggregate snapshot, OrderClosedEvent @event) + { + _recorder.RecordShouldDelete(@event); + return true; + } +}