diff --git a/src/AndreGoepel.AppFoundation.Hosting/AppFoundationOptions.cs b/src/AndreGoepel.AppFoundation.Hosting/AppFoundationOptions.cs index b7da849..ebaacc0 100644 --- a/src/AndreGoepel.AppFoundation.Hosting/AppFoundationOptions.cs +++ b/src/AndreGoepel.AppFoundation.Hosting/AppFoundationOptions.cs @@ -1,5 +1,6 @@ using AndreGoepel.Marten.Identity.Blazor; using JasperFx; +using JasperFx.Events.Daemon; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.HttpsPolicy; @@ -174,4 +175,24 @@ public sealed class AppFoundationOptions /// /// public IList DefaultRoles { get; } = new List(); + + /// + /// Opt in to Marten's async daemon, which runs async projections and subscriptions + /// in-process via a hosted service. Off by default so existing consumers see no + /// behavior change. A host with async-only projections/subscriptions (or that wants + /// Marten's built-in daemon instead of hand-rolling its own IHostedService + /// around IDocumentStore.BuildProjectionDaemonAsync) sets this to + /// true; see also . + /// + public bool EnableAsyncDaemon { get; set; } + + /// + /// Daemon mode used when is true, forwarded to + /// Marten's AddAsyncDaemon. Defaults to — the + /// right choice for a single running instance of the host; a host that runs multiple + /// instances and wants leader election for the daemon should set this to + /// instead. Has no effect unless + /// is true. + /// + public DaemonMode AsyncDaemonMode { get; set; } = DaemonMode.Solo; } diff --git a/src/AndreGoepel.AppFoundation.Hosting/Initialization.cs b/src/AndreGoepel.AppFoundation.Hosting/Initialization.cs index eb5790f..9f1cc43 100644 --- a/src/AndreGoepel.AppFoundation.Hosting/Initialization.cs +++ b/src/AndreGoepel.AppFoundation.Hosting/Initialization.cs @@ -113,26 +113,33 @@ public static WebApplicationBuilder AddAppFoundation( builder.Services.AddScoped, IdentityEmailSender>(); - builder - .Services.AddMarten(marten => - { - marten.Connection(connectionString); + var martenConfiguration = builder.Services.AddMarten(marten => + { + marten.Connection(connectionString); + + marten.InitializeIdentity(); - marten.InitializeIdentity(); + marten.AutoCreateSchemaObjects = schemaCreation; - marten.AutoCreateSchemaObjects = schemaCreation; + // The alias (and table name) is part of the storage contract — existing key ring rows must + // resolve under the same name on upgrade. + marten + .Schema.For() + .DocumentAlias("dataprotectionkeydocument"); - // The alias (and table name) is part of the storage contract — existing key ring rows must - // resolve under the same name on upgrade. - marten - .Schema.For() - .DocumentAlias("dataprotectionkeydocument"); + // Every admin-configured settings record shares one table; consuming apps register their own + // via AddSettingsDocument(). + marten.AddSettingsDocument(); + }); + + // Off by default (no behavior change for existing consumers); a host opts in via + // AppFoundationOptions.EnableAsyncDaemon when it has async projections/subscriptions to run. + if (options.EnableAsyncDaemon) + { + martenConfiguration.AddAsyncDaemon(options.AsyncDaemonMode); + } - // Every admin-configured settings record shares one table; consuming apps register their own - // via AddSettingsDocument(). - marten.AddSettingsDocument(); - }) - .IntegrateWithWolverine(); + martenConfiguration.IntegrateWithWolverine(); builder.Services.AddMemoryCache(); builder.Services.AddHttpContextAccessor(); diff --git a/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationAsyncDaemonTests.cs b/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationAsyncDaemonTests.cs new file mode 100644 index 0000000..01a9fa5 --- /dev/null +++ b/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationAsyncDaemonTests.cs @@ -0,0 +1,89 @@ +using AndreGoepel.AppFoundation.Hosting; +using JasperFx.Events.Daemon; +using Marten; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace AndreGoepel.AppFoundation.Tests.Hosting; + +public sealed class AddAppFoundationAsyncDaemonTests +{ + [Fact] + public void AddAppFoundation_DefaultOptions_LeavesAsyncDaemonDisabled() + { + // Arrange — EnableAsyncDaemon defaults to false, so existing consumers see no + // behavior change until they opt in (#153). + var builder = CreateBuilder(); + + // Act + builder.AddAppFoundation(); + + // Assert + Assert.Equal(DaemonMode.Disabled, ResolveAsyncMode(builder)); + } + + [Fact] + public void AddAppFoundation_EnableAsyncDaemon_DefaultsToSoloMode() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.AddAppFoundation(options => options.EnableAsyncDaemon = true); + + // Assert + Assert.Equal(DaemonMode.Solo, ResolveAsyncMode(builder)); + } + + [Fact] + public void AddAppFoundation_EnableAsyncDaemonWithExplicitMode_UsesThatMode() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.AddAppFoundation(options => + { + options.EnableAsyncDaemon = true; + options.AsyncDaemonMode = DaemonMode.HotCold; + }); + + // Assert + Assert.Equal(DaemonMode.HotCold, ResolveAsyncMode(builder)); + } + + [Fact] + public void AddAppFoundation_AsyncDaemonModeWithoutEnabling_HasNoEffect() + { + // Arrange — setting the mode alone must not turn the daemon on; EnableAsyncDaemon + // is the single on/off switch. + var builder = CreateBuilder(); + + // Act + builder.AddAppFoundation(options => options.AsyncDaemonMode = DaemonMode.HotCold); + + // Assert + Assert.Equal(DaemonMode.Disabled, ResolveAsyncMode(builder)); + } + + private static DaemonMode ResolveAsyncMode(WebApplicationBuilder builder) + { + using var provider = builder.Services.BuildServiceProvider(); + var store = provider.GetRequiredService(); + return ((StoreOptions)store.Options).Projections.AsyncMode; + } + + private static WebApplicationBuilder CreateBuilder() + { + var builder = WebApplication.CreateBuilder(); + builder.Configuration.AddInMemoryCollection( + new Dictionary + { + ["ConnectionStrings:appfoundation-database"] = + "Host=localhost;Port=5432;Database=test;Username=u;Password=p", + } + ); + return builder; + } +}