diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 107150ae4d..9777e3d7d9 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -224,6 +224,7 @@ const config: UserConfig = { { text: 'Rebuilding Projections', link: '/events/projections/rebuilding' }, { text: 'EF Core Projections', link: '/events/projections/efcore' }, { text: 'Projections and IoC Services', link: '/events/projections/ioc' }, + { text: 'Ancillary Stores in Projections', link: '/events/projections/ancillary-stores' }, { text: 'Async Daemon HealthChecks', link: '/events/projections/healthchecks' },] }, { diff --git a/docs/events/projections/ancillary-stores.md b/docs/events/projections/ancillary-stores.md new file mode 100644 index 0000000000..f7c209b1ae --- /dev/null +++ b/docs/events/projections/ancillary-stores.md @@ -0,0 +1,125 @@ +# Using Ancillary Stores in Projections + +## The Problem + +When building systems with multiple Marten stores (using `AddMartenStore()`), it's common to +need projections in one store that reference data from another. For example, a billing projection +in your primary store might need to look up tariff data from a separate `ITarievenStore`. + +The natural approach — constructor injection — **does not work reliably** and can cause your +application to freeze at startup: + +```csharp +// DO NOT DO THIS - can cause startup deadlock +public class InvoiceProjection( + ITarievenStore tarievenStore +) : SingleStreamProjection +{ + // ... +} +``` + +### Why It Freezes + +When you register a projection with `AddProjectionWithServices()`, Marten resolves the +projection instance during store construction. If the projection's constructor depends on an +ancillary `IDocumentStore`, the DI container may attempt to resolve that store while the primary +store is still being built — creating a circular dependency that deadlocks silently. + +## Solution: Inject `Lazy` + +Starting in Marten 8.x, `AddMartenStore()` automatically registers `Lazy` in the DI +container alongside the store itself. This lets you inject a lazy reference that defers +resolution until the store is actually needed — safely past the startup phase: + +```csharp +public interface ITarievenStore : IDocumentStore; + +public class InvoiceProjection : SingleStreamProjection +{ + private readonly Lazy _tarievenStore; + + public InvoiceProjection(Lazy tarievenStore) + { + _tarievenStore = tarievenStore; + } + + public override async Task EnrichEventsAsync( + SliceGroup group, + IQuerySession querySession, + CancellationToken cancellation) + { + // Safe - the store is fully constructed by the time + // EnrichEventsAsync runs + await using var session = _tarievenStore.Value.QuerySession(); + + var ids = group.Slices + .SelectMany(s => s.Events().OfType>()) + .Select(e => e.Data.TariefId) + .Distinct().ToArray(); + + var tarieven = await session.LoadManyAsync(cancellation, ids); + + foreach (var slice in group.Slices) + { + foreach (var e in slice.Events().OfType>()) + { + if (tarieven.TryGetValue(e.Data.TariefId, out var tarief)) + { + e.Data.ResolvedPrice = tarief.Price; + } + } + } + } +} +``` + +Register the projection using `AddProjectionWithServices()` with a **scoped** lifetime +to ensure it's resolved per-batch rather than during store construction: + +```csharp +services.AddMarten(opts => +{ + opts.Connection("primary connection string"); +}) +.AddProjectionWithServices( + ProjectionLifecycle.Async, + ServiceLifetime.Scoped); + +services.AddMartenStore(opts => +{ + opts.Connection("tarieven connection string"); +}); +``` + +### Why `Lazy` Works + +The `Lazy` wrapper is constructed immediately (it's just a thin wrapper), but the inner +`IDocumentStore` isn't resolved until `.Value` is accessed. By the time your projection's +`Apply`, `Create`, or `EnrichEventsAsync` methods execute, all stores are fully constructed +and the lazy resolution succeeds without deadlock. + +### Multiple Ancillary Stores + +You can inject multiple lazy store references: + +```csharp +public class CrossStoreProjection : SingleStreamProjection +{ + private readonly Lazy _tarieven; + private readonly Lazy _debtors; + + public CrossStoreProjection( + Lazy tarieven, + Lazy debtors) + { + _tarieven = tarieven; + _debtors = debtors; + } + + // Use _tarieven.Value and _debtors.Value in your projection methods +} +``` + +Each `AddMartenStore()` call automatically registers its own `Lazy`, so no +additional configuration is needed. diff --git a/src/CoreTests/lazy_ancillary_store_registration.cs b/src/CoreTests/lazy_ancillary_store_registration.cs new file mode 100644 index 0000000000..8e5ddc5b0a --- /dev/null +++ b/src/CoreTests/lazy_ancillary_store_registration.cs @@ -0,0 +1,92 @@ +using System; +using System.Threading.Tasks; +using Marten; +using Marten.Testing.Harness; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Xunit; + +namespace CoreTests; + +public interface ILazyTestStore : IDocumentStore; + +public class lazy_ancillary_store_registration +{ + [Fact] + public async Task lazy_of_ancillary_store_is_registered_as_singleton() + { + using var host = await Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddMarten(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = "lazy_test_primary"; + }); + + services.AddMartenStore(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = "lazy_test_ancillary"; + }); + }) + .StartAsync(); + + // Lazy should be resolvable + var lazy = host.Services.GetService>(); + lazy.ShouldNotBeNull(); + + // Should not be created yet + lazy.IsValueCreated.ShouldBeFalse(); + + // Resolving the value should return the same instance as direct resolution + var fromLazy = lazy.Value; + var direct = host.Services.GetRequiredService(); + + fromLazy.ShouldBeSameAs(direct); + } + + [Fact] + public async Task lazy_of_ancillary_store_can_be_injected_into_a_service() + { + using var host = await Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddMarten(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = "lazy_test2_primary"; + }); + + services.AddMartenStore(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = "lazy_test2_ancillary"; + }); + + services.AddSingleton(); + }) + .StartAsync(); + + var service = host.Services.GetRequiredService(); + service.ShouldNotBeNull(); + + // The store should be accessible through the lazy wrapper + var store = service.GetStore(); + store.ShouldNotBeNull(); + store.ShouldBeAssignableTo(); + } +} + +public class ServiceThatUsesLazyStore +{ + private readonly Lazy _store; + + public ServiceThatUsesLazyStore(Lazy store) + { + _store = store; + } + + public IDocumentStore GetStore() => _store.Value; +} diff --git a/src/EventSourcingTests/Bugs/Bug_4246_integer_out_of_range_in_quick_append_function.cs b/src/EventSourcingTests/Bugs/Bug_4246_integer_out_of_range_in_quick_append_function.cs index d2e1cca9af..ebdcd6a851 100644 --- a/src/EventSourcingTests/Bugs/Bug_4246_integer_out_of_range_in_quick_append_function.cs +++ b/src/EventSourcingTests/Bugs/Bug_4246_integer_out_of_range_in_quick_append_function.cs @@ -15,6 +15,7 @@ public Bug_4246_integer_out_of_range_in_quick_append_function() StoreOptions(opts => { opts.Events.AppendMode = EventAppendMode.Quick; + opts.Events.EnableBigIntEvents = true; opts.Connection(ConnectionSource.ConnectionString); }); } diff --git a/src/Marten/MartenServiceCollectionExtensions.cs b/src/Marten/MartenServiceCollectionExtensions.cs index c71e3185a3..17df4bd16f 100644 --- a/src/Marten/MartenServiceCollectionExtensions.cs +++ b/src/Marten/MartenServiceCollectionExtensions.cs @@ -313,6 +313,7 @@ public static MartenStoreExpression AddMartenStore(this IServiceCollection stores.Add(config); services.AddSingleton(s => config.Build(s)); + services.AddSingleton>(s => new Lazy(() => s.GetRequiredService())); // Default keyed session factory for the ancillary store services.AddKeyedSingleton(typeof(T), (sp, _) =>