From de29ce29a8c7dea66414b2072beb8d5fca6d2bf5 Mon Sep 17 00:00:00 2001 From: Brian Tyler Date: Fri, 24 Apr 2026 20:45:21 +0100 Subject: [PATCH 1/5] feat: update dashboard options and tests for prerendering behavior --- Directory.Packages.props | 4 ++-- .../appsettings.Development.json | 1 + .../Components/DashboardApp.razor | 10 +++++++++- .../ShedduellerDashboardOptions.cs | 8 ++++++++ .../DashboardEndpointTests.cs | 15 +++++++++++++-- 5 files changed, 33 insertions(+), 5 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0ee3a5c..2a77d19 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,7 +10,7 @@ - + @@ -19,4 +19,4 @@ - \ No newline at end of file + diff --git a/samples/Sheddueller.SampleHost/appsettings.Development.json b/samples/Sheddueller.SampleHost/appsettings.Development.json index 34f00ef..c39a972 100644 --- a/samples/Sheddueller.SampleHost/appsettings.Development.json +++ b/samples/Sheddueller.SampleHost/appsettings.Development.json @@ -1,4 +1,5 @@ { + "DetailedErrors": true, "Logging": { "LogLevel": { "Default": "Debug", diff --git a/src/Sheddueller.Dashboard/Components/DashboardApp.razor b/src/Sheddueller.Dashboard/Components/DashboardApp.razor index 17f11a6..1175756 100644 --- a/src/Sheddueller.Dashboard/Components/DashboardApp.razor +++ b/src/Sheddueller.Dashboard/Components/DashboardApp.razor @@ -1,4 +1,5 @@ @inject NavigationManager Navigation +@inject Microsoft.Extensions.Options.IOptions DashboardOptions @@ -13,7 +14,14 @@ - + + +@code { + private IComponentRenderMode DashboardRenderMode + => DashboardOptions.Value.Prerender + ? InteractiveServer + : new InteractiveServerRenderMode(prerender: false); +} diff --git a/src/Sheddueller.Dashboard/ShedduellerDashboardOptions.cs b/src/Sheddueller.Dashboard/ShedduellerDashboardOptions.cs index b514e92..6e81e33 100644 --- a/src/Sheddueller.Dashboard/ShedduellerDashboardOptions.cs +++ b/src/Sheddueller.Dashboard/ShedduellerDashboardOptions.cs @@ -5,6 +5,14 @@ namespace Sheddueller.Dashboard; /// public sealed class ShedduellerDashboardOptions { + /// + /// Gets or sets whether dashboard routes are prerendered into the initial HTTP response. + /// Defaults to . + /// Prerendering can conflict with certain browser extensions, such as React Developer Tools, + /// and may cause the dashboard to fail to load. + /// + public bool Prerender { get; set; } + /// /// Gets or sets how long job events are retained after their owning job reaches a terminal state. /// diff --git a/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs b/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs index b715f96..f5c0255 100644 --- a/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs +++ b/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs @@ -34,6 +34,17 @@ public async Task MapShedduellerDashboard_ApplicationBranch_RedirectsToCanonical response.Headers.Location?.OriginalString.ShouldBe("/sheddueller/"); } + [Fact] + public async Task MapShedduellerDashboard_DefaultOptions_DoesNotPrerenderRouteContent() + { + await using var app = await CreateStartedDashboardAsync(prerender: false); + var html = await GetOkHtmlAsync(app, "/sheddueller/"); + + html.ShouldContain("base href=\"http://localhost/sheddueller/\""); + html.ShouldContain("_framework/blazor.web.js"); + html.ShouldNotContain("Operational Control"); + } + [Fact] public async Task Overview_KnownData_RendersOperationalSummary() { @@ -351,7 +362,7 @@ public async Task JobDetail_MissingJob_RendersNotFoundWithDisabledCancelAction() disabled: true); } - private static async Task CreateStartedDashboardAsync() + private static async Task CreateStartedDashboardAsync(bool prerender = true) { var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); @@ -363,7 +374,7 @@ private static async Task CreateStartedDashboardAsync() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddShedduellerDashboard(); + builder.Services.AddShedduellerDashboard(options => options.Prerender = prerender); var app = builder.Build(); ((IApplicationBuilder)app).MapShedduellerDashboard("/sheddueller"); From 4887aa18b7c73985a45f8f6a244e4fb332a20d06 Mon Sep 17 00:00:00 2001 From: Brian Tyler Date: Fri, 24 Apr 2026 21:27:18 +0100 Subject: [PATCH 2/5] Refactor Sheddueller PostgreSQL integration and dashboard mapping - Introduced ShedduellerPostgresDataSourceOptions for configuring PostgreSQL data sources. - Added UsePostgres overloads to simplify PostgreSQL registration without manual data source management. - Implemented ApplyShedduellerPostgresMigrationsAsync for explicit migration handling. - Updated dashboard mapping to allow minimal hosting without casting to IApplicationBuilder. - Enhanced ShedduellerBuilder to support service provider-aware options configuration. - Removed direct Npgsql dependency from sample host and tests, relying on DI for data source management. - Improved tests to validate new registration and migration behaviors. - Updated README and documentation to reflect new usage patterns and best practices. --- README.md | 41 +-- docs/0.1-preview2.md | 255 ++++++++++++++++++ samples/Sheddueller.SampleHost/Program.cs | 29 +- .../Sheddueller.SampleHost.csproj | 4 - ...DashboardEndpointRouteBuilderExtensions.cs | 16 ++ .../Internal/OwnedPostgresDataSource.cs | 18 ++ .../Sheddueller.Postgres.csproj | 1 + .../ShedduellerPostgresBuilderExtensions.cs | 120 ++++++++- .../ShedduellerPostgresDataSourceOptions.cs | 23 ++ .../ShedduellerPostgresMigrationExtensions.cs | 42 +++ src/Sheddueller/ShedduellerBuilder.cs | 14 + .../DashboardEndpointTests.cs | 28 +- .../PostgresFixture.cs | 5 +- .../PostgresMigrationHelperTests.cs | 90 +++++++ .../PostgresRegistrationTests.cs | 110 ++++++++ test/Sheddueller.Tests/RegistrationTests.cs | 17 ++ 16 files changed, 762 insertions(+), 51 deletions(-) create mode 100644 docs/0.1-preview2.md create mode 100644 src/Sheddueller.Postgres/Internal/OwnedPostgresDataSource.cs create mode 100644 src/Sheddueller.Postgres/ShedduellerPostgresDataSourceOptions.cs create mode 100644 src/Sheddueller.Postgres/ShedduellerPostgresMigrationExtensions.cs create mode 100644 test/Sheddueller.Postgres.Tests/PostgresMigrationHelperTests.cs create mode 100644 test/Sheddueller.Postgres.Tests/PostgresRegistrationTests.cs diff --git a/README.md b/README.md index 4fb2f26..8455f2d 100644 --- a/README.md +++ b/README.md @@ -69,28 +69,37 @@ dotnet add package Sheddueller.Testing Register `AddSheddueller(...)` in processes that only submit work or manage schedules. Register `AddShedduellerWorker(...)` in processes that should also execute jobs. +`WorkerOptions` below is an application options type; the callback can read any service registered with DI. + ```csharp -using Npgsql; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Sheddueller; using Sheddueller.Postgres; -var dataSource = NpgsqlDataSource.Create( - builder.Configuration.GetConnectionString("Sheddueller") - ?? throw new InvalidOperationException("Missing Sheddueller connection string.")); - -builder.Services.AddSingleton(dataSource); +builder.Services.Configure( + builder.Configuration.GetSection("Worker")); builder.Services.AddTransient(); builder.Services.AddShedduellerWorker(sheddueller => sheddueller - .UsePostgres(postgres => - { - postgres.DataSource = dataSource; - postgres.SchemaName = "sheddueller"; - }) - .ConfigureOptions(options => + .UsePostgres( + serviceProvider => + { + var configuration = serviceProvider.GetRequiredService(); + return configuration.GetConnectionString("Sheddueller") + ?? throw new InvalidOperationException("Connection string 'ConnectionStrings:Sheddueller' is required."); + }, + (serviceProvider, postgres) => + { + var configuration = serviceProvider.GetRequiredService(); + postgres.SchemaName = configuration["Sheddueller:Postgres:SchemaName"] ?? "sheddueller"; + }) + .ConfigureOptions((serviceProvider, options) => { + var worker = serviceProvider.GetRequiredService>().Value; options.NodeId = Environment.MachineName; - options.MaxConcurrentExecutionsPerNode = 8; + options.MaxConcurrentExecutionsPerNode = worker.MaxConcurrentExecutions; options.DefaultRetryPolicy = new RetryPolicy( MaxAttempts: 3, BackoffKind: RetryBackoffKind.Exponential, @@ -102,11 +111,13 @@ builder.Services.AddShedduellerWorker(sheddueller => sheddueller Schema migrations are explicit: ```csharp -await app.Services.GetRequiredService().ApplyAsync(); +await app.ApplyShedduellerPostgresMigrationsAsync(); ``` Run migrations during deployment or before starting workers against a new schema. Normal startup validates the configured provider; it does not silently create the schema. +Use `UsePostgres(postgres => postgres.DataSource = dataSource)` when an application needs to share or own a prebuilt `NpgsqlDataSource`; in that mode, the application also owns disposal. + ## Enqueue Jobs Job methods return `Task` or `ValueTask` and receive the scheduler-owned `CancellationToken`. Use `Job.Context` when a handler needs durable logs, progress events, the job id, or the attempt number. @@ -164,7 +175,7 @@ builder.Services.AddShedduellerDashboard(options => }); app.UseAntiforgery(); -((IApplicationBuilder)app).MapShedduellerDashboard("/sheddueller"); +app.MapShedduellerDashboard("/sheddueller"); ``` The dashboard uses the configured Sheddueller provider and can be hosted by a worker process or a client-only web process. diff --git a/docs/0.1-preview2.md b/docs/0.1-preview2.md new file mode 100644 index 0000000..add8d29 --- /dev/null +++ b/docs/0.1-preview2.md @@ -0,0 +1,255 @@ +# Sheddueller 0.1.0-preview2 API Cleanup Plan + +## Summary + +The legacy Hangfire replacement trial showed that `0.1.0-preview1` is viable, but it exposed a handful of integration rough edges in the public API. `0.1.0-preview2` should keep the current runtime model and storage design, while making the common hosting path cleaner and reducing workarounds in consuming applications. + +This release is an API cleanup release. It should not introduce workflows, job dependencies, automatic migrations, or a new storage architecture. + +Primary goals: + +- Let applications configure PostgreSQL without manually creating and holding an `NpgsqlDataSource` variable in `Program.cs`. +- Keep migrations explicit, but make the explicit migration call less repetitive. +- Let minimal-hosting applications map the dashboard without casting to `IApplicationBuilder`. +- Update tests, README examples, and package docs so these are the preferred integration paths. + +## Current Pain Points From The Legacy Trial + +### Data Source Lifetime + +The legacy app currently needs a top-level variable like: + +```csharp +await using var shedduellerDataSource = + NpgsqlDataSource.Create(shedduellerOptions.PostgreSqlConnectionString); +``` + +That works because the variable lives until `app.RunAsync()` returns, but it is awkward application code. The app is forced to own provider infrastructure only because the provider API requires an already-created `NpgsqlDataSource`. + +### Early Configuration Reads + +The current provider setup pushes consumers toward reading configuration before the service provider is built: + +```csharp +var shedduellerOptions = builder.Configuration + .GetSection(nameof(LegacyShedduellerOptions)) + .Get() + ?? throw new InvalidOperationException("Legacy Sheddueller options are not configured."); +``` + +That duplicates the host options system and makes Sheddueller setup feel separate from the rest of the application. + +### Dashboard Mapping Cast + +Minimal-hosting apps currently need: + +```csharp +((IApplicationBuilder)app).MapShedduellerDashboard("/sheddueller"); +``` + +The cast is a public API smell. The package should make the common hosting shape natural. + +## Public API Changes + +### PostgreSQL Registration And Lifetime + +Add Sheddueller-owned data source overloads in `Sheddueller.Postgres`: + +```csharp +public static ShedduellerBuilder UsePostgres( + this ShedduellerBuilder builder, + string connectionString, + Action? configure = null); + +public static ShedduellerBuilder UsePostgres( + this ShedduellerBuilder builder, + Func connectionStringFactory, + Action? configure = null); +``` + +Add: + +```csharp +public sealed class ShedduellerPostgresDataSourceOptions +{ + public string SchemaName { get; set; } = "sheddueller"; + + public Action? ConfigureDataSourceBuilder { get; set; } +} +``` + +Behavior: + +- The connection-string overloads create an `NpgsqlDataSource` using `NpgsqlDataSourceBuilder`. +- The created data source is owned and disposed by DI. +- The existing `UsePostgres(options => options.DataSource = dataSource)` overload remains supported for caller-owned data sources. +- Documentation must clearly distinguish provider-owned and caller-owned data source lifetime. +- The core provider internals can continue to consume `ShedduellerPostgresOptions.DataSource`; the new overloads can adapt into that existing options shape. + +Preferred consuming shape: + +```csharp +builder.Services.AddShedduellerWorker(sheddueller => sheddueller + .UsePostgres( + sp => sp.GetRequiredService>().Value.PostgreSqlConnectionString, + (sp, postgres) => + { + postgres.SchemaName = sp.GetRequiredService>().Value.SchemaName; + })); +``` + +### Service-Provider-Aware Sheddueller Options + +Add: + +```csharp +public ShedduellerBuilder ConfigureOptions( + Action configure); +``` + +Behavior: + +- The overload registers through the normal options system. +- The callback runs with access to the final service provider. +- Existing `ConfigureOptions(Action)` remains unchanged. + +Preferred consuming shape: + +```csharp +builder.Services.AddShedduellerWorker(sheddueller => sheddueller + .ConfigureOptions((sp, options) => + { + var legacy = sp.GetRequiredService>().Value; + options.NodeId = Environment.MachineName; + options.MaxConcurrentExecutionsPerNode = legacy.WorkerCount ?? Environment.ProcessorCount; + })); +``` + +### Explicit Migration Helpers + +Add extension methods in `Sheddueller.Postgres`: + +```csharp +public static ValueTask ApplyShedduellerPostgresMigrationsAsync( + this IHost host, + CancellationToken cancellationToken = default); + +public static ValueTask ApplyShedduellerPostgresMigrationsAsync( + this IServiceProvider services, + CancellationToken cancellationToken = default); +``` + +Behavior: + +- The helper creates a scope, resolves `IPostgresMigrator`, and calls `ApplyAsync`. +- It must not be invoked automatically by `AddSheddueller`, `AddShedduellerWorker`, `UsePostgres`, or dashboard registration. +- Normal startup validation remains fail-only when the schema is missing or out of date. +- Applications still decide whether deployment, startup, or another process applies migrations. + +Preferred consuming shape: + +```csharp +if (deployInProcess) +{ + await app.ApplyShedduellerPostgresMigrationsAsync(); +} +``` + +### Dashboard Mapping + +Add a minimal-hosting overload in `Sheddueller.Dashboard`: + +```csharp +public static WebApplication MapShedduellerDashboard( + this WebApplication app, + string path = "/sheddueller"); +``` + +Behavior: + +- The overload delegates to the existing branch-based implementation so static asset, SignalR, antiforgery, and redirect behavior remain unchanged. +- It returns the same `WebApplication` for chaining. +- Existing `IApplicationBuilder` and `IEndpointRouteBuilder` overloads remain available. + +Preferred consuming shape: + +```csharp +app.MapShedduellerDashboard("/sheddueller"); +``` + +## Test Plan + +### Core Tests + +- `ConfigureOptions((sp, options) => ...)` can read a service from DI and configure `ShedduellerOptions`. +- Existing `ConfigureOptions(options => ...)` behavior remains unchanged. + +### PostgreSQL Registration Tests + +- `UsePostgres(string)` registers all PostgreSQL provider services. +- `UsePostgres(string)` creates a DI-owned data source and disposes it when the provider is disposed. +- `UsePostgres(Func)` can read options from DI. +- `ShedduellerPostgresDataSourceOptions.SchemaName` is honored. +- `ConfigureDataSourceBuilder` is invoked. +- Existing caller-owned `UsePostgres(options => options.DataSource = dataSource)` remains supported. + +### Migration Helper Tests + +- `host.ApplyShedduellerPostgresMigrationsAsync()` resolves `IPostgresMigrator` through a scope and applies migrations. +- `services.ApplyShedduellerPostgresMigrationsAsync()` behaves the same. +- No migration helper is called automatically during startup registration. + +### Dashboard Tests + +- `app.MapShedduellerDashboard("/sheddueller")` compiles and serves the same dashboard routes as the cast-based branch mapping. +- Existing `IApplicationBuilder` dashboard route tests continue to pass. + +### Validation Commands + +Run: + +```bash +dotnet build Sheddueller.slnx -c Debug +dotnet test --project test/Sheddueller.Tests/Sheddueller.Tests.csproj -c Debug +dotnet test --project test/Sheddueller.Testing.Tests/Sheddueller.Testing.Tests.csproj -c Debug +dotnet test --project test/Sheddueller.Dashboard.Tests/Sheddueller.Dashboard.Tests.csproj -c Debug +dotnet test --project test/Sheddueller.Postgres.Tests/Sheddueller.Postgres.Tests.csproj -c Debug +dotnet test --project test/Sheddueller.Worker.Tests/Sheddueller.Worker.Tests.csproj -c Debug +dotnet pack src/Sheddueller/Sheddueller.csproj -c Release --no-build +dotnet pack src/Sheddueller.Postgres/Sheddueller.Postgres.csproj -c Release --no-build +dotnet pack src/Sheddueller.Worker/Sheddueller.Worker.csproj -c Release --no-build +dotnet pack src/Sheddueller.Dashboard/Sheddueller.Dashboard.csproj -c Release --no-build +dotnet pack src/Sheddueller.Testing/Sheddueller.Testing.csproj -c Release --no-build +``` + +Adjust pack commands if the release pipeline packs at solution level. + +## README And Documentation Updates + +Update `README.md`: + +- Replace manual `NpgsqlDataSource.Create(...)` setup with the new `UsePostgres(connectionString...)` overload. +- Show `ConfigureOptions((sp, options) => ...)` for app-option-driven worker count. +- Replace direct `IPostgresMigrator` scope examples with `ApplyShedduellerPostgresMigrationsAsync`. +- Replace `((IApplicationBuilder)app).MapShedduellerDashboard(...)` with `app.MapShedduellerDashboard(...)`. +- Add a short note that caller-owned `NpgsqlDataSource` is still available for advanced scenarios. + +## Acceptance Criteria + +- A consuming app can configure PostgreSQL without a top-level `NpgsqlDataSource` variable. +- A consuming app can configure Sheddueller runtime options from DI-resolved app options. +- A consuming app can call `app.MapShedduellerDashboard("/sheddueller")` directly. +- Migration execution remains explicit but no longer requires repeated scope boilerplate. +- The legacy replacement can remove: + - `await using var shedduellerDataSource = ...`, + - the dashboard cast, + - duplicated migration helper methods. +- Existing package boundaries remain intact: `Sheddueller.Dashboard` and `Sheddueller.Postgres` remain sibling packages and do not reference each other. + +## Explicit Non-Goals + +- Do not add job dependencies or workflows. +- Do not add a manual recurring schedule trigger API in this preview. +- Do not add automatic migrations during normal startup. +- Do not replace `NpgsqlDataSource` as the PostgreSQL provider primitive. +- Do not change the provider's correctness model: PostgreSQL time remains authoritative, `LISTEN/NOTIFY` remains an optimization, polling remains the correctness fallback, and claim selection continues to use row locking with `SKIP LOCKED`. diff --git a/samples/Sheddueller.SampleHost/Program.cs b/samples/Sheddueller.SampleHost/Program.cs index 9687f39..f30c2eb 100644 --- a/samples/Sheddueller.SampleHost/Program.cs +++ b/samples/Sheddueller.SampleHost/Program.cs @@ -1,5 +1,3 @@ -using Npgsql; - using Sheddueller; using Sheddueller.Inspection.Jobs; using Sheddueller.Postgres; @@ -9,21 +7,22 @@ var builder = WebApplication.CreateBuilder(args); -var connectionString = builder.Configuration.GetConnectionString("Sheddueller") - ?? throw new InvalidOperationException("Connection string 'ConnectionStrings:Sheddueller' is required."); -var schemaName = builder.Configuration["Sheddueller:Postgres:SchemaName"] ?? "sheddueller"; -await using var dataSource = NpgsqlDataSource.Create(connectionString); - -builder.Services.AddSingleton(dataSource); builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddShedduellerWorker(sheddueller => sheddueller - .UsePostgres(options => - { - options.DataSource = dataSource; - options.SchemaName = schemaName; - }) + .UsePostgres( + serviceProvider => + { + var configuration = serviceProvider.GetRequiredService(); + return configuration.GetConnectionString("Sheddueller") + ?? throw new InvalidOperationException("Connection string 'ConnectionStrings:Sheddueller' is required."); + }, + (serviceProvider, options) => + { + var configuration = serviceProvider.GetRequiredService(); + options.SchemaName = configuration["Sheddueller:Postgres:SchemaName"] ?? "sheddueller"; + }) .ConfigureOptions(options => { options.NodeId = "sample-host"; @@ -37,7 +36,7 @@ var app = builder.Build(); -await app.Services.GetRequiredService().ApplyAsync(); +await app.ApplyShedduellerPostgresMigrationsAsync(); app.UseAntiforgery(); @@ -170,7 +169,7 @@ return RedirectWithMessage($"Queued cancelable delayed job {jobId:D} for {notBeforeUtc:O}."); }); -((IApplicationBuilder)app).MapShedduellerDashboard("/sheddueller"); +app.MapShedduellerDashboard("/sheddueller"); await app.RunAsync(); diff --git a/samples/Sheddueller.SampleHost/Sheddueller.SampleHost.csproj b/samples/Sheddueller.SampleHost/Sheddueller.SampleHost.csproj index 20b8cb6..1955927 100644 --- a/samples/Sheddueller.SampleHost/Sheddueller.SampleHost.csproj +++ b/samples/Sheddueller.SampleHost/Sheddueller.SampleHost.csproj @@ -10,8 +10,4 @@ - - - - diff --git a/src/Sheddueller.Dashboard/ShedduellerDashboardEndpointRouteBuilderExtensions.cs b/src/Sheddueller.Dashboard/ShedduellerDashboardEndpointRouteBuilderExtensions.cs index 335d630..45c2140 100644 --- a/src/Sheddueller.Dashboard/ShedduellerDashboardEndpointRouteBuilderExtensions.cs +++ b/src/Sheddueller.Dashboard/ShedduellerDashboardEndpointRouteBuilderExtensions.cs @@ -18,6 +18,22 @@ namespace Microsoft.AspNetCore.Builder; /// public static class ShedduellerDashboardEndpointRouteBuilderExtensions { + /// + /// Maps the dashboard UI and live update hub under the supplied path using minimal hosting. + /// + /// The web application to map the dashboard branch on. + /// The absolute route path for the dashboard root. + /// The same web application for chained registration. + public static WebApplication MapShedduellerDashboard( + this WebApplication app, + string path = "/sheddueller") + { + ArgumentNullException.ThrowIfNull(app); + + ((IApplicationBuilder)app).MapShedduellerDashboard(path); + return app; + } + /// /// Maps the dashboard UI and live update hub under the supplied path using an application branch. /// diff --git a/src/Sheddueller.Postgres/Internal/OwnedPostgresDataSource.cs b/src/Sheddueller.Postgres/Internal/OwnedPostgresDataSource.cs new file mode 100644 index 0000000..1194e58 --- /dev/null +++ b/src/Sheddueller.Postgres/Internal/OwnedPostgresDataSource.cs @@ -0,0 +1,18 @@ +namespace Sheddueller.Postgres.Internal; + +using Npgsql; + +internal sealed class OwnedPostgresDataSource( + NpgsqlDataSource dataSource, + string schemaName) : IDisposable, IAsyncDisposable +{ + public NpgsqlDataSource DataSource { get; } = dataSource; + + public string SchemaName { get; } = schemaName; + + public void Dispose() + => this.DataSource.Dispose(); + + public ValueTask DisposeAsync() + => this.DataSource.DisposeAsync(); +} diff --git a/src/Sheddueller.Postgres/Sheddueller.Postgres.csproj b/src/Sheddueller.Postgres/Sheddueller.Postgres.csproj index 7ccd99e..37f28c5 100644 --- a/src/Sheddueller.Postgres/Sheddueller.Postgres.csproj +++ b/src/Sheddueller.Postgres/Sheddueller.Postgres.csproj @@ -8,6 +8,7 @@ + all diff --git a/src/Sheddueller.Postgres/ShedduellerPostgresBuilderExtensions.cs b/src/Sheddueller.Postgres/ShedduellerPostgresBuilderExtensions.cs index 36e3a3d..9c9e1db 100644 --- a/src/Sheddueller.Postgres/ShedduellerPostgresBuilderExtensions.cs +++ b/src/Sheddueller.Postgres/ShedduellerPostgresBuilderExtensions.cs @@ -4,6 +4,8 @@ namespace Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Npgsql; + using Sheddueller; using Sheddueller.Inspection.ConcurrencyGroups; using Sheddueller.Inspection.Jobs; @@ -20,6 +22,79 @@ namespace Microsoft.Extensions.DependencyInjection; /// public static class ShedduellerPostgresBuilderExtensions { + /// + /// Uses the PostgreSQL job store provider with a Sheddueller-owned data source. + /// + /// The Sheddueller builder being configured. + /// The PostgreSQL connection string. + /// An optional callback for configuring the provider-owned data source. + /// The same builder for chained configuration. + /// + /// The created is owned by dependency injection and disposed with the + /// service provider. Apply provider migrations explicitly before starting workers against a new schema. + /// + public static ShedduellerBuilder UsePostgres( + this ShedduellerBuilder builder, + string connectionString, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); + + return builder.UsePostgres( + _ => connectionString, + (_, options) => configure?.Invoke(options)); + } + + /// + /// Uses the PostgreSQL job store provider with a Sheddueller-owned data source. + /// + /// The Sheddueller builder being configured. + /// A callback that resolves the PostgreSQL connection string from the service provider. + /// An optional callback for configuring the provider-owned data source. + /// The same builder for chained configuration. + /// + /// The created is owned by dependency injection and disposed with the + /// service provider. Apply provider migrations explicitly before starting workers against a new schema. + /// + public static ShedduellerBuilder UsePostgres( + this ShedduellerBuilder builder, + Func connectionStringFactory, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(connectionStringFactory); + + builder.Services.Replace(ServiceDescriptor.Singleton(serviceProvider => + { + var options = new ShedduellerPostgresDataSourceOptions(); + configure?.Invoke(serviceProvider, options); + ValidateDataSourceOptions(options); + + var connectionString = connectionStringFactory(serviceProvider); + ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); + + var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString); + options.ConfigureDataSourceBuilder?.Invoke(dataSourceBuilder); + + return new OwnedPostgresDataSource(dataSourceBuilder.Build(), options.SchemaName); + })); + builder.Services.Replace(ServiceDescriptor.Singleton(serviceProvider => + { + var dataSource = serviceProvider.GetRequiredService(); + var options = new ShedduellerPostgresOptions + { + DataSource = dataSource.DataSource, + SchemaName = dataSource.SchemaName, + }; + PostgresOptionsValidator.Validate(options); + return options; + })); + RegisterProviderServices(builder.Services); + + return builder; + } + /// /// Uses the PostgreSQL job store provider. /// @@ -41,21 +116,40 @@ public static ShedduellerBuilder UsePostgres( configure(options); PostgresOptionsValidator.Validate(options); + builder.Services.RemoveAll(); builder.Services.Replace(ServiceDescriptor.Singleton(options)); - builder.Services.Replace(ServiceDescriptor.Singleton()); - builder.Services.Replace(ServiceDescriptor.Singleton(serviceProvider => serviceProvider.GetRequiredService())); - builder.Services.Replace(ServiceDescriptor.Singleton(serviceProvider => serviceProvider.GetRequiredService())); - builder.Services.Replace(ServiceDescriptor.Singleton(serviceProvider => serviceProvider.GetRequiredService())); - builder.Services.Replace(ServiceDescriptor.Singleton(serviceProvider => serviceProvider.GetRequiredService())); - builder.Services.Replace(ServiceDescriptor.Singleton(serviceProvider => serviceProvider.GetRequiredService())); - builder.Services.Replace(ServiceDescriptor.Singleton(serviceProvider => serviceProvider.GetRequiredService())); - builder.Services.Replace(ServiceDescriptor.Singleton(serviceProvider => serviceProvider.GetRequiredService())); - builder.Services.Replace(ServiceDescriptor.Singleton(serviceProvider => serviceProvider.GetRequiredService())); - builder.Services.Replace(ServiceDescriptor.Singleton()); - builder.Services.Replace(ServiceDescriptor.Singleton()); - builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); - builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + RegisterProviderServices(builder.Services); return builder; } + + private static void ValidateDataSourceOptions(ShedduellerPostgresDataSourceOptions options) + { + if (string.IsNullOrWhiteSpace(options.SchemaName)) + { + throw new InvalidOperationException("ShedduellerPostgresDataSourceOptions.SchemaName must be a non-empty PostgreSQL schema name."); + } + + if (options.SchemaName.Contains('\0', StringComparison.Ordinal)) + { + throw new InvalidOperationException("ShedduellerPostgresDataSourceOptions.SchemaName cannot contain null characters."); + } + } + + private static void RegisterProviderServices(IServiceCollection services) + { + services.Replace(ServiceDescriptor.Singleton()); + services.Replace(ServiceDescriptor.Singleton(serviceProvider => serviceProvider.GetRequiredService())); + services.Replace(ServiceDescriptor.Singleton(serviceProvider => serviceProvider.GetRequiredService())); + services.Replace(ServiceDescriptor.Singleton(serviceProvider => serviceProvider.GetRequiredService())); + services.Replace(ServiceDescriptor.Singleton(serviceProvider => serviceProvider.GetRequiredService())); + services.Replace(ServiceDescriptor.Singleton(serviceProvider => serviceProvider.GetRequiredService())); + services.Replace(ServiceDescriptor.Singleton(serviceProvider => serviceProvider.GetRequiredService())); + services.Replace(ServiceDescriptor.Singleton(serviceProvider => serviceProvider.GetRequiredService())); + services.Replace(ServiceDescriptor.Singleton(serviceProvider => serviceProvider.GetRequiredService())); + services.Replace(ServiceDescriptor.Singleton()); + services.Replace(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + } } diff --git a/src/Sheddueller.Postgres/ShedduellerPostgresDataSourceOptions.cs b/src/Sheddueller.Postgres/ShedduellerPostgresDataSourceOptions.cs new file mode 100644 index 0000000..3fc2cf9 --- /dev/null +++ b/src/Sheddueller.Postgres/ShedduellerPostgresDataSourceOptions.cs @@ -0,0 +1,23 @@ +namespace Sheddueller.Postgres; + +using Npgsql; + +/// +/// Options for a Sheddueller-owned PostgreSQL data source. +/// +/// +/// These options are used by connection-string registration overloads. Use +/// when supplying a caller-owned data source. +/// +public sealed class ShedduellerPostgresDataSourceOptions +{ + /// + /// Gets or sets the PostgreSQL schema used by one logical Sheddueller cluster. + /// + public string SchemaName { get; set; } = "sheddueller"; + + /// + /// Gets or sets a callback for configuring the underlying Npgsql data source builder. + /// + public Action? ConfigureDataSourceBuilder { get; set; } +} diff --git a/src/Sheddueller.Postgres/ShedduellerPostgresMigrationExtensions.cs b/src/Sheddueller.Postgres/ShedduellerPostgresMigrationExtensions.cs new file mode 100644 index 0000000..aabda1b --- /dev/null +++ b/src/Sheddueller.Postgres/ShedduellerPostgresMigrationExtensions.cs @@ -0,0 +1,42 @@ +namespace Sheddueller.Postgres; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +/// +/// Convenience extensions for applying Sheddueller PostgreSQL migrations explicitly. +/// +public static class ShedduellerPostgresMigrationExtensions +{ + /// + /// Applies provider-owned PostgreSQL schema migrations through the host service provider. + /// + /// The host whose service provider contains the PostgreSQL provider. + /// A token for canceling migration work. + /// A task that completes when schema migration has finished. + public static ValueTask ApplyShedduellerPostgresMigrationsAsync( + this IHost host, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(host); + + return host.Services.ApplyShedduellerPostgresMigrationsAsync(cancellationToken); + } + + /// + /// Applies provider-owned PostgreSQL schema migrations through a scoped migrator. + /// + /// The service provider containing the PostgreSQL provider. + /// A token for canceling migration work. + /// A task that completes when schema migration has finished. + public static async ValueTask ApplyShedduellerPostgresMigrationsAsync( + this IServiceProvider services, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(services); + + using var scope = services.CreateScope(); + var migrator = scope.ServiceProvider.GetRequiredService(); + await migrator.ApplyAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Sheddueller/ShedduellerBuilder.cs b/src/Sheddueller/ShedduellerBuilder.cs index d89729b..35717bd 100644 --- a/src/Sheddueller/ShedduellerBuilder.cs +++ b/src/Sheddueller/ShedduellerBuilder.cs @@ -34,6 +34,20 @@ public ShedduellerBuilder ConfigureOptions(Action configure) return this; } + /// + /// Configures Sheddueller runtime options with access to the final service provider. + /// + /// The options callback to apply through the host options system. + /// The same builder for chained configuration. + public ShedduellerBuilder ConfigureOptions(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + + this.Services.AddOptions() + .Configure((options, serviceProvider) => configure(serviceProvider, options)); + return this; + } + /// /// Replaces the job payload serializer with a singleton implementation type. /// diff --git a/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs b/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs index f5c0255..1c07381 100644 --- a/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs +++ b/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs @@ -23,7 +23,7 @@ namespace Sheddueller.Dashboard.Tests; public sealed class DashboardEndpointTests { [Fact] - public async Task MapShedduellerDashboard_ApplicationBranch_RedirectsToCanonicalRoot() + public async Task MapShedduellerDashboard_MinimalHosting_RedirectsToCanonicalRoot() { await using var app = await CreateStartedDashboardAsync(); var client = app.GetTestClient(); @@ -34,6 +34,18 @@ public async Task MapShedduellerDashboard_ApplicationBranch_RedirectsToCanonical response.Headers.Location?.OriginalString.ShouldBe("/sheddueller/"); } + [Fact] + public async Task MapShedduellerDashboard_ApplicationBranch_RedirectsToCanonicalRoot() + { + await using var app = await CreateStartedDashboardAsync(mapWithWebApplication: false); + var client = app.GetTestClient(); + + var response = await client.GetAsync(new Uri("/sheddueller", UriKind.Relative)); + + response.StatusCode.ShouldBe(HttpStatusCode.Redirect); + response.Headers.Location?.OriginalString.ShouldBe("/sheddueller/"); + } + [Fact] public async Task MapShedduellerDashboard_DefaultOptions_DoesNotPrerenderRouteContent() { @@ -362,7 +374,9 @@ public async Task JobDetail_MissingJob_RendersNotFoundWithDisabledCancelAction() disabled: true); } - private static async Task CreateStartedDashboardAsync(bool prerender = true) + private static async Task CreateStartedDashboardAsync( + bool prerender = true, + bool mapWithWebApplication = true) { var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); @@ -377,7 +391,15 @@ private static async Task CreateStartedDashboardAsync(bool prere builder.Services.AddShedduellerDashboard(options => options.Prerender = prerender); var app = builder.Build(); - ((IApplicationBuilder)app).MapShedduellerDashboard("/sheddueller"); + if (mapWithWebApplication) + { + app.MapShedduellerDashboard("/sheddueller"); + } + else + { + ((IApplicationBuilder)app).MapShedduellerDashboard("/sheddueller"); + } + await app.StartAsync(); return app; diff --git a/test/Sheddueller.Postgres.Tests/PostgresFixture.cs b/test/Sheddueller.Postgres.Tests/PostgresFixture.cs index 2d816c5..202fd32 100644 --- a/test/Sheddueller.Postgres.Tests/PostgresFixture.cs +++ b/test/Sheddueller.Postgres.Tests/PostgresFixture.cs @@ -10,6 +10,8 @@ public sealed class PostgresFixture : IAsyncLifetime public NpgsqlDataSource DataSource { get; private set; } = null!; + public string ConnectionString { get; private set; } = string.Empty; + public async ValueTask InitializeAsync() { var image = Environment.GetEnvironmentVariable("SHEDDUELLER_POSTGRES_IMAGE"); @@ -25,7 +27,8 @@ public async ValueTask InitializeAsync() .Build(); await this._container.StartAsync(); - this.DataSource = NpgsqlDataSource.Create(this._container.GetConnectionString()); + this.ConnectionString = this._container.GetConnectionString(); + this.DataSource = NpgsqlDataSource.Create(this.ConnectionString); } public async ValueTask DisposeAsync() diff --git a/test/Sheddueller.Postgres.Tests/PostgresMigrationHelperTests.cs b/test/Sheddueller.Postgres.Tests/PostgresMigrationHelperTests.cs new file mode 100644 index 0000000..e778dc8 --- /dev/null +++ b/test/Sheddueller.Postgres.Tests/PostgresMigrationHelperTests.cs @@ -0,0 +1,90 @@ +namespace Sheddueller.Postgres.Tests; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +using Sheddueller.Postgres; + +using Shouldly; + +public sealed class PostgresMigrationHelperTests +{ + [Fact] + public async Task ApplyShedduellerPostgresMigrationsAsync_ServiceProvider_UsesScopedMigrator() + { + var recorder = new MigrationRecorder(); + await using var provider = CreateProvider(recorder); + using var cancellationTokenSource = new CancellationTokenSource(); + + await provider.ApplyShedduellerPostgresMigrationsAsync(cancellationTokenSource.Token); + + recorder.ApplyCount.ShouldBe(1); + recorder.CancellationToken.ShouldBe(cancellationTokenSource.Token); + recorder.ScopeWasDisposedDuringApply.ShouldBeFalse(); + recorder.ScopeWasDisposed.ShouldBeTrue(); + } + + [Fact] + public async Task ApplyShedduellerPostgresMigrationsAsync_Host_UsesScopedMigrator() + { + var builder = Host.CreateApplicationBuilder(); + var recorder = new MigrationRecorder(); + RegisterMigrator(builder.Services, recorder); + using var host = builder.Build(); + using var cancellationTokenSource = new CancellationTokenSource(); + + await host.ApplyShedduellerPostgresMigrationsAsync(cancellationTokenSource.Token); + + recorder.ApplyCount.ShouldBe(1); + recorder.CancellationToken.ShouldBe(cancellationTokenSource.Token); + recorder.ScopeWasDisposedDuringApply.ShouldBeFalse(); + recorder.ScopeWasDisposed.ShouldBeTrue(); + } + + private static ServiceProvider CreateProvider(MigrationRecorder recorder) + { + var services = new ServiceCollection(); + RegisterMigrator(services, recorder); + return services.BuildServiceProvider(); + } + + private static void RegisterMigrator( + IServiceCollection services, + MigrationRecorder recorder) + { + services.AddSingleton(recorder); + services.AddScoped(); + services.AddScoped(); + } + + private sealed class MigrationRecorder + { + public int ApplyCount { get; set; } + + public CancellationToken CancellationToken { get; set; } + + public bool ScopeWasDisposed { get; set; } + + public bool ScopeWasDisposedDuringApply { get; set; } + } + + private sealed class ScopeMarker(MigrationRecorder recorder) : IDisposable + { + public void Dispose() + => recorder.ScopeWasDisposed = true; + } + + private sealed class RecordingMigrator( + MigrationRecorder recorder, + ScopeMarker scopeMarker) : IPostgresMigrator + { + public ValueTask ApplyAsync(CancellationToken cancellationToken = default) + { + _ = scopeMarker; + recorder.ApplyCount++; + recorder.CancellationToken = cancellationToken; + recorder.ScopeWasDisposedDuringApply = recorder.ScopeWasDisposed; + return ValueTask.CompletedTask; + } + } +} diff --git a/test/Sheddueller.Postgres.Tests/PostgresRegistrationTests.cs b/test/Sheddueller.Postgres.Tests/PostgresRegistrationTests.cs new file mode 100644 index 0000000..bf027c0 --- /dev/null +++ b/test/Sheddueller.Postgres.Tests/PostgresRegistrationTests.cs @@ -0,0 +1,110 @@ +namespace Sheddueller.Postgres.Tests; + +using Microsoft.Extensions.DependencyInjection; + +using Npgsql; + +using Sheddueller.Inspection.Jobs; +using Sheddueller.Postgres; +using Sheddueller.Postgres.Internal; +using Sheddueller.Runtime; +using Sheddueller.Storage; + +using Shouldly; + +public sealed class PostgresRegistrationTests +{ + private const string TestConnectionString = "Host=127.0.0.1;Port=1;Username=postgres;Password=postgres;Database=sheddueller;Timeout=1"; + + [Fact] + public async Task UsePostgres_ConnectionString_RegistersProviderServices() + { + var services = new ServiceCollection(); + services.AddSheddueller(builder => builder.UsePostgres(TestConnectionString)); + + await using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService().SchemaName.ShouldBe("sheddueller"); + provider.GetRequiredService().ShouldBeSameAs(provider.GetRequiredService()); + provider.GetRequiredService().ShouldBeSameAs(provider.GetRequiredService()); + provider.GetRequiredService().ShouldBeOfType(); + provider.GetRequiredService().ShouldBeOfType(); + } + + [Fact] + public async Task UsePostgres_ServiceProviderFactory_CanReadRegisteredServices() + { + var schemaName = "sheddueller_" + Guid.NewGuid().ToString("N"); + var services = new ServiceCollection(); + services.AddSingleton(new PostgresRegistrationSettings(TestConnectionString, schemaName)); + services.AddSheddueller(builder => builder.UsePostgres( + serviceProvider => serviceProvider.GetRequiredService().ConnectionString, + (serviceProvider, options) => + { + options.SchemaName = serviceProvider.GetRequiredService().SchemaName; + })); + + await using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService().SchemaName.ShouldBe(schemaName); + } + + [Fact] + public async Task UsePostgres_ConfigureDataSourceBuilder_IsInvoked() + { + var configured = false; + var services = new ServiceCollection(); + services.AddSheddueller(builder => builder.UsePostgres( + TestConnectionString, + options => + { + options.ConfigureDataSourceBuilder = _ => configured = true; + })); + + await using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService().ShouldNotBeNull(); + configured.ShouldBeTrue(); + } + + [Fact] + public async Task UsePostgres_ConnectionString_DisposesOwnedDataSourceWithProvider() + { + var services = new ServiceCollection(); + services.AddSheddueller(builder => builder.UsePostgres(TestConnectionString)); + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService(); + + await provider.DisposeAsync(); + + await Should.ThrowAsync(async () => + { + await using var connection = await options.DataSource.OpenConnectionAsync(); + }); + } + + [Fact] + public async Task UsePostgres_CallerOwnedDataSource_IsNotDisposedWithProvider() + { + await using var dataSource = NpgsqlDataSource.Create(TestConnectionString); + var services = new ServiceCollection(); + services.AddSheddueller(builder => builder.UsePostgres(options => + { + options.DataSource = dataSource; + })); + var provider = services.BuildServiceProvider(); + + provider.GetRequiredService().DataSource.ShouldBeSameAs(dataSource); + + await provider.DisposeAsync(); + + await Should.ThrowAsync(async () => + { + await using var connection = await dataSource.OpenConnectionAsync(); + }); + } + + private sealed record PostgresRegistrationSettings( + string ConnectionString, + string SchemaName); +} diff --git a/test/Sheddueller.Tests/RegistrationTests.cs b/test/Sheddueller.Tests/RegistrationTests.cs index 808ac48..a09ef9f 100644 --- a/test/Sheddueller.Tests/RegistrationTests.cs +++ b/test/Sheddueller.Tests/RegistrationTests.cs @@ -2,6 +2,7 @@ namespace Sheddueller.Tests; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; using Sheddueller.Runtime; using Sheddueller.Serialization; @@ -55,6 +56,20 @@ public void AddSheddueller_WorkerOnlyOptions_AreNotHostedValidated() provider.GetServices().ShouldBeEmpty(); } + [Fact] + public void AddSheddueller_ServiceProviderAwareOptions_CanReadRegisteredServices() + { + var services = new ServiceCollection(); + services.AddSingleton(new NodeConfiguration("node-from-di")); + services.AddSheddueller(builder => builder.ConfigureOptions((serviceProvider, options) => + { + options.NodeId = serviceProvider.GetRequiredService().NodeId; + })); + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService>().Value.NodeId.ShouldBe("node-from-di"); + } + private sealed class PassThroughSerializer : IJobPayloadSerializer { public ValueTask SerializeAsync( @@ -73,4 +88,6 @@ public ValueTask SerializeAsync( return ValueTask.FromResult>(Array.Empty()); } } + + private sealed record NodeConfiguration(string NodeId); } From 2cfaa99e0129858a7cfa8fbdc2205edb4c64359b Mon Sep 17 00:00:00 2001 From: Brian Tyler Date: Sun, 26 Apr 2026 09:54:51 +0100 Subject: [PATCH 3/5] feat: Add manual trigger functionality for recurring schedules --- docs/0.1-preview2.md | 69 +++++- .../Components/Pages/Schedules.razor | 127 +++++++++- .../Internal/Operations/PostgresSchedules.cs | 62 ++--- .../TriggerRecurringScheduleOperation.cs | 56 +++++ .../Internal/PostgresJobStore.cs | 9 + .../CapturingRecurringScheduleManager.cs | 6 + .../FakeRecurringScheduleManager.cs | 52 ++++ src/Sheddueller.Testing/FakeTriggeredJob.cs | 116 +++++++++ src/Sheddueller/IRecurringScheduleManager.cs | 10 + .../RecurringScheduleTriggerResult.cs | 12 + .../RecurringScheduleTriggerStatus.cs | 22 ++ .../Runtime/RecurringScheduleManager.cs | 22 ++ src/Sheddueller/Storage/IJobStore.cs | 7 + .../TriggerRecurringScheduleRequest.cs | 8 + .../DashboardEndpointTests.cs | 30 ++- .../TriggerRecurringScheduleOperationTests.cs | 227 ++++++++++++++++++ .../PostgresTestContext.cs | 7 +- .../PostgresTestData.cs | 2 + .../JobStoreContractTests.cs | 140 ++++++++++- .../CapturingRecurringScheduleManagerTests.cs | 18 ++ .../FakeRecurringScheduleManagerTests.cs | 62 +++++ test/Sheddueller.Tests/RecordingJobStore.cs | 15 ++ .../RecurringScheduleManagerTests.cs | 90 +++++++ .../RegistrationTests.cs | 5 + 24 files changed, 1127 insertions(+), 47 deletions(-) create mode 100644 src/Sheddueller.Postgres/Internal/Operations/TriggerRecurringScheduleOperation.cs create mode 100644 src/Sheddueller.Testing/FakeTriggeredJob.cs create mode 100644 src/Sheddueller/RecurringScheduleTriggerResult.cs create mode 100644 src/Sheddueller/RecurringScheduleTriggerStatus.cs create mode 100644 src/Sheddueller/Storage/TriggerRecurringScheduleRequest.cs create mode 100644 test/Sheddueller.Postgres.Tests/Operations/TriggerRecurringScheduleOperationTests.cs create mode 100644 test/Sheddueller.Tests/RecurringScheduleManagerTests.cs diff --git a/docs/0.1-preview2.md b/docs/0.1-preview2.md index add8d29..f924adb 100644 --- a/docs/0.1-preview2.md +++ b/docs/0.1-preview2.md @@ -4,7 +4,7 @@ The legacy Hangfire replacement trial showed that `0.1.0-preview1` is viable, but it exposed a handful of integration rough edges in the public API. `0.1.0-preview2` should keep the current runtime model and storage design, while making the common hosting path cleaner and reducing workarounds in consuming applications. -This release is an API cleanup release. It should not introduce workflows, job dependencies, automatic migrations, or a new storage architecture. +This release is an API cleanup release. It should not introduce workflows, job dependencies, automatic migrations, or a new storage architecture. It does restore manual recurring schedule triggering as a first-class schedule operation. Primary goals: @@ -177,12 +177,59 @@ Preferred consuming shape: app.MapShedduellerDashboard("/sheddueller"); ``` +### Manual Recurring Schedule Trigger + +Add a recurring schedule trigger operation in core: + +```csharp +public interface IRecurringScheduleManager +{ + ValueTask TriggerAsync( + string scheduleKey, + CancellationToken cancellationToken = default); +} + +public enum RecurringScheduleTriggerStatus +{ + Enqueued, + NotFound, + SkippedActiveOccurrence, +} + +public sealed record RecurringScheduleTriggerResult( + RecurringScheduleTriggerStatus Status, + Guid? JobId = null, + long? EnqueueSequence = null); +``` + +Behavior: + +- The recurring schedule remains the template; triggering clones the current stored template snapshot into one ordinary queued job. +- The first preview2 trigger API does not accept priority, group, retry, tag, payload, or handler overrides. +- Missing schedules return `NotFound`. +- Paused schedules can still be triggered. Pause only stops automatic cadence. +- `RecurringOverlapMode.Skip` is honored: when a schedule already has a queued or claimed occurrence, triggering returns `SkippedActiveOccurrence`. +- `RecurringOverlapMode.Allow` always creates another queued job. +- Manual triggering does not advance `next_fire_at_utc`, update cron cadence, or change paused state. +- Triggered jobs copy the schedule's service, method, payload, priority, concurrency groups, retry policy, tags, invocation kind, and parameter bindings. +- If the schedule has no explicit retry policy, the app default retry policy is applied, matching automatic schedule materialization. +- Inserted jobs use `source_schedule_key = scheduleKey`, `schedule_occurrence_kind = ManualTrigger`, and `scheduled_fire_at_utc = null` so schedule latency metrics remain focused on automatic occurrences. +- Workers are notified only when a job is actually enqueued. + +Dashboard behavior: + +- The schedules table includes a trigger button next to pause/resume. +- Successful triggers show the created job id and link to `/jobs/{jobId}`. +- Skipped triggers show a non-error warning that the schedule already has an active occurrence. +- Missing schedules use the existing action failure style. + ## Test Plan ### Core Tests - `ConfigureOptions((sp, options) => ...)` can read a service from DI and configure `ShedduellerOptions`. - Existing `ConfigureOptions(options => ...)` behavior remains unchanged. +- `RecurringScheduleManager.TriggerAsync(...)` validates schedule keys, passes the default retry policy to storage, and wakes workers only when storage enqueues a job. ### PostgreSQL Registration Tests @@ -203,6 +250,23 @@ app.MapShedduellerDashboard("/sheddueller"); - `app.MapShedduellerDashboard("/sheddueller")` compiles and serves the same dashboard routes as the cast-based branch mapping. - Existing `IApplicationBuilder` dashboard route tests continue to pass. +- The schedules page renders trigger controls and the trigger action messages for success, skipped, and missing cases. + +### Provider Contract Tests + +- Missing schedule trigger returns `NotFound`. +- Triggering clones schedule metadata into a claimable job. +- Paused schedule trigger still enqueues a job. +- Skip overlap returns `SkippedActiveOccurrence` when an active occurrence exists. +- Allow overlap permits multiple active occurrences. +- Triggering does not advance the schedule or change paused state. + +### PostgreSQL Trigger Tests + +- Triggered jobs are claimable and copied fields round-trip. +- `ManualTrigger` appears in job and schedule inspection. +- `scheduled_fire_at_utc` is null for manual triggers. +- PostgreSQL wake notification occurs only when a job is enqueued. ### Validation Commands @@ -239,6 +303,7 @@ Update `README.md`: - A consuming app can configure PostgreSQL without a top-level `NpgsqlDataSource` variable. - A consuming app can configure Sheddueller runtime options from DI-resolved app options. - A consuming app can call `app.MapShedduellerDashboard("/sheddueller")` directly. +- A consuming app can manually trigger a recurring schedule through `IRecurringScheduleManager.TriggerAsync(...)` and get a structured result. - Migration execution remains explicit but no longer requires repeated scope boilerplate. - The legacy replacement can remove: - `await using var shedduellerDataSource = ...`, @@ -249,7 +314,7 @@ Update `README.md`: ## Explicit Non-Goals - Do not add job dependencies or workflows. -- Do not add a manual recurring schedule trigger API in this preview. +- Do not add recurring trigger overrides in this preview. - Do not add automatic migrations during normal startup. - Do not replace `NpgsqlDataSource` as the PostgreSQL provider primitive. - Do not change the provider's correctness model: PostgreSQL time remains authoritative, `LISTEN/NOTIFY` remains an optimization, polling remains the correctness fallback, and claim selection continues to use row locking with `SKIP LOCKED`. diff --git a/src/Sheddueller.Dashboard/Components/Pages/Schedules.razor b/src/Sheddueller.Dashboard/Components/Pages/Schedules.razor index 8e80127..b6085ae 100644 --- a/src/Sheddueller.Dashboard/Components/Pages/Schedules.razor +++ b/src/Sheddueller.Dashboard/Components/Pages/Schedules.razor @@ -44,6 +44,10 @@ { @_actionMessage + @if (_triggeredJobId is { } triggeredJobId) + { + @triggeredJobId.ToString("D") + } } @@ -140,6 +144,11 @@
+