From 837e4cec2e3b35bbb664158a509bcfd09f1477bd Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Mon, 6 Jul 2026 19:28:27 -0500 Subject: [PATCH 1/3] Record DaemonMode.ExternallyManaged under Wolverine-managed event subscription distribution (#3290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UseWolverineManagedEventSubscriptionDistribution replaces Marten's own AddAsyncDaemon() coordination outright, but Marten's only knowledge of the daemon state is Projections.AsyncMode. Left at Disabled, DocumentStore wrote a misleading "The async daemon is disabled ... projections will not be executed" console warning at startup even though Wolverine IS running the async projections. MartenOverrides (the IConfigureMarten hook covering the main store and, via MartenOverrides, every ancillary store) now records the real state as DaemonMode.ExternallyManaged (jasperfx#490): identical runtime posture to Disabled — Marten hosts no coordinator and starts no agents — but the warning gate stays quiet because the external host runs the projections. Only upgrades from Disabled, so an explicit user AddAsyncDaemon() choice is never overwritten in either call order. Replaces the closed #3329, whose AsyncMode = HotCold approach was rejected for implying Marten's own coordination runs in parallel. Co-Authored-By: Claude Fable 5 --- ...3290_misleading_daemon_disabled_warning.cs | 145 ++++++++++++++++++ .../Wolverine.Marten/MartenIntegration.cs | 18 +++ 2 files changed, 163 insertions(+) create mode 100644 src/Persistence/MartenTests/Bugs/Bug_3290_misleading_daemon_disabled_warning.cs 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/Wolverine.Marten/MartenIntegration.cs b/src/Persistence/Wolverine.Marten/MartenIntegration.cs index abceea335..e397b4a68 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 From ec2128f5691b392775e36e3cf152308bc4e373b4 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Mon, 6 Jul 2026 19:38:19 -0500 Subject: [PATCH 2/3] Record DaemonMode.ExternallyManaged for Polecat under Wolverine-managed distribution (#3290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polecat parity with the Marten side: UseWolverineManagedEventSubscriptionDistribution replaces Polecat's own AddAsyncDaemon()/AddProjectionCoordinator() hosting outright, but the store's only knowledge of the daemon state is DaemonSettings.AsyncMode, which the integration never set. PolecatOverrides (main store) and PolecatOverrides (ancillary stores, deferring to the main PolecatIntegration flag exactly like the IProjectionCoordinator factory does) now record DaemonMode.ExternallyManaged (jasperfx#490): identical runtime posture to Disabled — nothing Polecat-hosted starts — while any AsyncMode reader sees that async projections DO run under Wolverine's distribution. Only upgrades from Disabled, so an explicit user daemon choice is never overwritten in either call order. Co-Authored-By: Claude Fable 5 --- ...Bug_3290_externally_managed_daemon_mode.cs | 125 ++++++++++++++++++ ...illaryWolverineOptionsPolecatExtensions.cs | 13 ++ .../Wolverine.Polecat/PolecatIntegration.cs | 16 +++ 3 files changed, 154 insertions(+) create mode 100644 src/Persistence/PolecatTests/Bugs/Bug_3290_externally_managed_daemon_mode.cs 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.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; + } } } From cd1b04b92ffeebd6ab8ac843ff9da14c6df3d351 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Mon, 6 Jul 2026 19:52:52 -0500 Subject: [PATCH 3/3] Consume JasperFx 2.21.0 (DaemonMode.ExternallyManaged, jasperfx#490) Co-Authored-By: Claude Fable 5 --- Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index bffc19283..6f393d5bd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -35,8 +35,8 @@ - - + +