diff --git a/README.md b/README.md index 19bd8677..7370410c 100644 --- a/README.md +++ b/README.md @@ -473,11 +473,11 @@ var cachedRemoteProxy = Proxy.Create(id => remoteProxy.Execute(id)) --- ## Patterns Table -PatternKit currently tracks 120 production-readiness patterns. Each catalog pattern is represented in tests, documentation, real-world examples, IoC integration, and the BenchmarkDotNet coverage matrix. +PatternKit currently tracks 121 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 | 28 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Eventual Consistency Monitor, Feature Toggle, Identity Map, Lazy Load, Manual Task Gate, Materialized View, Ports and Adapters, Repository, Service Layer, Snapshot / Checkpoint Management, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object, Workflow Orchestration | +| Application Architecture | 29 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Eventual Consistency Monitor, Feature Toggle, Identity Map, Lazy Load, Manual Task Gate, Materialized View, Ports and Adapters, Repository, Service Layer, Snapshot / Checkpoint Management, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Compensating Transaction, Value Object, Workflow Orchestration | | Behavioral | 12 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Null Object, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 21 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Cache Stampede Protection, Circuit Breaker, Distributed Lock / Lease, 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 | 6 | Abstract Factory, Builder, Factory Method, Object Pool, Prototype, Singleton | @@ -623,6 +623,8 @@ BenchmarkDotNet guidance is documented in [docs/guides/benchmarks.md](docs/guide | Identity Map | Execution | 108.91 ns | 968 B | 94.83 ns | 968 B | Same allocation; generated was faster for scoped identity-map reuse. | | Leader Election | Construction | 14.28 ns | 104 B | 15.91 ns | 104 B | Same allocation; fluent was slightly faster in this microbenchmark. | | Leader Election | Execution | 43.62 ns | 360 B | 144.37 ns | 312 B | Generated allocated about 13% less memory, while fluent was faster in this path. | +| Compensating Transaction | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Compensating Transaction | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Distributed Lock / Lease | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Distributed Lock / Lease | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Materialized View | Construction | 140.9 ns | 1.05 KB | 147.4 ns | 1.05 KB | Same allocation; fluent was slightly faster in this microbenchmark. | diff --git a/benchmarks/PatternKit.Benchmarks/Application/CompensatingTransactionBenchmarks.cs b/benchmarks/PatternKit.Benchmarks/Application/CompensatingTransactionBenchmarks.cs new file mode 100644 index 00000000..b3870b0c --- /dev/null +++ b/benchmarks/PatternKit.Benchmarks/Application/CompensatingTransactionBenchmarks.cs @@ -0,0 +1,29 @@ +using BenchmarkDotNet.Attributes; +using PatternKit.Application.CompensatingTransactions; +using PatternKit.Examples.CompensatingTransactionDemo; + +namespace PatternKit.Benchmarks.Application; + +[BenchmarkCategory("ApplicationArchitecture", "CompensatingTransaction")] +public class CompensatingTransactionBenchmarks +{ + [Benchmark(Baseline = true, Description = "Fluent: create compensating transaction")] + [BenchmarkCategory("Fluent", "Construction")] + public CompensatingTransaction Fluent_CreateTransaction() + => CheckoutCompensatingTransactionDemo.CreateFluent(); + + [Benchmark(Description = "Generated: create compensating transaction")] + [BenchmarkCategory("Generated", "Construction")] + public CompensatingTransaction Generated_CreateTransaction() + => GeneratedCheckoutCompensatingTransaction.Create(); + + [Benchmark(Description = "Fluent: execute compensated checkout")] + [BenchmarkCategory("Fluent", "Execution")] + public ValueTask Fluent_ExecuteCompensatedCheckout() + => CheckoutCompensatingTransactionDemo.RunFluentAsync(); + + [Benchmark(Description = "Generated: execute compensated checkout")] + [BenchmarkCategory("Generated", "Execution")] + public ValueTask Generated_ExecuteCompensatedCheckout() + => CheckoutCompensatingTransactionDemo.RunGeneratedAsync(); +} diff --git a/docs/examples/checkout-compensating-transaction-pattern.md b/docs/examples/checkout-compensating-transaction-pattern.md new file mode 100644 index 00000000..b46ddb65 --- /dev/null +++ b/docs/examples/checkout-compensating-transaction-pattern.md @@ -0,0 +1,10 @@ +# Checkout Compensating Transaction Pattern + +The checkout compensating transaction example models inventory reservation, payment authorization, and shipment creation. When shipment creation fails, the transaction voids payment and releases inventory in reverse order. + +It demonstrates fluent and source-generated transaction construction, TinyBDD coverage, BenchmarkDotNet coverage, and `IServiceCollection` import through `AddCheckoutCompensatingTransactionDemo()`. + +Files: + +- `src/PatternKit.Examples/CompensatingTransactionDemo/CheckoutCompensatingTransactionDemo.cs` +- `test/PatternKit.Examples.Tests/CompensatingTransactionDemo/CheckoutCompensatingTransactionDemoTests.cs` diff --git a/docs/examples/index.md b/docs/examples/index.md index bdbc41d0..7883ad74 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -199,6 +199,7 @@ dotnet test PatternKit.slnx -c Release * **Inventory Ambassador:** `InventoryAmbassadorDemo` (+ `InventoryAmbassadorDemoTests`) — fluent and generated outbound connectivity wrapper with DI and ASP.NET Core mapping. * **Warehouse Leader Election:** `WarehouseLeaderElectionDemo` (+ `WarehouseLeaderElectionDemoTests`) — fluent and generated active worker lease coordination with DI and Generic Host mapping. * **Order Allocation Distributed Lock:** `OrderAllocationDistributedLockDemo` (+ `OrderAllocationDistributedLockDemoTests`) — fluent and generated resource lease coordination with DI and Generic Host mapping. +* **Checkout Compensating Transaction:** `CheckoutCompensatingTransactionDemo` (+ `CheckoutCompensatingTransactionDemoTests`) — fluent and generated reversible checkout steps with DI and Generic Host mapping. * **Warehouse Scheduler Agent Supervisor:** `WarehouseSchedulerAgentSupervisorDemo` (+ `WarehouseSchedulerAgentSupervisorDemoTests`) — fluent and generated scheduled worker supervision with DI and Generic Host mapping. * **Production-Ready Example Catalog:** `PatternKitExampleCatalog` (+ `PatternKitExampleCatalogTests`) — DI registration, generic host validation, ASP.NET Core endpoint mapping, and source/test/docs manifest checks. * **Tests:** `PatternKit.Examples.Tests/*` use TinyBDD scenarios that read like specs. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index bd59f5d3..ec53d0ea 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -343,6 +343,9 @@ - name: Order Allocation Distributed Lock href: order-allocation-distributed-lock.md +- name: Checkout Compensating Transaction + href: checkout-compensating-transaction-pattern.md + - name: Warehouse Scheduler Agent Supervisor href: warehouse-scheduler-agent-supervisor.md diff --git a/docs/generators/compensating-transaction.md b/docs/generators/compensating-transaction.md new file mode 100644 index 00000000..d2db0e1c --- /dev/null +++ b/docs/generators/compensating-transaction.md @@ -0,0 +1,24 @@ +# Compensating Transaction Generator + +`[GenerateCompensatingTransaction]` emits a `CompensatingTransaction` factory from ordered step methods and named compensation methods. + +```csharp +[GenerateCompensatingTransaction(TransactionName = "checkout")] +public static partial class CheckoutTransaction +{ + [CompensatingTransactionStep("reserve-inventory", 10, Compensation = nameof(ReleaseInventory))] + private static ValueTask ReserveInventory(CheckoutContext context, CancellationToken ct) => default; + + private static ValueTask ReleaseInventory(CheckoutContext context, CancellationToken ct) => default; +} +``` + +Diagnostics: + +| ID | Meaning | +| --- | --- | +| `PKCOMP001` | Host type must be partial. | +| `PKCOMP002` | At least one `[CompensatingTransactionStep]` method is required. | +| `PKCOMP003` | Step, compensation, or condition method signature is invalid. | +| `PKCOMP004` | Step names and orders must be unique. | +| `PKCOMP005` | Factory method, transaction name, or compensation configuration is invalid. | diff --git a/docs/generators/index.md b/docs/generators/index.md index 0f83419c..aba41909 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -71,6 +71,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**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]` | +| [**Compensating Transaction**](compensating-transaction.md) | Reversible business transactions from annotated step methods | `[GenerateCompensatingTransaction]` | | [**Data Mapper**](data-mapper.md) | Domain/data model mapper factories | `[GenerateDataMapper]` | | [**Identity Map**](identity-map.md) | Scoped object identity caches from key selectors | `[GenerateIdentityMap]` | | [**Lazy Load**](lazy-load.md) | Deferred value factories with caching, TTL, and invalidation | `[GenerateLazyLoad]` | diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 15d4f019..4575a3d3 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -309,6 +309,9 @@ - name: Unit of Work href: unit-of-work.md +- name: Compensating Transaction + href: compensating-transaction.md + - name: Transaction Script href: transaction-script.md diff --git a/docs/guides/benchmark-results.md b/docs/guides/benchmark-results.md index 05303aeb..a3051d8f 100644 --- a/docs/guides/benchmark-results.md +++ b/docs/guides/benchmark-results.md @@ -151,6 +151,8 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 | Identity Map | Execution | 108.91 ns | 968 B | 94.83 ns | 968 B | Same allocation; generated was faster for scoped identity-map reuse. | | Leader Election | Construction | 14.28 ns | 104 B | 15.91 ns | 104 B | Same allocation; fluent was slightly faster in this microbenchmark. | | Leader Election | Execution | 43.62 ns | 360 B | 144.37 ns | 312 B | Generated allocated about 13% less memory, while fluent was faster in this path. | +| Compensating Transaction | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Compensating Transaction | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Distributed Lock / Lease | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Distributed Lock / Lease | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Materialized View | Construction | 140.9 ns | 1.05 KB | 147.4 ns | 1.05 KB | Same allocation; fluent was slightly faster in this microbenchmark. | @@ -262,11 +264,11 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 ## Coverage Matrix Summary -The coverage matrix currently publishes 120 catalog patterns and 480 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 12 reusable hosting integration route results for package-level `IServiceCollection` registrations. +The coverage matrix currently publishes 121 catalog patterns and 484 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 12 reusable hosting integration route results for package-level `IServiceCollection` registrations. | Category | Patterns | Published route results | | --- | ---: | ---: | -| Application Architecture | 28 | 112 | +| Application Architecture | 29 | 116 | | Behavioral | 12 | 48 | | Cloud Architecture | 21 | 84 | | Creational | 6 | 24 | @@ -274,7 +276,7 @@ The coverage matrix currently publishes 120 catalog patterns and 480 pattern rou | Messaging Reliability | 4 | 16 | | Structural | 7 | 28 | -The generator matrix currently publishes 115 generator source route results. +The generator matrix currently publishes 116 generator source route results. ## Hosting Integration Matrix Results @@ -309,7 +311,8 @@ The generator matrix currently publishes 115 generator source route results. | Application Architecture | Bounded Context | Covered | Covered | Covered | Covered | | Application Architecture | Context Map | Covered | Covered | Covered | Covered | | Application Architecture | CQRS | Covered | Covered | Covered | Covered | -| Application Architecture | Data Mapper | Covered | Covered | Covered | Covered | +| Application Architecture | Compensating Transaction | Covered | Covered | Covered | Covered | +| Application Architecture | Data Mapper | Covered | Covered | Covered | Covered | | Application Architecture | Domain Event | Covered | Covered | Covered | Covered | | Application Architecture | Domain Service | Covered | Covered | Covered | Covered | | Application Architecture | Event Sourcing | Covered | Covered | Covered | Covered | @@ -444,9 +447,10 @@ The generator matrix currently publishes 115 generator source route results. | CanonicalDataModelGenerator | `src/PatternKit.Generators/CanonicalDataModel/CanonicalDataModelGenerator.cs` | Covered | | ChainGenerator | `src/PatternKit.Generators/Chain/ChainGenerator.cs` | Covered | | CircuitBreakerPolicyGenerator | `src/PatternKit.Generators/CircuitBreaker/CircuitBreakerPolicyGenerator.cs` | Covered | -| ExternalConfigurationStoreGenerator | `src/PatternKit.Generators/Cloud/ExternalConfigurationStoreGenerator.cs` | Covered | -| CommandGenerator | `src/PatternKit.Generators/Command/CommandGenerator.cs` | Covered | -| ComposerGenerator | `src/PatternKit.Generators/ComposerGenerator.cs` | Covered | +| ExternalConfigurationStoreGenerator | `src/PatternKit.Generators/Cloud/ExternalConfigurationStoreGenerator.cs` | Covered | +| CommandGenerator | `src/PatternKit.Generators/Command/CommandGenerator.cs` | Covered | +| CompensatingTransactionGenerator | `src/PatternKit.Generators/CompensatingTransactions/CompensatingTransactionGenerator.cs` | Covered | +| ComposerGenerator | `src/PatternKit.Generators/ComposerGenerator.cs` | Covered | | CompositeGenerator | `src/PatternKit.Generators/Composite/CompositeGenerator.cs` | Covered | | DataMapperGenerator | `src/PatternKit.Generators/DataMapping/DataMapperGenerator.cs` | Covered | | DecoratorGenerator | `src/PatternKit.Generators/DecoratorGenerator.cs` | Covered | @@ -556,5 +560,5 @@ dotnet run -c Release --framework net10.0 --project benchmarks/PatternKit.Benchm Run only the matrix routes when validating benchmark coverage changes: ```powershell -dotnet run -c Release --framework net10.0 --project benchmarks/PatternKit.Benchmarks -- --filter *Matrix* --artifacts artifacts/benchmarks --join --job short -``` +dotnet run -c Release --framework net10.0 --project benchmarks/PatternKit.Benchmarks -- --filter *Matrix* --artifacts artifacts/benchmarks --join --job short +``` diff --git a/docs/index.md b/docs/index.md index dd773b51..5101571d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,11 +66,11 @@ if (parser.Execute("123", out var value)) ## 📚 Available Patterns -PatternKit covers 120 production-readiness patterns with fluent APIs, source-generated routes where applicable, IoC integration examples, TinyBDD coverage, and BenchmarkDotNet coverage-matrix validation: +PatternKit covers 121 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 | 28 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Eventual Consistency Monitor, Feature Toggle, Identity Map, Lazy Load, Manual Task Gate, Materialized View, Ports and Adapters, Repository, Service Layer, Snapshot / Checkpoint Management, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object, Workflow Orchestration | +| Application Architecture | 29 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Eventual Consistency Monitor, Feature Toggle, Identity Map, Lazy Load, Manual Task Gate, Materialized View, Ports and Adapters, Repository, Service Layer, Snapshot / Checkpoint Management, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Compensating Transaction, Value Object, Workflow Orchestration | | Behavioral | 12 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Null Object, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 21 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Cache Stampede Protection, Circuit Breaker, Distributed Lock / Lease, 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 | 6 | Abstract Factory, Builder, Factory Method, Object Pool, Prototype, Singleton | diff --git a/docs/patterns/application/compensating-transaction.md b/docs/patterns/application/compensating-transaction.md new file mode 100644 index 00000000..24d21e96 --- /dev/null +++ b/docs/patterns/application/compensating-transaction.md @@ -0,0 +1,17 @@ +# Compensating Transaction + +Compensating Transaction records a sequence of reversible business steps. PatternKit's `CompensatingTransaction` executes each step in order and, when a later step fails, runs completed compensation actions in reverse order. + +```csharp +var transaction = CompensatingTransaction + .Create("checkout") + .AddStep("reserve-inventory", ReserveAsync, ReleaseAsync, step => step.At(10)) + .AddStep("authorize-payment", AuthorizeAsync, VoidAsync, step => step.At(20)) + .Build(); + +var execution = await transaction.ExecuteAsync(context, ct); +``` + +Use it for workflows that cross boundaries where a database transaction is unavailable: inventory reservations, payment authorization, shipment creation, tenant provisioning, and external API side effects. + +See [Compensating Transaction Generator](../../generators/compensating-transaction.md). diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index db639dca..84aae771 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -435,6 +435,8 @@ href: application/repository.md - name: Unit of Work href: application/unit-of-work.md + - name: Compensating Transaction + href: application/compensating-transaction.md - name: Data Mapper href: application/data-mapper.md - name: Identity Map diff --git a/src/PatternKit.Core/Application/CompensatingTransactions/CompensatingTransaction.cs b/src/PatternKit.Core/Application/CompensatingTransactions/CompensatingTransaction.cs new file mode 100644 index 00000000..89045dc1 --- /dev/null +++ b/src/PatternKit.Core/Application/CompensatingTransactions/CompensatingTransaction.cs @@ -0,0 +1,263 @@ +namespace PatternKit.Application.CompensatingTransactions; + +/// +/// Executes a reversible business transaction and compensates completed steps in reverse order when a later step fails. +/// +public sealed class CompensatingTransaction +{ + private readonly IReadOnlyList> _steps; + + private CompensatingTransaction(string name, IReadOnlyList> steps) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Compensating transaction name cannot be null, empty, or whitespace.", nameof(name)); + if (steps is null) + throw new ArgumentNullException(nameof(steps)); + if (steps.Count == 0) + throw new ArgumentException("Compensating transaction must contain at least one step.", nameof(steps)); + + Name = name; + _steps = steps; + } + + public string Name { get; } + + public IReadOnlyList> Steps => _steps; + + public static Builder Create(string name = "compensating-transaction") => new(name); + + public async ValueTask> ExecuteAsync(TContext context, CancellationToken cancellationToken = default) + { + var history = new List(); + var completed = new Stack>(); + + foreach (var step in _steps) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!step.ShouldRun(context)) + { + history.Add(CompensatingTransactionRecord.Skipped(step.Name)); + continue; + } + + try + { + await step.ExecuteAsync(context, cancellationToken).ConfigureAwait(false); + completed.Push(step); + history.Add(CompensatingTransactionRecord.Completed(step.Name)); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + history.Add(CompensatingTransactionRecord.Failed(step.Name, exception)); + var compensation = await CompensateAsync(completed, context, history, cancellationToken).ConfigureAwait(false); + return new CompensatingTransactionExecution( + Name, + context, + compensation.Succeeded ? CompensatingTransactionStatus.Compensated : CompensatingTransactionStatus.CompensationFailed, + history); + } + } + + return new CompensatingTransactionExecution(Name, context, CompensatingTransactionStatus.Completed, history); + } + + public CompensatingTransactionExecution Execute(TContext context) + => ExecuteAsync(context).AsTask().GetAwaiter().GetResult(); + + private static async ValueTask CompensateAsync( + Stack> completed, + TContext context, + List history, + CancellationToken cancellationToken) + { + var succeeded = true; + while (completed.Count > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + var step = completed.Pop(); + + try + { + await step.CompensateAsync(context, cancellationToken).ConfigureAwait(false); + history.Add(CompensatingTransactionRecord.Compensated(step.Name)); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + succeeded = false; + history.Add(CompensatingTransactionRecord.CompensationFailed(step.Name, exception)); + } + } + + return new CompensationOutcome(succeeded); + } + + public sealed class Builder + { + private readonly string _name; + private readonly List> _steps = []; + + internal Builder(string name) => _name = name; + + public Builder AddStep( + string name, + Func execute, + Func compensate, + Action>? configure = null) + { + var builder = new CompensatingTransactionStepBuilder(name, execute, compensate); + configure?.Invoke(builder); + _steps.Add(builder.Build()); + return this; + } + + public CompensatingTransaction Build() + { + var ordered = _steps + .OrderBy(static step => step.Order) + .ThenBy(static step => step.Name, StringComparer.Ordinal) + .ToArray(); + + if (ordered.Select(static step => step.Name).Distinct(StringComparer.Ordinal).Count() != ordered.Length) + throw new InvalidOperationException("Compensating transaction step names must be unique."); + + return new CompensatingTransaction(_name, ordered); + } + } + + private readonly struct CompensationOutcome + { + public CompensationOutcome(bool succeeded) => Succeeded = succeeded; + + public bool Succeeded { get; } + } +} + +public sealed class CompensatingTransactionStepBuilder +{ + private readonly string _name; + private readonly Func _execute; + private readonly Func _compensate; + private Func _condition = static _ => true; + private int _order; + + internal CompensatingTransactionStepBuilder( + string name, + Func execute, + Func compensate) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Compensating transaction step name cannot be null, empty, or whitespace.", nameof(name)); + + _name = name; + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + _compensate = compensate ?? throw new ArgumentNullException(nameof(compensate)); + } + + public CompensatingTransactionStepBuilder At(int order) + { + _order = order; + return this; + } + + public CompensatingTransactionStepBuilder When(Func condition) + { + _condition = condition ?? throw new ArgumentNullException(nameof(condition)); + return this; + } + + internal CompensatingTransactionStep Build() + => new(_name, _order, _condition, _execute, _compensate); +} + +public sealed class CompensatingTransactionStep +{ + internal CompensatingTransactionStep( + string name, + int order, + Func shouldRun, + Func execute, + Func compensate) + { + Name = name; + Order = order; + ShouldRun = shouldRun; + ExecuteAsync = execute; + CompensateAsync = compensate; + } + + public string Name { get; } + + public int Order { get; } + + internal Func ShouldRun { get; } + + internal Func ExecuteAsync { get; } + + internal Func CompensateAsync { get; } +} + +public sealed class CompensatingTransactionExecution +{ + public CompensatingTransactionExecution( + string transactionName, + TContext context, + CompensatingTransactionStatus status, + IReadOnlyList history) + { + TransactionName = transactionName; + Context = context; + Status = status; + History = history ?? throw new ArgumentNullException(nameof(history)); + } + + public string TransactionName { get; } + + public TContext Context { get; } + + public CompensatingTransactionStatus Status { get; } + + public IReadOnlyList History { get; } +} + +public sealed class CompensatingTransactionRecord +{ + public CompensatingTransactionRecord(string stepName, CompensatingTransactionRecordKind kind, string? errorMessage) + { + StepName = stepName; + Kind = kind; + ErrorMessage = errorMessage; + } + + public string StepName { get; } + + public CompensatingTransactionRecordKind Kind { get; } + + public string? ErrorMessage { get; } + + public static CompensatingTransactionRecord Completed(string stepName) => new(stepName, CompensatingTransactionRecordKind.Completed, null); + + public static CompensatingTransactionRecord Skipped(string stepName) => new(stepName, CompensatingTransactionRecordKind.Skipped, null); + + public static CompensatingTransactionRecord Failed(string stepName, Exception exception) => new(stepName, CompensatingTransactionRecordKind.Failed, exception.Message); + + public static CompensatingTransactionRecord Compensated(string stepName) => new(stepName, CompensatingTransactionRecordKind.Compensated, null); + + public static CompensatingTransactionRecord CompensationFailed(string stepName, Exception exception) => new(stepName, CompensatingTransactionRecordKind.CompensationFailed, exception.Message); +} + +public enum CompensatingTransactionStatus +{ + Completed, + Compensated, + CompensationFailed +} + +public enum CompensatingTransactionRecordKind +{ + Completed, + Skipped, + Failed, + Compensated, + CompensationFailed +} diff --git a/src/PatternKit.Examples/CompensatingTransactionDemo/CheckoutCompensatingTransactionDemo.cs b/src/PatternKit.Examples/CompensatingTransactionDemo/CheckoutCompensatingTransactionDemo.cs new file mode 100644 index 00000000..4dc2a5ef --- /dev/null +++ b/src/PatternKit.Examples/CompensatingTransactionDemo/CheckoutCompensatingTransactionDemo.cs @@ -0,0 +1,143 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.CompensatingTransactions; +using PatternKit.Generators.CompensatingTransactions; + +namespace PatternKit.Examples.CompensatingTransactionDemo; + +public sealed record CheckoutCompensatingTransactionRequest(string OrderId, bool ShipmentAvailable); + +public sealed record CheckoutCompensatingTransactionSummary( + CompensatingTransactionStatus Status, + IReadOnlyList Log, + IReadOnlyList History); + +public static class CheckoutCompensatingTransactionDemo +{ + public static async ValueTask RunFluentAsync(bool shipmentAvailable = false) + { + var context = new CheckoutCompensatingTransactionContext("order-1001", shipmentAvailable); + var transaction = CreateFluent(); + var execution = await transaction.ExecuteAsync(context); + return CreateSummary(execution); + } + + public static async ValueTask RunGeneratedAsync(bool shipmentAvailable = false) + { + var context = new CheckoutCompensatingTransactionContext("order-1001", shipmentAvailable); + var execution = await GeneratedCheckoutCompensatingTransaction.Create().ExecuteAsync(context); + return CreateSummary(execution); + } + + public static CompensatingTransaction CreateFluent() + => CompensatingTransaction + .Create("checkout-compensation") + .AddStep("reserve-inventory", ReserveInventory, ReleaseInventory, static step => step.At(10)) + .AddStep("authorize-payment", AuthorizePayment, VoidPayment, static step => step.At(20)) + .AddStep("create-shipment", CreateShipment, CancelShipment, static step => step.At(30)) + .Build(); + + private static CheckoutCompensatingTransactionSummary CreateSummary( + CompensatingTransactionExecution execution) + => new( + execution.Status, + execution.Context.Log.ToArray(), + execution.History.Select(static record => record.Kind).ToArray()); + + internal static ValueTask ReserveInventory(CheckoutCompensatingTransactionContext context, CancellationToken cancellationToken) + { + context.Log.Add("inventory-reserved"); + return default; + } + + internal static ValueTask ReleaseInventory(CheckoutCompensatingTransactionContext context, CancellationToken cancellationToken) + { + context.Log.Add("inventory-released"); + return default; + } + + internal static ValueTask AuthorizePayment(CheckoutCompensatingTransactionContext context, CancellationToken cancellationToken) + { + context.Log.Add("payment-authorized"); + return default; + } + + internal static ValueTask VoidPayment(CheckoutCompensatingTransactionContext context, CancellationToken cancellationToken) + { + context.Log.Add("payment-voided"); + return default; + } + + internal static ValueTask CreateShipment(CheckoutCompensatingTransactionContext context, CancellationToken cancellationToken) + { + if (!context.ShipmentAvailable) + throw new InvalidOperationException("shipment carrier unavailable"); + + context.Log.Add("shipment-created"); + return default; + } + + internal static ValueTask CancelShipment(CheckoutCompensatingTransactionContext context, CancellationToken cancellationToken) + { + context.Log.Add("shipment-canceled"); + return default; + } +} + +public sealed class CheckoutCompensatingTransactionContext(string orderId, bool shipmentAvailable) +{ + public string OrderId { get; } = orderId; + + public bool ShipmentAvailable { get; } = shipmentAvailable; + + public List Log { get; } = []; +} + +public sealed class CheckoutCompensatingTransactionWorkflow +{ + public ValueTask RunAsync(CheckoutCompensatingTransactionRequest request) + => request is null + ? throw new ArgumentNullException(nameof(request)) + : CheckoutCompensatingTransactionDemo.RunFluentAsync(request.ShipmentAvailable); +} + +public sealed record CheckoutCompensatingTransactionDemoRunner( + Func> RunFluentAsync, + Func> RunGeneratedAsync); + +public static class CheckoutCompensatingTransactionServiceCollectionExtensions +{ + public static IServiceCollection AddCheckoutCompensatingTransactionDemo(this IServiceCollection services) + { + services.AddSingleton(CheckoutCompensatingTransactionDemo.CreateFluent()); + services.AddSingleton(); + services.AddSingleton(new CheckoutCompensatingTransactionDemoRunner( + CheckoutCompensatingTransactionDemo.RunFluentAsync, + CheckoutCompensatingTransactionDemo.RunGeneratedAsync)); + return services; + } +} + +[GenerateCompensatingTransaction(TransactionName = "checkout-compensation")] +public static partial class GeneratedCheckoutCompensatingTransaction +{ + [CompensatingTransactionStep("reserve-inventory", 10, Compensation = nameof(ReleaseInventory))] + private static ValueTask ReserveInventory(CheckoutCompensatingTransactionContext context, CancellationToken cancellationToken) + => CheckoutCompensatingTransactionDemo.ReserveInventory(context, cancellationToken); + + private static ValueTask ReleaseInventory(CheckoutCompensatingTransactionContext context, CancellationToken cancellationToken) + => CheckoutCompensatingTransactionDemo.ReleaseInventory(context, cancellationToken); + + [CompensatingTransactionStep("authorize-payment", 20, Compensation = nameof(VoidPayment))] + private static ValueTask AuthorizePayment(CheckoutCompensatingTransactionContext context, CancellationToken cancellationToken) + => CheckoutCompensatingTransactionDemo.AuthorizePayment(context, cancellationToken); + + private static ValueTask VoidPayment(CheckoutCompensatingTransactionContext context, CancellationToken cancellationToken) + => CheckoutCompensatingTransactionDemo.VoidPayment(context, cancellationToken); + + [CompensatingTransactionStep("create-shipment", 30, Compensation = nameof(CancelShipment))] + private static ValueTask CreateShipment(CheckoutCompensatingTransactionContext context, CancellationToken cancellationToken) + => CheckoutCompensatingTransactionDemo.CreateShipment(context, cancellationToken); + + private static ValueTask CancelShipment(CheckoutCompensatingTransactionContext context, CancellationToken cancellationToken) + => CheckoutCompensatingTransactionDemo.CancelShipment(context, cancellationToken); +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index ca7b7a32..3badefe6 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -46,6 +46,7 @@ using PatternKit.Examples.Chain.ConfigDriven; using PatternKit.Examples.ChangeDataCaptureDemo; using PatternKit.Examples.CircuitBreakerDemo; +using PatternKit.Examples.CompensatingTransactionDemo; using PatternKit.Examples.ContextMapDemo; using PatternKit.Examples.DataMapperDemo; using PatternKit.Examples.DistributedLockDemo; @@ -229,6 +230,7 @@ public sealed record LoanApprovalSpecificationsExample(SpecificationRegistry("Checkout Unit of Work Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddCheckoutCompensatingTransactionPatternExample(this IServiceCollection services) + { + services.AddCheckoutCompensatingTransactionDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + sp.GetRequiredService())); + return services.RegisterExample("Checkout Compensating Transaction Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddOrderDataMapperPatternExample(this IServiceCollection services) { services.AddOrderDataMapperDemo(); diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 5b71347a..1c5f53c6 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -1008,6 +1008,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["Distributed Lock / Lease"], ["resource lease contention", "source-generated lock factory", "DI composition"]), + Descriptor( + "Checkout Compensating Transaction Pattern", + "src/PatternKit.Examples/CompensatingTransactionDemo/CheckoutCompensatingTransactionDemo.cs", + "test/PatternKit.Examples.Tests/CompensatingTransactionDemo/CheckoutCompensatingTransactionDemoTests.cs", + "docs/examples/checkout-compensating-transaction-pattern.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["Compensating Transaction"], + ["reversible checkout steps", "source-generated transaction factory", "DI composition"]), Descriptor( "Warehouse Scheduler Agent Supervisor", "src/PatternKit.Examples/SchedulerAgentSupervisorDemo/WarehouseSchedulerAgentSupervisorDemo.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index afa3d77d..2fa916d9 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -1376,6 +1376,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/UnitOfWorkDemo/CheckoutUnitOfWorkDemoTests.cs", ["fluent commit boundary", "generated unit-of-work factory", "DI-importable checkout example"]), + Pattern("Compensating Transaction", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/compensating-transaction.md", + "src/PatternKit.Core/Application/CompensatingTransactions/CompensatingTransaction.cs", + "test/PatternKit.Tests/Application/CompensatingTransactions/CompensatingTransactionTests.cs", + "docs/generators/compensating-transaction.md", + "src/PatternKit.Generators/CompensatingTransactions/CompensatingTransactionGenerator.cs", + "test/PatternKit.Generators.Tests/CompensatingTransactionGeneratorTests.cs", + null, + "docs/examples/checkout-compensating-transaction-pattern.md", + "src/PatternKit.Examples/CompensatingTransactionDemo/CheckoutCompensatingTransactionDemo.cs", + "test/PatternKit.Examples.Tests/CompensatingTransactionDemo/CheckoutCompensatingTransactionDemoTests.cs", + ["fluent reversible transaction log", "generated compensating transaction factory", "DI-importable checkout compensation example"]), + Pattern("Data Mapper", PatternFamily.ApplicationArchitecture, "docs/patterns/application/data-mapper.md", "src/PatternKit.Core/Application/DataMapping/DataMapper.cs", diff --git a/src/PatternKit.Generators.Abstractions/CompensatingTransactions/CompensatingTransactionAttributes.cs b/src/PatternKit.Generators.Abstractions/CompensatingTransactions/CompensatingTransactionAttributes.cs new file mode 100644 index 00000000..746b1bfa --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/CompensatingTransactions/CompensatingTransactionAttributes.cs @@ -0,0 +1,23 @@ +namespace PatternKit.Generators.CompensatingTransactions; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateCompensatingTransactionAttribute : Attribute +{ + public string FactoryMethodName { get; set; } = "Create"; + + public string TransactionName { get; set; } = "compensating-transaction"; +} + +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +public sealed class CompensatingTransactionStepAttribute(string name, int order) : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Compensating transaction step name is required.", nameof(name)) + : name; + + public int Order { get; } = order; + + public string Compensation { get; set; } = string.Empty; + + public string? Condition { get; set; } +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index dbfdcc1b..7a394a19 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -471,3 +471,8 @@ PKPA003 | PatternKit.Generators.PortsAndAdapters | Error | Ports and Adapters me PKPA004 | PatternKit.Generators.PortsAndAdapters | Error | Ports and Adapters factory name is invalid. PKDLOCK001 | PatternKit.Generators.DistributedLocks | Error | Distributed Lock host must be partial. PKDLOCK002 | PatternKit.Generators.DistributedLocks | Error | Distributed Lock configuration is invalid. +PKCOMP001 | PatternKit.Generators.CompensatingTransactions | Error | Compensating Transaction host must be partial. +PKCOMP002 | PatternKit.Generators.CompensatingTransactions | Error | Compensating Transaction must declare steps. +PKCOMP003 | PatternKit.Generators.CompensatingTransactions | Error | Compensating Transaction step signature is invalid. +PKCOMP004 | PatternKit.Generators.CompensatingTransactions | Error | Compensating Transaction step is duplicated. +PKCOMP005 | PatternKit.Generators.CompensatingTransactions | Error | Compensating Transaction configuration is invalid. diff --git a/src/PatternKit.Generators/CompensatingTransactions/CompensatingTransactionGenerator.cs b/src/PatternKit.Generators/CompensatingTransactions/CompensatingTransactionGenerator.cs new file mode 100644 index 00000000..84678c56 --- /dev/null +++ b/src/PatternKit.Generators/CompensatingTransactions/CompensatingTransactionGenerator.cs @@ -0,0 +1,259 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace PatternKit.Generators.CompensatingTransactions; + +[Generator] +public sealed class CompensatingTransactionGenerator : IIncrementalGenerator +{ + private const string GenerateAttributeName = "PatternKit.Generators.CompensatingTransactions.GenerateCompensatingTransactionAttribute"; + private const string StepAttributeName = "PatternKit.Generators.CompensatingTransactions.CompensatingTransactionStepAttribute"; + + 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( + "PKCOMP001", "Compensating transaction host must be partial", + "Type '{0}' is marked with [GenerateCompensatingTransaction] but is not declared as partial", + "PatternKit.Generators.CompensatingTransactions", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingSteps = new( + "PKCOMP002", "Compensating transaction must declare steps", + "Compensating transaction '{0}' must declare at least one [CompensatingTransactionStep] method", + "PatternKit.Generators.CompensatingTransactions", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidStep = new( + "PKCOMP003", "Compensating transaction step signature is invalid", + "Compensating transaction step '{0}' must accept (TContext, CancellationToken) and return ValueTask", + "PatternKit.Generators.CompensatingTransactions", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor DuplicateStep = new( + "PKCOMP004", "Compensating transaction step is duplicated", + "Compensating transaction '{0}' has duplicate step names or orders", + "PatternKit.Generators.CompensatingTransactions", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidConfiguration = new( + "PKCOMP005", "Compensating transaction configuration is invalid", + "Compensating transaction '{0}' must have valid FactoryMethodName, TransactionName, and Compensation values", + "PatternKit.Generators.CompensatingTransactions", DiagnosticSeverity.Error, true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + GenerateAttributeName, + 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() == GenerateAttributeName); + 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 factoryMethodName = GetNamedString(attribute, "FactoryMethodName") ?? "Create"; + var transactionName = GetNamedString(attribute, "TransactionName") ?? "compensating-transaction"; + if (!IsValidIdentifier(factoryMethodName) || string.IsNullOrWhiteSpace(transactionName)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidConfiguration, node.Identifier.GetLocation(), type.Name)); + return; + } + + var steps = GetSteps(type); + if (steps.Length == 0) + { + context.ReportDiagnostic(Diagnostic.Create(MissingSteps, node.Identifier.GetLocation(), type.Name)); + return; + } + + var contextType = steps[0].Method.Parameters.Length >= 1 ? steps[0].Method.Parameters[0].Type : null; + if (contextType is null || contextType.TypeKind == TypeKind.Error) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidStep, steps[0].Method.Locations.FirstOrDefault(), steps[0].Method.Name)); + return; + } + + foreach (var step in steps) + { + if (!HasValidStepSignature(step.Method, contextType) + || string.IsNullOrWhiteSpace(step.Compensation) + || !HasValidStepSignature(FindMethod(type, step.Compensation), contextType) + || (!string.IsNullOrWhiteSpace(step.Condition) && !HasValidCondition(type, step.Condition!, contextType))) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidStep, step.Method.Locations.FirstOrDefault(), step.Method.Name)); + return; + } + } + + if (steps.GroupBy(static step => step.Name).Any(static group => group.Count() > 1) + || steps.GroupBy(static step => step.Order).Any(static group => group.Count() > 1)) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateStep, node.Identifier.GetLocation(), type.Name)); + return; + } + + context.AddSource($"{type.Name}.CompensatingTransaction.g.cs", SourceText.From( + GenerateSource(type, contextType, factoryMethodName, transactionName, steps.OrderBy(static step => step.Order).ToArray()), + Encoding.UTF8)); + } + + private static StepModel[] GetSteps(INamedTypeSymbol type) + => type.GetMembers() + .OfType() + .Select(method => (Method: method, Attribute: method.GetAttributes().FirstOrDefault(static attribute => attribute.AttributeClass?.ToDisplayString() == StepAttributeName))) + .Where(static item => item.Attribute is not null) + .Select(static item => new StepModel( + item.Method, + item.Attribute!.ConstructorArguments[0].Value?.ToString() ?? item.Method.Name, + (int)(item.Attribute.ConstructorArguments[1].Value ?? 0), + GetNamedString(item.Attribute, "Compensation") ?? string.Empty, + GetNamedString(item.Attribute, "Condition"))) + .ToArray(); + + private static bool HasValidStepSignature(IMethodSymbol? method, ITypeSymbol? contextType) + => method is not null + && method.IsStatic + && !method.IsGenericMethod + && method.Parameters.Length == 2 + && contextType is not null + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, contextType) + && method.Parameters[1].Type.ToDisplayString() == "System.Threading.CancellationToken" + && method.ReturnType.ToDisplayString() == "System.Threading.Tasks.ValueTask"; + + private static bool HasValidCondition(INamedTypeSymbol type, string methodName, ITypeSymbol contextType) + { + var method = FindMethod(type, methodName); + return method is not null + && method.IsStatic + && !method.IsGenericMethod + && method.Parameters.Length == 1 + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, contextType) + && method.ReturnType.SpecialType == SpecialType.System_Boolean; + } + + private static IMethodSymbol? FindMethod(INamedTypeSymbol type, string methodName) + => type.GetMembers(methodName).OfType().FirstOrDefault(); + + private static string GenerateSource( + INamedTypeSymbol type, + ITypeSymbol contextType, + string factoryMethodName, + string transactionName, + IReadOnlyList steps) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var contextTypeName = contextType.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.CompensatingTransactions.CompensatingTransaction<").Append(contextTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.Append(memberIndent).AppendLine("{"); + sb.Append(bodyIndent).Append("return global::PatternKit.Application.CompensatingTransactions.CompensatingTransaction<").Append(contextTypeName).Append(">.Create(\"").Append(Escape(transactionName)).AppendLine("\")"); + foreach (var step in steps) + { + sb.Append(bodyIndent).Append(" .AddStep(\"").Append(Escape(step.Name)).Append("\", static (context, cancellationToken) => ").Append(step.Method.Name).Append("(context, cancellationToken), static (context, cancellationToken) => ").Append(step.Compensation).Append("(context, cancellationToken), step => step.At(").Append(step.Order).Append(')'); + if (!string.IsNullOrWhiteSpace(step.Condition)) + sb.Append(".When(static context => ").Append(step.Condition).Append("(context))"); + sb.AppendLine(")"); + } + + sb.Append(bodyIndent).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 "); + sb.Append(type.IsRecord ? (type.TypeKind == TypeKind.Struct ? "record struct" : "record class") : type.TypeKind == TypeKind.Struct ? "struct" : "class"); + sb.Append(' ').Append(type.Name).AppendLine(); + } + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static bool IsValidIdentifier(string value) + => !string.IsNullOrWhiteSpace(value) + && SyntaxFacts.IsValidIdentifier(value) + && SyntaxFacts.GetKeywordKind(value) == SyntaxKind.None + && SyntaxFacts.GetContextualKeywordKind(value) == SyntaxKind.None; + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; + + private sealed record StepModel( + IMethodSymbol Method, + string Name, + int Order, + string Compensation, + string? Condition); +} diff --git a/test/PatternKit.Examples.Tests/CompensatingTransactionDemo/CheckoutCompensatingTransactionDemoTests.cs b/test/PatternKit.Examples.Tests/CompensatingTransactionDemo/CheckoutCompensatingTransactionDemoTests.cs new file mode 100644 index 00000000..02a44122 --- /dev/null +++ b/test/PatternKit.Examples.Tests/CompensatingTransactionDemo/CheckoutCompensatingTransactionDemoTests.cs @@ -0,0 +1,67 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.CompensatingTransactions; +using PatternKit.Examples.CompensatingTransactionDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.CompensatingTransactionDemo; + +[Feature("Checkout compensating transaction example")] +public sealed class CheckoutCompensatingTransactionDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent and generated transactions compensate failed checkout")] + [Fact] + public Task Fluent_And_Generated_Transactions_Compensate_Failed_Checkout() + => Given("fluent and generated checkout transactions", (Func>)(async () => new Summaries( + await CheckoutCompensatingTransactionDemo.RunFluentAsync(), + await CheckoutCompensatingTransactionDemo.RunGeneratedAsync()))) + .Then("both paths compensate completed work", summaries => + { + ScenarioExpect.Equal(CompensatingTransactionStatus.Compensated, summaries.Fluent.Status); + ScenarioExpect.Equal(CompensatingTransactionStatus.Compensated, summaries.Generated.Status); + ScenarioExpect.Equal( + ["inventory-reserved", "payment-authorized", "payment-voided", "inventory-released"], + summaries.Fluent.Log); + ScenarioExpect.Equal(summaries.Fluent.Log, summaries.Generated.Log); + }) + .AssertPassed(); + + [Scenario("Checkout transaction completes when all resources are available")] + [Fact] + public Task Checkout_Transaction_Completes_When_All_Resources_Are_Available() + => Given("a checkout transaction with shipment capacity", () => CheckoutCompensatingTransactionDemo.RunFluentAsync(shipmentAvailable: true)) + .Then("the transaction completes without compensation", summary => + { + ScenarioExpect.Equal(CompensatingTransactionStatus.Completed, summary.Status); + ScenarioExpect.Equal(["inventory-reserved", "payment-authorized", "shipment-created"], summary.Log); + ScenarioExpect.True(summary.History.All(static kind => kind == CompensatingTransactionRecordKind.Completed)); + }) + .AssertPassed(); + + [Scenario("Compensating transaction example is importable through IServiceCollection")] + [Fact] + public Task Compensating_Transaction_Example_Is_Importable_Through_IServiceCollection() + => Given("a service collection importing the checkout compensating transaction example", () => + { + var services = new ServiceCollection(); + services.AddCheckoutCompensatingTransactionDemo(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("an importing application resolves and runs the workflow", provider => + { + using (provider) + return provider.GetRequiredService() + .RunAsync(new CheckoutCompensatingTransactionRequest("order-1001", ShipmentAvailable: true)) + .AsTask() + .GetAwaiter() + .GetResult(); + }) + .Then("the workflow completes", summary => + ScenarioExpect.Equal(CompensatingTransactionStatus.Completed, summary.Status)) + .AssertPassed(); + + private sealed record Summaries( + CheckoutCompensatingTransactionSummary Fluent, + CheckoutCompensatingTransactionSummary Generated); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs index 6ca3bc38..da4d6584 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("480 pattern route results", ctx.ResultsGuide)) + ScenarioExpect.Contains("484 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 == "PortsAndAdapters") return "Ports and Adapters"; + if (patternName == "CompensatingTransaction") + return "Compensating Transaction"; + if (patternName == "DistributedLock") return "Distributed Lock / Lease"; diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 32792250..d5d4e681 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -114,6 +114,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Value Object", "Repository", "Unit of Work", + "Compensating Transaction", "Data Mapper", "Identity Map", "Lazy Load", @@ -178,7 +179,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() ScenarioExpect.Equal(42, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(4, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(21, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); - ScenarioExpect.Equal(28, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(29, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 3e305032..25317611 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -15,6 +15,7 @@ using PatternKit.Generators.CircuitBreaker; using PatternKit.Generators.Cloud; using PatternKit.Generators.Command; +using PatternKit.Generators.CompensatingTransactions; using PatternKit.Generators.Composer; using PatternKit.Generators.Composite; using PatternKit.Generators.ContextMaps; @@ -125,6 +126,8 @@ private enum TestTrigger { typeof(CommandHostAttribute), AttributeTargets.Class, false, false }, { typeof(CommandCaseAttribute), AttributeTargets.Method, false, false }, { typeof(CommandUndoAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateCompensatingTransactionAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(CompensatingTransactionStepAttribute), AttributeTargets.Method, false, false }, { typeof(CompositeComponentAttribute), AttributeTargets.Interface | AttributeTargets.Class, false, false }, { typeof(CompositeIgnoreAttribute), AttributeTargets.Property | AttributeTargets.Method, false, false }, { typeof(ComposerAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, @@ -436,6 +439,30 @@ public void Distributed_Lock_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.Throws(() => new GenerateDistributedLockAttribute(null!)); } + [Scenario("Compensating Transaction Attributes Expose Defaults And Configuration")] + [Fact] + public void Compensating_Transaction_Attributes_Expose_Defaults_And_Configuration() + { + var generator = new GenerateCompensatingTransactionAttribute + { + FactoryMethodName = "BuildCheckout", + TransactionName = "checkout" + }; + var step = new CompensatingTransactionStepAttribute("reserve-inventory", 10) + { + Compensation = "ReleaseInventory", + Condition = "ShouldReserve" + }; + + ScenarioExpect.Equal("BuildCheckout", generator.FactoryMethodName); + ScenarioExpect.Equal("checkout", generator.TransactionName); + ScenarioExpect.Equal("reserve-inventory", step.Name); + ScenarioExpect.Equal(10, step.Order); + ScenarioExpect.Equal("ReleaseInventory", step.Compensation); + ScenarioExpect.Equal("ShouldReserve", step.Condition); + ScenarioExpect.Throws(() => new CompensatingTransactionStepAttribute("", 1)); + } + [Scenario("Activity Tracker Attributes Expose Defaults And Configuration")] [Fact] public void ActivityTracker_Attributes_Expose_Defaults_And_Configuration() diff --git a/test/PatternKit.Generators.Tests/CompensatingTransactionGeneratorTests.cs b/test/PatternKit.Generators.Tests/CompensatingTransactionGeneratorTests.cs new file mode 100644 index 00000000..a5ca0c79 --- /dev/null +++ b/test/PatternKit.Generators.Tests/CompensatingTransactionGeneratorTests.cs @@ -0,0 +1,163 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Application.CompensatingTransactions; +using PatternKit.Generators.CompensatingTransactions; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Compensating Transaction generator")] +public sealed partial class CompensatingTransactionGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates compensating transaction factory")] + [Fact] + public Task Generates_Compensating_Transaction_Factory() + => Given("an attributed transaction with ordered steps", () => Compile(""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.CompensatingTransactions; + + public sealed record CheckoutContext(bool RequiresReview); + + [GenerateCompensatingTransaction(FactoryMethodName = "Build", TransactionName = "checkout")] + public static partial class CheckoutTransaction + { + [CompensatingTransactionStep("capture", 20, Compensation = nameof(Refund))] + private static ValueTask Capture(CheckoutContext context, CancellationToken cancellationToken) => default; + + private static ValueTask Refund(CheckoutContext context, CancellationToken cancellationToken) => default; + + [CompensatingTransactionStep("reserve", 10, Compensation = nameof(Release), Condition = nameof(ShouldReserve))] + private static ValueTask Reserve(CheckoutContext context, CancellationToken cancellationToken) => default; + + private static bool ShouldReserve(CheckoutContext context) => true; + + private static ValueTask Release(CheckoutContext context, CancellationToken cancellationToken) => default; + } + """)) + .Then("the generated factory wires steps in order", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("CompensatingTransaction Build()", source); + ScenarioExpect.Contains("CompensatingTransaction.Create(\"checkout\")", source); + ScenarioExpect.Contains(".AddStep(\"reserve\"", source); + ScenarioExpect.Contains(".When(static context => ShouldReserve(context))", source); + ScenarioExpect.True(source.IndexOf("\"reserve\"", StringComparison.Ordinal) < source.IndexOf("\"capture\"", StringComparison.Ordinal)); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid compensating transaction declarations")] + [Theory] + [InlineData("[GenerateCompensatingTransaction] public static class Host;", "PKCOMP001")] + [InlineData("[GenerateCompensatingTransaction] public static partial class Host;", "PKCOMP002")] + [InlineData("[GenerateCompensatingTransaction(FactoryMethodName = \"class\")] public static partial class Host { [CompensatingTransactionStep(\"x\", 1, Compensation = nameof(Undo))] private static ValueTask Do(Ctx c, CancellationToken ct) => default; private static ValueTask Undo(Ctx c, CancellationToken ct) => default; }", "PKCOMP005")] + public Task Reports_Diagnostics_For_Invalid_Compensating_Transaction_Declarations(string declaration, string diagnosticId) + => Given("an invalid compensating transaction declaration", () => Compile($$""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.CompensatingTransactions; + public sealed class Ctx; + {{declaration}} + """)) + .Then("the matching diagnostic is reported", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == diagnosticId)) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid compensating transaction steps")] + [Theory] + [InlineData("[CompensatingTransactionStep(\"x\", 1, Compensation = nameof(Undo))] private static Task Do(Ctx c, CancellationToken ct) => Task.CompletedTask; private static ValueTask Undo(Ctx c, CancellationToken ct) => default;")] + [InlineData("[CompensatingTransactionStep(\"x\", 1, Compensation = nameof(Undo))] private ValueTask Do(Ctx c, CancellationToken ct) => default; private static ValueTask Undo(Ctx c, CancellationToken ct) => default;")] + [InlineData("[CompensatingTransactionStep(\"x\", 1)] private static ValueTask Do(Ctx c, CancellationToken ct) => default;")] + [InlineData("[CompensatingTransactionStep(\"x\", 1, Compensation = nameof(Missing))] private static ValueTask Do(Ctx c, CancellationToken ct) => default;")] + [InlineData("[CompensatingTransactionStep(\"x\", 1, Compensation = nameof(Undo), Condition = nameof(BadWhen))] private static ValueTask Do(Ctx c, CancellationToken ct) => default; private static ValueTask Undo(Ctx c, CancellationToken ct) => default; private static bool BadWhen() => true;")] + [InlineData("[CompensatingTransactionStep(\"x\", 1, Compensation = nameof(Undo), Condition = nameof(BadWhen))] private static ValueTask Do(Ctx c, CancellationToken ct) => default; private static ValueTask Undo(Ctx c, CancellationToken ct) => default; private bool BadWhen(Ctx c) => true;")] + public Task Reports_Diagnostics_For_Invalid_Compensating_Transaction_Steps(string members) + => Given("a compensating transaction with invalid steps", () => Compile($$""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.CompensatingTransactions; + public sealed class Ctx; + [GenerateCompensatingTransaction] + public static partial class Host { {{members}} } + """)) + .Then("the invalid step diagnostic is reported", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == "PKCOMP003")) + .AssertPassed(); + + [Scenario("Reports diagnostics for duplicate compensating transaction step identity")] + [Fact] + public Task Reports_Diagnostics_For_Duplicate_Compensating_Transaction_Step_Identity() + => Given("a compensating transaction with duplicate step names", () => Compile(""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.CompensatingTransactions; + public sealed class Ctx; + [GenerateCompensatingTransaction] + public static partial class Host + { + [CompensatingTransactionStep("x", 1, Compensation = nameof(Undo))] + private static ValueTask Do(Ctx c, CancellationToken ct) => default; + [CompensatingTransactionStep("x", 2, Compensation = nameof(Undo))] + private static ValueTask DoAgain(Ctx c, CancellationToken ct) => default; + private static ValueTask Undo(Ctx c, CancellationToken ct) => default; + } + """)) + .Then("the duplicate diagnostic is reported", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == "PKCOMP004")) + .AssertPassed(); + + [Scenario("Generates nested record host wrappers")] + [Fact] + public Task Generates_Nested_Record_Host_Wrappers() + => Given("a nested record host", () => Compile(""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.CompensatingTransactions; + namespace Demo; + public sealed class Ctx; + public partial record class Container + { + [GenerateCompensatingTransaction] + protected partial record class Host + { + [CompensatingTransactionStep("x", 1, Compensation = nameof(Undo))] + private static ValueTask Do(Ctx c, CancellationToken ct) => default; + private static ValueTask Undo(Ctx c, CancellationToken ct) => default; + } + } + """)) + .Then("the generated source preserves record wrappers", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var combined = string.Join("\n", result.GeneratedSources); + ScenarioExpect.Contains("public partial record class Container", combined); + ScenarioExpect.Contains("protected partial record class Host", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "CompensatingTransactionGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(CompensatingTransaction<>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new CompensatingTransactionGenerator(), 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/CompensatingTransactions/CompensatingTransactionTests.cs b/test/PatternKit.Tests/Application/CompensatingTransactions/CompensatingTransactionTests.cs new file mode 100644 index 00000000..c0d61498 --- /dev/null +++ b/test/PatternKit.Tests/Application/CompensatingTransactions/CompensatingTransactionTests.cs @@ -0,0 +1,114 @@ +using PatternKit.Application.CompensatingTransactions; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Application.CompensatingTransactions; + +[Feature("Compensating transaction")] +public sealed class CompensatingTransactionTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Transaction completes ordered conditional steps")] + [Fact] + public Task Transaction_Completes_Ordered_Conditional_Steps() + => Given("a transaction with ordered and conditional steps", () => + { + var context = new TransactionContext(runOptional: false); + var transaction = CompensatingTransaction + .Create("checkout") + .AddStep("capture", static (ctx, _) => { ctx.Events.Add("capture"); return default; }, static (ctx, _) => { ctx.Events.Add("refund"); return default; }, static step => step.At(20)) + .AddStep("optional", static (ctx, _) => { ctx.Events.Add("optional"); return default; }, static (ctx, _) => { ctx.Events.Add("undo-optional"); return default; }, static step => step.At(10).When(static ctx => ctx.RunOptional)) + .Build(); + return (context, transaction); + }) + .When("executing the transaction", (Func<(TransactionContext context, CompensatingTransaction transaction), ValueTask>>)(async ctx => await ctx.transaction.ExecuteAsync(ctx.context))) + .Then("only eligible steps run in order", execution => + { + ScenarioExpect.Equal(CompensatingTransactionStatus.Completed, execution.Status); + ScenarioExpect.Equal("checkout", execution.TransactionName); + ScenarioExpect.Equal(["optional", "capture"], execution.History.Select(static item => item.StepName).ToArray()); + ScenarioExpect.Equal(CompensatingTransactionRecordKind.Skipped, execution.History[0].Kind); + ScenarioExpect.Equal(CompensatingTransactionRecordKind.Completed, execution.History[1].Kind); + ScenarioExpect.Equal(["capture"], execution.Context.Events); + }) + .AssertPassed(); + + [Scenario("Transaction compensates completed work when a later step fails")] + [Fact] + public Task Transaction_Compensates_Completed_Work_When_A_Later_Step_Fails() + => Given("a checkout transaction with a failing shipment step", () => + { + var context = new TransactionContext(runOptional: true); + return CompensatingTransaction + .Create() + .AddStep("reserve", static (ctx, _) => { ctx.Events.Add("reserve"); return default; }, static (ctx, _) => { ctx.Events.Add("release"); return default; }, static step => step.At(10)) + .AddStep("ship", static (_, _) => throw new InvalidOperationException("carrier unavailable"), static (ctx, _) => { ctx.Events.Add("cancel-ship"); return default; }, static step => step.At(20)); + }) + .When("executing the transaction", (Func.Builder, ValueTask>>)(async builder => await builder.Build().ExecuteAsync(new TransactionContext(runOptional: true)))) + .Then("completed steps are compensated in reverse order", execution => + { + ScenarioExpect.Equal(CompensatingTransactionStatus.Compensated, execution.Status); + ScenarioExpect.Equal(["reserve", "release"], execution.Context.Events); + ScenarioExpect.Equal(CompensatingTransactionRecordKind.Failed, execution.History[1].Kind); + ScenarioExpect.Equal(CompensatingTransactionRecordKind.Compensated, execution.History[2].Kind); + }) + .AssertPassed(); + + [Scenario("Transaction records compensation failures")] + [Fact] + public Task Transaction_Records_Compensation_Failures() + => Given("a transaction whose compensation throws", () => CompensatingTransaction + .Create() + .AddStep("reserve", static (ctx, _) => { ctx.Events.Add("reserve"); return default; }, static (_, _) => throw new InvalidOperationException("release failed"), static step => step.At(10)) + .AddStep("capture", static (_, _) => throw new InvalidOperationException("capture failed"), static (_, _) => default, static step => step.At(20)) + .Build()) + .When("executing the transaction", transaction => transaction.Execute(new TransactionContext(runOptional: true))) + .Then("the failed compensation is observable", execution => + { + ScenarioExpect.Equal(CompensatingTransactionStatus.CompensationFailed, execution.Status); + ScenarioExpect.Contains(execution.History, static record => record.Kind == CompensatingTransactionRecordKind.CompensationFailed); + ScenarioExpect.Equal("release failed", execution.History.Last().ErrorMessage); + }) + .AssertPassed(); + + [Scenario("Transaction rejects invalid configuration")] + [Fact] + public Task Transaction_Rejects_Invalid_Configuration() + => Given("a compensating transaction builder", () => CompensatingTransaction.Create("checkout")) + .Then("invalid setup throws clear exceptions", builder => + { + ScenarioExpect.Throws(() => CompensatingTransaction.Create("").AddStep("x", static (_, _) => default, static (_, _) => default).Build()); + ScenarioExpect.Throws(() => CompensatingTransaction.Create().Build()); + ScenarioExpect.Throws(() => builder.AddStep("", static (_, _) => default, static (_, _) => default)); + ScenarioExpect.Throws(() => builder.AddStep("execute", null!, static (_, _) => default)); + ScenarioExpect.Throws(() => builder.AddStep("compensate", static (_, _) => default, null!)); + ScenarioExpect.Throws(() => builder.AddStep("condition", static (_, _) => default, static (_, _) => default, static step => step.When(null!))); + ScenarioExpect.Throws(() => CompensatingTransaction.Create().AddStep("same", static (_, _) => default, static (_, _) => default).AddStep("same", static (_, _) => default, static (_, _) => default).Build()); + ScenarioExpect.Throws(() => new CompensatingTransactionExecution("bad", new TransactionContext(false), CompensatingTransactionStatus.Completed, null!)); + }) + .AssertPassed(); + + [Scenario("Transaction honors cancellation before work starts")] + [Fact] + public Task Transaction_Honors_Cancellation_Before_Work_Starts() + => Given("a canceled token and a transaction", () => + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var transaction = CompensatingTransaction + .Create() + .AddStep("never", static (_, _) => throw new InvalidOperationException("should not run"), static (_, _) => default) + .Build(); + return (transaction, Token: cancellation.Token); + }) + .Then("execution is canceled before invoking any step", async ctx => + await ScenarioExpect.ThrowsAsync(() => ctx.transaction.ExecuteAsync(new TransactionContext(false), ctx.Token).AsTask())) + .AssertPassed(); + + private sealed class TransactionContext(bool runOptional) + { + public bool RunOptional { get; } = runOptional; + + public List Events { get; } = []; + } +}