diff --git a/docs/examples/index.md b/docs/examples/index.md index 2f90e702..ea991ba2 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -172,6 +172,7 @@ dotnet test PatternKit.slnx -c Release * **Commerce Backends for Frontends:** `CommerceBackendsForFrontendsDemo` (+ `CommerceBackendsForFrontendsDemoTests`) — fluent and generated client-specific facade shaping with DI and ASP.NET Core mapping. * **Inventory Ambassador:** `InventoryAmbassadorDemo` (+ `InventoryAmbassadorDemoTests`) — fluent and generated outbound connectivity wrapper with DI and ASP.NET Core mapping. * **Warehouse Leader Election:** `WarehouseLeaderElectionDemo` (+ `WarehouseLeaderElectionDemoTests`) — fluent and generated active worker lease coordination with DI and Generic Host mapping. +* **Warehouse Scheduler Agent Supervisor:** `WarehouseSchedulerAgentSupervisorDemo` (+ `WarehouseSchedulerAgentSupervisorDemoTests`) — fluent and generated scheduled worker supervision with DI and Generic Host mapping. * **Production-Ready Example Catalog:** `PatternKitExampleCatalog` (+ `PatternKitExampleCatalogTests`) — DI registration, generic host validation, ASP.NET Core endpoint mapping, and source/test/docs manifest checks. * **Tests:** `PatternKit.Examples.Tests/*` use TinyBDD scenarios that read like specs. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index a3ef4b3f..ff528113 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -258,3 +258,6 @@ - name: Warehouse Leader Election href: warehouse-leader-election.md + +- name: Warehouse Scheduler Agent Supervisor + href: warehouse-scheduler-agent-supervisor.md diff --git a/docs/examples/warehouse-scheduler-agent-supervisor.md b/docs/examples/warehouse-scheduler-agent-supervisor.md new file mode 100644 index 00000000..dd840b07 --- /dev/null +++ b/docs/examples/warehouse-scheduler-agent-supervisor.md @@ -0,0 +1,12 @@ +# Warehouse Scheduler Agent Supervisor + +The warehouse scheduler demo shows scheduled replenishment work imported through Microsoft dependency injection and Generic Host. + +```csharp +services.AddWarehouseSchedulerAgentSupervisorDemo(); + +var runner = provider.GetRequiredService(); +var results = runner.RunGenerated(); +``` + +The example includes fluent and source-generated construction, an `IServiceCollection` extension, retry supervision, result capture, and a hosted service that schedules and dispatches work during host startup. diff --git a/docs/generators/index.md b/docs/generators/index.md index 53a9e0a0..1dd99854 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -128,6 +128,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Backends for Frontends**](backends-for-frontends.md) | Client-specific facade factories | `[GenerateBackendsForFrontends]` | | [**Ambassador**](ambassador.md) | Outbound connectivity wrapper factories | `[GenerateAmbassador]` | | [**Leader Election**](leader-election.md) | Lease-backed active worker factories | `[GenerateLeaderElection]` | +| [**Scheduler Agent Supervisor**](scheduler-agent-supervisor.md) | Scheduled worker supervision factories | `[GenerateSchedulerAgentSupervisor]` | ## Quick Reference diff --git a/docs/generators/scheduler-agent-supervisor.md b/docs/generators/scheduler-agent-supervisor.md new file mode 100644 index 00000000..fee41cc8 --- /dev/null +++ b/docs/generators/scheduler-agent-supervisor.md @@ -0,0 +1,19 @@ +# Scheduler Agent Supervisor Generator + +`[GenerateSchedulerAgentSupervisor]` creates a typed `SchedulerAgentSupervisor` factory from agent methods and an optional retry predicate. + +```csharp +[GenerateSchedulerAgentSupervisor(typeof(WarehouseReplenishmentWork), typeof(WarehouseReplenishmentSummary), SupervisorName = "warehouse-replenishment-scheduler")] +public static partial class GeneratedWarehouseScheduler +{ + [SchedulerAgent("release-replenishment")] + private static WarehouseReplenishmentSummary Release(SchedulerAgentContext context) + => new(context.Work.BatchId, context.Attempt); + + [SchedulerRetryWhen] + private static bool Retry(Exception exception, SchedulerAgentContext context) + => exception is InvalidOperationException; +} +``` + +Generated factories configure `MaxAttempts`, `RetryDelayMilliseconds`, all declared agents, and the retry predicate. Agent methods must be static and return the configured result type from `SchedulerAgentContext`. diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 66049895..5a4006b6 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -181,6 +181,9 @@ - name: Leader Election href: leader-election.md +- name: Scheduler Agent Supervisor + href: scheduler-agent-supervisor.md + - name: Queue Load Leveling href: queue-load-leveling.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 57dd8805..84124bb1 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -93,6 +93,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Cloud Architecture | Backends for Frontends | `BackendsForFrontends` | Backends for Frontends generator | | Cloud Architecture | Ambassador | `Ambassador` | Ambassador generator | | Cloud Architecture | Leader Election | `LeaderElection` | Leader Election generator | +| Cloud Architecture | Scheduler Agent Supervisor | `SchedulerAgentSupervisor` | Scheduler Agent Supervisor generator | | Application Architecture | CQRS | Mediator/dispatcher command-query split | Dispatcher generator | | Application Architecture | Specification | `Specification` and named registries | Specification generator | | Application Architecture | Repository | `IRepository` and `InMemoryRepository` | Repository generator | diff --git a/docs/patterns/cloud/scheduler-agent-supervisor.md b/docs/patterns/cloud/scheduler-agent-supervisor.md new file mode 100644 index 00000000..ad91df3d --- /dev/null +++ b/docs/patterns/cloud/scheduler-agent-supervisor.md @@ -0,0 +1,21 @@ +# Scheduler Agent Supervisor + +Scheduler Agent Supervisor coordinates due work, dispatches it to an agent, and applies supervision rules for retries and exhaustion. + +```csharp +var scheduler = SchedulerAgentSupervisor + .Create("warehouse-replenishment-scheduler") + .Supervision(SchedulerSupervisionPolicy.Create() + .MaxAttempts(2) + .RetryDelay(TimeSpan.FromSeconds(5)) + .Build()) + .Agent("release-replenishment", ctx => new(ctx.Work.BatchId, ctx.Attempt)) + .Build(); + +scheduler.Schedule("replenish:B-100", work, dueAt); +var results = scheduler.RunDue(now); +``` + +Use it for scheduled background jobs where the application needs deterministic result capture, retry policy, and a clear supervision boundary. Production integrations should back scheduling state with durable storage and import the supervisor through `IServiceCollection`. + +The source-generated path uses `[GenerateSchedulerAgentSupervisor]`, `[SchedulerAgent]`, and `[SchedulerRetryWhen]`. Import the example through `AddWarehouseSchedulerAgentSupervisorDemo()` or `AddPatternKitExamples()`. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 3f5e1773..4517fee8 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -385,6 +385,8 @@ href: cloud/ambassador.md - name: Leader Election href: cloud/leader-election.md + - name: Scheduler Agent Supervisor + href: cloud/scheduler-agent-supervisor.md - name: Application Architecture items: - name: Anti-Corruption Layer diff --git a/src/PatternKit.Core/Cloud/SchedulerAgentSupervisor/SchedulerAgentSupervisor.cs b/src/PatternKit.Core/Cloud/SchedulerAgentSupervisor/SchedulerAgentSupervisor.cs new file mode 100644 index 00000000..ada9f160 --- /dev/null +++ b/src/PatternKit.Core/Cloud/SchedulerAgentSupervisor/SchedulerAgentSupervisor.cs @@ -0,0 +1,256 @@ +namespace PatternKit.Cloud.SchedulerAgentSupervisor; + +public sealed class SchedulerAgentContext +{ + internal SchedulerAgentContext(string supervisorName, string jobName, TWork work, int attempt) + => (SupervisorName, JobName, Work, Attempt) = (supervisorName, jobName, work, attempt); + + public string SupervisorName { get; } + + public string JobName { get; } + + public TWork Work { get; } + + public int Attempt { get; } + + public IDictionary Items { get; } = new Dictionary(); + + public IList Events { get; } = new List(); +} + +public sealed class SchedulerAgentResult +{ + private SchedulerAgentResult( + string supervisorName, + string jobName, + string agentName, + int attempt, + TResult? response, + Exception? exception, + IReadOnlyList events, + bool succeeded, + bool retryScheduled, + bool exhausted) + => (SupervisorName, JobName, AgentName, Attempt, Response, Exception, Events, Succeeded, RetryScheduled, Exhausted) = + (supervisorName, jobName, agentName, attempt, response, exception, events, succeeded, retryScheduled, exhausted); + + public string SupervisorName { get; } + + public string JobName { get; } + + public string AgentName { get; } + + public int Attempt { get; } + + public TResult? Response { get; } + + public Exception? Exception { get; } + + public IReadOnlyList Events { get; } + + public bool Succeeded { get; } + + public bool Failed => !Succeeded; + + public bool RetryScheduled { get; } + + public bool Exhausted { get; } + + public static SchedulerAgentResult Success(string supervisorName, string jobName, string agentName, int attempt, TResult response, IReadOnlyList events) + { + if (response is null) + throw new ArgumentNullException(nameof(response)); + if (events is null) + throw new ArgumentNullException(nameof(events)); + return new(supervisorName, jobName, agentName, attempt, response, null, events, succeeded: true, retryScheduled: false, exhausted: false); + } + + public static SchedulerAgentResult Failure(string supervisorName, string jobName, string agentName, int attempt, Exception exception, IReadOnlyList events, bool retryScheduled, bool exhausted) + { + if (exception is null) + throw new ArgumentNullException(nameof(exception)); + if (events is null) + throw new ArgumentNullException(nameof(events)); + return new(supervisorName, jobName, agentName, attempt, default, exception, events, succeeded: false, retryScheduled, exhausted); + } +} + +public sealed class SchedulerSupervisionPolicy +{ + private SchedulerSupervisionPolicy(int maxAttempts, TimeSpan retryDelay, Func, bool>? shouldRetry) + => (MaxAttempts, RetryDelay, ShouldRetry) = (maxAttempts, retryDelay, shouldRetry ?? ((_, _) => true)); + + public int MaxAttempts { get; } + + public TimeSpan RetryDelay { get; } + + public Func, bool> ShouldRetry { get; } + + public static Builder Create() => new(); + + public sealed class Builder + { + private int _maxAttempts = 3; + private TimeSpan _retryDelay = TimeSpan.FromSeconds(1); + private Func, bool>? _shouldRetry; + + public Builder MaxAttempts(int maxAttempts) + { + if (maxAttempts <= 0) + throw new ArgumentOutOfRangeException(nameof(maxAttempts)); + _maxAttempts = maxAttempts; + return this; + } + + public Builder RetryDelay(TimeSpan retryDelay) + { + if (retryDelay < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(retryDelay)); + _retryDelay = retryDelay; + return this; + } + + public Builder RetryWhen(Func, bool> shouldRetry) + { + _shouldRetry = shouldRetry ?? throw new ArgumentNullException(nameof(shouldRetry)); + return this; + } + + public SchedulerSupervisionPolicy Build() => new(_maxAttempts, _retryDelay, _shouldRetry); + } +} + +public sealed class SchedulerAgentSupervisor +{ + private readonly List _jobs = []; + private readonly List _agents = []; + private readonly Func _clock; + private readonly SchedulerSupervisionPolicy _policy; + + private SchedulerAgentSupervisor(string name, Func? clock, SchedulerSupervisionPolicy? policy, IReadOnlyList agents) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Supervisor name is required.", nameof(name)); + if (agents.Count == 0) + throw new InvalidOperationException("Scheduler Agent Supervisor requires at least one agent."); + + Name = name; + _clock = clock ?? (() => DateTimeOffset.UtcNow); + _policy = policy ?? SchedulerSupervisionPolicy.Create().Build(); + _agents.AddRange(agents); + } + + public string Name { get; } + + public IReadOnlyList PendingJobs => _jobs.Select(static job => job.Name).ToArray(); + + public static Builder Create(string name = "scheduler-agent-supervisor") => new(name); + + public SchedulerAgentSupervisor Schedule(string jobName, TWork work, DateTimeOffset dueAt) + { + if (string.IsNullOrWhiteSpace(jobName)) + throw new ArgumentException("Job name is required.", nameof(jobName)); + if (work is null) + throw new ArgumentNullException(nameof(work)); + _jobs.Add(new ScheduledJob(jobName, work, dueAt, attempt: 0)); + return this; + } + + public IReadOnlyList> RunDue() => RunDue(_clock()); + + public IReadOnlyList> RunDue(DateTimeOffset now) + { + var due = _jobs.Where(job => job.DueAt <= now).OrderBy(static job => job.DueAt).ToArray(); + if (due.Length == 0) + return Array.Empty>(); + + foreach (var job in due) + _jobs.Remove(job); + + var results = new List>(due.Length); + foreach (var job in due) + results.Add(Dispatch(job, now)); + + return results; + } + + private SchedulerAgentResult Dispatch(ScheduledJob job, DateTimeOffset now) + { + var agent = _agents[0]; + var attempt = job.Attempt + 1; + var context = new SchedulerAgentContext(Name, job.Name, job.Work, attempt); + context.Events.Add($"dispatch:{agent.Name}:{attempt}"); + try + { + var response = agent.Execute(context); + return SchedulerAgentResult.Success(Name, job.Name, agent.Name, attempt, response, context.Events.ToArray()); + } + catch (Exception exception) + { + var canRetry = attempt < _policy.MaxAttempts && _policy.ShouldRetry(exception, context); + if (canRetry) + _jobs.Add(new ScheduledJob(job.Name, job.Work, now.Add(_policy.RetryDelay), attempt)); + return SchedulerAgentResult.Failure(Name, job.Name, agent.Name, attempt, exception, context.Events.ToArray(), canRetry, exhausted: !canRetry); + } + } + + public sealed class Builder + { + private readonly string _name; + private readonly List _agents = []; + private Func? _clock; + private SchedulerSupervisionPolicy? _policy; + + internal Builder(string name) => _name = name; + + public Builder Clock(Func clock) + { + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + return this; + } + + public Builder Supervision(SchedulerSupervisionPolicy policy) + { + _policy = policy ?? throw new ArgumentNullException(nameof(policy)); + return this; + } + + public Builder Agent(string name, Func, TResult> execute) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Agent name is required.", nameof(name)); + if (execute is null) + throw new ArgumentNullException(nameof(execute)); + if (_agents.Any(agent => agent.Name == name)) + throw new InvalidOperationException($"Scheduler agent '{name}' is already registered."); + _agents.Add(new Agent(name, execute)); + return this; + } + + public SchedulerAgentSupervisor Build() => new(_name, _clock, _policy, _agents); + } + + private sealed class Agent + { + public Agent(string name, Func, TResult> execute) + => (Name, Execute) = (name, execute); + + public string Name { get; } + + public Func, TResult> Execute { get; } + } + + private sealed class ScheduledJob + { + public ScheduledJob(string name, TWork work, DateTimeOffset dueAt, int attempt) + => (Name, Work, DueAt, Attempt) = (name, work, dueAt, attempt); + + public string Name { get; } + + public TWork Work { get; } + + public DateTimeOffset DueAt { get; } + + public int Attempt { get; } + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 3bd5b5ca..37c3746f 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -61,6 +61,7 @@ using PatternKit.Examples.RateLimitingDemo; using PatternKit.Examples.RepositoryDemo; using PatternKit.Examples.RetryDemo; +using PatternKit.Examples.SchedulerAgentSupervisorDemo; using PatternKit.Examples.ServiceLayerDemo; using PatternKit.Examples.SidecarDemo; using PatternKit.Examples.Singleton; @@ -214,6 +215,7 @@ public sealed record OrderTelemetrySidecarExample(OrderTelemetrySidecarDemoRunne public sealed record CommerceBackendsForFrontendsExample(CommerceBackendsForFrontendsDemoRunner Runner, CommerceBackendsForFrontendsService Service); public sealed record InventoryAmbassadorExample(InventoryAmbassadorDemoRunner Runner, InventoryAmbassadorService Service); public sealed record WarehouseLeaderElectionExample(WarehouseLeaderElectionDemoRunner Runner, WarehouseLeaderElectionService Service); +public sealed record WarehouseSchedulerAgentSupervisorExample(WarehouseSchedulerDemoRunner Runner, WarehouseSchedulerService Service); /// /// Fluent registration helpers for importing every documented PatternKit example into Microsoft.Extensions.DependencyInjection. @@ -309,7 +311,8 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddOrderTelemetrySidecarExample() .AddCommerceBackendsForFrontendsExample() .AddInventoryAmbassadorExample() - .AddWarehouseLeaderElectionExample(); + .AddWarehouseLeaderElectionExample() + .AddWarehouseSchedulerAgentSupervisorExample(); public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services) { @@ -1101,6 +1104,15 @@ public static IServiceCollection AddWarehouseLeaderElectionExample(this IService return services.RegisterExample("Warehouse Leader Election", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddWarehouseSchedulerAgentSupervisorExample(this IServiceCollection services) + { + services.AddWarehouseSchedulerAgentSupervisorDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + sp.GetRequiredService())); + return services.RegisterExample("Warehouse Scheduler Agent Supervisor", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + private static IServiceCollection RegisterExample( this IServiceCollection services, string name, diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index bfe3e33f..595a2714 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -783,7 +783,15 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog "docs/examples/warehouse-leader-election.md", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["Leader Election"], - ["single active worker lease", "source-generated candidate factory", "Generic Host hosted service"]) + ["single active worker lease", "source-generated candidate factory", "Generic Host hosted service"]), + Descriptor( + "Warehouse Scheduler Agent Supervisor", + "src/PatternKit.Examples/SchedulerAgentSupervisorDemo/WarehouseSchedulerAgentSupervisorDemo.cs", + "test/PatternKit.Examples.Tests/SchedulerAgentSupervisorDemo/WarehouseSchedulerAgentSupervisorDemoTests.cs", + "docs/examples/warehouse-scheduler-agent-supervisor.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["Scheduler Agent Supervisor"], + ["scheduled work dispatch", "source-generated supervisor factory", "Generic Host hosted service"]) ]; public IReadOnlyList Entries => Items; diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index e35704b5..4423fa34 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -1000,6 +1000,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/LeaderElectionDemo/WarehouseLeaderElectionDemoTests.cs", ["fluent lease election", "generated candidate factory", "DI-importable Generic Host worker example"]), + Pattern("Scheduler Agent Supervisor", PatternFamily.CloudArchitecture, + "docs/patterns/cloud/scheduler-agent-supervisor.md", + "src/PatternKit.Core/Cloud/SchedulerAgentSupervisor/SchedulerAgentSupervisor.cs", + "test/PatternKit.Tests/Cloud/SchedulerAgentSupervisor/SchedulerAgentSupervisorTests.cs", + "docs/generators/scheduler-agent-supervisor.md", + "src/PatternKit.Generators/SchedulerAgentSupervisor/SchedulerAgentSupervisorGenerator.cs", + "test/PatternKit.Generators.Tests/SchedulerAgentSupervisorGeneratorTests.cs", + null, + "docs/examples/warehouse-scheduler-agent-supervisor.md", + "src/PatternKit.Examples/SchedulerAgentSupervisorDemo/WarehouseSchedulerAgentSupervisorDemo.cs", + "test/PatternKit.Examples.Tests/SchedulerAgentSupervisorDemo/WarehouseSchedulerAgentSupervisorDemoTests.cs", + ["fluent scheduled worker supervision", "generated supervisor factory", "DI-importable Generic Host scheduler example"]), + Pattern("CQRS", PatternFamily.ApplicationArchitecture, "docs/generators/dispatcher.md", "src/PatternKit.Core/Behavioral/Mediator/Mediator.cs", diff --git a/src/PatternKit.Examples/SchedulerAgentSupervisorDemo/WarehouseSchedulerAgentSupervisorDemo.cs b/src/PatternKit.Examples/SchedulerAgentSupervisorDemo/WarehouseSchedulerAgentSupervisorDemo.cs new file mode 100644 index 00000000..800f4d30 --- /dev/null +++ b/src/PatternKit.Examples/SchedulerAgentSupervisorDemo/WarehouseSchedulerAgentSupervisorDemo.cs @@ -0,0 +1,115 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using PatternKit.Cloud.SchedulerAgentSupervisor; +using PatternKit.Generators.SchedulerAgentSupervisor; + +namespace PatternKit.Examples.SchedulerAgentSupervisorDemo; + +public sealed record WarehouseReplenishmentWork(string BatchId, bool FailFirstAttempt, List Log); + +public sealed record WarehouseReplenishmentSummary(string BatchId, int Attempt); + +public sealed class WarehouseSchedulerService(SchedulerAgentSupervisor supervisor) +{ + public SchedulerAgentSupervisor Supervisor { get; } = supervisor; + + public void Schedule(WarehouseReplenishmentWork work, DateTimeOffset dueAt) + => Supervisor.Schedule($"replenish:{work.BatchId}", work, dueAt); + + public IReadOnlyList> RunDue(DateTimeOffset now) + => Supervisor.RunDue(now); +} + +public sealed class WarehouseSchedulerHostedService(WarehouseSchedulerService service) : IHostedService +{ + public DateTimeOffset Now { get; } = new(2026, 5, 22, 8, 0, 0, TimeSpan.Zero); + + public WarehouseReplenishmentWork Work { get; } = new("B-100", FailFirstAttempt: false, []); + + public IReadOnlyList> LastResults { get; private set; } = []; + + public Task StartAsync(CancellationToken cancellationToken) + { + service.Schedule(Work, Now); + LastResults = service.RunDue(Now); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +public static class WarehouseSchedulers +{ + public static SchedulerAgentSupervisor CreateFluent() + => SchedulerAgentSupervisor + .Create("warehouse-replenishment-scheduler") + .Supervision(SchedulerSupervisionPolicy.Create() + .MaxAttempts(2) + .RetryDelay(TimeSpan.FromSeconds(5)) + .RetryWhen((_, ctx) => ctx.Work.FailFirstAttempt) + .Build()) + .Agent("release-replenishment", ctx => + { + ctx.Work.Log.Add($"fluent:{ctx.Attempt}"); + return new WarehouseReplenishmentSummary(ctx.Work.BatchId, ctx.Attempt); + }) + .Build(); +} + +[GenerateSchedulerAgentSupervisor( + typeof(WarehouseReplenishmentWork), + typeof(WarehouseReplenishmentSummary), + FactoryMethodName = "Create", + SupervisorName = "warehouse-replenishment-scheduler", + MaxAttempts = 2, + RetryDelayMilliseconds = 5000)] +public static partial class GeneratedWarehouseScheduler +{ + [SchedulerAgent("release-replenishment")] + private static WarehouseReplenishmentSummary Release(SchedulerAgentContext context) + { + context.Work.Log.Add($"generated:{context.Attempt}"); + if (context.Work.FailFirstAttempt && context.Attempt == 1) + throw new InvalidOperationException("inventory feed unavailable"); + + return new WarehouseReplenishmentSummary(context.Work.BatchId, context.Attempt); + } + + [SchedulerRetryWhen] + private static bool Retry(Exception exception, SchedulerAgentContext context) + => exception is InvalidOperationException && context.Work.FailFirstAttempt; +} + +public sealed class WarehouseSchedulerDemoRunner(WarehouseSchedulerService service) +{ + public IReadOnlyList> RunGenerated() + { + var now = new DateTimeOffset(2026, 5, 22, 8, 0, 0, TimeSpan.Zero); + var work = new WarehouseReplenishmentWork("B-100", FailFirstAttempt: true, []); + service.Schedule(work, now); + var first = service.RunDue(now); + var retry = service.RunDue(now.AddSeconds(5)); + return first.Concat(retry).ToArray(); + } + + public SchedulerAgentResult RunFluent() + { + var now = new DateTimeOffset(2026, 5, 22, 8, 0, 0, TimeSpan.Zero); + var work = new WarehouseReplenishmentWork("B-200", FailFirstAttempt: false, []); + var supervisor = WarehouseSchedulers.CreateFluent().Schedule("replenish:B-200", work, now); + return supervisor.RunDue(now).Single(); + } +} + +public static class WarehouseSchedulerServiceCollectionExtensions +{ + public static IServiceCollection AddWarehouseSchedulerAgentSupervisorDemo(this IServiceCollection services) + { + services.AddSingleton(_ => GeneratedWarehouseScheduler.Create()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + return services; + } +} diff --git a/src/PatternKit.Generators.Abstractions/Cloud/SchedulerAgentSupervisorAttributes.cs b/src/PatternKit.Generators.Abstractions/Cloud/SchedulerAgentSupervisorAttributes.cs new file mode 100644 index 00000000..3b909bc6 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Cloud/SchedulerAgentSupervisorAttributes.cs @@ -0,0 +1,28 @@ +namespace PatternKit.Generators.SchedulerAgentSupervisor; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateSchedulerAgentSupervisorAttribute(Type workType, Type resultType) : Attribute +{ + public Type WorkType { get; } = workType ?? throw new ArgumentNullException(nameof(workType)); + + public Type ResultType { get; } = resultType ?? throw new ArgumentNullException(nameof(resultType)); + + public string FactoryMethodName { get; set; } = "Create"; + + public string SupervisorName { get; set; } = "scheduler-agent-supervisor"; + + public int MaxAttempts { get; set; } = 3; + + public int RetryDelayMilliseconds { get; set; } = 1000; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class SchedulerAgentAttribute(string name) : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Agent name is required.", nameof(name)) : name; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class SchedulerRetryWhenAttribute : Attribute +{ +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 4d9ac12b..63116ba9 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -318,6 +318,11 @@ PKLE001 | PatternKit.Generators.LeaderElection | Error | Leader Election host mu PKLE002 | PatternKit.Generators.LeaderElection | Error | Leader Election members are missing. PKLE003 | PatternKit.Generators.LeaderElection | Error | Leader Election method signature is invalid. PKLE004 | PatternKit.Generators.LeaderElection | Error | Leader Election lease duration is invalid. +PKSAS001 | PatternKit.Generators.SchedulerAgentSupervisor | Error | Scheduler Agent Supervisor host must be partial. +PKSAS002 | PatternKit.Generators.SchedulerAgentSupervisor | Error | Scheduler Agent Supervisor agents are missing. +PKSAS003 | PatternKit.Generators.SchedulerAgentSupervisor | Error | Scheduler Agent Supervisor method signature is invalid. +PKSAS004 | PatternKit.Generators.SchedulerAgentSupervisor | Error | Scheduler Agent Supervisor configuration is invalid. +PKSAS005 | PatternKit.Generators.SchedulerAgentSupervisor | Error | Scheduler Agent Supervisor agent is duplicated. PKGR001 | PatternKit.Generators.GatewayRouting | Error | Gateway Routing host must be partial. PKGR002 | PatternKit.Generators.GatewayRouting | Error | Gateway Routing members are missing. PKGR003 | PatternKit.Generators.GatewayRouting | Error | Gateway Routing method signature is invalid. diff --git a/src/PatternKit.Generators/SchedulerAgentSupervisor/SchedulerAgentSupervisorGenerator.cs b/src/PatternKit.Generators/SchedulerAgentSupervisor/SchedulerAgentSupervisorGenerator.cs new file mode 100644 index 00000000..1350042b --- /dev/null +++ b/src/PatternKit.Generators/SchedulerAgentSupervisor/SchedulerAgentSupervisorGenerator.cs @@ -0,0 +1,226 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.SchedulerAgentSupervisor; + +[Generator] +public sealed class SchedulerAgentSupervisorGenerator : IIncrementalGenerator +{ + private const string AttributeName = "PatternKit.Generators.SchedulerAgentSupervisor.GenerateSchedulerAgentSupervisorAttribute"; + private const string AgentAttributeName = "PatternKit.Generators.SchedulerAgentSupervisor.SchedulerAgentAttribute"; + private const string RetryAttributeName = "PatternKit.Generators.SchedulerAgentSupervisor.SchedulerRetryWhenAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKSAS001", "Scheduler Agent Supervisor host must be partial", + "Type '{0}' is marked with [GenerateSchedulerAgentSupervisor] but is not declared as partial", + "PatternKit.Generators.SchedulerAgentSupervisor", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingMembers = new( + "PKSAS002", "Scheduler Agent Supervisor agents are missing", + "Scheduler Agent Supervisor type '{0}' must declare at least one scheduler agent", + "PatternKit.Generators.SchedulerAgentSupervisor", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidMember = new( + "PKSAS003", "Scheduler Agent Supervisor method signature is invalid", + "Scheduler Agent Supervisor method '{0}' has an invalid static signature", + "PatternKit.Generators.SchedulerAgentSupervisor", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidConfiguration = new( + "PKSAS004", "Scheduler Agent Supervisor configuration is invalid", + "Scheduler Agent Supervisor '{0}' must have MaxAttempts > 0 and RetryDelayMilliseconds >= 0", + "PatternKit.Generators.SchedulerAgentSupervisor", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor DuplicateAgent = new( + "PKSAS005", "Scheduler Agent Supervisor agent is duplicated", + "Scheduler Agent Supervisor agent '{0}' is registered more than once", + "PatternKit.Generators.SchedulerAgentSupervisor", DiagnosticSeverity.Error, true); + + private static readonly SymbolDisplayFormat TypeFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + AttributeName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(static a => a.AttributeClass?.ToDisplayString() == AttributeName); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate(SourceProductionContext context, INamedTypeSymbol type, TypeDeclarationSyntax node, AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var workType = attribute.ConstructorArguments.Length >= 1 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + var resultType = attribute.ConstructorArguments.Length >= 2 ? attribute.ConstructorArguments[1].Value as INamedTypeSymbol : null; + if (workType is null || resultType is null) + return; + + var maxAttempts = GetNamedInt(attribute, "MaxAttempts") ?? 3; + var retryDelay = GetNamedInt(attribute, "RetryDelayMilliseconds") ?? 1000; + if (maxAttempts <= 0 || retryDelay < 0) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidConfiguration, node.Identifier.GetLocation(), type.Name)); + return; + } + + var agents = MembersWith(type, AgentAttributeName); + var retries = MembersWith(type, RetryAttributeName); + if (agents.Length == 0 || retries.Length > 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingMembers, node.Identifier.GetLocation(), type.Name)); + return; + } + + foreach (var agent in agents) + { + if (!IsAgent(agent, workType, resultType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, agent.Locations.FirstOrDefault(), agent.Name)); + return; + } + } + + if (retries.Length == 1 && !IsRetry(retries[0], workType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, retries[0].Locations.FirstOrDefault(), retries[0].Name)); + return; + } + + var agentNames = agents.Select(GetAgentName).ToArray(); + var duplicate = agentNames.GroupBy(static name => name).FirstOrDefault(static group => group.Count() > 1)?.Key; + if (duplicate is not null) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateAgent, node.Identifier.GetLocation(), duplicate)); + return; + } + + context.AddSource($"{type.Name}.SchedulerAgentSupervisor.g.cs", SourceText.From(GenerateSource( + type, + workType, + resultType, + agents.Zip(agentNames, static (method, name) => (MethodName: method.Name, AgentName: name)).ToArray(), + retries.FirstOrDefault()?.Name, + GetNamedString(attribute, "FactoryMethodName") ?? "Create", + GetNamedString(attribute, "SupervisorName") ?? "scheduler-agent-supervisor", + maxAttempts, + retryDelay), Encoding.UTF8)); + } + + private static IMethodSymbol[] MembersWith(INamedTypeSymbol type, string attributeName) + => type.GetMembers().OfType() + .Where(method => method.GetAttributes().Any(attr => attr.AttributeClass?.ToDisplayString() == attributeName)) + .ToArray(); + + private static bool IsAgent(IMethodSymbol method, INamedTypeSymbol workType, INamedTypeSymbol resultType) + => method.IsStatic && + SymbolEqualityComparer.Default.Equals(method.ReturnType, resultType) && + method.Parameters.Length == 1 && + IsSchedulerContext(method.Parameters[0].Type, workType); + + private static bool IsRetry(IMethodSymbol method, INamedTypeSymbol workType) + => method.IsStatic && + method.ReturnType.SpecialType == SpecialType.System_Boolean && + method.Parameters.Length == 2 && + method.Parameters[0].Type.ToDisplayString() == "System.Exception" && + IsSchedulerContext(method.Parameters[1].Type, workType); + + private static bool IsSchedulerContext(ITypeSymbol type, INamedTypeSymbol workType) + => type is INamedTypeSymbol named && + named.ConstructedFrom.ToDisplayString() == "PatternKit.Cloud.SchedulerAgentSupervisor.SchedulerAgentContext" && + named.TypeArguments.Length == 1 && + SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], workType); + + private static string GetAgentName(IMethodSymbol method) + => method.GetAttributes() + .First(attr => attr.AttributeClass?.ToDisplayString() == AgentAttributeName) + .ConstructorArguments[0].Value as string ?? method.Name; + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol workType, + INamedTypeSymbol resultType, + IReadOnlyList<(string MethodName, string AgentName)> agents, + string? retryName, + string factoryMethodName, + string supervisorName, + int maxAttempts, + int retryDelayMilliseconds) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var workTypeName = workType.ToDisplayString(TypeFormat); + var resultTypeName = resultType.ToDisplayString(TypeFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + if (type.IsStatic) + sb.Append("static "); + else if (type.IsAbstract && type.TypeKind == TypeKind.Class) + sb.Append("abstract "); + else if (type.IsSealed && type.TypeKind == TypeKind.Class) + sb.Append("sealed "); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Cloud.SchedulerAgentSupervisor.SchedulerAgentSupervisor<").Append(workTypeName).Append(", ").Append(resultTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" var policy = global::PatternKit.Cloud.SchedulerAgentSupervisor.SchedulerSupervisionPolicy<").Append(workTypeName).AppendLine(">.Create()"); + sb.Append(" .MaxAttempts(").Append(maxAttempts).AppendLine(")"); + sb.Append(" .RetryDelay(global::System.TimeSpan.FromMilliseconds(").Append(retryDelayMilliseconds).AppendLine("))"); + if (retryName is not null) + sb.Append(" .RetryWhen(").Append(retryName).AppendLine(")"); + sb.AppendLine(" .Build();"); + sb.AppendLine(); + sb.Append(" return global::PatternKit.Cloud.SchedulerAgentSupervisor.SchedulerAgentSupervisor<").Append(workTypeName).Append(", ").Append(resultTypeName).Append(">.Create(\"").Append(Escape(supervisorName)).AppendLine("\")"); + sb.AppendLine(" .Supervision(policy)"); + foreach (var agent in agents) + sb.Append(" .Agent(\"").Append(Escape(agent.AgentName)).Append("\", ").Append(agent.MethodName).AppendLine(")"); + sb.AppendLine(" .Build();"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static int? GetNamedInt(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as int?; + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 8b8cfc39..40ab5e0d 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -87,6 +87,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Backends for Frontends", "Ambassador", "Leader Election", + "Scheduler Agent Supervisor", "CQRS", "Specification", "Repository", @@ -144,7 +145,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() { ScenarioExpect.Equal(30, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); - ScenarioExpect.Equal(16, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); + ScenarioExpect.Equal(17, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); ScenarioExpect.Equal(15, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Examples.Tests/SchedulerAgentSupervisorDemo/WarehouseSchedulerAgentSupervisorDemoTests.cs b/test/PatternKit.Examples.Tests/SchedulerAgentSupervisorDemo/WarehouseSchedulerAgentSupervisorDemoTests.cs new file mode 100644 index 00000000..035d4164 --- /dev/null +++ b/test/PatternKit.Examples.Tests/SchedulerAgentSupervisorDemo/WarehouseSchedulerAgentSupervisorDemoTests.cs @@ -0,0 +1,123 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.ProductionReadiness; +using PatternKit.Examples.SchedulerAgentSupervisorDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.SchedulerAgentSupervisorDemo; + +[Feature("Warehouse Scheduler Agent Supervisor demo")] +public sealed class WarehouseSchedulerAgentSupervisorDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent scheduler agent supervisor dispatches replenishment work")] + [Fact] + public Task Fluent_Scheduler_Agent_Supervisor_Dispatches_Replenishment_Work() + => Given("the fluent warehouse scheduler", WarehouseSchedulers.CreateFluent) + .When("a replenishment job is due", scheduler => + { + var now = new DateTimeOffset(2026, 5, 22, 8, 0, 0, TimeSpan.Zero); + var work = new WarehouseReplenishmentWork("B-200", FailFirstAttempt: false, []); + scheduler.Schedule("replenish:B-200", work, now); + return scheduler.RunDue(now); + }) + .Then("work is dispatched successfully", results => + { + var result = ScenarioExpect.Single(results); + ScenarioExpect.True(result.Succeeded); + ScenarioExpect.Equal("release-replenishment", result.AgentName); + ScenarioExpect.Equal("B-200", result.Response!.BatchId); + }) + .AssertPassed(); + + [Scenario("Generated scheduler is importable through IServiceCollection")] + [Fact] + public Task Generated_Scheduler_Is_Importable_Through_IServiceCollection() + => Given("a service provider configured with the scheduler demo", () => + { + var services = new ServiceCollection(); + services.AddWarehouseSchedulerAgentSupervisorDemo(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("the demo runner resolves and runs", provider => + { + using (provider) + return provider.GetRequiredService().RunGenerated(); + }) + .Then("retry supervision captures failure and retry success", results => + { + ScenarioExpect.Equal(2, results.Count); + ScenarioExpect.True(results[0].RetryScheduled); + ScenarioExpect.True(results[1].Succeeded); + ScenarioExpect.Equal(2, results[1].Attempt); + }) + .AssertPassed(); + + [Scenario("Hosted service participates in scheduler lifecycle")] + [Fact] + public Task Hosted_Service_Participates_In_Scheduler_Lifecycle() + => Given("a hosted warehouse scheduler service", () => + { + var services = new ServiceCollection(); + services.AddWarehouseSchedulerAgentSupervisorDemo(); + var provider = services.BuildServiceProvider(validateScopes: true); + return new { Provider = provider, Hosted = provider.GetServices().OfType().Single() }; + }) + .When("the host starts and stops the service", ctx => RunHostedLifecycle(ctx.Provider, ctx.Hosted)) + .Then("scheduled work is dispatched", results => + { + var result = ScenarioExpect.Single(results); + ScenarioExpect.True(result.Succeeded); + ScenarioExpect.Equal("B-100", result.Response!.BatchId); + }) + .AssertPassed(); + + [Scenario("Warehouse scheduler appears in production catalogs")] + [Fact] + public Task Warehouse_Scheduler_Appears_In_Production_Catalogs() + => Given("the production catalogs", () => new + { + Examples = new PatternKitExampleCatalog(), + Patterns = new PatternKitPatternCatalog() + }) + .Then("the example catalog includes the scheduler demo", ctx => + ScenarioExpect.Contains(ctx.Examples.Entries, entry => entry.Name == "Warehouse Scheduler Agent Supervisor")) + .And("the pattern catalog includes Scheduler Agent Supervisor", ctx => + ScenarioExpect.Contains(ctx.Patterns.Patterns, pattern => pattern.Name == "Scheduler Agent Supervisor")) + .AssertPassed(); + + [Scenario("Aggregate example registration includes warehouse scheduler")] + [Fact] + public Task Aggregate_Example_Registration_Includes_Warehouse_Scheduler() + => Given("all PatternKit examples registered in a service collection", () => + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("the scheduler example is resolved", provider => + { + using (provider) + return provider.GetRequiredService().Runner.RunGenerated(); + }) + .Then("the registered example executes retry supervision", results => + { + ScenarioExpect.Equal(2, results.Count); + ScenarioExpect.True(results[1].Succeeded); + }) + .AssertPassed(); + + private static async Task>> RunHostedLifecycle( + ServiceProvider provider, + WarehouseSchedulerHostedService hosted) + { + using (provider) + { + await hosted.StartAsync(CancellationToken.None); + await hosted.StopAsync(CancellationToken.None); + return hosted.LastResults; + } + } +} diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 00c1cd51..f4771842 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -36,6 +36,7 @@ using PatternKit.Generators.RateLimiting; using PatternKit.Generators.Repository; using PatternKit.Generators.Retry; +using PatternKit.Generators.SchedulerAgentSupervisor; using PatternKit.Generators.ServiceLayer; using PatternKit.Generators.Sidecar; using PatternKit.Generators.Singleton; @@ -245,6 +246,9 @@ private enum TestTrigger { typeof(LeaderAcquiredAttribute), AttributeTargets.Method, false, false }, { typeof(LeaderRenewedAttribute), AttributeTargets.Method, false, false }, { typeof(LeaderReleasedAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateSchedulerAgentSupervisorAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(SchedulerAgentAttribute), AttributeTargets.Method, false, false }, + { typeof(SchedulerRetryWhenAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateGatewayRoutingAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GatewayRouteAttribute), AttributeTargets.Method, false, false }, { typeof(GatewayRouteHandlerAttribute), AttributeTargets.Method, false, false }, @@ -657,6 +661,32 @@ public void Leader_Election_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.IsType(new LeaderReleasedAttribute()); } + [Scenario("Scheduler Agent Supervisor Attributes Expose Defaults And Configuration")] + [Fact] + public void Scheduler_Agent_Supervisor_Attributes_Expose_Defaults_And_Configuration() + { + var scheduler = new GenerateSchedulerAgentSupervisorAttribute(typeof(string), typeof(int)) + { + FactoryMethodName = "BuildScheduler", + SupervisorName = "orders-scheduler", + MaxAttempts = 5, + RetryDelayMilliseconds = 250 + }; + var agent = new SchedulerAgentAttribute("release-agent"); + + ScenarioExpect.Equal(typeof(string), scheduler.WorkType); + ScenarioExpect.Equal(typeof(int), scheduler.ResultType); + ScenarioExpect.Equal("BuildScheduler", scheduler.FactoryMethodName); + ScenarioExpect.Equal("orders-scheduler", scheduler.SupervisorName); + ScenarioExpect.Equal(5, scheduler.MaxAttempts); + ScenarioExpect.Equal(250, scheduler.RetryDelayMilliseconds); + ScenarioExpect.Equal("release-agent", agent.Name); + ScenarioExpect.Throws(() => new GenerateSchedulerAgentSupervisorAttribute(null!, typeof(int))); + ScenarioExpect.Throws(() => new GenerateSchedulerAgentSupervisorAttribute(typeof(string), null!)); + ScenarioExpect.Throws(() => new SchedulerAgentAttribute("")); + ScenarioExpect.IsType(new SchedulerRetryWhenAttribute()); + } + [Scenario("Strangler Fig Attributes Expose Defaults And Configuration")] [Fact] public void StranglerFig_Attributes_Expose_Defaults_And_Configuration() diff --git a/test/PatternKit.Generators.Tests/SchedulerAgentSupervisorGeneratorTests.cs b/test/PatternKit.Generators.Tests/SchedulerAgentSupervisorGeneratorTests.cs new file mode 100644 index 00000000..0a03da2d --- /dev/null +++ b/test/PatternKit.Generators.Tests/SchedulerAgentSupervisorGeneratorTests.cs @@ -0,0 +1,117 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Cloud.SchedulerAgentSupervisor; +using PatternKit.Generators.SchedulerAgentSupervisor; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Scheduler Agent Supervisor generator")] +public sealed partial class SchedulerAgentSupervisorGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates scheduler agent supervisor factory")] + [Fact] + public Task Generates_Scheduler_Agent_Supervisor_Factory() + => Given("a scheduler agent supervisor declaration", () => Compile(""" + using System; + using PatternKit.Cloud.SchedulerAgentSupervisor; + using PatternKit.Generators.SchedulerAgentSupervisor; + namespace Demo; + public sealed record Work(string Id); + public sealed record Summary(string Id); + [GenerateSchedulerAgentSupervisor(typeof(Work), typeof(Summary), FactoryMethodName = "Build", SupervisorName = "orders-scheduler", MaxAttempts = 4, RetryDelayMilliseconds = 250)] + public static partial class OrdersScheduler + { + [SchedulerAgent("release-agent")] + private static Summary Release(SchedulerAgentContext context) => new(context.Work.Id); + [SchedulerRetryWhen] + private static bool Retry(Exception exception, SchedulerAgentContext context) => context.Attempt < 3; + } + """)) + .Then("the generated source creates a configured supervisor", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("Build()", source); + ScenarioExpect.Contains("SchedulerSupervisionPolicy.Create()", source); + ScenarioExpect.Contains(".MaxAttempts(4)", source); + ScenarioExpect.Contains(".RetryDelay(global::System.TimeSpan.FromMilliseconds(250))", source); + ScenarioExpect.Contains(".RetryWhen(Retry)", source); + ScenarioExpect.Contains("SchedulerAgentSupervisor.Create(\"orders-scheduler\")", source); + ScenarioExpect.Contains(".Agent(\"release-agent\", Release)", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid scheduler declarations")] + [Fact] + public Task Reports_Diagnostics_For_Invalid_Scheduler_Declarations() + => Given("invalid scheduler declarations", () => new[] + { + Compile(""" + using PatternKit.Generators.SchedulerAgentSupervisor; + [GenerateSchedulerAgentSupervisor(typeof(string), typeof(string))] + public static class SchedulerHost; + """), + Compile(""" + using PatternKit.Generators.SchedulerAgentSupervisor; + [GenerateSchedulerAgentSupervisor(typeof(string), typeof(string))] + public static partial class SchedulerHost; + """), + Compile(""" + using PatternKit.Generators.SchedulerAgentSupervisor; + [GenerateSchedulerAgentSupervisor(typeof(string), typeof(string))] + public static partial class SchedulerHost + { + [SchedulerAgent("agent")] + private static int Run(string value) => 1; + } + """), + Compile(""" + using PatternKit.Cloud.SchedulerAgentSupervisor; + using PatternKit.Generators.SchedulerAgentSupervisor; + [GenerateSchedulerAgentSupervisor(typeof(string), typeof(string), MaxAttempts = 0)] + public static partial class SchedulerHost + { + [SchedulerAgent("agent")] + private static string Run(SchedulerAgentContext context) => context.Work; + } + """), + Compile(""" + using PatternKit.Cloud.SchedulerAgentSupervisor; + using PatternKit.Generators.SchedulerAgentSupervisor; + [GenerateSchedulerAgentSupervisor(typeof(string), typeof(string))] + public static partial class SchedulerHost + { + [SchedulerAgent("agent")] + private static string Run(SchedulerAgentContext context) => context.Work; + [SchedulerAgent("agent")] + private static string RunAgain(SchedulerAgentContext context) => context.Work; + } + """) + }) + .Then("diagnostics identify invalid declarations", results => + { + ScenarioExpect.Contains(results[0].Diagnostics, diagnostic => diagnostic.Id == "PKSAS001"); + ScenarioExpect.Contains(results[1].Diagnostics, diagnostic => diagnostic.Id == "PKSAS002"); + ScenarioExpect.Contains(results[2].Diagnostics, diagnostic => diagnostic.Id == "PKSAS003"); + ScenarioExpect.Contains(results[3].Diagnostics, diagnostic => diagnostic.Id == "PKSAS004"); + ScenarioExpect.Contains(results[4].Diagnostics, diagnostic => diagnostic.Id == "PKSAS005"); + }) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "SchedulerAgentSupervisorGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(SchedulerAgentSupervisor<,>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new SchedulerAgentSupervisorGenerator(), out var run, out var updated); + var result = run.Results.Single(); + var emit = updated.Emit(Stream.Null); + return new(result.Diagnostics.ToArray(), result.GeneratedSources.Select(static source => source.SourceText.ToString()).ToArray(), emit.Success, emit.Diagnostics.Select(static diagnostic => diagnostic.ToString()).ToArray()); + } + + private sealed record GeneratorResult(IReadOnlyList Diagnostics, IReadOnlyList GeneratedSources, bool EmitSuccess, IReadOnlyList EmitDiagnostics); +} diff --git a/test/PatternKit.Tests/Cloud/SchedulerAgentSupervisor/SchedulerAgentSupervisorTests.cs b/test/PatternKit.Tests/Cloud/SchedulerAgentSupervisor/SchedulerAgentSupervisorTests.cs new file mode 100644 index 00000000..e9a67b18 --- /dev/null +++ b/test/PatternKit.Tests/Cloud/SchedulerAgentSupervisor/SchedulerAgentSupervisorTests.cs @@ -0,0 +1,164 @@ +using PatternKit.Cloud.SchedulerAgentSupervisor; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Cloud.SchedulerAgentSupervisor; + +[Feature("Scheduler Agent Supervisor")] +public sealed class SchedulerAgentSupervisorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Scheduler dispatches due work to an agent")] + [Fact] + public Task Scheduler_Dispatches_Due_Work_To_An_Agent() + => Given("a scheduler agent supervisor", () => + { + var now = new DateTimeOffset(2026, 5, 22, 8, 0, 0, TimeSpan.Zero); + var supervisor = SchedulerAgentSupervisor + .Create("warehouse-supervisor") + .Clock(() => now) + .Agent("release-agent", ctx => + { + ctx.Events.Add($"released:{ctx.Work.BatchId}"); + return new DispatchSummary(ctx.Work.BatchId, ctx.Attempt); + }) + .Build() + .Schedule("release-backlog", new OrderBatch("B-100"), now); + return supervisor; + }) + .When("due work is run", supervisor => supervisor.RunDue()) + .Then("the agent result captures dispatch details", results => + { + var result = ScenarioExpect.Single(results); + ScenarioExpect.True(result.Succeeded); + ScenarioExpect.Equal("warehouse-supervisor", result.SupervisorName); + ScenarioExpect.Equal("release-backlog", result.JobName); + ScenarioExpect.Equal("release-agent", result.AgentName); + ScenarioExpect.Equal(1, result.Attempt); + ScenarioExpect.Equal("B-100", result.Response!.BatchId); + ScenarioExpect.Equal(["dispatch:release-agent:1", "released:B-100"], result.Events); + }) + .AssertPassed(); + + [Scenario("Scheduler only dispatches due jobs")] + [Fact] + public Task Scheduler_Only_Dispatches_Due_Jobs() + => Given("a scheduler with due and future jobs", () => + { + var now = new DateTimeOffset(2026, 5, 22, 8, 0, 0, TimeSpan.Zero); + var supervisor = CreateSupervisor(now); + supervisor.Schedule("due", new OrderBatch("B-1"), now); + supervisor.Schedule("future", new OrderBatch("B-2"), now.AddMinutes(5)); + return new { Supervisor = supervisor, Now = now }; + }) + .When("due work is run", ctx => ctx.Supervisor.RunDue(ctx.Now)) + .Then("only due work is dispatched", results => + { + var result = ScenarioExpect.Single(results); + ScenarioExpect.Equal("due", result.JobName); + }) + .AssertPassed(); + + [Scenario("Supervisor reschedules retryable failures")] + [Fact] + public Task Supervisor_Reschedules_Retryable_Failures() + => Given("a scheduler with retry policy", () => + { + var now = new DateTimeOffset(2026, 5, 22, 8, 0, 0, TimeSpan.Zero); + var attempts = 0; + var supervisor = SchedulerAgentSupervisor + .Create("warehouse-supervisor") + .Supervision(SchedulerSupervisionPolicy.Create().MaxAttempts(3).RetryDelay(TimeSpan.FromSeconds(5)).Build()) + .Agent("release-agent", ctx => + { + attempts++; + if (attempts == 1) + throw new InvalidOperationException("warehouse unavailable"); + return new DispatchSummary(ctx.Work.BatchId, ctx.Attempt); + }) + .Build() + .Schedule("release-backlog", new OrderBatch("B-100"), now); + return new { Supervisor = supervisor, Now = now }; + }) + .When("the first attempt fails", ctx => new { ctx.Supervisor, ctx.Now, First = ctx.Supervisor.RunDue(ctx.Now) }) + .Then("a retry is scheduled", ctx => + { + var first = ScenarioExpect.Single(ctx.First); + ScenarioExpect.True(first.Failed); + ScenarioExpect.True(first.RetryScheduled); + ScenarioExpect.False(first.Exhausted); + ScenarioExpect.Equal(["release-backlog"], ctx.Supervisor.PendingJobs); + }) + .And("the retry succeeds when due", ctx => + { + var retry = ScenarioExpect.Single(ctx.Supervisor.RunDue(ctx.Now.AddSeconds(5))); + ScenarioExpect.True(retry.Succeeded); + ScenarioExpect.Equal(2, retry.Attempt); + }) + .AssertPassed(); + + [Scenario("Supervisor exhausts failures at retry limit")] + [Fact] + public Task Supervisor_Exhausts_Failures_At_Retry_Limit() + => Given("a scheduler with one allowed attempt", () => + { + var now = new DateTimeOffset(2026, 5, 22, 8, 0, 0, TimeSpan.Zero); + var supervisor = SchedulerAgentSupervisor + .Create("warehouse-supervisor") + .Supervision(SchedulerSupervisionPolicy.Create() + .MaxAttempts(1) + .RetryWhen((_, ctx) => ctx.Work.BatchId != "never") + .Build()) + .Agent("release-agent", _ => throw new InvalidOperationException("failed")) + .Build() + .Schedule("release-backlog", new OrderBatch("never"), now); + return new { Supervisor = supervisor, Now = now }; + }) + .When("the job fails", ctx => ctx.Supervisor.RunDue(ctx.Now)) + .Then("the result is exhausted and not retried", results => + { + var result = ScenarioExpect.Single(results); + ScenarioExpect.True(result.Failed); + ScenarioExpect.False(result.RetryScheduled); + ScenarioExpect.True(result.Exhausted); + }) + .AssertPassed(); + + [Scenario("Scheduler validates configuration")] + [Fact] + public Task Scheduler_Validates_Configuration() + => Given("invalid scheduler inputs", () => new object()) + .Then("invalid configuration throws", _ => + { + ScenarioExpect.Throws(() => SchedulerAgentSupervisor.Create("").Agent("a", Complete).Build()); + ScenarioExpect.Throws(() => SchedulerAgentSupervisor.Create().Build()); + ScenarioExpect.Throws(() => SchedulerAgentSupervisor.Create().Agent("", Complete)); + ScenarioExpect.Throws(() => SchedulerAgentSupervisor.Create().Agent("a", null!)); + ScenarioExpect.Throws(() => SchedulerAgentSupervisor.Create().Clock(null!)); + ScenarioExpect.Throws(() => SchedulerAgentSupervisor.Create().Supervision(null!)); + ScenarioExpect.Throws(() => SchedulerAgentSupervisor.Create().Agent("a", Complete).Agent("a", Complete)); + ScenarioExpect.Throws(() => CreateSupervisor(DateTimeOffset.UtcNow).Schedule("", new OrderBatch("B"), DateTimeOffset.UtcNow)); + ScenarioExpect.Throws(() => CreateSupervisor(DateTimeOffset.UtcNow).Schedule("job", null!, DateTimeOffset.UtcNow)); + ScenarioExpect.Throws(() => SchedulerSupervisionPolicy.Create().MaxAttempts(0)); + ScenarioExpect.Throws(() => SchedulerSupervisionPolicy.Create().RetryDelay(TimeSpan.FromMilliseconds(-1))); + ScenarioExpect.Throws(() => SchedulerSupervisionPolicy.Create().RetryWhen(null!)); + ScenarioExpect.Throws(() => SchedulerAgentResult.Success("s", "j", "a", 1, null!, [])); + ScenarioExpect.Throws(() => SchedulerAgentResult.Success("s", "j", "a", 1, new("B", 1), null!)); + ScenarioExpect.Throws(() => SchedulerAgentResult.Failure("s", "j", "a", 1, null!, [], false, true)); + ScenarioExpect.Throws(() => SchedulerAgentResult.Failure("s", "j", "a", 1, new InvalidOperationException(), null!, false, true)); + }) + .AssertPassed(); + + private static SchedulerAgentSupervisor CreateSupervisor(DateTimeOffset now) + => SchedulerAgentSupervisor + .Create("warehouse-supervisor") + .Clock(() => now) + .Agent("release-agent", Complete) + .Build(); + + private static DispatchSummary Complete(SchedulerAgentContext ctx) => new(ctx.Work.BatchId, ctx.Attempt); + + private sealed record OrderBatch(string BatchId); + + private sealed record DispatchSummary(string BatchId, int Attempt); +}