diff --git a/README.md b/README.md index 447bb49b..e003c5c3 100644 --- a/README.md +++ b/README.md @@ -473,11 +473,11 @@ var cachedRemoteProxy = Proxy.Create(id => remoteProxy.Execute(id)) --- ## Patterns Table -PatternKit currently tracks 106 production-readiness patterns. Each catalog pattern is represented in tests, documentation, real-world examples, IoC integration, and the BenchmarkDotNet coverage matrix. +PatternKit currently tracks 107 production-readiness patterns. Each catalog pattern is represented in tests, documentation, real-world examples, IoC integration, and the BenchmarkDotNet coverage matrix. | Category | Count | Patterns | | --- | ---: | --- | -| Application Architecture | 21 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Feature Toggle, Identity Map, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Transaction Script, Unit of Work, Value Object | +| Application Architecture | 22 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Feature Toggle, Identity Map, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object | | Behavioral | 11 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 17 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig | | Creational | 5 | Abstract Factory, Builder, Factory Method, Prototype, Singleton | @@ -497,6 +497,8 @@ BenchmarkDotNet guidance is documented in [docs/guides/benchmarks.md](docs/guide | Adapter | Execution | 59.084 ns | 416 B | 20.479 ns | 80 B | Generated adapter execution was faster and allocated less for shipment adaptation. | | Activity Tracker | Construction | 13.09 ns | 152 B | 12.98 ns | 152 B | Same allocation; generated was slightly faster in this microbenchmark. | | Activity Tracker | Execution | 446.88 ns | 1,656 B | 452.36 ns | 1,656 B | Same allocation; fluent was slightly faster for dashboard loading gates. | +| Timeout Manager | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Timeout Manager | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Aggregate Root | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Aggregate Root | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Aggregator | Construction | 14.562 ns | 168 B | 15.235 ns | 168 B | Same allocation; fluent was slightly faster in this microbenchmark. | diff --git a/benchmarks/PatternKit.Benchmarks/Application/TimeoutManagerBenchmarks.cs b/benchmarks/PatternKit.Benchmarks/Application/TimeoutManagerBenchmarks.cs new file mode 100644 index 00000000..42a43172 --- /dev/null +++ b/benchmarks/PatternKit.Benchmarks/Application/TimeoutManagerBenchmarks.cs @@ -0,0 +1,36 @@ +using BenchmarkDotNet.Attributes; +using PatternKit.Application.Timeouts; +using PatternKit.Examples.TimeoutManagerDemo; + +namespace PatternKit.Benchmarks.Application; + +[BenchmarkCategory("ApplicationArchitecture", "TimeoutManager")] +public class TimeoutManagerBenchmarks +{ + private static readonly OrderReservationRequest Request = new( + Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + "REQ-100", + TimeSpan.FromMinutes(15)); + + private static readonly DateTimeOffset ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(20); + + [Benchmark(Baseline = true, Description = "Fluent: create timeout manager")] + [BenchmarkCategory("Fluent", "Construction")] + public TimeoutManager Fluent_CreateTimeoutManager() + => OrderReservationTimeoutManagers.CreateFluent(); + + [Benchmark(Description = "Generated: create timeout manager")] + [BenchmarkCategory("Generated", "Construction")] + public TimeoutManager Generated_CreateTimeoutManager() + => GeneratedOrderReservationTimeoutManager.CreateGenerated(); + + [Benchmark(Description = "Fluent: expire order reservation timeout")] + [BenchmarkCategory("Fluent", "Execution")] + public OrderReservationTimeoutSummary Fluent_ExpireOrderReservation() + => OrderReservationTimeoutDemoRunner.RunFluent(Request, ExpiresAt); + + [Benchmark(Description = "Generated: expire order reservation timeout")] + [BenchmarkCategory("Generated", "Execution")] + public OrderReservationTimeoutSummary Generated_ExpireOrderReservation() + => OrderReservationTimeoutDemoRunner.RunGeneratedStatic(Request, ExpiresAt); +} diff --git a/docs/examples/order-reservation-timeout-manager.md b/docs/examples/order-reservation-timeout-manager.md new file mode 100644 index 00000000..27c5c2aa --- /dev/null +++ b/docs/examples/order-reservation-timeout-manager.md @@ -0,0 +1,23 @@ +# Order Reservation Timeout Manager + +This example models an order reservation hold that expires if the customer does not complete checkout before the deadline. + +The fluent path builds the manager directly: + +```csharp +var manager = OrderReservationTimeoutManagers.CreateFluent(); +``` + +The generated path uses a source-generated factory: + +```csharp +var manager = GeneratedOrderReservationTimeoutManager.CreateGenerated(); +``` + +The example is importable through standard dependency injection: + +```csharp +services.AddOrderReservationTimeoutDemo(); +``` + +`OrderReservationTimeoutDemoRunner` schedules a reservation timeout and expires due holds. Production applications can pair the expired order identifiers with a message channel, outbox, saga, or service layer operation to release inventory and notify customers. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 3d118bad..12863ff2 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -7,6 +7,9 @@ - name: Dashboard Activity Tracker href: dashboard-activity-tracker.md +- name: Order Reservation Timeout Manager + href: order-reservation-timeout-manager.md + - name: Auth & Logging with `ActionChain` href: auth-logging-chain.md diff --git a/docs/generators/index.md b/docs/generators/index.md index bc54e69d..82b0506d 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -62,6 +62,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Repository**](repository.md) | In-memory repository factories from key selectors | `[GenerateRepository]` | | [**Anti-Corruption Layer**](anti-corruption-layer.md) | External-to-domain translation boundaries with validation | `[GenerateAntiCorruptionLayer]` | | [**Activity Tracker**](activity-tracker.md) | Active-work tracker gates for loading and readiness workflows | `[GenerateActivityTracker]` | +| [**Timeout Manager**](timeout-manager.md) | Deadline registry for expiring pending workflow work | `[GenerateTimeoutManager]` | | [**Audit Log**](audit-log.md) | Append-only audit log factories from key selectors | `[GenerateAuditLog]` | | [**Unit of Work**](unit-of-work.md) | Ordered commit and rollback units | `[GenerateUnitOfWork]` | | [**Data Mapper**](data-mapper.md) | Domain/data model mapper factories | `[GenerateDataMapper]` | diff --git a/docs/generators/timeout-manager.md b/docs/generators/timeout-manager.md new file mode 100644 index 00000000..a97c09c3 --- /dev/null +++ b/docs/generators/timeout-manager.md @@ -0,0 +1,23 @@ +# Timeout Manager Generator + +The Timeout Manager generator creates a strongly typed factory for `TimeoutManager` from a partial host type. + +```csharp +using PatternKit.Generators.Timeouts; + +[GenerateTimeoutManager(typeof(Guid), FactoryMethodName = "CreateGenerated", ManagerName = "order-reservation-timeouts")] +public static partial class GeneratedOrderReservationTimeoutManager; +``` + +Generated output: + +```csharp +TimeoutManager manager = GeneratedOrderReservationTimeoutManager.CreateGenerated(); +``` + +The generated path removes repeated factory boilerplate while keeping the runtime manager explicit and importable through normal `IServiceCollection` registration. + +## Diagnostics + +- `PKTM001`: the host type must be partial. +- `PKTM002`: `FactoryMethodName` and `ManagerName` must be non-empty. diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 4798d3ac..b0b03259 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -261,6 +261,9 @@ - name: Table Data Gateway href: table-data-gateway.md +- name: Timeout Manager + href: timeout-manager.md + - name: Strategy href: strategy.md diff --git a/docs/guides/benchmark-results.md b/docs/guides/benchmark-results.md index 2d85e7e9..d49529d8 100644 --- a/docs/guides/benchmark-results.md +++ b/docs/guides/benchmark-results.md @@ -17,6 +17,8 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 | Adapter | Execution | 59.084 ns | 416 B | 20.479 ns | 80 B | Generated adapter execution was faster and allocated less for shipment adaptation. | | Activity Tracker | Construction | 13.09 ns | 152 B | 12.98 ns | 152 B | Same allocation; generated was slightly faster in this microbenchmark. | | Activity Tracker | Execution | 446.88 ns | 1,656 B | 452.36 ns | 1,656 B | Same allocation; fluent was slightly faster for dashboard loading gates. | +| Timeout Manager | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Timeout Manager | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Aggregate Root | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Aggregate Root | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Aggregator | Construction | 14.562 ns | 168 B | 15.235 ns | 168 B | Same allocation; fluent was slightly faster in this microbenchmark. | @@ -230,11 +232,11 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 ## Coverage Matrix Summary -The coverage matrix currently publishes 106 catalog patterns and 424 pattern route results. Each pattern has four BenchmarkDotNet routes: fluent construction, fluent execution, source-generated construction, and source-generated execution. The reusable hosting integration matrix publishes 9 reusable hosting integration route results for package-level `IServiceCollection` registrations. +The coverage matrix currently publishes 107 catalog patterns and 428 pattern route results. Each pattern has four BenchmarkDotNet routes: fluent construction, fluent execution, source-generated construction, and source-generated execution. The reusable hosting integration matrix publishes 9 reusable hosting integration route results for package-level `IServiceCollection` registrations. | Category | Patterns | Published route results | | --- | ---: | ---: | -| Application Architecture | 21 | 84 | +| Application Architecture | 22 | 88 | | Behavioral | 11 | 44 | | Cloud Architecture | 17 | 68 | | Creational | 5 | 20 | @@ -242,7 +244,7 @@ The coverage matrix currently publishes 106 catalog patterns and 424 pattern rou | Messaging Reliability | 3 | 12 | | Structural | 7 | 28 | -The generator matrix currently publishes 101 generator source route results. +The generator matrix currently publishes 102 generator source route results. ## Hosting Integration Matrix Results @@ -263,6 +265,7 @@ The generator matrix currently publishes 101 generator source route results. | Category | Pattern | Fluent construction | Fluent execution | Generated construction | Generated execution | | --- | --- | --- | --- | --- | --- | | Application Architecture | Activity Tracker | Covered | Covered | Covered | Covered | +| Application Architecture | Timeout Manager | Covered | Covered | Covered | Covered | | Application Architecture | Aggregate Root | Covered | Covered | Covered | Covered | | Application Architecture | Anti-Corruption Layer | Covered | Covered | Covered | Covered | | Application Architecture | Audit Log | Covered | Covered | Covered | Covered | @@ -373,6 +376,7 @@ The generator matrix currently publishes 101 generator source route results. | Generator | Source | Matrix result | | --- | --- | --- | | ActivityTrackerGenerator | `src/PatternKit.Generators/ActivityTracking/ActivityTrackerGenerator.cs` | Covered | +| TimeoutManagerGenerator | `src/PatternKit.Generators/Timeouts/TimeoutManagerGenerator.cs` | Covered | | AggregateCommandHandlerGenerator | `src/PatternKit.Generators/Aggregates/AggregateCommandHandlerGenerator.cs` | Covered | | AdapterGenerator | `src/PatternKit.Generators/Adapter/AdapterGenerator.cs` | Covered | | AmbassadorGenerator | `src/PatternKit.Generators/Ambassador/AmbassadorGenerator.cs` | Covered | diff --git a/docs/guides/benchmarks.md b/docs/guides/benchmarks.md index 003a38c7..7d69b257 100644 --- a/docs/guides/benchmarks.md +++ b/docs/guides/benchmarks.md @@ -34,6 +34,8 @@ The following numbers were captured on Windows 11, Intel Core i9-14900K, .NET SD | Adapter | Execution | 59.084 ns | 416 B | 20.479 ns | 80 B | Generated adapter execution was faster and allocated less for shipment adaptation. | | Activity Tracker | Construction | 13.09 ns | 152 B | 12.98 ns | 152 B | Same allocation; generated was slightly faster in this microbenchmark. | | Activity Tracker | Execution | 446.88 ns | 1,656 B | 452.36 ns | 1,656 B | Same allocation; fluent was slightly faster for dashboard loading gates. | +| Timeout Manager | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Timeout Manager | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Aggregate Root | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Aggregate Root | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Aggregator | Construction | 14.562 ns | 168 B | 15.235 ns | 168 B | Same allocation; fluent was slightly faster in this microbenchmark. | diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 18be3bd3..1b9cb8fc 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -125,6 +125,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Application Architecture | Materialized View | `IMaterializedView` and `MaterializedView` | Materialized View generator | | Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer` | Anti-Corruption Layer generator | | Application Architecture | Activity Tracker | `ActivityTracker` | Activity Tracker generator | +| Application Architecture | Timeout Manager | `TimeoutManager` | Timeout Manager generator | ## Research Baselines diff --git a/docs/index.md b/docs/index.md index 12f205d3..5e98ae16 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,11 +66,11 @@ if (parser.Execute("123", out var value)) ## 📚 Available Patterns -PatternKit covers 106 production-readiness patterns with fluent APIs, source-generated routes where applicable, IoC integration examples, TinyBDD coverage, and BenchmarkDotNet coverage-matrix validation: +PatternKit covers 107 production-readiness patterns with fluent APIs, source-generated routes where applicable, IoC integration examples, TinyBDD coverage, and BenchmarkDotNet coverage-matrix validation: | Category | Count | Patterns | | --- | ---: | --- | -| Application Architecture | 21 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Feature Toggle, Identity Map, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Transaction Script, Unit of Work, Value Object | +| Application Architecture | 22 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Feature Toggle, Identity Map, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object | | Behavioral | 11 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 17 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig | | Creational | 5 | Abstract Factory, Builder, Factory Method, Prototype, Singleton | diff --git a/docs/patterns/application/timeout-manager.md b/docs/patterns/application/timeout-manager.md new file mode 100644 index 00000000..ef80c054 --- /dev/null +++ b/docs/patterns/application/timeout-manager.md @@ -0,0 +1,28 @@ +# Timeout Manager + +Timeout Manager tracks deadlines for workflow work that should expire unless it is completed or canceled first. Use it when a saga, reservation, approval, shipment hold, or background operation needs a clear time boundary without coupling the domain workflow to a specific scheduler. + +`TimeoutManager` provides the fluent runtime path: + +```csharp +var manager = TimeoutManager + .Create("order-reservation-timeouts") + .Build(); + +manager.ScheduleAfter(orderId, TimeSpan.FromMinutes(15), requestId); +var expired = manager.ExpireDue(DateTimeOffset.UtcNow.AddMinutes(20)); +``` + +The manager is in-memory and intentionally small. Application code can persist the returned `TimeoutRecord` values in its own store or use the manager inside a hosted service that loads pending deadlines, calls `ExpireDue`, and dispatches expiration work through existing PatternKit messaging or workflow patterns. + +## Use When + +- Work can enter and leave a pending state at arbitrary times. +- The only decision needed by downstream code is whether deadlines are still pending or due. +- Expiration should be explicit and testable instead of hidden in timers. + +## Compare With + +- Use Saga / Process Manager when the workflow owns multiple correlated messages and state transitions. +- Use Scheduler Agent Supervisor when the system owns recurring scheduled work. +- Use Activity Tracker when the concern is active/inactive gating rather than time-based expiration. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 88338454..11ca34b4 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -439,6 +439,8 @@ href: application/context-map.md - name: Table Data Gateway href: application/table-data-gateway.md + - name: Timeout Manager + href: application/timeout-manager.md - name: Event Sourcing href: application/event-sourcing.md - name: Feature Toggle diff --git a/src/PatternKit.Core/Application/Timeouts/TimeoutManager.cs b/src/PatternKit.Core/Application/Timeouts/TimeoutManager.cs new file mode 100644 index 00000000..3d14df83 --- /dev/null +++ b/src/PatternKit.Core/Application/Timeouts/TimeoutManager.cs @@ -0,0 +1,178 @@ +namespace PatternKit.Application.Timeouts; + +/// +/// Coordinates time-based deadlines for workflows that must expire, complete, or cancel pending work. +/// +public sealed class TimeoutManager + where TKey : notnull +{ + private readonly object _gate = new(); + private readonly Dictionary> _timeouts; + private readonly Func _clock; + + private TimeoutManager(string name, IEqualityComparer keyComparer, Func clock) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Timeout manager name cannot be null, empty, or whitespace.", nameof(name)); + + Name = name; + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _timeouts = new Dictionary>(keyComparer ?? throw new ArgumentNullException(nameof(keyComparer))); + } + + public string Name { get; } + + public int PendingCount + { + get + { + lock (_gate) + return _timeouts.Count; + } + } + + public TimeoutRecord Schedule(TKey key, DateTimeOffset deadline, string? correlationId = null) + { + var now = _clock(); + return ScheduleCore(key, correlationId, now, deadline); + } + + public TimeoutRecord ScheduleAfter(TKey key, TimeSpan dueAfter, string? correlationId = null) + { + if (dueAfter < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(dueAfter), dueAfter, "Timeout duration cannot be negative."); + + var now = _clock(); + return ScheduleCore(key, correlationId, now, now.Add(dueAfter)); + } + + private TimeoutRecord ScheduleCore(TKey key, string? correlationId, DateTimeOffset now, DateTimeOffset deadline) + { + if (deadline < now) + throw new ArgumentOutOfRangeException(nameof(deadline), deadline, "Timeout deadline cannot be before the current clock value."); + + var record = new TimeoutRecord(key, correlationId, now, deadline); + lock (_gate) + _timeouts[key] = record; + + return record; + } + + public bool Complete(TKey key) => Remove(key); + + public bool Cancel(TKey key) => Remove(key); + + public IReadOnlyList> ExpireDue() + => ExpireDue(_clock()); + + public IReadOnlyList> ExpireDue(DateTimeOffset now) + { + lock (_gate) + { + var due = _timeouts.Values + .Where(timeout => timeout.Deadline <= now) + .OrderBy(static timeout => timeout.Deadline) + .ThenBy(static timeout => timeout.CorrelationId, StringComparer.Ordinal) + .ToArray(); + + foreach (var timeout in due) + _timeouts.Remove(timeout.Key); + + return due; + } + } + + public IReadOnlyList> Snapshot() + { + lock (_gate) + return _timeouts.Values + .OrderBy(static timeout => timeout.Deadline) + .ThenBy(static timeout => timeout.CorrelationId, StringComparer.Ordinal) + .ToArray(); + } + + public TimeoutManagerState GetState() + { + lock (_gate) + { + var pendingTimeouts = _timeouts.Values + .OrderBy(static timeout => timeout.Deadline) + .ThenBy(static timeout => timeout.CorrelationId, StringComparer.Ordinal) + .ToArray(); + + return new(Name, pendingTimeouts.Length, pendingTimeouts); + } + } + + public static Builder Create(string name = "timeout-manager") => new(name); + + private bool Remove(TKey key) + { + lock (_gate) + return _timeouts.Remove(key); + } + + public sealed class Builder + { + private readonly string _name; + private IEqualityComparer _keyComparer = EqualityComparer.Default; + private Func _clock = static () => DateTimeOffset.UtcNow; + + internal Builder(string name) + => _name = name; + + public Builder WithKeyComparer(IEqualityComparer keyComparer) + { + _keyComparer = keyComparer ?? throw new ArgumentNullException(nameof(keyComparer)); + return this; + } + + public Builder WithClock(Func clock) + { + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + return this; + } + + public TimeoutManager Build() + => new(_name, _keyComparer, _clock); + } +} + +public sealed class TimeoutRecord + where TKey : notnull +{ + public TimeoutRecord(TKey key, string? correlationId, DateTimeOffset scheduledAt, DateTimeOffset deadline) + { + Key = key; + CorrelationId = correlationId; + ScheduledAt = scheduledAt; + Deadline = deadline; + } + + public TKey Key { get; } + + public string? CorrelationId { get; } + + public DateTimeOffset ScheduledAt { get; } + + public DateTimeOffset Deadline { get; } + + public bool IsDue(DateTimeOffset now) => Deadline <= now; +} + +public sealed class TimeoutManagerState + where TKey : notnull +{ + public TimeoutManagerState(string managerName, int pendingCount, IReadOnlyList> pendingTimeouts) + { + ManagerName = managerName; + PendingCount = pendingCount; + PendingTimeouts = pendingTimeouts ?? throw new ArgumentNullException(nameof(pendingTimeouts)); + } + + public string ManagerName { get; } + + public int PendingCount { get; } + + public IReadOnlyList> PendingTimeouts { get; } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 17268f35..0684ffe3 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using PatternKit.Application.ActivityTracking; using PatternKit.Application.AntiCorruption; using PatternKit.Application.Specification; +using PatternKit.Application.Timeouts; using PatternKit.Behavioral.Chain; using PatternKit.Behavioral.Interpreter; using PatternKit.Behavioral.Strategy; @@ -77,6 +78,7 @@ using PatternKit.Examples.Strategies.Composed; using PatternKit.Examples.TableDataGatewayDemo; using PatternKit.Examples.TemplateDemo; +using PatternKit.Examples.TimeoutManagerDemo; using PatternKit.Examples.TransactionScriptDemo; using PatternKit.Examples.UnitOfWorkDemo; using PatternKit.Examples.ValueObjectDemo; @@ -244,6 +246,7 @@ public sealed record CommerceBackendsForFrontendsExample(CommerceBackendsForFron public sealed record InventoryAmbassadorExample(InventoryAmbassadorDemoRunner Runner, InventoryAmbassadorService Service); public sealed record WarehouseLeaderElectionExample(WarehouseLeaderElectionDemoRunner Runner, WarehouseLeaderElectionService Service); public sealed record WarehouseSchedulerAgentSupervisorExample(WarehouseSchedulerDemoRunner Runner, WarehouseSchedulerService Service); +public sealed record OrderReservationTimeoutPatternExample(TimeoutManager Manager, OrderReservationTimeoutDemoRunner Runner); /// /// Fluent registration helpers for importing every documented PatternKit example into Microsoft.Extensions.DependencyInjection. @@ -357,7 +360,8 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddCommerceBackendsForFrontendsExample() .AddInventoryAmbassadorExample() .AddWarehouseLeaderElectionExample() - .AddWarehouseSchedulerAgentSupervisorExample(); + .AddWarehouseSchedulerAgentSupervisorExample() + .AddOrderReservationTimeoutPatternExample(); public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services) { @@ -1304,6 +1308,15 @@ public static IServiceCollection AddWarehouseSchedulerAgentSupervisorExample(thi return services.RegisterExample("Warehouse Scheduler Agent Supervisor", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddOrderReservationTimeoutPatternExample(this IServiceCollection services) + { + services.AddOrderReservationTimeoutDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService())); + return services.RegisterExample("Order Reservation Timeout Manager", 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 31d7d648..55428676 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -664,6 +664,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["ActivityTracker"], ["tracked active work", "source-generated tracker factory", "DI composition"]), + Descriptor( + "Order Reservation Timeout Manager", + "src/PatternKit.Examples/TimeoutManagerDemo/OrderReservationTimeoutDemo.cs", + "test/PatternKit.Examples.Tests/TimeoutManagerDemo/OrderReservationTimeoutDemoTests.cs", + "docs/examples/order-reservation-timeout-manager.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["TimeoutManager"], + ["reservation deadline tracking", "source-generated timeout manager factory", "DI composition"]), Descriptor( "Generated Mailbox", "src/PatternKit.Examples/Messaging/MailboxExample.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index bdf2546b..7a5440de 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -1426,7 +1426,20 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "docs/examples/dashboard-activity-tracker.md", "src/PatternKit.Examples/ActivityTrackingDemo/DashboardActivityTrackerDemo.cs", "test/PatternKit.Examples.Tests/ActivityTrackingDemo/DashboardActivityTrackerDemoTests.cs", - ["fluent activity gate", "generated tracker factory", "DI-importable loading gate example"]) + ["fluent activity gate", "generated tracker factory", "DI-importable loading gate example"]), + + Pattern("Timeout Manager", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/timeout-manager.md", + "src/PatternKit.Core/Application/Timeouts/TimeoutManager.cs", + "test/PatternKit.Tests/Application/Timeouts/TimeoutManagerTests.cs", + "docs/generators/timeout-manager.md", + "src/PatternKit.Generators/Timeouts/TimeoutManagerGenerator.cs", + "test/PatternKit.Generators.Tests/TimeoutManagerGeneratorTests.cs", + null, + "docs/examples/order-reservation-timeout-manager.md", + "src/PatternKit.Examples/TimeoutManagerDemo/OrderReservationTimeoutDemo.cs", + "test/PatternKit.Examples.Tests/TimeoutManagerDemo/OrderReservationTimeoutDemoTests.cs", + ["fluent deadline registry", "generated timeout manager factory", "DI-importable order reservation expiration example"]) ]; public IReadOnlyList Patterns => Items; diff --git a/src/PatternKit.Examples/TimeoutManagerDemo/OrderReservationTimeoutDemo.cs b/src/PatternKit.Examples/TimeoutManagerDemo/OrderReservationTimeoutDemo.cs new file mode 100644 index 00000000..08fbfcbf --- /dev/null +++ b/src/PatternKit.Examples/TimeoutManagerDemo/OrderReservationTimeoutDemo.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.Timeouts; +using PatternKit.Generators.Timeouts; + +namespace PatternKit.Examples.TimeoutManagerDemo; + +public sealed record OrderReservationRequest(Guid OrderId, string RequestId, TimeSpan HoldFor); + +public sealed record OrderReservationTimeoutSummary(int PendingReservations, IReadOnlyList ExpiredOrders); + +public static partial class OrderReservationTimeoutManagers +{ + public static TimeoutManager CreateFluent() + => TimeoutManager.Create("order-reservation-timeouts").Build(); +} + +[GenerateTimeoutManager(typeof(Guid), FactoryMethodName = "CreateGenerated", ManagerName = "order-reservation-timeouts")] +public static partial class GeneratedOrderReservationTimeoutManager; + +public sealed class OrderReservationTimeoutService(TimeoutManager timeouts) +{ + public TimeoutRecord Reserve(OrderReservationRequest request) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + + return timeouts.ScheduleAfter(request.OrderId, request.HoldFor, request.RequestId); + } + + public bool Complete(Guid orderId) => timeouts.Complete(orderId); + + public OrderReservationTimeoutSummary ExpireDue(DateTimeOffset now) + { + var expired = timeouts.ExpireDue(now); + return new(timeouts.PendingCount, expired.Select(static timeout => timeout.Key).ToArray()); + } +} + +public sealed class OrderReservationTimeoutDemoRunner(OrderReservationTimeoutService service) +{ + public OrderReservationTimeoutSummary RunGenerated(OrderReservationRequest request, DateTimeOffset expiresAt) + { + service.Reserve(request); + return service.ExpireDue(expiresAt); + } + + public static OrderReservationTimeoutSummary RunFluent(OrderReservationRequest request, DateTimeOffset expiresAt) + { + var service = new OrderReservationTimeoutService(OrderReservationTimeoutManagers.CreateFluent()); + service.Reserve(request); + return service.ExpireDue(expiresAt); + } + + public static OrderReservationTimeoutSummary RunGeneratedStatic(OrderReservationRequest request, DateTimeOffset expiresAt) + { + var service = new OrderReservationTimeoutService(GeneratedOrderReservationTimeoutManager.CreateGenerated()); + service.Reserve(request); + return service.ExpireDue(expiresAt); + } +} + +public static class OrderReservationTimeoutDemoServiceCollectionExtensions +{ + public static IServiceCollection AddOrderReservationTimeoutDemo(this IServiceCollection services) + { + services.AddSingleton(static _ => GeneratedOrderReservationTimeoutManager.CreateGenerated()); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Generators.Abstractions/Timeouts/TimeoutManagerAttributes.cs b/src/PatternKit.Generators.Abstractions/Timeouts/TimeoutManagerAttributes.cs new file mode 100644 index 00000000..037a3a44 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Timeouts/TimeoutManagerAttributes.cs @@ -0,0 +1,14 @@ +namespace PatternKit.Generators.Timeouts; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateTimeoutManagerAttribute : Attribute +{ + public GenerateTimeoutManagerAttribute(Type keyType) + => KeyType = keyType; + + public Type KeyType { get; } + + public string FactoryMethodName { get; set; } = "Create"; + + public string ManagerName { get; set; } = "timeout-manager"; +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 871c0d2b..d7fc3e86 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -429,3 +429,5 @@ PKDOM004 | PatternKit.Generators.DomainServices | Error | Domain service operati PKVO001 | PatternKit.Generators.ValueObjects | Error | Value Object host must be partial. PKVO002 | PatternKit.Generators.ValueObjects | Error | Value Object host must be a class. PKVO003 | PatternKit.Generators.ValueObjects | Error | Value Object must declare at least one component. +PKTM001 | PatternKit.Generators.Timeouts | Error | Timeout Manager host must be partial. +PKTM002 | PatternKit.Generators.Timeouts | Error | Timeout Manager key type is invalid. diff --git a/src/PatternKit.Generators/Timeouts/TimeoutManagerGenerator.cs b/src/PatternKit.Generators/Timeouts/TimeoutManagerGenerator.cs new file mode 100644 index 00000000..0a8403bd --- /dev/null +++ b/src/PatternKit.Generators/Timeouts/TimeoutManagerGenerator.cs @@ -0,0 +1,154 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace PatternKit.Generators.Timeouts; + +[Generator] +public sealed class TimeoutManagerGenerator : IIncrementalGenerator +{ + private const string AttributeName = "PatternKit.Generators.Timeouts.GenerateTimeoutManagerAttribute"; + + private static readonly SymbolDisplayFormat TypeFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKTM001", + "Timeout manager host must be partial", + "Type '{0}' is marked with [GenerateTimeoutManager] but is not declared as partial", + "PatternKit.Generators.Timeouts", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidConfiguration = new( + "PKTM002", + "Timeout manager configuration is invalid", + "Timeout manager '{0}' must have non-empty FactoryMethodName and ManagerName values", + "PatternKit.Generators.Timeouts", + DiagnosticSeverity.Error, + true); + + 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 attribute => + attribute.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 keyType = attribute.ConstructorArguments.Length >= 1 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + if (keyType is null || keyType.TypeKind == TypeKind.Error) + return; + + var factoryMethodName = GetNamedString(attribute, "FactoryMethodName") ?? "Create"; + var managerName = GetNamedString(attribute, "ManagerName") ?? "timeout-manager"; + if (string.IsNullOrWhiteSpace(factoryMethodName) || string.IsNullOrWhiteSpace(managerName)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidConfiguration, node.Identifier.GetLocation(), type.Name)); + return; + } + + context.AddSource($"{type.Name}.TimeoutManager.g.cs", SourceText.From( + GenerateSource(type, keyType, factoryMethodName, managerName), + Encoding.UTF8)); + } + + private static string GenerateSource(INamedTypeSymbol type, INamedTypeSymbol keyType, string factoryMethodName, string managerName) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var keyTypeName = keyType.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(); + } + + var indent = string.Empty; + foreach (var containingType in GetContainingTypes(type)) + { + AppendTypeDeclaration(sb, containingType, indent); + sb.Append(indent).AppendLine("{"); + indent += " "; + } + + AppendTypeDeclaration(sb, type, indent); + sb.Append(indent).AppendLine("{"); + var memberIndent = indent + " "; + var bodyIndent = memberIndent + " "; + sb.Append(memberIndent).Append("public static global::PatternKit.Application.Timeouts.TimeoutManager<").Append(keyTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.Append(memberIndent).AppendLine("{"); + sb.Append(bodyIndent).Append("return global::PatternKit.Application.Timeouts.TimeoutManager<").Append(keyTypeName).Append(">.Create(\"").Append(Escape(managerName)).AppendLine("\").Build();"); + sb.Append(memberIndent).AppendLine("}"); + sb.Append(indent).AppendLine("}"); + while (indent.Length > 0) + { + indent = indent.Substring(0, indent.Length - 4); + sb.Append(indent).AppendLine("}"); + } + + return sb.ToString(); + } + + private static IReadOnlyList GetContainingTypes(INamedTypeSymbol type) + { + var stack = new Stack(); + for (var current = type.ContainingType; current is not null; current = current.ContainingType) + stack.Push(current); + return stack.ToArray(); + } + + private static void AppendTypeDeclaration(StringBuilder sb, INamedTypeSymbol type, string indent) + { + sb.Append(indent).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(); + } + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + 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/PatternKitBenchmarkCoverageTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs index eedf713f..2b68279f 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs @@ -107,7 +107,7 @@ public Task Published_Benchmark_Results_Include_Every_Catalog_Pattern() .Then("every catalog pattern appears in the benchmark results matrix", ctx => ScenarioExpect.Empty(ctx.MissingPatterns)) .And("the guide publishes the route result total", ctx => - ScenarioExpect.Contains("424 pattern route results", ctx.ResultsGuide)) + ScenarioExpect.Contains("428 pattern route results", ctx.ResultsGuide)) .AssertPassed(); [Scenario("Published benchmark results include reusable hosting integrations")] diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 1a76cac6..5990058a 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -119,7 +119,8 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Audit Log", "Materialized View", "Anti-Corruption Layer", - "Activity Tracker" + "Activity Tracker", + "Timeout Manager" ]; [Scenario("Catalog covers every canonical GoF pattern")] @@ -163,7 +164,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() ScenarioExpect.Equal(41, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(17, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); - ScenarioExpect.Equal(21, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(22, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Examples.Tests/TimeoutManagerDemo/OrderReservationTimeoutDemoTests.cs b/test/PatternKit.Examples.Tests/TimeoutManagerDemo/OrderReservationTimeoutDemoTests.cs new file mode 100644 index 00000000..877d9c2b --- /dev/null +++ b/test/PatternKit.Examples.Tests/TimeoutManagerDemo/OrderReservationTimeoutDemoTests.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.Timeouts; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.TimeoutManagerDemo; +using TinyBDD; + +namespace PatternKit.Examples.Tests.TimeoutManagerDemo; + +public sealed class OrderReservationTimeoutDemoTests +{ + [Scenario("Fluent timeout manager expires order reservations")] + [Fact] + public void Fluent_Timeout_Manager_Expires_Order_Reservations() + { + var request = CreateRequest(); + var summary = OrderReservationTimeoutDemoRunner.RunFluent(request, DateTimeOffset.UtcNow.AddMinutes(20)); + + ScenarioExpect.Equal(0, summary.PendingReservations); + ScenarioExpect.Equal([request.OrderId], summary.ExpiredOrders); + } + + [Scenario("Generated timeout manager matches fluent behavior")] + [Fact] + public void Generated_Timeout_Manager_Matches_Fluent_Behavior() + { + var request = CreateRequest(); + var expiresAt = DateTimeOffset.UtcNow.AddMinutes(20); + + var fluent = OrderReservationTimeoutDemoRunner.RunFluent(request, expiresAt); + var generated = OrderReservationTimeoutDemoRunner.RunGeneratedStatic(request, expiresAt); + + ScenarioExpect.Equal(fluent.PendingReservations, generated.PendingReservations); + ScenarioExpect.Equal(fluent.ExpiredOrders, generated.ExpiredOrders); + } + + [Scenario("ServiceCollection imports timeout manager example")] + [Fact] + public void ServiceCollection_Imports_Timeout_Manager_Example() + { + var services = new ServiceCollection(); + services.AddOrderReservationTimeoutDemo(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var runner = provider.GetRequiredService(); + var request = CreateRequest(); + var summary = runner.RunGenerated(request, DateTimeOffset.UtcNow.AddMinutes(20)); + + ScenarioExpect.Equal([request.OrderId], summary.ExpiredOrders); + ScenarioExpect.NotNull(provider.GetRequiredService>()); + } + + [Scenario("Order reservation timeout service validates and completes reservations")] + [Fact] + public void Order_Reservation_Timeout_Service_Validates_And_Completes_Reservations() + { + var service = new OrderReservationTimeoutService(OrderReservationTimeoutManagers.CreateFluent()); + var request = CreateRequest(); + + service.Reserve(request); + + ScenarioExpect.True(service.Complete(request.OrderId)); + ScenarioExpect.False(service.Complete(request.OrderId)); + ScenarioExpect.Throws(() => service.Reserve(null!)); + } + + [Scenario("Aggregate examples import timeout manager example")] + [Fact] + public void Aggregate_Examples_Import_Timeout_Manager_Example() + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var example = provider.GetRequiredService(); + var request = CreateRequest(); + var summary = example.Runner.RunGenerated(request, DateTimeOffset.UtcNow.AddMinutes(20)); + + ScenarioExpect.Equal([request.OrderId], summary.ExpiredOrders); + ScenarioExpect.NotNull(example.Manager); + } + + private static OrderReservationRequest CreateRequest() + => new(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), "REQ-100", TimeSpan.FromMinutes(15)); +} diff --git a/test/PatternKit.Generators.Tests/AbstractionsTests.cs b/test/PatternKit.Generators.Tests/AbstractionsTests.cs index 18481394..a8108746 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsTests.cs @@ -8,6 +8,38 @@ namespace PatternKit.Generators.Tests; /// public class AbstractionsTests { + #region GenerateTimeoutManagerAttribute Tests + + [Scenario("GenerateTimeoutManagerAttribute Constructor Sets Properties")] + [Fact] + public void GenerateTimeoutManagerAttribute_Constructor_Sets_Properties() + { + var attr = new PatternKit.Generators.Timeouts.GenerateTimeoutManagerAttribute(typeof(Guid)) + { + FactoryMethodName = "CreateReservationTimeouts", + ManagerName = "reservation-timeouts" + }; + + ScenarioExpect.Equal(typeof(Guid), attr.KeyType); + ScenarioExpect.Equal("CreateReservationTimeouts", attr.FactoryMethodName); + ScenarioExpect.Equal("reservation-timeouts", attr.ManagerName); + } + + [Scenario("GenerateTimeoutManagerAttribute Has Correct AttributeUsage")] + [Fact] + public void GenerateTimeoutManagerAttribute_Has_Correct_AttributeUsage() + { + var usage = typeof(PatternKit.Generators.Timeouts.GenerateTimeoutManagerAttribute) + .GetCustomAttributes(typeof(AttributeUsageAttribute), false) + .Cast() + .Single(); + + ScenarioExpect.Equal(AttributeTargets.Class | AttributeTargets.Struct, usage.ValidOn); + ScenarioExpect.False(usage.Inherited); + } + + #endregion + #region GenerateStrategyAttribute Tests [Scenario("GenerateStrategyAttribute Action Constructor Sets Properties")] diff --git a/test/PatternKit.Generators.Tests/TimeoutManagerGeneratorTests.cs b/test/PatternKit.Generators.Tests/TimeoutManagerGeneratorTests.cs new file mode 100644 index 00000000..7c37e1cd --- /dev/null +++ b/test/PatternKit.Generators.Tests/TimeoutManagerGeneratorTests.cs @@ -0,0 +1,144 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Generators.Timeouts; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Timeout Manager generator")] +public sealed partial class TimeoutManagerGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates timeout manager factory")] + [Fact] + public Task Generates_Timeout_Manager_Factory() + => Given("a timeout manager declaration", () => Compile(""" + using PatternKit.Generators.Timeouts; + namespace Demo; + [GenerateTimeoutManager(typeof(string), FactoryMethodName = "Build", ManagerName = "reservation-timeouts")] + public static partial class ReservationTimeouts; + """)) + .Then("the generated source creates the configured manager", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("public static partial class ReservationTimeouts", source); + ScenarioExpect.Contains("TimeoutManager Build()", source); + ScenarioExpect.Contains("TimeoutManager.Create(\"reservation-timeouts\").Build()", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid timeout manager declarations")] + [Fact] + public Task Reports_Diagnostics_For_Invalid_Timeout_Manager_Declarations() + => Given("invalid timeout manager declarations", () => new[] + { + Compile(""" + using PatternKit.Generators.Timeouts; + [GenerateTimeoutManager(typeof(string))] + public static class ReservationTimeouts; + """), + Compile(""" + using PatternKit.Generators.Timeouts; + [GenerateTimeoutManager(typeof(string), FactoryMethodName = "")] + public static partial class ReservationTimeouts; + """), + Compile(""" + using PatternKit.Generators.Timeouts; + [GenerateTimeoutManager(typeof(string), ManagerName = " ")] + public static partial class ReservationTimeouts; + """) + }) + .Then("diagnostics identify the invalid declarations", results => + { + ScenarioExpect.Contains(results[0].Diagnostics, diagnostic => diagnostic.Id == "PKTM001"); + ScenarioExpect.Contains(results[1].Diagnostics, diagnostic => diagnostic.Id == "PKTM002"); + ScenarioExpect.Contains(results[2].Diagnostics, diagnostic => diagnostic.Id == "PKTM002"); + }) + .AssertPassed(); + + [Scenario("Generates timeout manager defaults and nested host wrappers")] + [Fact] + public Task Generates_Timeout_Manager_Defaults_And_Nested_Host_Wrappers() + => Given("nested timeout manager declarations", () => Compile(""" + using PatternKit.Generators.Timeouts; + namespace Demo; + public static partial class FulfillmentModule + { + internal abstract partial class Timeouts + { + [GenerateTimeoutManager(typeof(System.Guid), ManagerName = "order\\\"timeouts")] + private sealed partial class ReservationTimeouts; + } + } + """)) + .Then("generated sources preserve containing partial type wrappers", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("public static partial class FulfillmentModule", source); + ScenarioExpect.Contains("internal abstract partial class Timeouts", source); + ScenarioExpect.Contains("private sealed partial class ReservationTimeouts", source); + ScenarioExpect.Contains("TimeoutManager.Create(\"order\\\\\\\"timeouts\")", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Generates timeout manager factory for global namespace struct host")] + [Fact] + public Task Generates_Timeout_Manager_Factory_For_Global_Namespace_Struct_Host() + => Given("a struct timeout manager host without a namespace", () => Compile(""" + using PatternKit.Generators.Timeouts; + [GenerateTimeoutManager(typeof(int))] + internal partial struct ReservationTimeouts; + """)) + .Then("the generated source preserves the struct host shape", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("internal partial struct ReservationTimeouts", source); + ScenarioExpect.Contains("TimeoutManager Create()", source); + ScenarioExpect.Contains("TimeoutManager.Create(\"timeout-manager\").Build()", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Skips timeout manager generation for malformed key type")] + [Fact] + public Task Skips_Timeout_Manager_Generation_For_Malformed_Key_Type() + => Given("a timeout manager declaration with an unresolved key type", () => Compile(""" + using PatternKit.Generators.Timeouts; + [GenerateTimeoutManager(typeof(MissingKey))] + public static partial class MissingTimeouts; + """)) + .Then("no generated source is produced by the generator", result => + { + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Empty(result.GeneratedSources); + ScenarioExpect.False(result.EmitSuccess); + }) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "TimeoutManagerGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(PatternKit.Application.Timeouts.TimeoutManager<>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new TimeoutManagerGenerator(), out var run, out var updated); + var result = run.Results.Single(); + var emit = updated.Emit(Stream.Null); + return new GeneratorResult( + 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/Application/Timeouts/TimeoutManagerTests.cs b/test/PatternKit.Tests/Application/Timeouts/TimeoutManagerTests.cs new file mode 100644 index 00000000..0659582c --- /dev/null +++ b/test/PatternKit.Tests/Application/Timeouts/TimeoutManagerTests.cs @@ -0,0 +1,114 @@ +using PatternKit.Application.Timeouts; +using TinyBDD; + +namespace PatternKit.Tests.Application.Timeouts; + +public sealed class TimeoutManagerTests +{ + [Scenario("Timeout manager schedules completes and expires deadlines")] + [Fact] + public void Timeout_Manager_Schedules_Completes_And_Expires_Deadlines() + { + var now = new DateTimeOffset(2026, 5, 30, 9, 0, 0, TimeSpan.Zero); + var manager = TimeoutManager.Create("reservations") + .WithClock(() => now) + .Build(); + + manager.ScheduleAfter("ORDER-1", TimeSpan.FromMinutes(5), "REQ-1"); + manager.ScheduleAfter("ORDER-2", TimeSpan.FromMinutes(10), "REQ-2"); + + ScenarioExpect.Equal(2, manager.PendingCount); + ScenarioExpect.True(manager.Complete("ORDER-2")); + ScenarioExpect.False(manager.Complete("ORDER-2")); + + var expired = manager.ExpireDue(now.AddMinutes(6)); + + var timeout = ScenarioExpect.Single(expired); + ScenarioExpect.Equal("ORDER-1", timeout.Key); + ScenarioExpect.Equal("REQ-1", timeout.CorrelationId); + ScenarioExpect.Equal(now, timeout.ScheduledAt); + ScenarioExpect.Equal(now.AddMinutes(5), timeout.Deadline); + ScenarioExpect.Equal(0, manager.PendingCount); + } + + [Scenario("Timeout manager supports immediate deadlines")] + [Fact] + public void Timeout_Manager_Supports_Immediate_Deadlines() + { + var now = new DateTimeOffset(2026, 5, 30, 9, 0, 0, TimeSpan.Zero); + var manager = TimeoutManager.Create().WithClock(() => now).Build(); + + var timeout = manager.ScheduleAfter("ORDER-1", TimeSpan.Zero, "REQ-1"); + var expired = manager.ExpireDue(now); + + ScenarioExpect.Equal(now, timeout.ScheduledAt); + ScenarioExpect.Equal(now, timeout.Deadline); + ScenarioExpect.Equal("ORDER-1", ScenarioExpect.Single(expired).Key); + ScenarioExpect.Equal(0, manager.PendingCount); + } + + [Scenario("Timeout manager replaces existing deadlines by key")] + [Fact] + public void Timeout_Manager_Replaces_Existing_Deadlines_By_Key() + { + var now = new DateTimeOffset(2026, 5, 30, 9, 0, 0, TimeSpan.Zero); + var manager = TimeoutManager.Create() + .WithClock(() => now) + .WithKeyComparer(StringComparer.OrdinalIgnoreCase) + .Build(); + + manager.ScheduleAfter("ORDER-1", TimeSpan.FromMinutes(5), "first"); + manager.Schedule("order-1", now.AddMinutes(15), "second"); + + var timeout = ScenarioExpect.Single(manager.Snapshot()); + ScenarioExpect.Equal("second", timeout.CorrelationId); + ScenarioExpect.Equal(now.AddMinutes(15), timeout.Deadline); + } + + [Scenario("Timeout manager state orders pending deadlines")] + [Fact] + public void Timeout_Manager_State_Orders_Pending_Deadlines() + { + var now = new DateTimeOffset(2026, 5, 30, 9, 0, 0, TimeSpan.Zero); + var manager = TimeoutManager.Create("reservation-timeouts") + .WithClock(() => now) + .Build(); + + manager.ScheduleAfter("late", TimeSpan.FromMinutes(30)); + manager.ScheduleAfter("early", TimeSpan.FromMinutes(2)); + + var state = manager.GetState(); + + ScenarioExpect.Equal("reservation-timeouts", state.ManagerName); + ScenarioExpect.Equal(2, state.PendingCount); + ScenarioExpect.Equal(["early", "late"], state.PendingTimeouts.Select(static timeout => timeout.Key).ToArray()); + ScenarioExpect.True(state.PendingTimeouts[0].IsDue(now.AddMinutes(3))); + } + + [Scenario("Timeout manager cancels pending deadlines")] + [Fact] + public void Timeout_Manager_Cancels_Pending_Deadlines() + { + var manager = TimeoutManager.Create().Build(); + + manager.ScheduleAfter("ORDER-1", TimeSpan.FromMinutes(5)); + + ScenarioExpect.True(manager.Cancel("ORDER-1")); + ScenarioExpect.False(manager.Cancel("ORDER-1")); + ScenarioExpect.Empty(manager.Snapshot()); + } + + [Scenario("Timeout manager rejects invalid configuration")] + [Fact] + public void Timeout_Manager_Rejects_Invalid_Configuration() + { + var manager = TimeoutManager.Create().Build(); + + ScenarioExpect.Throws(() => TimeoutManager.Create("").Build()); + ScenarioExpect.Throws(() => TimeoutManager.Create().WithClock(null!)); + ScenarioExpect.Throws(() => TimeoutManager.Create().WithKeyComparer(null!)); + ScenarioExpect.Throws(() => manager.ScheduleAfter("ORDER-1", TimeSpan.FromMilliseconds(-1))); + ScenarioExpect.Throws(() => manager.Schedule("ORDER-1", DateTimeOffset.UtcNow.AddDays(-1))); + ScenarioExpect.Throws(() => new TimeoutManagerState("name", 0, null!)); + } +}