diff --git a/Directory.Packages.props b/Directory.Packages.props index f20df00cc..7d0e1a1e4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -35,8 +35,8 @@ - - + + diff --git a/src/Persistence/MartenTests/Bugs/Bug_3290_misleading_daemon_disabled_warning.cs b/src/Persistence/MartenTests/Bugs/Bug_3290_misleading_daemon_disabled_warning.cs new file mode 100644 index 000000000..4c32ec550 --- /dev/null +++ b/src/Persistence/MartenTests/Bugs/Bug_3290_misleading_daemon_disabled_warning.cs @@ -0,0 +1,145 @@ +using IntegrationTests; +using JasperFx.Core.Reflection; +using JasperFx.Events.Daemon; +using JasperFx.Events.Projections; +using Marten; +using MartenTests.Distribution.TripDomain; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.Marten; +using Wolverine.Marten.Distribution; + +namespace MartenTests.Bugs; + +// GH-3290: with UseWolverineManagedEventSubscriptionDistribution = true, Marten's +// DocumentStore wrote a misleading "The async daemon is disabled ... will not be +// executed" console warning at startup even though Wolverine's managed distribution +// does run the async projections. The Marten integration now records the real daemon +// state as DaemonMode.ExternallyManaged (jasperfx#490) — same runtime posture as +// Disabled (no Marten-hosted coordinator, no Marten-side agents), but the warning +// gate stays quiet because the external host runs the projections. +public class Bug_3290_misleading_daemon_disabled_warning : PostgresqlContext +{ + private static IHostBuilder configureHost(bool useWolverineManagedDistribution, + DaemonMode? explicitDaemonMode = null, + bool explicitDaemonBeforeIntegration = false) + { + return Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + var marten = opts.Services.AddMarten(m => + { + m.DisableNpgsqlLogging = true; + m.Connection(Servers.PostgresConnectionString); + m.DatabaseSchemaName = "bug3290"; + + m.Projections.Add(ProjectionLifecycle.Async); + }); + + if (explicitDaemonMode.HasValue && explicitDaemonBeforeIntegration) + { + marten.AddAsyncDaemon(explicitDaemonMode.Value); + } + + marten.IntegrateWithWolverine(m => + { + m.UseWolverineManagedEventSubscriptionDistribution = useWolverineManagedDistribution; + }); + + if (explicitDaemonMode.HasValue && !explicitDaemonBeforeIntegration) + { + marten.AddAsyncDaemon(explicitDaemonMode.Value); + } + }); + } + + // Marten writes the async-daemon warning with Console.WriteLine inside the + // DocumentStore constructor, so capture stdout around the first resolution of + // the store. The MartenTests assembly runs a single collection (no parallel + // test classes), so swapping Console.Out here is safe. + private static (DocumentStore Store, string ConsoleOutput) buildStoreCapturingConsole(IHost host) + { + var original = Console.Out; + var writer = new StringWriter(); + Console.SetOut(writer); + try + { + var store = host.Services.GetRequiredService().As(); + return (store, writer.ToString()); + } + finally + { + Console.SetOut(original); + } + } + + [Fact] + public void managed_distribution_records_externally_managed_and_does_not_warn() + { + using var host = configureHost(useWolverineManagedDistribution: true).Build(); + + var (store, console) = buildStoreCapturingConsole(host); + + // Wolverine's managed distribution replaces Marten's own daemon coordination + // outright, so Marten should see the daemon as externally managed... + store.Options.Projections.AsyncMode.ShouldBe(DaemonMode.ExternallyManaged); + + // ...and must not claim the async projections will not be executed + console.ShouldNotContain("The async daemon is disabled"); + } + + [Fact] + public void managed_distribution_starts_no_marten_hosted_coordinator() + { + using var host = configureHost(useWolverineManagedDistribution: true).Build(); + + buildStoreCapturingConsole(host); + + // The whole point of ExternallyManaged over #3329's HotCold: nothing + // Marten-hosted starts. Marten's own ProjectionCoordinator only ever enters + // the container as an IHostedService via AddAsyncDaemon(), so no registered + // hosted service may be a projection coordinator of any flavor. + host.Services.GetServices() + .OfType() + .ShouldBeEmpty(); + + // Marten's coordinator interface resolves to Wolverine's distribution-backed + // coordinator (a plain singleton, never hosted), not Marten's ProjectionCoordinator + host.Services.GetRequiredService() + .ShouldBeOfType(); + } + + [Fact] + public void without_managed_distribution_the_warning_still_fires() + { + using var host = configureHost(useWolverineManagedDistribution: false).Build(); + + var (store, console) = buildStoreCapturingConsole(host); + + store.Options.Projections.AsyncMode.ShouldBe(DaemonMode.Disabled); + console.ShouldContain("The async daemon is disabled"); + } + + [Fact] + public void explicit_add_async_daemon_choice_after_integration_is_not_overwritten() + { + using var host = configureHost(useWolverineManagedDistribution: true, DaemonMode.Solo).Build(); + + var (store, _) = buildStoreCapturingConsole(host); + + store.Options.Projections.AsyncMode.ShouldBe(DaemonMode.Solo); + } + + [Fact] + public void explicit_add_async_daemon_choice_before_integration_is_not_overwritten() + { + using var host = configureHost(useWolverineManagedDistribution: true, DaemonMode.Solo, + explicitDaemonBeforeIntegration: true).Build(); + + var (store, _) = buildStoreCapturingConsole(host); + + store.Options.Projections.AsyncMode.ShouldBe(DaemonMode.Solo); + } +} diff --git a/src/Persistence/PolecatTests/Bugs/Bug_3290_externally_managed_daemon_mode.cs b/src/Persistence/PolecatTests/Bugs/Bug_3290_externally_managed_daemon_mode.cs new file mode 100644 index 000000000..7112630a2 --- /dev/null +++ b/src/Persistence/PolecatTests/Bugs/Bug_3290_externally_managed_daemon_mode.cs @@ -0,0 +1,125 @@ +using IntegrationTests; +using JasperFx.Core.Reflection; +using JasperFx.Events.Daemon; +using JasperFx.Events.Projections; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Polecat; +using Polecat.Internal; +using PolecatTests.Distribution.TripDomain; +using Shouldly; +using Wolverine; +using Wolverine.Polecat; +using Wolverine.Polecat.Distribution; + +namespace PolecatTests.Bugs; + +// GH-3290 (Polecat parity with the Marten side): with +// UseWolverineManagedEventSubscriptionDistribution = true, Wolverine replaces Polecat's +// own daemon/coordinator hosting outright, but the store's only knowledge of the daemon +// state was DaemonSettings.AsyncMode, which the integration never set. The integration +// now records DaemonMode.ExternallyManaged (jasperfx#490) — the same runtime posture as +// Disabled (nothing Polecat-hosted starts), but any AsyncMode reader sees that the async +// projections DO run under Wolverine's distribution. +public class Bug_3290_externally_managed_daemon_mode +{ + private static IHostBuilder configureHost(bool useWolverineManagedDistribution, + DaemonMode? explicitDaemonMode = null, + bool explicitDaemonBeforeIntegration = false) + { + return Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + var polecat = opts.Services.AddPolecat(m => + { + m.ConnectionString = Servers.SqlServerConnectionString; + m.DatabaseSchemaName = "bug3290"; + + m.Projections.Add(ProjectionLifecycle.Async); + }); + + if (explicitDaemonMode.HasValue && explicitDaemonBeforeIntegration) + { + polecat.AddAsyncDaemon(explicitDaemonMode.Value); + } + + polecat.IntegrateWithWolverine(o => + { + o.UseWolverineManagedEventSubscriptionDistribution = useWolverineManagedDistribution; + }); + + if (explicitDaemonMode.HasValue && !explicitDaemonBeforeIntegration) + { + polecat.AddAsyncDaemon(explicitDaemonMode.Value); + } + }); + } + + private static DocumentStore buildStore(IHost host) + { + return host.Services.GetRequiredService().As(); + } + + [Fact] + public void managed_distribution_records_externally_managed() + { + using var host = configureHost(useWolverineManagedDistribution: true).Build(); + + var store = buildStore(host); + + store.Options.DaemonSettings.AsyncMode.ShouldBe(DaemonMode.ExternallyManaged); + } + + [Fact] + public void managed_distribution_starts_nothing_polecat_hosted() + { + using var host = configureHost(useWolverineManagedDistribution: true).Build(); + + buildStore(host); + + // The same runtime posture as Disabled: Polecat's daemon hosted service and its + // coordinator only ever enter the container as hosted services via the user's + // explicit AddAsyncDaemon()/AddProjectionCoordinator() calls, so no registered + // hosted service may be a daemon runner or a projection coordinator of any flavor. + var hostedServices = host.Services.GetServices().ToArray(); + hostedServices.OfType().ShouldBeEmpty(); + hostedServices.OfType().ShouldBeEmpty(); + hostedServices.OfType().ShouldBeEmpty(); + + // The daemon coordination surface is Wolverine's distribution-backed coordinator, + // registered as a plain singleton — never hosted + host.Services.GetRequiredService() + .ShouldBeOfType(); + } + + [Fact] + public void without_managed_distribution_the_mode_stays_disabled() + { + using var host = configureHost(useWolverineManagedDistribution: false).Build(); + + var store = buildStore(host); + + store.Options.DaemonSettings.AsyncMode.ShouldBe(DaemonMode.Disabled); + } + + [Fact] + public void explicit_add_async_daemon_choice_after_integration_is_not_overwritten() + { + using var host = configureHost(useWolverineManagedDistribution: true, DaemonMode.Solo).Build(); + + var store = buildStore(host); + + store.Options.DaemonSettings.AsyncMode.ShouldBe(DaemonMode.Solo); + } + + [Fact] + public void explicit_add_async_daemon_choice_before_integration_is_not_overwritten() + { + using var host = configureHost(useWolverineManagedDistribution: true, DaemonMode.Solo, + explicitDaemonBeforeIntegration: true).Build(); + + var store = buildStore(host); + + store.Options.DaemonSettings.AsyncMode.ShouldBe(DaemonMode.Solo); + } +} diff --git a/src/Persistence/Wolverine.Marten/MartenIntegration.cs b/src/Persistence/Wolverine.Marten/MartenIntegration.cs index 67c6ee106..4f2f3978b 100644 --- a/src/Persistence/Wolverine.Marten/MartenIntegration.cs +++ b/src/Persistence/Wolverine.Marten/MartenIntegration.cs @@ -2,6 +2,7 @@ using JasperFx.Core; using JasperFx.Core.Reflection; using JasperFx.Events; +using JasperFx.Events.Daemon; using Marten; using Marten.Events; using Marten.Exceptions; @@ -168,6 +169,23 @@ public void Configure(IServiceProvider services, StoreOptions options) { options.Events.MessageOutbox = new MartenToWolverineOutbox(services, StoreType); + // GH-3290: when Wolverine manages the event subscription distribution, it replaces + // Marten's AddAsyncDaemon() registration outright — Marten hosts no coordinator and + // starts no agents, but the async projections and subscriptions DO run under + // Wolverine's distribution. Marten's only knowledge of the daemon state is + // Projections.AsyncMode; left at Disabled, DocumentStore writes a misleading + // "The async daemon is disabled ... projections will not be executed" warning at + // startup. Record the real state: ExternallyManaged keeps Marten's runtime posture + // identical to Disabled (no Marten-side coordination starts) while suppressing the + // warning. Only upgrades from Disabled — an explicit user AddAsyncDaemon() choice + // is never overwritten, regardless of call order relative to IntegrateWithWolverine. + var integration = services.GetService(); + if (integration is { UseWolverineManagedEventSubscriptionDistribution: true } + && options.Projections.AsyncMode == DaemonMode.Disabled) + { + options.Projections.AsyncMode = DaemonMode.ExternallyManaged; + } + // Envelope is Wolverine's operational outbox document. Keep it // single-tenant and unpartitioned regardless of blanket document // policies the user has applied (AllDocumentsAreMultiTenanted or diff --git a/src/Persistence/Wolverine.Polecat/AncillaryWolverineOptionsPolecatExtensions.cs b/src/Persistence/Wolverine.Polecat/AncillaryWolverineOptionsPolecatExtensions.cs index f861da515..9cc7b62a2 100644 --- a/src/Persistence/Wolverine.Polecat/AncillaryWolverineOptionsPolecatExtensions.cs +++ b/src/Persistence/Wolverine.Polecat/AncillaryWolverineOptionsPolecatExtensions.cs @@ -332,5 +332,18 @@ public void Configure(IServiceProvider services, StoreOptions options) // Marten ancillary side. Without this the ancillary store silently drops projection-published // messages. options.Events.MessageOutbox = new PolecatToWolverineOutbox(services); + + // GH-3290: mirror the primary store (PolecatOverrides) — the ancillary managed + // distribution flag lives on the main PolecatIntegration (see the + // IProjectionCoordinator factory registration above), so defer to it here too. + // ExternallyManaged keeps the runtime posture of Disabled (nothing Polecat-hosted + // starts) while recording that the async projections DO run under Wolverine's + // distribution. Only upgrades from Disabled — never overrides an explicit user choice. + var integration = services.GetService(); + if (integration is { UseWolverineManagedEventSubscriptionDistribution: true } + && options.DaemonSettings.AsyncMode == JasperFx.Events.Daemon.DaemonMode.Disabled) + { + options.DaemonSettings.AsyncMode = JasperFx.Events.Daemon.DaemonMode.ExternallyManaged; + } } } diff --git a/src/Persistence/Wolverine.Polecat/PolecatIntegration.cs b/src/Persistence/Wolverine.Polecat/PolecatIntegration.cs index bc5ecaa6a..dfc4f00b5 100644 --- a/src/Persistence/Wolverine.Polecat/PolecatIntegration.cs +++ b/src/Persistence/Wolverine.Polecat/PolecatIntegration.cs @@ -2,6 +2,7 @@ using JasperFx.Core; using JasperFx.Core.Reflection; using JasperFx.Events; +using JasperFx.Events.Daemon; using Polecat; using Microsoft.Extensions.DependencyInjection; using Wolverine.ErrorHandling; @@ -132,6 +133,21 @@ public void Configure(IServiceProvider services, StoreOptions options) // Wolverine after the projection batch's SQL transaction commits. Mirrors // the Marten side at MartenIntegration.cs:153. See wolverine#2774. options.Events.MessageOutbox = new PolecatToWolverineOutbox(services); + + // GH-3290 (Polecat parity with the Marten side): when Wolverine manages the event + // subscription distribution, it replaces Polecat's own daemon/coordinator hosting + // outright, but the store's only knowledge of the daemon state is + // DaemonSettings.AsyncMode. Record the real state: ExternallyManaged keeps the + // store's runtime posture identical to Disabled (nothing Polecat-hosted starts) + // while telling any AsyncMode reader that the async projections DO run. Only + // upgrades from Disabled — an explicit user AddAsyncDaemon()/AddProjectionCoordinator() + // choice is never overwritten, regardless of call order relative to IntegrateWithWolverine. + var integration = services.GetService(); + if (integration is { UseWolverineManagedEventSubscriptionDistribution: true } + && options.DaemonSettings.AsyncMode == DaemonMode.Disabled) + { + options.DaemonSettings.AsyncMode = DaemonMode.ExternallyManaged; + } } }