From dea5231c0a0e51dd2b5a483ae6e61d0a12b42968 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Sat, 30 May 2026 14:02:38 -0500 Subject: [PATCH] feat: add snapshot checkpoint management pattern --- README.md | 6 +- .../SnapshotCheckpointManagementBenchmarks.cs | 29 ++ docs/examples/index.md | 4 + .../order-replay-snapshot-checkpoint.md | 23 ++ docs/examples/toc.yml | 3 + docs/generators/index.md | 1 + .../snapshot-checkpoint-management.md | 28 ++ docs/generators/toc.yml | 3 + docs/guides/benchmark-results.md | 10 +- docs/guides/benchmarks.md | 2 + docs/guides/pattern-coverage.md | 1 + docs/index.md | 4 +- .../snapshot-checkpoint-management.md | 34 ++ docs/patterns/toc.yml | 2 + .../SnapshotCheckpointManager.cs | 298 ++++++++++++++++++ ...rnKitExampleServiceCollectionExtensions.cs | 15 +- .../PatternKitExampleCatalog.cs | 10 +- .../PatternKitPatternCatalog.cs | 13 + .../OrderReplaySnapshotCheckpointDemo.cs | 171 ++++++++++ .../SnapshotCheckpointAttributes.cs | 19 ++ .../AnalyzerReleases.Unshipped.md | 2 + .../SnapshotCheckpointManagerGenerator.cs | 170 ++++++++++ .../PatternKitBenchmarkCoverageTests.cs | 5 +- .../PatternKitPatternCatalogTests.cs | 3 +- .../OrderReplaySnapshotCheckpointDemoTests.cs | 111 +++++++ .../AbstractionsTests.cs | 33 ++ ...SnapshotCheckpointManagerGeneratorTests.cs | 162 ++++++++++ .../SnapshotCheckpointManagerTests.cs | 135 ++++++++ 28 files changed, 1286 insertions(+), 11 deletions(-) create mode 100644 benchmarks/PatternKit.Benchmarks/Application/SnapshotCheckpointManagementBenchmarks.cs create mode 100644 docs/examples/order-replay-snapshot-checkpoint.md create mode 100644 docs/generators/snapshot-checkpoint-management.md create mode 100644 docs/patterns/application/snapshot-checkpoint-management.md create mode 100644 src/PatternKit.Core/Application/SnapshotCheckpoints/SnapshotCheckpointManager.cs create mode 100644 src/PatternKit.Examples/SnapshotCheckpointDemo/OrderReplaySnapshotCheckpointDemo.cs create mode 100644 src/PatternKit.Generators.Abstractions/SnapshotCheckpoints/SnapshotCheckpointAttributes.cs create mode 100644 src/PatternKit.Generators/SnapshotCheckpoints/SnapshotCheckpointManagerGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/SnapshotCheckpointDemo/OrderReplaySnapshotCheckpointDemoTests.cs create mode 100644 test/PatternKit.Generators.Tests/SnapshotCheckpointManagerGeneratorTests.cs create mode 100644 test/PatternKit.Tests/Application/SnapshotCheckpoints/SnapshotCheckpointManagerTests.cs diff --git a/README.md b/README.md index 741fcf27..ba08a0eb 100644 --- a/README.md +++ b/README.md @@ -473,11 +473,11 @@ var cachedRemoteProxy = Proxy.Create(id => remoteProxy.Execute(id)) --- ## Patterns Table -PatternKit currently tracks 112 production-readiness patterns. Each catalog pattern is represented in tests, documentation, real-world examples, IoC integration, and the BenchmarkDotNet coverage matrix. +PatternKit currently tracks 113 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 | 24 | 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, Manual Task Gate, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object, Workflow Orchestration | +| Application Architecture | 25 | 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, Manual Task Gate, Materialized View, Repository, Service Layer, Snapshot / Checkpoint Management, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object, Workflow Orchestration | | Behavioral | 11 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 20 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Cache Stampede Protection, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Read-Through Cache, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig, Write-Through Cache | | Creational | 5 | Abstract Factory, Builder, Factory Method, Prototype, Singleton | @@ -501,6 +501,8 @@ BenchmarkDotNet guidance is documented in [docs/guides/benchmarks.md](docs/guide | Manual Task Gate | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Workflow Orchestration | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Workflow Orchestration | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Snapshot / Checkpoint Management | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Snapshot / Checkpoint Management | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | 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. | diff --git a/benchmarks/PatternKit.Benchmarks/Application/SnapshotCheckpointManagementBenchmarks.cs b/benchmarks/PatternKit.Benchmarks/Application/SnapshotCheckpointManagementBenchmarks.cs new file mode 100644 index 00000000..5fc5c036 --- /dev/null +++ b/benchmarks/PatternKit.Benchmarks/Application/SnapshotCheckpointManagementBenchmarks.cs @@ -0,0 +1,29 @@ +using BenchmarkDotNet.Attributes; +using PatternKit.Application.SnapshotCheckpoints; +using PatternKit.Examples.SnapshotCheckpointDemo; + +namespace PatternKit.Benchmarks.Application; + +[BenchmarkCategory("ApplicationArchitecture", "SnapshotCheckpointManagement")] +public class SnapshotCheckpointManagementBenchmarks +{ + [Benchmark(Baseline = true, Description = "Fluent: create snapshot checkpoint manager")] + [BenchmarkCategory("Fluent", "Construction")] + public SnapshotCheckpointManager Fluent_CreateSnapshotCheckpointManager() + => OrderReplaySnapshotCheckpointPolicies.CreateFluentManager(); + + [Benchmark(Description = "Generated: create snapshot checkpoint manager")] + [BenchmarkCategory("Generated", "Construction")] + public SnapshotCheckpointManager Generated_CreateSnapshotCheckpointManager() + => GeneratedOrderReplayCheckpoints.CreateManager(); + + [Benchmark(Description = "Fluent: replay order with snapshot checkpoint")] + [BenchmarkCategory("Fluent", "Execution")] + public OrderReplaySummary Fluent_ReplayOrderWithSnapshotCheckpoint() + => OrderReplaySnapshotCheckpointDemo.RunFluentAsync().AsTask().GetAwaiter().GetResult(); + + [Benchmark(Description = "Generated: replay order with snapshot checkpoint")] + [BenchmarkCategory("Generated", "Execution")] + public OrderReplaySummary Generated_ReplayOrderWithSnapshotCheckpoint() + => OrderReplaySnapshotCheckpointDemo.RunGeneratedAsync().AsTask().GetAwaiter().GetResult(); +} diff --git a/docs/examples/index.md b/docs/examples/index.md index e3d12e31..19810e02 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -19,6 +19,7 @@ Welcome! This section collects small, focused demos that show **how to compose b * **Messaging backplane facade** for host-style setup, typed request/reply, and publish/subscribe over an application-owned transport boundary. * **Production-readiness catalog** for DI, generic host, and ASP.NET Core diagnostics that maps every documented example to its source, TinyBDD tests, docs page, integration surfaces, and production checks. * **Workflow orchestration** for explicit ordered fulfillment steps with retries, conditional gates, compensation, and execution history. +* **Snapshot / checkpoint management** for resumable event stream replay and projection rebuilds. ## Demos in this section @@ -43,6 +44,9 @@ Welcome! This section collects small, focused demos that show **how to compose b * **Fulfillment Workflow Orchestration** A Generic Host importable fulfillment workflow with fluent and source-generated routes for inventory reservation, fraud review, payment capture, retries, warehouse release, and compensation. See [Fulfillment Workflow Orchestration](fulfillment-workflow-orchestration.md). +* **Order Replay Snapshot Checkpoint Management** + A Generic Host importable replay service with fluent and source-generated checkpoint manager routes for event-sourced order rebuilds. See [Order Replay Snapshot Checkpoint Management](order-replay-snapshot-checkpoint.md). + * **Minimal Web Request Router** A tiny "API gateway" that separates **first-match middleware** (side effects/logging/auth) from **first-match routes** and **content negotiation**. A crisp example of Strategy patterns in an HTTP-ish setting. diff --git a/docs/examples/order-replay-snapshot-checkpoint.md b/docs/examples/order-replay-snapshot-checkpoint.md new file mode 100644 index 00000000..1d310fe6 --- /dev/null +++ b/docs/examples/order-replay-snapshot-checkpoint.md @@ -0,0 +1,23 @@ +# Order Replay Snapshot Checkpoint Management + +This example models an event-sourced order replay service that stores compact checkpoints after rebuilding order state. A replay can resume from a usable checkpoint and only apply later events. + +The fluent path builds the checkpoint manager directly: + +```csharp +var manager = OrderReplaySnapshotCheckpointPolicies.CreateFluentManager(); +``` + +The generated path uses a source-generated factory: + +```csharp +var manager = GeneratedOrderReplayCheckpoints.CreateManager(); +``` + +The example is importable through standard dependency injection: + +```csharp +services.AddOrderReplaySnapshotCheckpointDemo(); +``` + +`OrderReplayService` composes an `IEventStore` with `SnapshotCheckpointManager`. Production applications can replace the in-memory event store with their own storage while keeping the checkpoint manager construction, stale checkpoint handling, and replay tests intact. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index d361b32e..2e71348a 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -16,6 +16,9 @@ - name: Fulfillment Workflow Orchestration href: fulfillment-workflow-orchestration.md +- name: Order Replay Snapshot Checkpoint Management + href: order-replay-snapshot-checkpoint.md + - name: Auth & Logging with `ActionChain` href: auth-logging-chain.md diff --git a/docs/generators/index.md b/docs/generators/index.md index fbe67228..77d29501 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -64,6 +64,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Activity Tracker**](activity-tracker.md) | Active-work tracker gates for loading and readiness workflows | `[GenerateActivityTracker]` | | [**Manual Task Gate**](manual-task-gate.md) | Human approval gates for workflow pauses and manual decisions | `[GenerateManualTaskGate]` | | [**Workflow Orchestration**](workflow-orchestration.md) | Ordered workflow factories from annotated step methods | `[WorkflowOrchestration]` | +| [**Snapshot / Checkpoint Management**](snapshot-checkpoint-management.md) | Replay checkpoint manager factories for resumable processors | `[GenerateSnapshotCheckpointManager]` | | [**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]` | diff --git a/docs/generators/snapshot-checkpoint-management.md b/docs/generators/snapshot-checkpoint-management.md new file mode 100644 index 00000000..4c9bf25c --- /dev/null +++ b/docs/generators/snapshot-checkpoint-management.md @@ -0,0 +1,28 @@ +# Snapshot / Checkpoint Management Generator + +The Snapshot / Checkpoint Management generator creates a strongly typed factory for a `SnapshotCheckpointManager` from a partial host type. + +```csharp +using PatternKit.Generators.SnapshotCheckpoints; + +[GenerateSnapshotCheckpointManager( + typeof(string), + typeof(OrderReplaySnapshot), + FactoryMethodName = "CreateManager", + ManagerName = "order-replay-checkpoints")] +public static partial class GeneratedOrderReplayCheckpoints; +``` + +Generated output: + +```csharp +SnapshotCheckpointManager manager = + GeneratedOrderReplayCheckpoints.CreateManager(); +``` + +Use the fluent manager when runtime configuration needs a custom comparer, clock, or stale write policy. Use the generated factory when a module wants a discoverable, allocation-light construction path with the manager name and types fixed at compile time. + +## Diagnostics + +- `PKSCP001`: the snapshot checkpoint manager host type must be partial. +- `PKSCP002`: `FactoryMethodName` and `ManagerName` must be non-empty. diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 65015131..5a993edb 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -276,6 +276,9 @@ - name: Timeout Manager href: timeout-manager.md +- name: Snapshot / Checkpoint Management + href: snapshot-checkpoint-management.md + - name: Strategy href: strategy.md diff --git a/docs/guides/benchmark-results.md b/docs/guides/benchmark-results.md index 21f2bfdb..b23afb76 100644 --- a/docs/guides/benchmark-results.md +++ b/docs/guides/benchmark-results.md @@ -21,6 +21,8 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 | Manual Task Gate | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Workflow Orchestration | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Workflow Orchestration | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Snapshot / Checkpoint Management | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Snapshot / Checkpoint Management | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | 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. | @@ -244,11 +246,11 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 ## Coverage Matrix Summary -The coverage matrix currently publishes 112 catalog patterns and 448 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 113 catalog patterns and 452 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 | 24 | 96 | +| Application Architecture | 25 | 100 | | Behavioral | 11 | 44 | | Cloud Architecture | 20 | 80 | | Creational | 5 | 20 | @@ -256,7 +258,7 @@ The coverage matrix currently publishes 112 catalog patterns and 448 pattern rou | Messaging Reliability | 3 | 12 | | Structural | 7 | 28 | -The generator matrix currently publishes 106 generator source route results. +The generator matrix currently publishes 107 generator source route results. ## Hosting Integration Matrix Results @@ -279,6 +281,7 @@ The generator matrix currently publishes 106 generator source route results. | Application Architecture | Activity Tracker | Covered | Covered | Covered | Covered | | Application Architecture | Manual Task Gate | Covered | Covered | Covered | Covered | | Application Architecture | Workflow Orchestration | Covered | Covered | Covered | Covered | +| Application Architecture | Snapshot / Checkpoint Management | 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 | @@ -396,6 +399,7 @@ The generator matrix currently publishes 106 generator source route results. | TimeoutManagerGenerator | `src/PatternKit.Generators/Timeouts/TimeoutManagerGenerator.cs` | Covered | | ManualTaskGateGenerator | `src/PatternKit.Generators/ManualTaskGates/ManualTaskGateGenerator.cs` | Covered | | WorkflowOrchestrationGenerator | `src/PatternKit.Generators/WorkflowOrchestration/WorkflowOrchestrationGenerator.cs` | Covered | +| SnapshotCheckpointManagerGenerator | `src/PatternKit.Generators/SnapshotCheckpoints/SnapshotCheckpointManagerGenerator.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 2c430930..92473016 100644 --- a/docs/guides/benchmarks.md +++ b/docs/guides/benchmarks.md @@ -38,6 +38,8 @@ The following numbers were captured on Windows 11, Intel Core i9-14900K, .NET SD | Manual Task Gate | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Workflow Orchestration | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Workflow Orchestration | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Snapshot / Checkpoint Management | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Snapshot / Checkpoint Management | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | 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. | diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 13b6c67f..ad99cc26 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -130,6 +130,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Application Architecture | Activity Tracker | `ActivityTracker` | Activity Tracker generator | | Application Architecture | Manual Task Gate | `ManualTaskGate` | Manual Task Gate generator | | Application Architecture | Workflow Orchestration | `WorkflowOrchestrator` | Workflow Orchestration generator | +| Application Architecture | Snapshot / Checkpoint Management | `SnapshotCheckpointManager` | Snapshot / Checkpoint Management generator | | Application Architecture | Timeout Manager | `TimeoutManager` | Timeout Manager generator | ## Research Baselines diff --git a/docs/index.md b/docs/index.md index 60c31c3f..c964eb09 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,11 +66,11 @@ if (parser.Execute("123", out var value)) ## 📚 Available Patterns -PatternKit covers 112 production-readiness patterns with fluent APIs, source-generated routes where applicable, IoC integration examples, TinyBDD coverage, and BenchmarkDotNet coverage-matrix validation: +PatternKit covers 113 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 | 24 | 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, Manual Task Gate, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object, Workflow Orchestration | +| Application Architecture | 25 | 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, Manual Task Gate, Materialized View, Repository, Service Layer, Snapshot / Checkpoint Management, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object, Workflow Orchestration | | Behavioral | 11 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 20 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Cache Stampede Protection, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Read-Through Cache, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig, Write-Through Cache | | Creational | 5 | Abstract Factory, Builder, Factory Method, Prototype, Singleton | diff --git a/docs/patterns/application/snapshot-checkpoint-management.md b/docs/patterns/application/snapshot-checkpoint-management.md new file mode 100644 index 00000000..0b4ec265 --- /dev/null +++ b/docs/patterns/application/snapshot-checkpoint-management.md @@ -0,0 +1,34 @@ +# Snapshot / Checkpoint Management + +Snapshot / Checkpoint Management stores compact replay state for long-running processors, event-sourced streams, projections, and resumable imports. It lets a processor resume from a known version instead of replaying the entire history on every run. + +`SnapshotCheckpointManager` provides the fluent runtime path: + +```csharp +var checkpoints = SnapshotCheckpointManager + .Create("order-replay-checkpoints") + .UseComparer(StringComparer.OrdinalIgnoreCase) + .Build(); + +checkpoints.Save("ORDER-100", 42, snapshot, "replay:ORDER-100"); + +var load = checkpoints.Load("ORDER-100", minimumVersion: 40); +if (load.IsUsable) +{ + var resumeFrom = load.Checkpoint!.Version; +} +``` + +The manager reports missing and stale checkpoints separately, rejects stale writes by default, and can be configured to overwrite stale writes when a caller owns that policy. + +## Use When + +- Replaying event streams or rebuilding projections should resume from compacted state. +- Processors need clear missing, found, and stale checkpoint outcomes. +- A checkpoint store should be importable through `IServiceCollection` and tested independently from storage infrastructure. + +## Compare With + +- Use Event Sourcing for append-only facts and Snapshot / Checkpoint Management for compact replay state. +- Use Materialized View when the snapshot is the user-facing read model. +- Use Inbox or Outbox when the concern is message idempotency or reliable publication. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 08c464fe..56efa8fd 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -447,6 +447,8 @@ href: application/manual-task-gate.md - name: Workflow Orchestration href: application/workflow-orchestration.md + - name: Snapshot / Checkpoint Management + href: application/snapshot-checkpoint-management.md - name: Timeout Manager href: application/timeout-manager.md - name: Event Sourcing diff --git a/src/PatternKit.Core/Application/SnapshotCheckpoints/SnapshotCheckpointManager.cs b/src/PatternKit.Core/Application/SnapshotCheckpoints/SnapshotCheckpointManager.cs new file mode 100644 index 00000000..2d66d184 --- /dev/null +++ b/src/PatternKit.Core/Application/SnapshotCheckpoints/SnapshotCheckpointManager.cs @@ -0,0 +1,298 @@ +namespace PatternKit.Application.SnapshotCheckpoints; + +public sealed class SnapshotCheckpointManager + where TKey : notnull +{ + private readonly object _gate = new(); + private readonly Dictionary> _checkpoints; + private readonly Func _clock; + private readonly SnapshotCheckpointStaleWritePolicy _staleWritePolicy; + + private SnapshotCheckpointManager( + string name, + IEqualityComparer? comparer, + Func clock, + SnapshotCheckpointStaleWritePolicy staleWritePolicy) + { + Name = name; + _checkpoints = new Dictionary>(comparer); + _clock = clock; + _staleWritePolicy = staleWritePolicy; + } + + public string Name { get; } + + public int Count + { + get + { + lock (_gate) + return _checkpoints.Count; + } + } + + public static Builder Create(string name = "snapshot-checkpoints") => new(name); + + public SnapshotCheckpointSaveResult Save( + TKey key, + long version, + TSnapshot snapshot, + string? correlationId = null, + IReadOnlyDictionary? metadata = null) + { + if (key is null) + throw new ArgumentNullException(nameof(key)); + if (version < 0) + throw new ArgumentOutOfRangeException(nameof(version)); + if (snapshot is null) + throw new ArgumentNullException(nameof(snapshot)); + + lock (_gate) + { + if (_checkpoints.TryGetValue(key, out var existing) + && version < existing.Version + && _staleWritePolicy == SnapshotCheckpointStaleWritePolicy.Reject) + return SnapshotCheckpointSaveResult.RejectedStale(existing, version); + + var checkpoint = new SnapshotCheckpoint( + key, + version, + snapshot, + _clock(), + correlationId, + metadata ?? new Dictionary()); + _checkpoints[key] = checkpoint; + return SnapshotCheckpointSaveResult.Saved(checkpoint, existing); + } + } + + public SnapshotCheckpointLoadResult Load(TKey key, long minimumVersion = 0) + { + if (key is null) + throw new ArgumentNullException(nameof(key)); + if (minimumVersion < 0) + throw new ArgumentOutOfRangeException(nameof(minimumVersion)); + + lock (_gate) + { + if (!_checkpoints.TryGetValue(key, out var checkpoint)) + return SnapshotCheckpointLoadResult.Missing(key, minimumVersion); + + return checkpoint.Version < minimumVersion + ? SnapshotCheckpointLoadResult.Stale(checkpoint, minimumVersion) + : SnapshotCheckpointLoadResult.Found(checkpoint, minimumVersion); + } + } + + public bool Remove(TKey key) + { + if (key is null) + throw new ArgumentNullException(nameof(key)); + + lock (_gate) + return _checkpoints.Remove(key); + } + + public IReadOnlyList> Snapshot() + { + lock (_gate) + return _checkpoints.Values.OrderBy(static checkpoint => checkpoint.Version).ToArray(); + } + + public SnapshotCheckpointManagerState GetState() + => new(Name, Count, Snapshot()); + + public sealed class Builder + { + private readonly string _name; + private IEqualityComparer? _comparer; + private Func _clock = () => DateTimeOffset.UtcNow; + private SnapshotCheckpointStaleWritePolicy _staleWritePolicy = SnapshotCheckpointStaleWritePolicy.Reject; + + internal Builder(string name) + { + _name = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Snapshot checkpoint manager name is required.", nameof(name)) + : name; + } + + public Builder UseComparer(IEqualityComparer comparer) + { + _comparer = comparer ?? throw new ArgumentNullException(nameof(comparer)); + return this; + } + + public Builder WithClock(Func clock) + { + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + return this; + } + + public Builder WithStaleWritePolicy(SnapshotCheckpointStaleWritePolicy policy) + { + if (policy != SnapshotCheckpointStaleWritePolicy.Reject && policy != SnapshotCheckpointStaleWritePolicy.Overwrite) + throw new ArgumentOutOfRangeException(nameof(policy)); + + _staleWritePolicy = policy; + return this; + } + + public SnapshotCheckpointManager Build() + => new(_name, _comparer, _clock, _staleWritePolicy); + } +} + +public sealed class SnapshotCheckpoint + where TKey : notnull +{ + public SnapshotCheckpoint( + TKey key, + long version, + TSnapshot snapshot, + DateTimeOffset savedAt, + string? correlationId, + IReadOnlyDictionary metadata) + { + if (key is null) + throw new ArgumentNullException(nameof(key)); + if (version < 0) + throw new ArgumentOutOfRangeException(nameof(version)); + if (snapshot is null) + throw new ArgumentNullException(nameof(snapshot)); + + Key = key; + Version = version; + Snapshot = snapshot; + SavedAt = savedAt; + CorrelationId = correlationId; + Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); + } + + public TKey Key { get; } + + public long Version { get; } + + public TSnapshot Snapshot { get; } + + public DateTimeOffset SavedAt { get; } + + public string? CorrelationId { get; } + + public IReadOnlyDictionary Metadata { get; } +} + +public sealed class SnapshotCheckpointSaveResult + where TKey : notnull +{ + private SnapshotCheckpointSaveResult( + SnapshotCheckpointSaveStatus status, + SnapshotCheckpoint? checkpoint, + SnapshotCheckpoint? previousCheckpoint, + long attemptedVersion) + { + Status = status; + Checkpoint = checkpoint; + PreviousCheckpoint = previousCheckpoint; + AttemptedVersion = attemptedVersion; + } + + public SnapshotCheckpointSaveStatus Status { get; } + + public SnapshotCheckpoint? Checkpoint { get; } + + public SnapshotCheckpoint? PreviousCheckpoint { get; } + + public long AttemptedVersion { get; } + + public bool IsSaved => Status == SnapshotCheckpointSaveStatus.Saved; + + public static SnapshotCheckpointSaveResult Saved( + SnapshotCheckpoint checkpoint, + SnapshotCheckpoint? previousCheckpoint) + => new(SnapshotCheckpointSaveStatus.Saved, checkpoint, previousCheckpoint, checkpoint.Version); + + public static SnapshotCheckpointSaveResult RejectedStale( + SnapshotCheckpoint existing, + long attemptedVersion) + => new(SnapshotCheckpointSaveStatus.RejectedStale, existing, existing, attemptedVersion); +} + +public sealed class SnapshotCheckpointLoadResult + where TKey : notnull +{ + private SnapshotCheckpointLoadResult( + SnapshotCheckpointLoadStatus status, + TKey key, + SnapshotCheckpoint? checkpoint, + long minimumVersion) + { + Key = key; + Status = status; + Checkpoint = checkpoint; + MinimumVersion = minimumVersion; + } + + public TKey Key { get; } + + public SnapshotCheckpointLoadStatus Status { get; } + + public SnapshotCheckpoint? Checkpoint { get; } + + public long MinimumVersion { get; } + + public bool IsUsable => Status == SnapshotCheckpointLoadStatus.Found; + + public static SnapshotCheckpointLoadResult Found( + SnapshotCheckpoint checkpoint, + long minimumVersion) + => new(SnapshotCheckpointLoadStatus.Found, checkpoint.Key, checkpoint, minimumVersion); + + public static SnapshotCheckpointLoadResult Missing(TKey key, long minimumVersion) + => new(SnapshotCheckpointLoadStatus.Missing, key, null, minimumVersion); + + public static SnapshotCheckpointLoadResult Stale( + SnapshotCheckpoint checkpoint, + long minimumVersion) + => new(SnapshotCheckpointLoadStatus.Stale, checkpoint.Key, checkpoint, minimumVersion); +} + +public sealed class SnapshotCheckpointManagerState + where TKey : notnull +{ + public SnapshotCheckpointManagerState( + string name, + int count, + IReadOnlyList> checkpoints) + { + Name = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Snapshot checkpoint manager name is required.", nameof(name)) + : name; + Count = count < 0 ? throw new ArgumentOutOfRangeException(nameof(count)) : count; + Checkpoints = checkpoints ?? throw new ArgumentNullException(nameof(checkpoints)); + } + + public string Name { get; } + + public int Count { get; } + + public IReadOnlyList> Checkpoints { get; } +} + +public enum SnapshotCheckpointLoadStatus +{ + Missing, + Found, + Stale +} + +public enum SnapshotCheckpointSaveStatus +{ + Saved, + RejectedStale +} + +public enum SnapshotCheckpointStaleWritePolicy +{ + Reject, + Overwrite +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 084c7d1f..ece9f21c 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.ManualTaskGates; +using PatternKit.Application.SnapshotCheckpoints; using PatternKit.Application.Specification; using PatternKit.Application.Timeouts; using PatternKit.Application.WorkflowOrchestration; @@ -79,6 +80,7 @@ using PatternKit.Examples.ServiceLayerDemo; using PatternKit.Examples.SidecarDemo; using PatternKit.Examples.Singleton; +using PatternKit.Examples.SnapshotCheckpointDemo; using PatternKit.Examples.SpecificationDemo; using PatternKit.Examples.StranglerFigDemo; using PatternKit.Examples.Strategies.Coercion; @@ -259,6 +261,7 @@ public sealed record WarehouseSchedulerAgentSupervisorExample(WarehouseScheduler public sealed record OrderApprovalManualTaskGatePatternExample(ManualTaskGate Gate, OrderApprovalManualTaskGateDemoRunner Runner); public sealed record OrderReservationTimeoutPatternExample(TimeoutManager Manager, OrderReservationTimeoutDemoRunner Runner); public sealed record FulfillmentWorkflowOrchestrationPatternExample(WorkflowOrchestrator Workflow, FulfillmentWorkflowOrchestrationDemoRunner Runner); +public sealed record OrderReplaySnapshotCheckpointPatternExample(SnapshotCheckpointManager Manager, OrderReplaySnapshotCheckpointDemoRunner Runner); /// /// Fluent registration helpers for importing every documented PatternKit example into Microsoft.Extensions.DependencyInjection. @@ -377,7 +380,8 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddWarehouseSchedulerAgentSupervisorExample() .AddOrderApprovalManualTaskGatePatternExample() .AddOrderReservationTimeoutPatternExample() - .AddFulfillmentWorkflowOrchestrationPatternExample(); + .AddFulfillmentWorkflowOrchestrationPatternExample() + .AddOrderReplaySnapshotCheckpointPatternExample(); public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services) { @@ -1369,6 +1373,15 @@ public static IServiceCollection AddFulfillmentWorkflowOrchestrationPatternExamp return services.RegisterExample("Fulfillment Workflow Orchestration", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddOrderReplaySnapshotCheckpointPatternExample(this IServiceCollection services) + { + services.AddOrderReplaySnapshotCheckpointDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService())); + return services.RegisterExample("Order Replay Snapshot Checkpoint Management", 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 8c662df1..9989a550 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -967,7 +967,15 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog "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"]) + ["scheduled work dispatch", "source-generated supervisor factory", "Generic Host hosted service"]), + Descriptor( + "Order Replay Snapshot Checkpoint Management", + "src/PatternKit.Examples/SnapshotCheckpointDemo/OrderReplaySnapshotCheckpointDemo.cs", + "test/PatternKit.Examples.Tests/SnapshotCheckpointDemo/OrderReplaySnapshotCheckpointDemoTests.cs", + "docs/examples/order-replay-snapshot-checkpoint.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["Snapshot / Checkpoint Management", "Event Sourcing"], + ["event stream replay resume point", "stale checkpoint handling", "source-generated checkpoint manager factory", "DI composition"]) ]; public IReadOnlyList Entries => Items; diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index aa46d16e..13b88318 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -1493,6 +1493,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/WorkflowOrchestrationDemo/FulfillmentWorkflowOrchestrationDemoTests.cs", ["fluent ordered workflow", "generated annotated workflow methods", "DI-importable fulfillment orchestration example"]), + Pattern("Snapshot / Checkpoint Management", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/snapshot-checkpoint-management.md", + "src/PatternKit.Core/Application/SnapshotCheckpoints/SnapshotCheckpointManager.cs", + "test/PatternKit.Tests/Application/SnapshotCheckpoints/SnapshotCheckpointManagerTests.cs", + "docs/generators/snapshot-checkpoint-management.md", + "src/PatternKit.Generators/SnapshotCheckpoints/SnapshotCheckpointManagerGenerator.cs", + "test/PatternKit.Generators.Tests/SnapshotCheckpointManagerGeneratorTests.cs", + null, + "docs/examples/order-replay-snapshot-checkpoint.md", + "src/PatternKit.Examples/SnapshotCheckpointDemo/OrderReplaySnapshotCheckpointDemo.cs", + "test/PatternKit.Examples.Tests/SnapshotCheckpointDemo/OrderReplaySnapshotCheckpointDemoTests.cs", + ["fluent checkpoint manager", "generated checkpoint manager factory", "DI-importable event replay checkpoint example"]), + Pattern("Timeout Manager", PatternFamily.ApplicationArchitecture, "docs/patterns/application/timeout-manager.md", "src/PatternKit.Core/Application/Timeouts/TimeoutManager.cs", diff --git a/src/PatternKit.Examples/SnapshotCheckpointDemo/OrderReplaySnapshotCheckpointDemo.cs b/src/PatternKit.Examples/SnapshotCheckpointDemo/OrderReplaySnapshotCheckpointDemo.cs new file mode 100644 index 00000000..858295b3 --- /dev/null +++ b/src/PatternKit.Examples/SnapshotCheckpointDemo/OrderReplaySnapshotCheckpointDemo.cs @@ -0,0 +1,171 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.EventSourcing; +using PatternKit.Application.SnapshotCheckpoints; +using PatternKit.Generators.SnapshotCheckpoints; + +namespace PatternKit.Examples.SnapshotCheckpointDemo; + +public static class OrderReplaySnapshotCheckpointDemo +{ + public static async ValueTask RunFluentAsync() + { + var manager = OrderReplaySnapshotCheckpointPolicies.CreateFluentManager(); + var service = new OrderReplayService(CreateSeededStore(), manager); + return await service.ReplayAsync("order-100", 2); + } + + public static async ValueTask RunGeneratedAsync() + { + var manager = GeneratedOrderReplayCheckpoints.CreateManager(); + var service = new OrderReplayService(CreateSeededStore(), manager); + return await service.ReplayAsync("order-200", 3); + } + + public static IEventStore CreateSeededStore() + { + var store = InMemoryEventStore.Create("order-replay-events").Build(); + Seed(store, "order-100").GetAwaiter().GetResult(); + Seed(store, "order-200").GetAwaiter().GetResult(); + return store; + } + + private static async Task Seed(IEventStore store, string orderId) + { + _ = await store.AppendAsync(orderId, 0, [ + new OrderReplayPlaced(orderId, "customer-1", 125m, DateTimeOffset.UtcNow), + new OrderReplayPaid(orderId, "payment-1", 125m, DateTimeOffset.UtcNow), + new OrderReplayShipped(orderId, "tracking-1", DateTimeOffset.UtcNow) + ]).ConfigureAwait(false); + } +} + +public abstract record OrderReplayEvent(string OrderId, DateTimeOffset OccurredAt); + +public sealed record OrderReplayPlaced(string OrderId, string CustomerId, decimal Total, DateTimeOffset OccurredAt) + : OrderReplayEvent(OrderId, OccurredAt); + +public sealed record OrderReplayPaid(string OrderId, string PaymentId, decimal Amount, DateTimeOffset OccurredAt) + : OrderReplayEvent(OrderId, OccurredAt); + +public sealed record OrderReplayShipped(string OrderId, string TrackingNumber, DateTimeOffset OccurredAt) + : OrderReplayEvent(OrderId, OccurredAt); + +public sealed record OrderReplaySnapshot( + string OrderId, + string CustomerId, + decimal Total, + decimal PaidTotal, + bool Shipped, + long Version); + +public sealed record OrderReplaySummary( + string ManagerName, + string OrderId, + long StartingVersion, + long FinalVersion, + bool UsedCheckpoint, + bool RebuiltCheckpoint, + bool Shipped, + decimal PaidTotal); + +public static class OrderReplaySnapshotCheckpointPolicies +{ + public static SnapshotCheckpointManager CreateFluentManager() + => SnapshotCheckpointManager + .Create("order-replay-checkpoints") + .UseComparer(StringComparer.OrdinalIgnoreCase) + .Build(); +} + +public sealed class OrderReplayService +{ + private readonly IEventStore _store; + private readonly SnapshotCheckpointManager _checkpoints; + + public OrderReplayService( + IEventStore store, + SnapshotCheckpointManager checkpoints) + { + _store = store; + _checkpoints = checkpoints; + } + + public async ValueTask ReplayAsync( + string orderId, + long minimumCheckpointVersion, + CancellationToken cancellationToken = default) + { + var load = _checkpoints.Load(orderId, minimumCheckpointVersion); + var startingVersion = load.IsUsable ? load.Checkpoint!.Version : 0; + var snapshot = load.IsUsable + ? load.Checkpoint!.Snapshot + : new OrderReplaySnapshot(orderId, "", 0m, 0m, false, 0); + + var events = await _store.ReadStreamAsync(orderId, cancellationToken).ConfigureAwait(false); + foreach (var stored in events.Where(entry => entry.Version > startingVersion).OrderBy(static entry => entry.Version)) + snapshot = Apply(snapshot, stored.Event, stored.Version); + + var save = _checkpoints.Save( + orderId, + snapshot.Version, + snapshot, + $"replay:{orderId}", + new Dictionary { ["source"] = "event-store" }); + + return new OrderReplaySummary( + _checkpoints.Name, + snapshot.OrderId, + startingVersion, + snapshot.Version, + load.IsUsable, + save.IsSaved && !load.IsUsable, + snapshot.Shipped, + snapshot.PaidTotal); + } + + private static OrderReplaySnapshot Apply(OrderReplaySnapshot snapshot, OrderReplayEvent @event, long version) + => @event switch + { + OrderReplayPlaced placed => snapshot with + { + OrderId = placed.OrderId, + CustomerId = placed.CustomerId, + Total = placed.Total, + Version = version + }, + OrderReplayPaid paid => snapshot with + { + OrderId = paid.OrderId, + PaidTotal = snapshot.PaidTotal + paid.Amount, + Version = version + }, + OrderReplayShipped shipped => snapshot with + { + OrderId = shipped.OrderId, + Shipped = true, + Version = version + }, + _ => snapshot + }; +} + +public sealed record OrderReplaySnapshotCheckpointDemoRunner( + Func> RunFluentAsync, + Func> RunGeneratedAsync); + +public static class OrderReplaySnapshotCheckpointServiceCollectionExtensions +{ + public static IServiceCollection AddOrderReplaySnapshotCheckpointDemo(this IServiceCollection services) + { + services.AddSingleton>(_ => OrderReplaySnapshotCheckpointDemo.CreateSeededStore()); + services.AddSingleton(_ => OrderReplaySnapshotCheckpointPolicies.CreateFluentManager()); + services.AddTransient(); + services.AddSingleton(new OrderReplaySnapshotCheckpointDemoRunner( + OrderReplaySnapshotCheckpointDemo.RunFluentAsync, + OrderReplaySnapshotCheckpointDemo.RunGeneratedAsync)); + return services; + } +} + +[GenerateSnapshotCheckpointManager(typeof(string), typeof(OrderReplaySnapshot), FactoryMethodName = "CreateManager", ManagerName = "order-replay-checkpoints")] +public static partial class GeneratedOrderReplayCheckpoints; diff --git a/src/PatternKit.Generators.Abstractions/SnapshotCheckpoints/SnapshotCheckpointAttributes.cs b/src/PatternKit.Generators.Abstractions/SnapshotCheckpoints/SnapshotCheckpointAttributes.cs new file mode 100644 index 00000000..07c8ee2b --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/SnapshotCheckpoints/SnapshotCheckpointAttributes.cs @@ -0,0 +1,19 @@ +namespace PatternKit.Generators.SnapshotCheckpoints; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateSnapshotCheckpointManagerAttribute : Attribute +{ + public GenerateSnapshotCheckpointManagerAttribute(Type keyType, Type snapshotType) + { + KeyType = keyType; + SnapshotType = snapshotType; + } + + public Type KeyType { get; } + + public Type SnapshotType { get; } + + public string FactoryMethodName { get; set; } = "Create"; + + public string ManagerName { get; set; } = "snapshot-checkpoints"; +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 89aeb1c5..dc9fa820 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -442,3 +442,5 @@ PKWO002 | PatternKit.Generators.WorkflowOrchestration | Error | Workflow orchest PKWO003 | PatternKit.Generators.WorkflowOrchestration | Error | Workflow orchestration step signature is invalid. PKWO004 | PatternKit.Generators.WorkflowOrchestration | Error | Workflow orchestration step is duplicated. PKWO005 | PatternKit.Generators.WorkflowOrchestration | Error | Workflow orchestration configuration is invalid. +PKSCP001 | PatternKit.Generators.SnapshotCheckpoints | Error | Snapshot checkpoint manager host must be partial. +PKSCP002 | PatternKit.Generators.SnapshotCheckpoints | Error | Snapshot checkpoint manager configuration is invalid. diff --git a/src/PatternKit.Generators/SnapshotCheckpoints/SnapshotCheckpointManagerGenerator.cs b/src/PatternKit.Generators/SnapshotCheckpoints/SnapshotCheckpointManagerGenerator.cs new file mode 100644 index 00000000..39588eff --- /dev/null +++ b/src/PatternKit.Generators/SnapshotCheckpoints/SnapshotCheckpointManagerGenerator.cs @@ -0,0 +1,170 @@ +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.SnapshotCheckpoints; + +[Generator] +public sealed class SnapshotCheckpointManagerGenerator : IIncrementalGenerator +{ + private const string AttributeName = "PatternKit.Generators.SnapshotCheckpoints.GenerateSnapshotCheckpointManagerAttribute"; + + 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( + "PKSCP001", + "Snapshot checkpoint manager host must be partial", + "Type '{0}' is marked with [GenerateSnapshotCheckpointManager] but is not declared as partial", + "PatternKit.Generators.SnapshotCheckpoints", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidConfiguration = new( + "PKSCP002", + "Snapshot checkpoint manager configuration is invalid", + "Snapshot checkpoint manager '{0}' must have non-empty FactoryMethodName and ManagerName values", + "PatternKit.Generators.SnapshotCheckpoints", + 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; + var snapshotType = attribute.ConstructorArguments.Length >= 2 ? attribute.ConstructorArguments[1].Value as INamedTypeSymbol : null; + if (keyType is null || snapshotType is null || keyType.TypeKind == TypeKind.Error || snapshotType.TypeKind == TypeKind.Error) + return; + + var factoryMethodName = GetNamedString(attribute, "FactoryMethodName") ?? "Create"; + var managerName = GetNamedString(attribute, "ManagerName") ?? "snapshot-checkpoints"; + if (string.IsNullOrWhiteSpace(factoryMethodName) || string.IsNullOrWhiteSpace(managerName)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidConfiguration, node.Identifier.GetLocation(), type.Name)); + return; + } + + context.AddSource($"{type.Name}.SnapshotCheckpointManager.g.cs", SourceText.From( + GenerateSource(type, keyType, snapshotType, factoryMethodName, managerName), + Encoding.UTF8)); + } + + private static string GenerateSource(INamedTypeSymbol type, INamedTypeSymbol keyType, INamedTypeSymbol snapshotType, string factoryMethodName, string managerName) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var keyTypeName = keyType.ToDisplayString(TypeFormat); + var snapshotTypeName = snapshotType.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.SnapshotCheckpoints.SnapshotCheckpointManager<") + .Append(keyTypeName) + .Append(", ") + .Append(snapshotTypeName) + .Append("> ") + .Append(factoryMethodName) + .AppendLine("()"); + sb.Append(memberIndent).AppendLine("{"); + sb.Append(bodyIndent) + .Append("return global::PatternKit.Application.SnapshotCheckpoints.SnapshotCheckpointManager<") + .Append(keyTypeName) + .Append(", ") + .Append(snapshotTypeName) + .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 4b1433fc..292899fe 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("448 pattern route results", ctx.ResultsGuide)) + ScenarioExpect.Contains("452 pattern route results", ctx.ResultsGuide)) .AssertPassed(); [Scenario("Published benchmark results include reusable hosting integrations")] @@ -242,6 +242,9 @@ private static string HumanizeScenarioBenchmarkName(string benchmarkClassName) if (patternName == "EventSourcing") return "Event Sourcing"; + if (patternName == "SnapshotCheckpointManagement") + return "Snapshot / Checkpoint Management"; + if (patternName == "FeatureToggle") return "Feature Toggle"; diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 284abe3b..952cb527 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -125,6 +125,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Activity Tracker", "Manual Task Gate", "Workflow Orchestration", + "Snapshot / Checkpoint Management", "Timeout Manager" ]; @@ -169,7 +170,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(20, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); - ScenarioExpect.Equal(24, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(25, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Examples.Tests/SnapshotCheckpointDemo/OrderReplaySnapshotCheckpointDemoTests.cs b/test/PatternKit.Examples.Tests/SnapshotCheckpointDemo/OrderReplaySnapshotCheckpointDemoTests.cs new file mode 100644 index 00000000..7bc95b87 --- /dev/null +++ b/test/PatternKit.Examples.Tests/SnapshotCheckpointDemo/OrderReplaySnapshotCheckpointDemoTests.cs @@ -0,0 +1,111 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.EventSourcing; +using PatternKit.Application.SnapshotCheckpoints; +using PatternKit.Examples.SnapshotCheckpointDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.SnapshotCheckpointDemo; + +[Feature("Order Replay Snapshot Checkpoint demo")] +public sealed partial class OrderReplaySnapshotCheckpointDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Order Replay Snapshot Checkpoint demo resumes replay through fluent and generated paths")] + [Theory] + [InlineData(false)] + [InlineData(true)] + public Task Order_Replay_Snapshot_Checkpoint_Demo_Resumes_Replay_Through_Fluent_And_Generated_Paths(bool sourceGenerated) + => Given("the order replay snapshot checkpoint demo", () => sourceGenerated) + .When("the selected path runs", (Func>)(async generated => + generated + ? await OrderReplaySnapshotCheckpointDemo.RunGeneratedAsync() + : await OrderReplaySnapshotCheckpointDemo.RunFluentAsync())) + .Then("the replay saves a checkpointed shipped order", summary => + { + ScenarioExpect.Equal("order-replay-checkpoints", summary.ManagerName); + ScenarioExpect.Equal(0, summary.StartingVersion); + ScenarioExpect.Equal(3, summary.FinalVersion); + ScenarioExpect.False(summary.UsedCheckpoint); + ScenarioExpect.True(summary.RebuiltCheckpoint); + ScenarioExpect.True(summary.Shipped); + ScenarioExpect.Equal(125m, summary.PaidTotal); + ScenarioExpect.False(string.IsNullOrWhiteSpace(summary.OrderId)); + }) + .AssertPassed(); + + [Scenario("Order Replay Snapshot Checkpoint demo reuses a valid checkpoint")] + [Fact] + public Task Order_Replay_Snapshot_Checkpoint_Demo_Reuses_A_Valid_Checkpoint() + => Given("a seeded event store and checkpoint manager", () => + { + var store = OrderReplaySnapshotCheckpointDemo.CreateSeededStore(); + var manager = OrderReplaySnapshotCheckpointPolicies.CreateFluentManager(); + manager.Save("order-100", 2, new OrderReplaySnapshot("order-100", "customer-1", 125m, 125m, false, 2)); + return new OrderReplayService(store, manager); + }) + .When("the replay only needs events after the checkpoint", (Func>)(service => + service.ReplayAsync("order-100", 2))) + .Then("the replay starts from the checkpoint and advances to the latest version", summary => + { + ScenarioExpect.True(summary.UsedCheckpoint); + ScenarioExpect.False(summary.RebuiltCheckpoint); + ScenarioExpect.Equal(2, summary.StartingVersion); + ScenarioExpect.Equal(3, summary.FinalVersion); + ScenarioExpect.True(summary.Shipped); + }) + .AssertPassed(); + + [Scenario("Order Replay Snapshot Checkpoint demo is importable through IServiceCollection")] + [Fact] + public Task Order_Replay_Snapshot_Checkpoint_Demo_Is_Importable_Through_IServiceCollection() + => Given("a service provider with the order replay snapshot checkpoint demo", () => + { + var services = new ServiceCollection(); + services.AddOrderReplaySnapshotCheckpointDemo(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("a scoped workflow replays an order", (Func>)(async provider => + { + using (provider) + using (var scope = provider.CreateScope()) + { + var service = scope.ServiceProvider.GetRequiredService(); + return await service.ReplayAsync("order-200", 1); + } + })) + .Then("the imported manager and event store checkpoint the replay", summary => + { + ScenarioExpect.Equal("order-replay-checkpoints", summary.ManagerName); + ScenarioExpect.Equal("order-200", summary.OrderId); + ScenarioExpect.Equal(3, summary.FinalVersion); + ScenarioExpect.True(summary.RebuiltCheckpoint); + ScenarioExpect.True(summary.Shipped); + }) + .AssertPassed(); + + [Scenario("Order Replay Snapshot Checkpoint generated factory creates the same manager")] + [Fact] + public void Order_Replay_Snapshot_Checkpoint_Generated_Factory_Creates_The_Same_Manager() + { + var manager = GeneratedOrderReplayCheckpoints.CreateManager(); + + ScenarioExpect.Equal("order-replay-checkpoints", manager.Name); + ScenarioExpect.IsType>(manager); + } + + [Scenario("Order Replay Snapshot Checkpoint seeded store contains replay events")] + [Fact] + public Task Order_Replay_Snapshot_Checkpoint_Seeded_Store_Contains_Replay_Events() + => Given("the seeded replay store", () => OrderReplaySnapshotCheckpointDemo.CreateSeededStore()) + .When("reading a seeded stream", (Func, ValueTask>>>)(store => + store.ReadStreamAsync("order-100"))) + .Then("the stream contains the placed paid and shipped events", stream => + { + ScenarioExpect.Equal(3, stream.Count); + ScenarioExpect.IsType(stream[0].Event); + ScenarioExpect.IsType(stream[1].Event); + ScenarioExpect.IsType(stream[2].Event); + }) + .AssertPassed(); +} diff --git a/test/PatternKit.Generators.Tests/AbstractionsTests.cs b/test/PatternKit.Generators.Tests/AbstractionsTests.cs index e64cb314..3efd1156 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsTests.cs @@ -140,6 +140,39 @@ public void GenerateTimeoutManagerAttribute_Has_Correct_AttributeUsage() #endregion + #region GenerateSnapshotCheckpointManagerAttribute Tests + + [Scenario("GenerateSnapshotCheckpointManagerAttribute Constructor Sets Properties")] + [Fact] + public void GenerateSnapshotCheckpointManagerAttribute_Constructor_Sets_Properties() + { + var attr = new PatternKit.Generators.SnapshotCheckpoints.GenerateSnapshotCheckpointManagerAttribute(typeof(Guid), typeof(string)) + { + FactoryMethodName = "CreateOrderReplayCheckpoints", + ManagerName = "order-replay-checkpoints" + }; + + ScenarioExpect.Equal(typeof(Guid), attr.KeyType); + ScenarioExpect.Equal(typeof(string), attr.SnapshotType); + ScenarioExpect.Equal("CreateOrderReplayCheckpoints", attr.FactoryMethodName); + ScenarioExpect.Equal("order-replay-checkpoints", attr.ManagerName); + } + + [Scenario("GenerateSnapshotCheckpointManagerAttribute Has Correct AttributeUsage")] + [Fact] + public void GenerateSnapshotCheckpointManagerAttribute_Has_Correct_AttributeUsage() + { + var usage = typeof(PatternKit.Generators.SnapshotCheckpoints.GenerateSnapshotCheckpointManagerAttribute) + .GetCustomAttributes(typeof(AttributeUsageAttribute), false) + .Cast() + .Single(); + + ScenarioExpect.Equal(AttributeTargets.Class | AttributeTargets.Struct, usage.ValidOn); + ScenarioExpect.False(usage.Inherited); + } + + #endregion + #region WorkflowOrchestrationAttribute Tests [Scenario("WorkflowOrchestrationAttribute Constructor Sets Properties")] diff --git a/test/PatternKit.Generators.Tests/SnapshotCheckpointManagerGeneratorTests.cs b/test/PatternKit.Generators.Tests/SnapshotCheckpointManagerGeneratorTests.cs new file mode 100644 index 00000000..f51a4994 --- /dev/null +++ b/test/PatternKit.Generators.Tests/SnapshotCheckpointManagerGeneratorTests.cs @@ -0,0 +1,162 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Generators.SnapshotCheckpoints; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Snapshot Checkpoint Manager generator")] +public sealed partial class SnapshotCheckpointManagerGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates snapshot checkpoint manager factory")] + [Fact] + public Task Generates_Snapshot_Checkpoint_Manager_Factory() + => Given("a snapshot checkpoint manager declaration", () => Compile(""" + using PatternKit.Generators.SnapshotCheckpoints; + namespace Demo; + public sealed record OrderSnapshot(string OrderId, decimal Total); + [GenerateSnapshotCheckpointManager(typeof(string), typeof(OrderSnapshot), FactoryMethodName = "Build", ManagerName = "order-checkpoints")] + public static partial class OrderCheckpoints; + """)) + .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 OrderCheckpoints", source); + ScenarioExpect.Contains("SnapshotCheckpointManager Build()", source); + ScenarioExpect.Contains("SnapshotCheckpointManager.Create(\"order-checkpoints\").Build()", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid snapshot checkpoint manager declarations")] + [Fact] + public Task Reports_Diagnostics_For_Invalid_Snapshot_Checkpoint_Manager_Declarations() + => Given("invalid snapshot checkpoint manager declarations", () => new[] + { + Compile(""" + using PatternKit.Generators.SnapshotCheckpoints; + public sealed record OrderSnapshot(string OrderId); + [GenerateSnapshotCheckpointManager(typeof(string), typeof(OrderSnapshot))] + public static class OrderCheckpoints; + """), + Compile(""" + using PatternKit.Generators.SnapshotCheckpoints; + public sealed record OrderSnapshot(string OrderId); + [GenerateSnapshotCheckpointManager(typeof(string), typeof(OrderSnapshot), FactoryMethodName = "")] + public static partial class OrderCheckpoints; + """), + Compile(""" + using PatternKit.Generators.SnapshotCheckpoints; + public sealed record OrderSnapshot(string OrderId); + [GenerateSnapshotCheckpointManager(typeof(string), typeof(OrderSnapshot), ManagerName = " ")] + public static partial class OrderCheckpoints; + """) + }) + .Then("diagnostics identify the invalid declarations", results => + { + ScenarioExpect.Contains(results[0].Diagnostics, diagnostic => diagnostic.Id == "PKSCP001"); + ScenarioExpect.Contains(results[1].Diagnostics, diagnostic => diagnostic.Id == "PKSCP002"); + ScenarioExpect.Contains(results[2].Diagnostics, diagnostic => diagnostic.Id == "PKSCP002"); + }) + .AssertPassed(); + + [Scenario("Generates snapshot checkpoint manager defaults and nested host wrappers")] + [Fact] + public Task Generates_Snapshot_Checkpoint_Manager_Defaults_And_Nested_Host_Wrappers() + => Given("nested snapshot checkpoint manager declarations", () => Compile(""" + using PatternKit.Generators.SnapshotCheckpoints; + namespace Demo; + public sealed record OrderSnapshot(string OrderId); + public static partial class FulfillmentModule + { + internal abstract partial class Checkpoints + { + [GenerateSnapshotCheckpointManager(typeof(System.Guid), typeof(OrderSnapshot), ManagerName = "order\\\"checkpoints")] + private sealed partial class OrderCheckpoints; + } + } + """)) + .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 Checkpoints", source); + ScenarioExpect.Contains("private sealed partial class OrderCheckpoints", source); + ScenarioExpect.Contains("SnapshotCheckpointManager.Create(\"order\\\\\\\"checkpoints\")", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Generates snapshot checkpoint manager factory for global namespace struct host")] + [Fact] + public Task Generates_Snapshot_Checkpoint_Manager_Factory_For_Global_Namespace_Struct_Host() + => Given("a struct snapshot checkpoint manager host without a namespace", () => Compile(""" + using PatternKit.Generators.SnapshotCheckpoints; + public sealed record OrderSnapshot(string OrderId); + [GenerateSnapshotCheckpointManager(typeof(int), typeof(OrderSnapshot))] + internal partial struct OrderCheckpoints; + """)) + .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 OrderCheckpoints", source); + ScenarioExpect.Contains("SnapshotCheckpointManager Create()", source); + ScenarioExpect.Contains("SnapshotCheckpointManager.Create(\"snapshot-checkpoints\").Build()", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Skips snapshot checkpoint manager generation for malformed types")] + [Fact] + public Task Skips_Snapshot_Checkpoint_Manager_Generation_For_Malformed_Types() + => Given("snapshot checkpoint declarations with unresolved types", () => new[] + { + Compile(""" + using PatternKit.Generators.SnapshotCheckpoints; + public sealed record OrderSnapshot(string OrderId); + [GenerateSnapshotCheckpointManager(typeof(MissingKey), typeof(OrderSnapshot))] + public static partial class MissingKeyCheckpoints; + """), + Compile(""" + using PatternKit.Generators.SnapshotCheckpoints; + [GenerateSnapshotCheckpointManager(typeof(string), typeof(MissingSnapshot))] + public static partial class MissingSnapshotCheckpoints; + """) + }) + .Then("no generated source is produced by the generator", results => + { + ScenarioExpect.Empty(results[0].Diagnostics); + ScenarioExpect.Empty(results[0].GeneratedSources); + ScenarioExpect.False(results[0].EmitSuccess); + ScenarioExpect.Empty(results[1].Diagnostics); + ScenarioExpect.Empty(results[1].GeneratedSources); + ScenarioExpect.False(results[1].EmitSuccess); + }) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "SnapshotCheckpointManagerGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(PatternKit.Application.SnapshotCheckpoints.SnapshotCheckpointManager<,>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new SnapshotCheckpointManagerGenerator(), 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/SnapshotCheckpoints/SnapshotCheckpointManagerTests.cs b/test/PatternKit.Tests/Application/SnapshotCheckpoints/SnapshotCheckpointManagerTests.cs new file mode 100644 index 00000000..877455e2 --- /dev/null +++ b/test/PatternKit.Tests/Application/SnapshotCheckpoints/SnapshotCheckpointManagerTests.cs @@ -0,0 +1,135 @@ +using PatternKit.Application.SnapshotCheckpoints; +using TinyBDD; + +namespace PatternKit.Tests.Application.SnapshotCheckpoints; + +public sealed class SnapshotCheckpointManagerTests +{ + [Scenario("Snapshot checkpoint manager saves and loads a usable checkpoint")] + [Fact] + public void Snapshot_Checkpoint_Manager_Saves_And_Loads_A_Usable_Checkpoint() + { + var now = new DateTimeOffset(2026, 5, 30, 9, 0, 0, TimeSpan.Zero); + var manager = SnapshotCheckpointManager.Create("order-checkpoints") + .WithClock(() => now) + .Build(); + + var save = manager.Save("ORDER-1", 5, new OrderSnapshot("ORDER-1", 125m), "REQ-1", new Dictionary { ["source"] = "test" }); + var load = manager.Load("ORDER-1", 4); + + ScenarioExpect.True(save.IsSaved); + ScenarioExpect.Equal(SnapshotCheckpointSaveStatus.Saved, save.Status); + ScenarioExpect.Null(save.PreviousCheckpoint); + ScenarioExpect.True(load.IsUsable); + ScenarioExpect.Equal(SnapshotCheckpointLoadStatus.Found, load.Status); + ScenarioExpect.Equal("order-checkpoints", manager.Name); + ScenarioExpect.Equal("ORDER-1", load.Checkpoint!.Key); + ScenarioExpect.Equal(5, load.Checkpoint.Version); + ScenarioExpect.Equal(now, load.Checkpoint.SavedAt); + ScenarioExpect.Equal("REQ-1", load.Checkpoint.CorrelationId); + ScenarioExpect.Equal("test", load.Checkpoint.Metadata["source"]); + } + + [Scenario("Snapshot checkpoint manager reports missing and stale checkpoints")] + [Fact] + public void Snapshot_Checkpoint_Manager_Reports_Missing_And_Stale_Checkpoints() + { + var manager = SnapshotCheckpointManager.Create().Build(); + manager.Save("ORDER-1", 3, new OrderSnapshot("ORDER-1", 75m)); + + var missing = manager.Load("ORDER-2", 1); + var stale = manager.Load("ORDER-1", 4); + + ScenarioExpect.Equal(SnapshotCheckpointLoadStatus.Missing, missing.Status); + ScenarioExpect.Equal("ORDER-2", missing.Key); + ScenarioExpect.Equal(1, missing.MinimumVersion); + ScenarioExpect.Null(missing.Checkpoint); + ScenarioExpect.False(missing.IsUsable); + ScenarioExpect.Equal(SnapshotCheckpointLoadStatus.Stale, stale.Status); + ScenarioExpect.Equal(3, stale.Checkpoint!.Version); + ScenarioExpect.Equal(4, stale.MinimumVersion); + ScenarioExpect.False(stale.IsUsable); + } + + [Scenario("Snapshot checkpoint manager rejects stale writes by default")] + [Fact] + public void Snapshot_Checkpoint_Manager_Rejects_Stale_Writes_By_Default() + { + var manager = SnapshotCheckpointManager.Create().Build(); + manager.Save("ORDER-1", 10, new OrderSnapshot("ORDER-1", 100m)); + + var save = manager.Save("ORDER-1", 9, new OrderSnapshot("ORDER-1", 90m)); + var load = manager.Load("ORDER-1"); + + ScenarioExpect.False(save.IsSaved); + ScenarioExpect.Equal(SnapshotCheckpointSaveStatus.RejectedStale, save.Status); + ScenarioExpect.Equal(9, save.AttemptedVersion); + ScenarioExpect.Equal(10, save.Checkpoint!.Version); + ScenarioExpect.Equal(10, load.Checkpoint!.Version); + } + + [Scenario("Snapshot checkpoint manager can overwrite stale writes when configured")] + [Fact] + public void Snapshot_Checkpoint_Manager_Can_Overwrite_Stale_Writes_When_Configured() + { + var manager = SnapshotCheckpointManager.Create() + .WithStaleWritePolicy(SnapshotCheckpointStaleWritePolicy.Overwrite) + .UseComparer(StringComparer.OrdinalIgnoreCase) + .Build(); + manager.Save("ORDER-1", 10, new OrderSnapshot("ORDER-1", 100m)); + + var save = manager.Save("order-1", 9, new OrderSnapshot("ORDER-1", 90m)); + var load = manager.Load("ORDER-1"); + + ScenarioExpect.True(save.IsSaved); + ScenarioExpect.NotNull(save.PreviousCheckpoint); + ScenarioExpect.Equal(10, save.PreviousCheckpoint!.Version); + ScenarioExpect.Equal(9, load.Checkpoint!.Version); + ScenarioExpect.Equal(90m, load.Checkpoint.Snapshot.Total); + } + + [Scenario("Snapshot checkpoint manager state snapshots and removals are stable")] + [Fact] + public void Snapshot_Checkpoint_Manager_State_Snapshots_And_Removals_Are_Stable() + { + var manager = SnapshotCheckpointManager.Create("stateful").Build(); + manager.Save("ORDER-2", 2, new OrderSnapshot("ORDER-2", 20m)); + manager.Save("ORDER-1", 1, new OrderSnapshot("ORDER-1", 10m)); + + var state = manager.GetState(); + var removed = manager.Remove("ORDER-1"); + var missingRemove = manager.Remove("ORDER-1"); + + ScenarioExpect.Equal("stateful", state.Name); + ScenarioExpect.Equal(2, state.Count); + ScenarioExpect.Equal(["ORDER-1", "ORDER-2"], state.Checkpoints.Select(static checkpoint => checkpoint.Key).ToArray()); + ScenarioExpect.True(removed); + ScenarioExpect.False(missingRemove); + ScenarioExpect.Equal(1, manager.Count); + } + + [Scenario("Snapshot checkpoint manager rejects invalid configuration")] + [Fact] + public void Snapshot_Checkpoint_Manager_Rejects_Invalid_Configuration() + { + var manager = SnapshotCheckpointManager.Create().Build(); + var snapshot = new OrderSnapshot("ORDER-1", 10m); + + ScenarioExpect.Throws(() => SnapshotCheckpointManager.Create("").Build()); + ScenarioExpect.Throws(() => SnapshotCheckpointManager.Create().UseComparer(null!)); + ScenarioExpect.Throws(() => SnapshotCheckpointManager.Create().WithClock(null!)); + ScenarioExpect.Throws(() => SnapshotCheckpointManager.Create().WithStaleWritePolicy((SnapshotCheckpointStaleWritePolicy)99)); + ScenarioExpect.Throws(() => manager.Save(null!, 1, snapshot)); + ScenarioExpect.Throws(() => manager.Save("ORDER-1", -1, snapshot)); + ScenarioExpect.Throws(() => manager.Save("ORDER-1", 1, null!)); + ScenarioExpect.Throws(() => manager.Load(null!)); + ScenarioExpect.Throws(() => manager.Load("ORDER-1", -1)); + ScenarioExpect.Throws(() => manager.Remove(null!)); + ScenarioExpect.Throws(() => new SnapshotCheckpoint("ORDER-1", 1, snapshot, DateTimeOffset.UtcNow, null, null!)); + ScenarioExpect.Throws(() => new SnapshotCheckpointManagerState("", 0, [])); + ScenarioExpect.Throws(() => new SnapshotCheckpointManagerState("state", -1, [])); + ScenarioExpect.Throws(() => new SnapshotCheckpointManagerState("state", 0, null!)); + } + + private sealed record OrderSnapshot(string OrderId, decimal Total); +}