diff --git a/.github/workflows/on-push-do-ci-build-pg15-jsonnet-eventsourcing.yml b/.github/workflows/on-push-do-ci-build-pg15-jsonnet-eventsourcing.yml index ee9994d892..b446bab732 100644 --- a/.github/workflows/on-push-do-ci-build-pg15-jsonnet-eventsourcing.yml +++ b/.github/workflows/on-push-do-ci-build-pg15-jsonnet-eventsourcing.yml @@ -83,3 +83,8 @@ jobs: run: ./build.sh test-event-sourcing shell: bash + - name: test-modular-config + if: ${{ success() || failure() }} + run: ./build.sh test-modular-config + shell: bash + diff --git a/.github/workflows/on-push-do-ci-build-pg15-jsonnet.yml b/.github/workflows/on-push-do-ci-build-pg15-jsonnet.yml index 317b2d5a42..17988b77f7 100644 --- a/.github/workflows/on-push-do-ci-build-pg15-jsonnet.yml +++ b/.github/workflows/on-push-do-ci-build-pg15-jsonnet.yml @@ -93,6 +93,11 @@ jobs: run: ./build.sh test-document-db shell: bash + - name: test-modular-config + if: ${{ success() || failure() }} + run: ./build.sh test-modular-config + shell: bash + - name: test-cli if: ${{ success() || failure() }} run: ./build.sh test-cli diff --git a/.github/workflows/on-push-do-ci-build-pgLatest-systemtextjson-eventsourcing.yml b/.github/workflows/on-push-do-ci-build-pgLatest-systemtextjson-eventsourcing.yml index 1793da2103..71e43c84d8 100644 --- a/.github/workflows/on-push-do-ci-build-pgLatest-systemtextjson-eventsourcing.yml +++ b/.github/workflows/on-push-do-ci-build-pgLatest-systemtextjson-eventsourcing.yml @@ -83,3 +83,8 @@ jobs: run: ./build.sh test-event-sourcing shell: bash + - name: test-modular-config + if: ${{ success() || failure() }} + run: ./build.sh test-modular-config + shell: bash + diff --git a/.github/workflows/on-push-do-ci-build-pgLatest-systemtextjson.yml b/.github/workflows/on-push-do-ci-build-pgLatest-systemtextjson.yml index dd148aa70e..a4c07136d5 100644 --- a/.github/workflows/on-push-do-ci-build-pgLatest-systemtextjson.yml +++ b/.github/workflows/on-push-do-ci-build-pgLatest-systemtextjson.yml @@ -93,6 +93,11 @@ jobs: run: ./build.sh test-document-db shell: bash + - name: test-modular-config + if: ${{ success() || failure() }} + run: ./build.sh test-modular-config + shell: bash + - name: test-cli if: ${{ success() || failure() }} run: ./build.sh test-cli diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index fe5ac7fd44..391d9215f4 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -48,12 +48,12 @@ "TestAspnetcore", "TestBaseLib", "TestCli", - "TestCodeGen", "TestCore", "TestDocumentDb", "TestEventSourcing", "TestExtensions", "TestLinq", + "TestModularConfig", "TestMultiTenancy", "TestNodaTime", "TestPatching", diff --git a/build/build.cs b/build/build.cs index 1b0505a9ef..8df695e2c0 100644 --- a/build/build.cs +++ b/build/build.cs @@ -33,6 +33,7 @@ class Build : NukeBuild .DependsOn(TestCore) .DependsOn(TestDocumentDb) .DependsOn(TestEventSourcing) + .DependsOn(TestModularConfig) .DependsOn(TestCli) .DependsOn(TestLinq) .DependsOn(TestMultiTenancy) @@ -183,6 +184,18 @@ class Build : NukeBuild .SetFramework(Framework)); }); + Target TestModularConfig => _ => _ + .ProceedAfterFailure() + .Executes(() => + { + DotNetTest(c => c + .SetProjectFile("src/ModularConfigTests") + .SetConfiguration(Configuration) + .EnableNoBuild() + .EnableNoRestore() + .SetFramework(Framework)); + }); + Target TestLinq => _ => _ .ProceedAfterFailure() .Executes(() => diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index df85f931ae..bcae7a1701 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -136,6 +136,7 @@ const config: UserConfig = { collapsed: true, items: [ { text: 'Bootstrapping Marten', link: '/configuration/hostbuilder' }, + { text: 'Composite Configuration Across Assemblies', link: '/configuration/composite-configuration' }, { text: 'Configuring Document Storage', link: '/configuration/storeoptions' }, { text: 'Json Serialization', link: '/configuration/json' }, { text: 'Resiliency Policies', link: '/configuration/retries' }, diff --git a/docs/configuration/composite-configuration.md b/docs/configuration/composite-configuration.md new file mode 100644 index 0000000000..75045aed21 --- /dev/null +++ b/docs/configuration/composite-configuration.md @@ -0,0 +1,114 @@ +# Composite Configuration Across Satellite Assemblies + +Marten supports a "modular monolith" deployment shape where projection types, event types, and `StoreOptions` tweaks live in **satellite assemblies** owned by individual feature teams, and a main host composes them together via dependency injection. Each satellite contributes its own `IConfigureMarten` (sync) or `IAsyncConfigureMarten` (async) implementation; the main host's `AddMarten(...)` call carries only shared infrastructure (connection string, default serializer, etc.). + +This page documents the contracts that compose those satellites into a single `DocumentStore`. + +## The pattern + +Each satellite assembly: + +1. Carries `[assembly: JasperFx.JasperFxAssembly]` in an `AssemblyInfo.cs` file. +2. Declares its projection classes as `partial`. +3. References `JasperFx.Events.SourceGenerator` as an analyzer-only `PackageReference` so `[GeneratedEvolver]` attributes are emitted at compile time for the satellite's own projection types: + + ```xml + + ``` + +4. Exposes one or more `IConfigureMarten` / `IAsyncConfigureMarten` implementations that register the satellite's projections, event types, or option tweaks. + +The main host: + +```csharp +var builder = Host.CreateApplicationBuilder(); + +// Each satellite's IConfigureMarten gets wired into DI. +builder.Services.AddSingleton(); // SatelliteA +// For IAsyncConfigureMarten, use ConfigureMartenWithServices() so the +// hosted service that drains async configs gets registered. A raw +// AddSingleton() would add the type to DI but +// never invoke its Configure method. +builder.Services.ConfigureMartenWithServices(); // SatelliteB + +// Main host carries only shared infrastructure. +builder.Services.AddMarten(opts => +{ + opts.Connection(connectionString); + opts.DatabaseSchemaName = "modular_monolith"; +}); + +using var host = builder.Build(); +await host.StartAsync(); +``` + +The canonical worked example lives under `src/ModularConfigTests/` in the Marten repo — that's the regression-gate fixture the rest of this page links back to. + +## `[assembly: JasperFxAssembly]` + +The marker isn't required for Marten's `DiscoverGeneratedEvolvers` to find a satellite's `[GeneratedEvolver]` attributes — that scan walks every loaded assembly in `AppDomain.CurrentDomain.GetAssemblies()` regardless. It IS required for other Critter Stack scanning surfaces (`CommandFactory`, extension discovery). Mark every satellite that participates in modular Marten composition with it for forward-compat with those surfaces. + +## Locked-in design contracts + +These four behaviors are pinned by the regression-gate fixture in `src/ModularConfigTests/`. Any change that breaks them surfaces in CI. + +### 1. Registration order = invocation order + +`IEnumerable` is resolved from DI; `Configure` is invoked in DI registration order. If two satellites both write to the same `StoreOptions` scalar property, the **later-registered** call wins. + +```csharp +builder.Services.AddSingleton(new SetNameLength(100)); +builder.Services.AddSingleton(new SetNameLength(250)); +// → final NameDataLength == 250 +``` + +Pin test: `src/ModularConfigTests/OrderingTests.cs`. + +### 2. Last-wins on scalar setter conflicts; idempotent on event-type registration + +Two satellites registering the same scalar `StoreOptions` setter (`NameDataLength`, `DatabaseSchemaName`, etc.) end up with the last-registered value. Two satellites registering the same event type via `options.Events.AddEventType(typeof(SomeEvent))` is **idempotent** — no exception, the event is registered once. + +Projection registration is the exception: two satellites registering the same projection class throws `DuplicateSubscriptionNamesException` at host build. The error message points to the `Name` property to disambiguate; set it explicitly on each satellite's projection class to coexist. + +Pin test: `src/ModularConfigTests/LastWinsTests.cs`. + +### 3. `AddMarten` timing is order-independent + +`IConfigureMarten` registered **after** `services.AddMarten(...)` still applies. The `StoreOptions` factory resolves `IEnumerable` at store-build time from the final DI snapshot — not at `AddMarten` time. Teams can register their satellite contributions in any order relative to the main `AddMarten` call. + +Pin test: `src/ModularConfigTests/AddMartenTimingTests.cs`. + +### 4. `IConfigureMarten` and `IAsyncConfigureMarten` compose + +A host can mix both. Sync contributions apply during the `StoreOptions` factory's resolution (synchronous, on first `IDocumentStore` resolution). Async contributions apply inside the `AsyncConfigureMartenApplication` hosted service, which is inserted ahead of `MartenActivator` in the `IHostedService` chain — so async configs are visible by the time anything else consumes the store. + +The registration APIs are asymmetric: + +| Contract | Sync | Async | +| --- | --- | --- | +| Bare `AddSingleton<...>` works | ✅ | ❌ (hosted service not registered) | +| Extension API | `services.AddSingleton()` or `services.ConfigureMarten(...)` | `services.ConfigureMartenWithServices()` | + +Pin test: `src/ModularConfigTests/AsyncComposeTests.cs`. + +## Required satellite setup checklist + +| Step | Why | +| --- | --- | +| `[assembly: JasperFx.JasperFxAssembly]` in an `AssemblyInfo.cs` | Forward-compat with Critter Stack scanning surfaces | +| Projection classes marked `partial` | Post-#276, the SG-emitted dispatcher merges into the projection class via partial; non-partial silently skips SG emission and the runtime fail-fast at `AssembleAndAssertValidity` throws | +| `JasperFx.Events.SourceGenerator` as analyzer-only `PackageReference` | Marten's own csproj sets `PrivateAssets="all"` on the SG so the analyzer doesn't flow transitively. Each satellite that declares its own projection types needs the analyzer wired locally | +| Satellite ProjectReference'd from the main host (or referenced via type) | `AppDomain.CurrentDomain.GetAssemblies()` only returns LOADED assemblies. A `typeof(SatelliteType)` reference or an `IConfigureMarten` singleton registration is enough to force the load | + +## Out of scope + +* NuGet-package distribution scenarios (satellite as a `.nupkg` consumed by downstream apps) are tracked separately. The contracts above hold for ProjectReference-composed assemblies. +* The order of `IConfigureMarten` execution relative to `IAsyncConfigureMarten` execution is not part of the locked contracts — sync configs apply at store-build time, async configs apply during host start. Don't write code that depends on the relative order. + +## See also + +* The regression fixture: [`src/ModularConfigTests/SmokeTest.cs`](https://github.com/JasperFx/marten/blob/master/src/ModularConfigTests/SmokeTest.cs) (end-to-end) +* The four pin tests: `OrderingTests.cs`, `LastWinsTests.cs`, `AddMartenTimingTests.cs`, `AsyncComposeTests.cs` in the same directory +* [Bootstrapping Marten](./hostbuilder.md) for the basic `AddMarten` shape this page builds on top of diff --git a/src/Marten.slnx b/src/Marten.slnx index fd57e4b841..483a1cdfcc 100644 --- a/src/Marten.slnx +++ b/src/Marten.slnx @@ -60,6 +60,9 @@ + + + diff --git a/src/ModularConfigTests.SatelliteA/AssemblyInfo.cs b/src/ModularConfigTests.SatelliteA/AssemblyInfo.cs new file mode 100644 index 0000000000..a0f9382762 --- /dev/null +++ b/src/ModularConfigTests.SatelliteA/AssemblyInfo.cs @@ -0,0 +1,9 @@ +using JasperFx; + +// Marker so the Critter Stack scans this assembly for command-line extensions +// and other discovery surfaces. Note: Marten's DiscoverGeneratedEvolvers does +// NOT filter by this attribute (it walks AppDomain.CurrentDomain.GetAssemblies()), +// but the chip-prescribed pattern is to mark every satellite that participates +// in modular Marten composition with it for forward-compat with the broader +// Critter Stack scanning model. +[assembly: JasperFxAssembly] diff --git a/src/ModularConfigTests.SatelliteA/ModularConfigTests.SatelliteA.csproj b/src/ModularConfigTests.SatelliteA/ModularConfigTests.SatelliteA.csproj new file mode 100644 index 0000000000..6d764e44ee --- /dev/null +++ b/src/ModularConfigTests.SatelliteA/ModularConfigTests.SatelliteA.csproj @@ -0,0 +1,26 @@ + + + + Satellite assembly for Marten#4472 modular composite configuration tests. Carries [assembly: JasperFxAssembly] and its own IConfigureMarten implementation that registers a partial SingleStreamProjection. Wired with the JasperFx.Events.SourceGenerator analyzer so [GeneratedEvolver] is emitted at compile time for the satellite's own projections. + false + false + false + false + false + false + false + false + false + + + + + + + + + diff --git a/src/ModularConfigTests.SatelliteA/Order.cs b/src/ModularConfigTests.SatelliteA/Order.cs new file mode 100644 index 0000000000..aba9e257e2 --- /dev/null +++ b/src/ModularConfigTests.SatelliteA/Order.cs @@ -0,0 +1,10 @@ +using System; + +namespace ModularConfigTests.SatelliteA; + +public class Order +{ + public Guid Id { get; set; } + public decimal Amount { get; set; } + public bool IsShipped { get; set; } +} diff --git a/src/ModularConfigTests.SatelliteA/OrderEvents.cs b/src/ModularConfigTests.SatelliteA/OrderEvents.cs new file mode 100644 index 0000000000..068abf2b16 --- /dev/null +++ b/src/ModularConfigTests.SatelliteA/OrderEvents.cs @@ -0,0 +1,7 @@ +using System; + +namespace ModularConfigTests.SatelliteA; + +public record OrderPlaced(Guid OrderId, decimal Amount); + +public record OrderShipped(Guid OrderId); diff --git a/src/ModularConfigTests.SatelliteA/OrderProjection.cs b/src/ModularConfigTests.SatelliteA/OrderProjection.cs new file mode 100644 index 0000000000..6ae7aaa852 --- /dev/null +++ b/src/ModularConfigTests.SatelliteA/OrderProjection.cs @@ -0,0 +1,22 @@ +using System; +using Marten.Events.Aggregation; + +namespace ModularConfigTests.SatelliteA; + +// `partial` is required so the JasperFx.Events.SourceGenerator can emit +// the dispatcher's `Evolve` override into this class declaration. Post-#276 +// the SG-emitted [GeneratedEvolver] is the only apply-dispatch path; the +// SG silently skips emission for non-partial classes, after which the +// runtime fail-fast at AssembleAndAssertValidity throws. +public partial class OrderProjection : SingleStreamProjection +{ + public void Apply(OrderPlaced @event, Order snapshot) + { + snapshot.Amount = @event.Amount; + } + + public void Apply(OrderShipped @event, Order snapshot) + { + snapshot.IsShipped = true; + } +} diff --git a/src/ModularConfigTests.SatelliteA/OrdersConfig.cs b/src/ModularConfigTests.SatelliteA/OrdersConfig.cs new file mode 100644 index 0000000000..3b283addab --- /dev/null +++ b/src/ModularConfigTests.SatelliteA/OrdersConfig.cs @@ -0,0 +1,21 @@ +using System; +using JasperFx.Events.Projections; +using Marten; + +namespace ModularConfigTests.SatelliteA; + +/// +/// Satellite-owned that registers this +/// assembly's projection with the host's . +/// Composes via DI: the main host wires this class as a singleton, then +/// Marten's AsyncConfigureMartenApplication calls +/// after AddMarten() and before the store +/// is built. +/// +public class OrdersConfig : IConfigureMarten +{ + public void Configure(IServiceProvider services, StoreOptions options) + { + options.Projections.Add(ProjectionLifecycle.Inline); + } +} diff --git a/src/ModularConfigTests.SatelliteB/AssemblyInfo.cs b/src/ModularConfigTests.SatelliteB/AssemblyInfo.cs new file mode 100644 index 0000000000..5161d767e1 --- /dev/null +++ b/src/ModularConfigTests.SatelliteB/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using JasperFx; + +// See ModularConfigTests.SatelliteA.AssemblyInfo for rationale. +[assembly: JasperFxAssembly] diff --git a/src/ModularConfigTests.SatelliteB/Daily.cs b/src/ModularConfigTests.SatelliteB/Daily.cs new file mode 100644 index 0000000000..aef6b5c077 --- /dev/null +++ b/src/ModularConfigTests.SatelliteB/Daily.cs @@ -0,0 +1,8 @@ +namespace ModularConfigTests.SatelliteB; + +public class Daily +{ + public string Id { get; set; } = ""; + public int OpenCount { get; set; } + public int CloseCount { get; set; } +} diff --git a/src/ModularConfigTests.SatelliteB/DailyEvents.cs b/src/ModularConfigTests.SatelliteB/DailyEvents.cs new file mode 100644 index 0000000000..18014ba547 --- /dev/null +++ b/src/ModularConfigTests.SatelliteB/DailyEvents.cs @@ -0,0 +1,5 @@ +namespace ModularConfigTests.SatelliteB; + +public record DailyOpened(string Day); + +public record DailyClosed(string Day); diff --git a/src/ModularConfigTests.SatelliteB/DailyProjection.cs b/src/ModularConfigTests.SatelliteB/DailyProjection.cs new file mode 100644 index 0000000000..7e9e6e298b --- /dev/null +++ b/src/ModularConfigTests.SatelliteB/DailyProjection.cs @@ -0,0 +1,28 @@ +using Marten.Events.Projections; + +namespace ModularConfigTests.SatelliteB; + +// `partial` required for the JasperFx.Events.SourceGenerator to emit the +// dispatcher partial-class merge. See OrderProjection in SatelliteA for +// the post-#276 SG-only contract. +public partial class DailyProjection : MultiStreamProjection +{ + public DailyProjection() + { + // Multi-stream keyer: both event types route to the Daily whose + // Id matches the event's Day string. Lets events from many source + // streams contribute to one shared daily counter. + Identity(x => x.Day); + Identity(x => x.Day); + } + + public void Apply(DailyOpened @event, Daily snapshot) + { + snapshot.OpenCount++; + } + + public void Apply(DailyClosed @event, Daily snapshot) + { + snapshot.CloseCount++; + } +} diff --git a/src/ModularConfigTests.SatelliteB/ModularConfigTests.SatelliteB.csproj b/src/ModularConfigTests.SatelliteB/ModularConfigTests.SatelliteB.csproj new file mode 100644 index 0000000000..6689f6d8ec --- /dev/null +++ b/src/ModularConfigTests.SatelliteB/ModularConfigTests.SatelliteB.csproj @@ -0,0 +1,23 @@ + + + + Satellite assembly for Marten#4472 modular composite configuration tests. Carries [assembly: JasperFxAssembly] and its own IAsyncConfigureMarten implementation that registers a partial MultiStreamProjection. Wired with the JasperFx.Events.SourceGenerator analyzer so [GeneratedEvolver] is emitted at compile time. + false + false + false + false + false + false + false + false + false + + + + + + + + + diff --git a/src/ModularConfigTests.SatelliteB/ReportingConfig.cs b/src/ModularConfigTests.SatelliteB/ReportingConfig.cs new file mode 100644 index 0000000000..335e5a047c --- /dev/null +++ b/src/ModularConfigTests.SatelliteB/ReportingConfig.cs @@ -0,0 +1,22 @@ +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Events.Projections; +using Marten; + +namespace ModularConfigTests.SatelliteB; + +/// +/// Satellite-owned that registers +/// this assembly's MultiStreamProjection with the host's StoreOptions. +/// Composes with the sync from SatelliteA +/// via DI — the chip's async-compose pin test asserts both contributions +/// appear in the final store options. +/// +public class ReportingConfig : IAsyncConfigureMarten +{ + public ValueTask Configure(StoreOptions options, CancellationToken cancellationToken) + { + options.Projections.Add(ProjectionLifecycle.Inline); + return ValueTask.CompletedTask; + } +} diff --git a/src/ModularConfigTests/AddMartenTimingTests.cs b/src/ModularConfigTests/AddMartenTimingTests.cs new file mode 100644 index 0000000000..7baab9a638 --- /dev/null +++ b/src/ModularConfigTests/AddMartenTimingTests.cs @@ -0,0 +1,56 @@ +using System.Threading.Tasks; +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; + +namespace ModularConfigTests; + +/// +/// Pin test for the AddMarten-timing contract: +/// registered AFTER services.AddMarten(...) in the DI sequence still +/// applies to the built . The contract is +/// order-independent for the AddMarten-vs-IConfigureMarten registration +/// pair — `IEnumerable<IConfigureMarten>` is resolved at store-build +/// time from the final DI snapshot. +/// +public class AddMartenTimingTests +{ + [Fact] + public async Task configure_marten_registered_after_AddMarten_still_applies() + { + var schemaName = ConfigurationFixture.UniqueSchemaName("modular_timing"); + + var builder = Host.CreateApplicationBuilder(); + + // AddMarten FIRST (so the StoreOptions factory is in DI before + // the IConfigureMarten singleton). + ConfigurationFixture.AddBaselineMarten(builder.Services, schemaName); + + // Then register the IConfigureMarten AFTER AddMarten. + builder.Services.AddSingleton(new SetNameLength(NameDataLengthSentinel)); + + using var host = builder.Build(); + await host.StartAsync(); + + try + { + var store = (DocumentStore)host.Services.GetRequiredService(); + store.Options.NameDataLength.ShouldBe(NameDataLengthSentinel); + } + finally + { + await host.StopAsync(); + } + } + + private const int NameDataLengthSentinel = 250; + + private sealed class SetNameLength : IConfigureMarten + { + private readonly int _value; + public SetNameLength(int value) => _value = value; + public void Configure(System.IServiceProvider services, StoreOptions options) + => options.NameDataLength = _value; + } +} diff --git a/src/ModularConfigTests/AsyncComposeTests.cs b/src/ModularConfigTests/AsyncComposeTests.cs new file mode 100644 index 0000000000..8e68e56b0a --- /dev/null +++ b/src/ModularConfigTests/AsyncComposeTests.cs @@ -0,0 +1,65 @@ +using System.Threading; +using System.Threading.Tasks; +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; + +namespace ModularConfigTests; + +/// +/// Pin test for the IConfigureMarten + IAsyncConfigureMarten compose +/// contract: both run when registered, both contributions land on the +/// final . The hosted service that drains +/// IAsyncConfigureMarten (AsyncConfigureMartenApplication) is +/// inserted ahead of MartenActivator in the IHostedService order so +/// async configs apply before the store is consumed. +/// +public class AsyncComposeTests +{ + [Fact] + public async Task sync_and_async_configure_marten_both_run() + { + var schemaName = ConfigurationFixture.UniqueSchemaName("modular_async"); + + var builder = Host.CreateApplicationBuilder(); + + builder.Services.AddSingleton(new SyncContribution()); + // Use the extension method (not raw AddSingleton) + // so AsyncConfigureMartenApplication gets registered too. See #4493. + builder.Services.ConfigureMartenWithServices(); + + ConfigurationFixture.AddBaselineMarten(builder.Services, schemaName); + + using var host = builder.Build(); + await host.StartAsync(); + + try + { + var store = (DocumentStore)host.Services.GetRequiredService(); + // Sync contribution sets NameDataLength to 100; + // async contribution flips DisableNpgsqlLogging to true. + store.Options.NameDataLength.ShouldBe(100); + store.Options.DisableNpgsqlLogging.ShouldBeTrue(); + } + finally + { + await host.StopAsync(); + } + } + + private sealed class SyncContribution : IConfigureMarten + { + public void Configure(System.IServiceProvider services, StoreOptions options) + => options.NameDataLength = 100; + } + + private sealed class AsyncContribution : IAsyncConfigureMarten + { + public ValueTask Configure(StoreOptions options, CancellationToken cancellationToken) + { + options.DisableNpgsqlLogging = true; + return ValueTask.CompletedTask; + } + } +} diff --git a/src/ModularConfigTests/ConfigurationFixture.cs b/src/ModularConfigTests/ConfigurationFixture.cs new file mode 100644 index 0000000000..3c9d2ebb82 --- /dev/null +++ b/src/ModularConfigTests/ConfigurationFixture.cs @@ -0,0 +1,39 @@ +using System; +using JasperFx.CodeGeneration; +using Marten; +using Marten.Testing.Harness; +using Microsoft.Extensions.DependencyInjection; + +namespace ModularConfigTests; + +/// +/// Shared helpers for the modular-config test fixture. Each test calls +/// to register Marten with a unique +/// per-test schema name so the suite can run in parallel without +/// cross-test interference. +/// +internal static class ConfigurationFixture +{ + /// + /// Stable per-call schema name derived from a caller-provided prefix. + /// Postgres identifiers cap at 63 bytes; trim accordingly. + /// + public static string UniqueSchemaName(string prefix) + { + var suffix = Guid.NewGuid().ToString("N").Substring(0, 8); + var candidate = $"{prefix}_{suffix}"; + return candidate.Length > 50 ? candidate.Substring(0, 50) : candidate; + } + + /// + /// Standard AddMarten registration the test harness uses. Takes a + /// per-test schema name so isolated runs don't trip over each other. + /// + public static void AddBaselineMarten(IServiceCollection services, string schemaName) => + services.AddMarten(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = schemaName; + opts.GeneratedCodeMode = TypeLoadMode.Auto; + }); +} diff --git a/src/ModularConfigTests/LastWinsTests.cs b/src/ModularConfigTests/LastWinsTests.cs new file mode 100644 index 0000000000..9036a45243 --- /dev/null +++ b/src/ModularConfigTests/LastWinsTests.cs @@ -0,0 +1,76 @@ +using System.Threading.Tasks; +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; + +namespace ModularConfigTests; + +/// +/// Pin test for the last-wins conflict-resolution contract: two +/// implementations register the same event +/// type (idempotent registration) and overlap on the same scalar +/// property. The host must not throw on the +/// event-type re-registration, and the scalar property must reflect the +/// later config's value (LIFO over the IEnumerable<IConfigureMarten> +/// resolved from DI). +/// +/// This pin guards against a future change that would throw on idempotent +/// duplicate event-type registration — composite-config patterns rely on +/// the idempotency to let two satellites independently declare the same +/// shared event without coordinating. +/// +public class LastWinsTests +{ + [Fact] + public async Task duplicate_event_type_registration_is_idempotent_and_last_scalar_wins() + { + var schemaName = ConfigurationFixture.UniqueSchemaName("modular_lastwins"); + + var builder = Host.CreateApplicationBuilder(); + + // Each contribution registers SatelliteA's OrderPlaced event AND sets + // NameDataLength to its own sentinel value. The first registration's + // event registration is idempotent w.r.t. the second; the scalar + // setter is overwritten. + builder.Services.AddSingleton(new DuplicateContribution(nameLength: 100)); + builder.Services.AddSingleton(new DuplicateContribution(nameLength: 250)); + + ConfigurationFixture.AddBaselineMarten(builder.Services, schemaName); + + using var host = builder.Build(); + await host.StartAsync(); + + try + { + var store = (DocumentStore)host.Services.GetRequiredService(); + // No DuplicateSubscriptionNamesException / NotSupportedException + // on host build: event-type re-registration is idempotent. + // Last contribution's scalar value wins. + // The host built without throwing — that's the dispositive + // evidence for the idempotent-event-registration claim + // (otherwise AddEventType would have thrown on the second + // contribution). + store.ShouldNotBeNull(); + // Last contribution's scalar value wins (LIFO over the DI + // resolution of IEnumerable). + store.Options.NameDataLength.ShouldBe(250); + } + finally + { + await host.StopAsync(); + } + } + + private sealed class DuplicateContribution : IConfigureMarten + { + private readonly int _nameLength; + public DuplicateContribution(int nameLength) => _nameLength = nameLength; + + public void Configure(System.IServiceProvider services, StoreOptions options) + { + options.Events.AddEventType(typeof(SatelliteA.OrderPlaced)); + options.NameDataLength = _nameLength; + } + } +} diff --git a/src/ModularConfigTests/ModularConfigTests.csproj b/src/ModularConfigTests/ModularConfigTests.csproj new file mode 100644 index 0000000000..1f9a77c321 --- /dev/null +++ b/src/ModularConfigTests/ModularConfigTests.csproj @@ -0,0 +1,54 @@ + + + + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + Harness\ConnectionSource.cs + + + + + + + + diff --git a/src/ModularConfigTests/OrderingTests.cs b/src/ModularConfigTests/OrderingTests.cs new file mode 100644 index 0000000000..2dbf946313 --- /dev/null +++ b/src/ModularConfigTests/OrderingTests.cs @@ -0,0 +1,66 @@ +using System.Threading.Tasks; +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; + +namespace ModularConfigTests; + +/// +/// Pin test for the modular-config registration-order contract: when two +/// implementations both write to the same +/// property, the LATER-registered one wins — +/// invocations happen in DI registration order, so the second call +/// overwrites the first. +/// +public class OrderingTests +{ + [Fact] + public async Task last_registered_configure_marten_wins_when_setting_same_property() + { + var schemaName = ConfigurationFixture.UniqueSchemaName("modular_order"); + + var builder = Host.CreateApplicationBuilder(); + + builder.Services.AddSingleton(new SetUserName("first")); + builder.Services.AddSingleton(new SetUserName("second")); + builder.Services.AddSingleton(new SetUserName("third")); + + ConfigurationFixture.AddBaselineMarten(builder.Services, schemaName); + + using var host = builder.Build(); + await host.StartAsync(); + + try + { + var store = (DocumentStore)host.Services.GetRequiredService(); + store.Options.NameDataLength.ShouldBe(SetUserName.GetValue("third")); + } + finally + { + await host.StopAsync(); + } + } + + /// + /// Sets to a per-instance + /// deterministic value so the test can read the final value and + /// identify which IConfigureMarten ran last. + /// + private sealed class SetUserName : IConfigureMarten + { + private readonly string _label; + public SetUserName(string label) => _label = label; + + public void Configure(System.IServiceProvider services, StoreOptions options) + => options.NameDataLength = GetValue(_label); + + public static int GetValue(string label) => label switch + { + "first" => 100, + "second" => 200, + "third" => 300, + _ => 0 + }; + } +} diff --git a/src/ModularConfigTests/SmokeTest.cs b/src/ModularConfigTests/SmokeTest.cs new file mode 100644 index 0000000000..16271de214 --- /dev/null +++ b/src/ModularConfigTests/SmokeTest.cs @@ -0,0 +1,115 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Marten; +using Marten.Events; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ModularConfigTests.SatelliteA; +using ModularConfigTests.SatelliteB; +using Shouldly; + +namespace ModularConfigTests; + +/// +/// Marten#4472 headline smoke test. Composes a real modular host: the main +/// assembly registers each satellite's IConfigureMarten / +/// IAsyncConfigureMarten via DI, AddMarten() runs with no inline projection +/// wiring, and the satellites' contributions land on the StoreOptions +/// before the DocumentStore is built. The test then exercises both +/// satellites' projections end-to-end against the CI Postgres database. +/// +public class SmokeTest +{ + [Fact] + public async Task modular_configuration_with_satellite_assemblies_works_end_to_end() + { + var schemaName = ConfigurationFixture.UniqueSchemaName("modular_smoke"); + + var builder = Host.CreateApplicationBuilder(); + + // Force satellite assemblies to load via type reference. ProjectReference + // puts the .dll in the bin dir, but AppDomain.CurrentDomain.GetAssemblies() + // only returns LOADED assemblies — the typeof() references below + the + // IConfigureMarten / IAsyncConfigureMarten singletons force the load so + // Marten's DiscoverGeneratedEvolvers can see them. + builder.Services.AddSingleton(); + + // IAsyncConfigureMarten implementations only run if the + // AsyncConfigureMartenApplication hosted service is also registered. + // services.ConfigureMartenWithServices() wires both; a bare + // AddSingleton() would add the implementation + // to DI but never invoke it (the hosted service is the only entry + // point that calls IAsyncConfigureMarten.Configure). See #4493. + builder.Services.ConfigureMartenWithServices(); + + ConfigurationFixture.AddBaselineMarten(builder.Services, schemaName); + + using var host = builder.Build(); + await host.StartAsync(); + + try + { + // ASSERTION 1: Both satellites' projections are registered. If + // DiscoverGeneratedEvolvers didn't find them, the post-#276 + // fail-fast (JasperFxAggregationProjectionBase.AssembleAndAssertValidity) + // would have thrown InvalidProjectionException during AddMarten / + // host.Build() — implicit pass when we reach this point. + // Cast to the concrete DocumentStore to reach the writeable + // StoreOptions.Projections (the IDocumentStore-facing + // IReadOnlyStoreOptions doesn't expose the projection graph). + var store = (DocumentStore)host.Services.GetRequiredService(); + var registered = store.Options.Projections.All.Select(p => p.GetType()).ToList(); + registered.ShouldContain(typeof(OrderProjection)); + registered.ShouldContain(typeof(DailyProjection)); + + // ASSERTION 2: End-to-end dispatch through SatelliteA's + // SingleStreamProjection. StartStream + Append two events, + // SaveChanges runs the inline OrderProjection over them, and + // LoadAsync resolves the stored aggregate. + var orderId = Guid.NewGuid(); + await using (var session = store.LightweightSession()) + { + session.Events.StartStream(orderId, new OrderPlaced(orderId, 100m)); + session.Events.Append(orderId, new OrderShipped(orderId)); + await session.SaveChangesAsync(); + } + + await using (var query = store.QuerySession()) + { + var order = await query.LoadAsync(orderId); + order.ShouldNotBeNull(); + order!.Amount.ShouldBe(100m); + order.IsShipped.ShouldBeTrue(); + } + + // ASSERTION 3: End-to-end dispatch through SatelliteB's + // MultiStreamProjection. Two source streams contribute Daily + // events keyed on the same Day, and the projection rolls them + // up into one Daily aggregate. + var day = DateTime.UtcNow.ToString("yyyy-MM-dd"); + var streamA = Guid.NewGuid(); + var streamB = Guid.NewGuid(); + + await using (var session = store.LightweightSession()) + { + session.Events.StartStream(streamA, new DailyOpened(day)); + session.Events.StartStream(streamB, new DailyOpened(day)); + session.Events.Append(streamA, new DailyClosed(day)); + await session.SaveChangesAsync(); + } + + await using (var query = store.QuerySession()) + { + var daily = await query.LoadAsync(day); + daily.ShouldNotBeNull(); + daily!.OpenCount.ShouldBe(2); + daily.CloseCount.ShouldBe(1); + } + } + finally + { + await host.StopAsync(); + } + } +}