diff --git a/README.md b/README.md index 2e178bf8..19bd8677 100644 --- a/README.md +++ b/README.md @@ -473,13 +473,13 @@ var cachedRemoteProxy = Proxy.Create(id => remoteProxy.Execute(id)) --- ## Patterns Table -PatternKit currently tracks 119 production-readiness patterns. Each catalog pattern is represented in tests, documentation, real-world examples, IoC integration, and the BenchmarkDotNet coverage matrix. +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. | 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 | | Behavioral | 12 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Null Object, 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 | +| 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 | | Enterprise Integration | 42 | Aggregator, Canonical Data Model, Change Data Capture, Channel Adapter, Channel Purger, Claim Check, Competing Consumers, Content Enricher, Content-Based Router, Control Bus, Correlation Identifier, Dead Letter Channel, Durable Subscriber, Dynamic Router, Event Notification, Event-Carried State Transfer, Event-Driven Consumer, Guaranteed Delivery, Invalid Message Channel, Mailbox, Message Bus, Message Channel, Message Envelope, Message Expiration, Message Filter, Message History, Message Store, Message Translator, Messaging Bridge, Messaging Gateway, Pipes and Filters, Polling Consumer, Publish-Subscribe, Recipient List, Request-Reply, Resequencer, Routing Slip, Saga / Process Manager, Scatter-Gather, Service Activator, Splitter, Wire Tap | | Messaging Reliability | 4 | Backpressure, Idempotent Receiver, Inbox, Outbox | @@ -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. | +| 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. | | Materialized View | Execution | 389.5 ns | 2.02 KB | 386.0 ns | 2.02 KB | Effectively equivalent for this scenario. | | Mailbox | Construction | 17.030 ns | 216 B | 29.867 ns | 360 B | Fluent was faster and allocated less for disposable mailbox construction. | diff --git a/benchmarks/PatternKit.Benchmarks/Cloud/DistributedLockBenchmarks.cs b/benchmarks/PatternKit.Benchmarks/Cloud/DistributedLockBenchmarks.cs new file mode 100644 index 00000000..5ae612d0 --- /dev/null +++ b/benchmarks/PatternKit.Benchmarks/Cloud/DistributedLockBenchmarks.cs @@ -0,0 +1,37 @@ +using BenchmarkDotNet.Attributes; +using PatternKit.Cloud.DistributedLocks; +using PatternKit.Examples.DistributedLockDemo; + +namespace PatternKit.Benchmarks.Cloud; + +[BenchmarkCategory("Cloud", "DistributedLockLease")] +public class DistributedLockBenchmarks +{ + private static readonly OrderAllocationRequest Request = new("ORDER-100", "allocator-a", 4); + + [Benchmark(Baseline = true, Description = "Fluent: create distributed lock")] + [BenchmarkCategory("Fluent", "Construction")] + public DistributedLock Fluent_CreateDistributedLock() + => OrderAllocationDistributedLocks.CreateFluent(); + + [Benchmark(Description = "Generated: create distributed lock")] + [BenchmarkCategory("Generated", "Construction")] + public DistributedLock Generated_CreateDistributedLock() + => GeneratedOrderAllocationDistributedLock.Create(); + + [Benchmark(Description = "Fluent: allocate order under lease")] + [BenchmarkCategory("Fluent", "Execution")] + public OrderAllocationSummary Fluent_AllocateOrder() + { + var workflow = new OrderAllocationLockWorkflow(OrderAllocationDistributedLocks.CreateFluent()); + return workflow.Allocate(Request); + } + + [Benchmark(Description = "Generated: allocate order under lease")] + [BenchmarkCategory("Generated", "Execution")] + public OrderAllocationSummary Generated_AllocateOrder() + { + var workflow = new OrderAllocationLockWorkflow(GeneratedOrderAllocationDistributedLock.Create()); + return workflow.Allocate(Request); + } +} diff --git a/docs/examples/index.md b/docs/examples/index.md index f98ac63f..bdbc41d0 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -198,6 +198,7 @@ dotnet test PatternKit.slnx -c Release * **Commerce Backends for Frontends:** `CommerceBackendsForFrontendsDemo` (+ `CommerceBackendsForFrontendsDemoTests`) — fluent and generated client-specific facade shaping with DI and ASP.NET Core mapping. * **Inventory Ambassador:** `InventoryAmbassadorDemo` (+ `InventoryAmbassadorDemoTests`) — fluent and generated outbound connectivity wrapper with DI and ASP.NET Core mapping. * **Warehouse Leader Election:** `WarehouseLeaderElectionDemo` (+ `WarehouseLeaderElectionDemoTests`) — fluent and generated active worker lease coordination with DI and Generic Host mapping. +* **Order Allocation Distributed Lock:** `OrderAllocationDistributedLockDemo` (+ `OrderAllocationDistributedLockDemoTests`) — fluent and generated resource lease coordination with DI and Generic Host mapping. * **Warehouse Scheduler Agent Supervisor:** `WarehouseSchedulerAgentSupervisorDemo` (+ `WarehouseSchedulerAgentSupervisorDemoTests`) — fluent and generated scheduled worker supervision with DI and Generic Host mapping. * **Production-Ready Example Catalog:** `PatternKitExampleCatalog` (+ `PatternKitExampleCatalogTests`) — DI registration, generic host validation, ASP.NET Core endpoint mapping, and source/test/docs manifest checks. * **Tests:** `PatternKit.Examples.Tests/*` use TinyBDD scenarios that read like specs. diff --git a/docs/examples/order-allocation-distributed-lock.md b/docs/examples/order-allocation-distributed-lock.md new file mode 100644 index 00000000..5a75f59b --- /dev/null +++ b/docs/examples/order-allocation-distributed-lock.md @@ -0,0 +1,12 @@ +# Order Allocation Distributed Lock + +The order allocation example protects inventory allocation with a resource lease so competing workers cannot mutate the same order concurrently. + +```csharp +services.AddOrderAllocationDistributedLockDemo(); + +var runner = provider.GetRequiredService(); +var summary = runner.RunGenerated(); +``` + +The example includes fluent and source-generated construction, a container-owned workflow, and an `IServiceCollection` extension that can be imported into a standard .NET host. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index ca383462..bd59f5d3 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -340,6 +340,9 @@ - name: Warehouse Leader Election href: warehouse-leader-election.md +- name: Order Allocation Distributed Lock + href: order-allocation-distributed-lock.md + - name: Warehouse Scheduler Agent Supervisor href: warehouse-scheduler-agent-supervisor.md diff --git a/docs/generators/distributed-lock.md b/docs/generators/distributed-lock.md new file mode 100644 index 00000000..d09281d7 --- /dev/null +++ b/docs/generators/distributed-lock.md @@ -0,0 +1,15 @@ +# Distributed Lock Generator + +`[GenerateDistributedLock]` creates a typed `DistributedLock` factory with configured lock name and lease duration. + +```csharp +[GenerateDistributedLock(typeof(string), LockName = "order-allocation-lock", LeaseDurationMilliseconds = 30000)] +public static partial class OrderAllocationLocks; + +var mutex = OrderAllocationLocks.Create(); +``` + +Diagnostics: + +- `PKDLOCK001`: host type must be partial. +- `PKDLOCK002`: factory name, lock name, and lease duration must be valid. diff --git a/docs/generators/index.md b/docs/generators/index.md index 9ca307ee..0f83419c 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -152,6 +152,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Backends for Frontends**](backends-for-frontends.md) | Client-specific facade factories | `[GenerateBackendsForFrontends]` | | [**Ambassador**](ambassador.md) | Outbound connectivity wrapper factories | `[GenerateAmbassador]` | | [**Leader Election**](leader-election.md) | Lease-backed active worker factories | `[GenerateLeaderElection]` | +| [**Distributed Lock**](distributed-lock.md) | Resource lease lock factories | `[GenerateDistributedLock]` | | [**Scheduler Agent Supervisor**](scheduler-agent-supervisor.md) | Scheduled worker supervision factories | `[GenerateSchedulerAgentSupervisor]` | ## Quick Reference diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index bdc63740..15d4f019 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -250,6 +250,9 @@ - name: Leader Election href: leader-election.md +- name: Distributed Lock + href: distributed-lock.md + - name: Scheduler Agent Supervisor href: scheduler-agent-supervisor.md diff --git a/docs/guides/benchmark-results.md b/docs/guides/benchmark-results.md index b56455ca..05303aeb 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. | +| 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. | | Materialized View | Execution | 389.5 ns | 2.02 KB | 386.0 ns | 2.02 KB | Effectively equivalent for this scenario. | | Mailbox | Construction | 17.030 ns | 216 B | 29.867 ns | 360 B | Fluent was faster and allocated less for disposable mailbox construction. | @@ -260,19 +262,19 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 ## Coverage Matrix Summary -The coverage matrix currently publishes 119 catalog patterns and 476 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 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. | Category | Patterns | Published route results | | --- | ---: | ---: | | Application Architecture | 28 | 112 | | Behavioral | 12 | 48 | -| Cloud Architecture | 20 | 80 | +| Cloud Architecture | 21 | 84 | | Creational | 6 | 24 | | Enterprise Integration | 42 | 168 | | Messaging Reliability | 4 | 16 | | Structural | 7 | 28 | -The generator matrix currently publishes 114 generator source route results. +The generator matrix currently publishes 115 generator source route results. ## Hosting Integration Matrix Results @@ -340,8 +342,9 @@ The generator matrix currently publishes 114 generator source route results. | Cloud Architecture | Bulkhead | Covered | Covered | Covered | Covered | | Cloud Architecture | Cache Stampede Protection | Covered | Covered | Covered | Covered | | Cloud Architecture | Cache-Aside | Covered | Covered | Covered | Covered | -| Cloud Architecture | Circuit Breaker | Covered | Covered | Covered | Covered | -| Cloud Architecture | External Configuration Store | Covered | Covered | Covered | Covered | +| Cloud Architecture | Circuit Breaker | Covered | Covered | Covered | Covered | +| Cloud Architecture | Distributed Lock / Lease | Covered | Covered | Covered | Covered | +| Cloud Architecture | External Configuration Store | Covered | Covered | Covered | Covered | | Cloud Architecture | Gateway Aggregation | Covered | Covered | Covered | Covered | | Cloud Architecture | Gateway Routing | Covered | Covered | Covered | Covered | | Cloud Architecture | Health Endpoint Monitoring | Covered | Covered | Covered | Covered | @@ -465,8 +468,9 @@ The generator matrix currently publishes 114 generator source route results. | IdentityMapGenerator | `src/PatternKit.Generators/IdentityMap/IdentityMapGenerator.cs` | Covered | | LazyLoadGenerator | `src/PatternKit.Generators/LazyLoading/LazyLoadGenerator.cs` | Covered | | InterpreterGenerator | `src/PatternKit.Generators/Interpreter/InterpreterGenerator.cs` | Covered | -| IteratorGenerator | `src/PatternKit.Generators/Iterator/IteratorGenerator.cs` | Covered | -| LeaderElectionGenerator | `src/PatternKit.Generators/LeaderElection/LeaderElectionGenerator.cs` | Covered | +| IteratorGenerator | `src/PatternKit.Generators/Iterator/IteratorGenerator.cs` | Covered | +| LeaderElectionGenerator | `src/PatternKit.Generators/LeaderElection/LeaderElectionGenerator.cs` | Covered | +| DistributedLockGenerator | `src/PatternKit.Generators/DistributedLocks/DistributedLockGenerator.cs` | Covered | | MaterializedViewGenerator | `src/PatternKit.Generators/MaterializedViews/MaterializedViewGenerator.cs` | Covered | | MementoGenerator | `src/PatternKit.Generators/MementoGenerator.cs` | Covered | | NullObjectGenerator | `src/PatternKit.Generators/NullObject/NullObjectGenerator.cs` | Covered | diff --git a/docs/index.md b/docs/index.md index 47da762f..dd773b51 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,13 +66,13 @@ if (parser.Execute("123", out var value)) ## 📚 Available Patterns -PatternKit covers 119 production-readiness patterns with fluent APIs, source-generated routes where applicable, IoC integration examples, TinyBDD coverage, and BenchmarkDotNet coverage-matrix validation: +PatternKit covers 120 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 | | Behavioral | 12 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Null Object, 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 | +| 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 | | Enterprise Integration | 42 | Aggregator, Canonical Data Model, Change Data Capture, Channel Adapter, Channel Purger, Claim Check, Competing Consumers, Content Enricher, Content-Based Router, Control Bus, Correlation Identifier, Dead Letter Channel, Durable Subscriber, Dynamic Router, Event Notification, Event-Carried State Transfer, Event-Driven Consumer, Guaranteed Delivery, Invalid Message Channel, Mailbox, Message Bus, Message Channel, Message Envelope, Message Expiration, Message Filter, Message History, Message Store, Message Translator, Messaging Bridge, Messaging Gateway, Pipes and Filters, Polling Consumer, Publish-Subscribe, Recipient List, Request-Reply, Resequencer, Routing Slip, Saga / Process Manager, Scatter-Gather, Service Activator, Splitter, Wire Tap | | Messaging Reliability | 4 | Backpressure, Idempotent Receiver, Inbox, Outbox | diff --git a/docs/patterns/cloud/distributed-lock-lease.md b/docs/patterns/cloud/distributed-lock-lease.md new file mode 100644 index 00000000..fae20fba --- /dev/null +++ b/docs/patterns/cloud/distributed-lock-lease.md @@ -0,0 +1,18 @@ +# Distributed Lock / Lease + +Distributed Lock / Lease coordinates exclusive ownership of a resource through expiring tokens. Use it when one worker, request, or host should mutate a resource while contenders wait or retry. + +```csharp +var mutex = DistributedLock + .Create("order-allocation-lock") + .LeaseDuration(TimeSpan.FromSeconds(30)) + .Build(); + +var acquired = mutex.TryAcquire("ORDER-100", "allocator-a"); +var renewed = mutex.Renew(acquired.Lease!); +var released = mutex.Release(renewed.Lease!); +``` + +The fluent path exposes acquisition, contention, renewal, expiry, release, snapshots, and blocked state without requiring a container. The source-generated path uses `[GenerateDistributedLock]` to create a configured `DistributedLock` factory for repeatable application composition. + +Import the production-shaped example through `AddOrderAllocationDistributedLockDemo()` or the aggregate `AddPatternKitExamples()` registration. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 3cfb88d2..db639dca 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -419,6 +419,8 @@ href: cloud/ambassador.md - name: Leader Election href: cloud/leader-election.md + - name: Distributed Lock / Lease + href: cloud/distributed-lock-lease.md - name: Scheduler Agent Supervisor href: cloud/scheduler-agent-supervisor.md - name: Application Architecture diff --git a/src/PatternKit.Core/Cloud/DistributedLocks/DistributedLock.cs b/src/PatternKit.Core/Cloud/DistributedLocks/DistributedLock.cs new file mode 100644 index 00000000..a31bf777 --- /dev/null +++ b/src/PatternKit.Core/Cloud/DistributedLocks/DistributedLock.cs @@ -0,0 +1,279 @@ +namespace PatternKit.Cloud.DistributedLocks; + +/// +/// Coordinates mutually exclusive ownership of named resources through expiring leases. +/// +public sealed class DistributedLock + where TKey : notnull +{ + private readonly object _gate = new(); + private readonly Dictionary> _locks; + private readonly Func _clock; + private readonly TimeSpan _leaseDuration; + + private DistributedLock(string name, TimeSpan leaseDuration, IEqualityComparer keyComparer, Func clock) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Distributed lock name cannot be null, empty, or whitespace.", nameof(name)); + if (leaseDuration <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(leaseDuration), leaseDuration, "Lease duration must be positive."); + + Name = name; + _leaseDuration = leaseDuration; + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _locks = new Dictionary>(keyComparer ?? throw new ArgumentNullException(nameof(keyComparer))); + } + + public string Name { get; } + + public bool IsBlocked => ActiveCount > 0; + + public int ActiveCount + { + get + { + lock (_gate) + { + ExpireStaleLocks(_clock()); + return _locks.Count; + } + } + } + + public DistributedLockResult TryAcquire(TKey resourceKey, string ownerId) + => TryAcquire(resourceKey, ownerId, null); + + public DistributedLockResult TryAcquire(TKey resourceKey, string ownerId, TimeSpan? leaseDuration) + { + ValidateOwner(ownerId); + var duration = leaseDuration ?? _leaseDuration; + ValidateLeaseDuration(duration); + + lock (_gate) + { + var now = _clock(); + ExpireStaleLocks(now); + if (_locks.TryGetValue(resourceKey, out var current)) + return DistributedLockResult.Failure(Name, resourceKey, ownerId, new InvalidOperationException($"Resource is locked by '{current.OwnerId}'."), current); + + var record = new DistributedLockRecord(resourceKey, ownerId, Guid.NewGuid().ToString("N"), now, now.Add(duration)); + _locks[resourceKey] = record; + return DistributedLockResult.Acquisition(Name, record); + } + } + + public DistributedLockResult Renew(DistributedLockRecord lease) + => Renew(lease, null); + + public DistributedLockResult Renew(DistributedLockRecord lease, TimeSpan? leaseDuration) + { + if (lease is null) + throw new ArgumentNullException(nameof(lease)); + + var duration = leaseDuration ?? _leaseDuration; + ValidateLeaseDuration(duration); + + lock (_gate) + { + var now = _clock(); + ExpireStaleLocks(now); + if (!_locks.TryGetValue(lease.ResourceKey, out var current)) + return DistributedLockResult.Failure(Name, lease.ResourceKey, lease.OwnerId, new InvalidOperationException("No active lease exists.")); + if (!current.Matches(lease)) + return DistributedLockResult.Failure(Name, lease.ResourceKey, lease.OwnerId, new InvalidOperationException("Lease token is not current."), current); + + var renewed = current.Renew(now.Add(duration)); + _locks[lease.ResourceKey] = renewed; + return DistributedLockResult.Renewal(Name, renewed); + } + } + + public DistributedLockResult Release(DistributedLockRecord lease) + { + if (lease is null) + throw new ArgumentNullException(nameof(lease)); + + lock (_gate) + { + ExpireStaleLocks(_clock()); + if (!_locks.TryGetValue(lease.ResourceKey, out var current)) + return DistributedLockResult.Failure(Name, lease.ResourceKey, lease.OwnerId, new InvalidOperationException("No active lease exists.")); + if (!current.Matches(lease)) + return DistributedLockResult.Failure(Name, lease.ResourceKey, lease.OwnerId, new InvalidOperationException("Lease token is not current."), current); + + _locks.Remove(lease.ResourceKey); + return DistributedLockResult.Release(Name, current); + } + } + + public bool IsLocked(TKey resourceKey) + { + lock (_gate) + { + ExpireStaleLocks(_clock()); + return _locks.ContainsKey(resourceKey); + } + } + + public IReadOnlyList> Snapshot() + { + lock (_gate) + { + ExpireStaleLocks(_clock()); + return _locks.Values + .OrderBy(static lease => lease.AcquiredAt) + .ThenBy(static lease => lease.Token, StringComparer.Ordinal) + .ToArray(); + } + } + + public DistributedLockState GetState() + { + var leases = Snapshot(); + return new(Name, leases.Count > 0, leases.Count, leases); + } + + public static Builder Create(string name = "distributed-lock") => new(name); + + private void ExpireStaleLocks(DateTimeOffset now) + { + foreach (var key in _locks.Where(pair => pair.Value.ExpiresAt <= now).Select(static pair => pair.Key).ToArray()) + _locks.Remove(key); + } + + private static void ValidateOwner(string ownerId) + { + if (string.IsNullOrWhiteSpace(ownerId)) + throw new ArgumentException("Lock owner id cannot be null, empty, or whitespace.", nameof(ownerId)); + } + + private static void ValidateLeaseDuration(TimeSpan leaseDuration) + { + if (leaseDuration <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(leaseDuration), leaseDuration, "Lease duration must be positive."); + } + + public sealed class Builder + { + private readonly string _name; + private TimeSpan _leaseDuration = TimeSpan.FromSeconds(30); + private IEqualityComparer _keyComparer = EqualityComparer.Default; + private Func _clock = static () => DateTimeOffset.UtcNow; + + internal Builder(string name) => _name = name; + + public Builder LeaseDuration(TimeSpan leaseDuration) + { + _leaseDuration = leaseDuration; + return this; + } + + public Builder WithKeyComparer(IEqualityComparer keyComparer) + { + _keyComparer = keyComparer ?? throw new ArgumentNullException(nameof(keyComparer)); + return this; + } + + public Builder WithClock(Func clock) + { + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + return this; + } + + public DistributedLock Build() => new(_name, _leaseDuration, _keyComparer, _clock); + } +} + +public sealed class DistributedLockRecord + where TKey : notnull +{ + public DistributedLockRecord(TKey resourceKey, string ownerId, string token, DateTimeOffset acquiredAt, DateTimeOffset expiresAt) + { + if (string.IsNullOrWhiteSpace(ownerId)) + throw new ArgumentException("Lock owner id cannot be null, empty, or whitespace.", nameof(ownerId)); + if (string.IsNullOrWhiteSpace(token)) + throw new ArgumentException("Lock token cannot be null, empty, or whitespace.", nameof(token)); + + ResourceKey = resourceKey; + OwnerId = ownerId; + Token = token; + AcquiredAt = acquiredAt; + ExpiresAt = expiresAt; + } + + public TKey ResourceKey { get; } + + public string OwnerId { get; } + + public string Token { get; } + + public DateTimeOffset AcquiredAt { get; } + + public DateTimeOffset ExpiresAt { get; } + + public DistributedLockRecord Renew(DateTimeOffset expiresAt) + => new(ResourceKey, OwnerId, Token, AcquiredAt, expiresAt); + + internal bool Matches(DistributedLockRecord lease) + => string.Equals(OwnerId, lease.OwnerId, StringComparison.Ordinal) + && string.Equals(Token, lease.Token, StringComparison.Ordinal); +} + +public sealed class DistributedLockResult + where TKey : notnull +{ + private DistributedLockResult(string lockName, TKey resourceKey, string ownerId, DistributedLockRecord? lease, Exception? exception, bool acquired, bool renewed, bool released) + => (LockName, ResourceKey, OwnerId, Lease, Exception, Acquired, Renewed, Released) = (lockName, resourceKey, ownerId, lease, exception, acquired, renewed, released); + + public string LockName { get; } + + public TKey ResourceKey { get; } + + public string OwnerId { get; } + + public DistributedLockRecord? Lease { get; } + + public Exception? Exception { get; } + + public bool Acquired { get; } + + public bool Renewed { get; } + + public bool Released { get; } + + public bool Succeeded => Exception is null; + + public bool Failed => !Succeeded; + + public static DistributedLockResult Acquisition(string lockName, DistributedLockRecord lease) + => new(lockName, lease.ResourceKey, lease.OwnerId, lease, null, acquired: true, renewed: false, released: false); + + public static DistributedLockResult Renewal(string lockName, DistributedLockRecord lease) + => new(lockName, lease.ResourceKey, lease.OwnerId, lease, null, acquired: false, renewed: true, released: false); + + public static DistributedLockResult Release(string lockName, DistributedLockRecord lease) + => new(lockName, lease.ResourceKey, lease.OwnerId, lease, null, acquired: false, renewed: false, released: true); + + public static DistributedLockResult Failure(string lockName, TKey resourceKey, string ownerId, Exception exception, DistributedLockRecord? lease = null) + => new(lockName, resourceKey, ownerId, lease, exception ?? throw new ArgumentNullException(nameof(exception)), acquired: false, renewed: false, released: false); +} + +public sealed class DistributedLockState + where TKey : notnull +{ + public DistributedLockState(string lockName, bool isBlocked, int activeCount, IReadOnlyList> activeLeases) + { + LockName = lockName; + IsBlocked = isBlocked; + ActiveCount = activeCount; + ActiveLeases = activeLeases ?? throw new ArgumentNullException(nameof(activeLeases)); + } + + public string LockName { get; } + + public bool IsBlocked { get; } + + public int ActiveCount { get; } + + public IReadOnlyList> ActiveLeases { get; } +} diff --git a/src/PatternKit.Examples/CacheStampedeProtectionDemo/ProductCatalogStampedeProtectionDemo.cs b/src/PatternKit.Examples/CacheStampedeProtectionDemo/ProductCatalogStampedeProtectionDemo.cs index 9daab22e..c144f3b9 100644 --- a/src/PatternKit.Examples/CacheStampedeProtectionDemo/ProductCatalogStampedeProtectionDemo.cs +++ b/src/PatternKit.Examples/CacheStampedeProtectionDemo/ProductCatalogStampedeProtectionDemo.cs @@ -31,7 +31,7 @@ public async ValueTask LoadAsync(ProductAvailabilit throw new ArgumentNullException(nameof(request)); Interlocked.Increment(ref _loads); - await Task.Delay(50, cancellationToken).ConfigureAwait(false); + await Task.Delay(150, cancellationToken).ConfigureAwait(false); return new ProductAvailabilitySnapshot(request.Sku, request.Region, 42, DateTimeOffset.UtcNow); } } diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index c8c0c7d0..ca7b7a32 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -48,6 +48,7 @@ using PatternKit.Examples.CircuitBreakerDemo; using PatternKit.Examples.ContextMapDemo; using PatternKit.Examples.DataMapperDemo; +using PatternKit.Examples.DistributedLockDemo; using PatternKit.Examples.DomainEventDemo; using PatternKit.Examples.DomainServiceDemo; using PatternKit.Examples.EnterpriseFeatureSlices; @@ -233,6 +234,7 @@ public sealed record OrderIdentityMapPatternExample(OrderIdentityMapDemoRunner R public sealed record CustomerProfileLazyLoadPatternExample(CustomerProfileLazyLoadService Service); public sealed record ProductCatalogChangeDataCaptureExample(ProductCatalogChangeDataCaptureService Service); public sealed record OrderEntryPortsAndAdaptersPatternExample(OrderEntryPortsAndAdaptersWorkflow Workflow); +public sealed record OrderAllocationDistributedLockPatternExample(OrderAllocationDistributedLockDemoRunner Runner, OrderAllocationLockWorkflow Workflow); public sealed record OrderTransactionScriptPatternExample(OrderTransactionScriptDemoRunner Runner); public sealed record CustomerServiceLayerPatternExample(CustomerServiceLayerDemoRunner Runner); public sealed record OrderDomainEventPatternExample(OrderDomainEventDemoRunner Runner); @@ -360,6 +362,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddCustomerProfileLazyLoadPatternExample() .AddProductCatalogChangeDataCaptureExample() .AddOrderEntryPortsAndAdaptersPatternExample() + .AddOrderAllocationDistributedLockPatternExample() .AddOrderTransactionScriptPatternExample() .AddCustomerServiceLayerPatternExample() .AddOrderDomainEventPatternExample() @@ -1068,6 +1071,15 @@ public static IServiceCollection AddOrderEntryPortsAndAdaptersPatternExample(thi return services.RegisterExample("Order Entry Ports and Adapters", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddOrderAllocationDistributedLockPatternExample(this IServiceCollection services) + { + services.AddOrderAllocationDistributedLockDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + sp.GetRequiredService())); + return services.RegisterExample("Order Allocation Distributed Lock", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddOrderTransactionScriptPatternExample(this IServiceCollection services) { services.AddOrderTransactionScriptDemo(); diff --git a/src/PatternKit.Examples/DistributedLockDemo/OrderAllocationDistributedLockDemo.cs b/src/PatternKit.Examples/DistributedLockDemo/OrderAllocationDistributedLockDemo.cs new file mode 100644 index 00000000..190b8bfd --- /dev/null +++ b/src/PatternKit.Examples/DistributedLockDemo/OrderAllocationDistributedLockDemo.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Cloud.DistributedLocks; +using PatternKit.Generators.DistributedLocks; + +namespace PatternKit.Examples.DistributedLockDemo; + +public sealed record OrderAllocationRequest(string OrderId, string WorkerId, int Quantity); + +public sealed record OrderAllocationSummary(string OrderId, string WorkerId, bool Acquired, bool Released, bool BlockedWhileActive); + +public sealed class OrderAllocationLockWorkflow(DistributedLock distributedLock) +{ + public OrderAllocationSummary Allocate(OrderAllocationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var acquired = distributedLock.TryAcquire(request.OrderId, request.WorkerId); + if (!acquired.Acquired) + return new(request.OrderId, request.WorkerId, false, false, true); + + var blocked = distributedLock.TryAcquire(request.OrderId, "competing-worker"); + var released = distributedLock.Release(acquired.Lease!); + return new(request.OrderId, request.WorkerId, true, released.Released, blocked.Failed); + } +} + +public static class OrderAllocationDistributedLocks +{ + public static DistributedLock CreateFluent(Func? clock = null) + => DistributedLock.Create("order-allocation-lock") + .LeaseDuration(TimeSpan.FromSeconds(30)) + .WithClock(clock ?? (() => DateTimeOffset.UtcNow)) + .Build(); +} + +[GenerateDistributedLock(typeof(string), FactoryMethodName = "Create", LockName = "order-allocation-lock", LeaseDurationMilliseconds = 30000)] +public static partial class GeneratedOrderAllocationDistributedLock; + +public sealed class OrderAllocationDistributedLockDemoRunner(OrderAllocationLockWorkflow workflow) +{ + public OrderAllocationSummary RunGenerated() + => workflow.Allocate(new("ORDER-100", "allocator-a", 4)); + + public static OrderAllocationSummary RunFluent() + { + var workflow = new OrderAllocationLockWorkflow(OrderAllocationDistributedLocks.CreateFluent()); + return workflow.Allocate(new("ORDER-100", "allocator-a", 4)); + } +} + +public static class OrderAllocationDistributedLockServiceCollectionExtensions +{ + public static IServiceCollection AddOrderAllocationDistributedLockDemo(this IServiceCollection services) + { + services.AddSingleton(static _ => GeneratedOrderAllocationDistributedLock.Create()); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 3f200637..5b71347a 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -1000,6 +1000,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["Leader Election"], ["single active worker lease", "source-generated candidate factory", "Generic Host hosted service"]), + Descriptor( + "Order Allocation Distributed Lock", + "src/PatternKit.Examples/DistributedLockDemo/OrderAllocationDistributedLockDemo.cs", + "test/PatternKit.Examples.Tests/DistributedLockDemo/OrderAllocationDistributedLockDemoTests.cs", + "docs/examples/order-allocation-distributed-lock.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["Distributed Lock / Lease"], + ["resource lease contention", "source-generated lock 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 2f8ce8d2..afa3d77d 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -1234,6 +1234,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/LeaderElectionDemo/WarehouseLeaderElectionDemoTests.cs", ["fluent lease election", "generated candidate factory", "DI-importable Generic Host worker example"]), + Pattern("Distributed Lock / Lease", PatternFamily.CloudArchitecture, + "docs/patterns/cloud/distributed-lock-lease.md", + "src/PatternKit.Core/Cloud/DistributedLocks/DistributedLock.cs", + "test/PatternKit.Tests/Cloud/DistributedLocks/DistributedLockTests.cs", + "docs/generators/distributed-lock.md", + "src/PatternKit.Generators/DistributedLocks/DistributedLockGenerator.cs", + "test/PatternKit.Generators.Tests/DistributedLockGeneratorTests.cs", + null, + "docs/examples/order-allocation-distributed-lock.md", + "src/PatternKit.Examples/DistributedLockDemo/OrderAllocationDistributedLockDemo.cs", + "test/PatternKit.Examples.Tests/DistributedLockDemo/OrderAllocationDistributedLockDemoTests.cs", + ["fluent resource lease", "generated lock factory", "DI-importable order allocation example"]), + Pattern("Scheduler Agent Supervisor", PatternFamily.CloudArchitecture, "docs/patterns/cloud/scheduler-agent-supervisor.md", "src/PatternKit.Core/Cloud/SchedulerAgentSupervisor/SchedulerAgentSupervisor.cs", diff --git a/src/PatternKit.Generators.Abstractions/DistributedLocks/DistributedLockAttributes.cs b/src/PatternKit.Generators.Abstractions/DistributedLocks/DistributedLockAttributes.cs new file mode 100644 index 00000000..b4153763 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/DistributedLocks/DistributedLockAttributes.cs @@ -0,0 +1,16 @@ +namespace PatternKit.Generators.DistributedLocks; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateDistributedLockAttribute : Attribute +{ + public GenerateDistributedLockAttribute(Type keyType) + => KeyType = keyType ?? throw new ArgumentNullException(nameof(keyType)); + + public Type KeyType { get; } + + public string FactoryMethodName { get; set; } = "Create"; + + public string LockName { get; set; } = "distributed-lock"; + + public int LeaseDurationMilliseconds { get; set; } = 30000; +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index a1d91130..dbfdcc1b 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -469,3 +469,5 @@ PKPA001 | PatternKit.Generators.PortsAndAdapters | Error | Ports and Adapters ho PKPA002 | PatternKit.Generators.PortsAndAdapters | Error | Ports and Adapters method is missing. PKPA003 | PatternKit.Generators.PortsAndAdapters | Error | Ports and Adapters method signature is invalid. 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. diff --git a/src/PatternKit.Generators/DistributedLocks/DistributedLockGenerator.cs b/src/PatternKit.Generators/DistributedLocks/DistributedLockGenerator.cs new file mode 100644 index 00000000..0d14f9ef --- /dev/null +++ b/src/PatternKit.Generators/DistributedLocks/DistributedLockGenerator.cs @@ -0,0 +1,172 @@ +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.DistributedLocks; + +[Generator] +public sealed class DistributedLockGenerator : IIncrementalGenerator +{ + private const string AttributeName = "PatternKit.Generators.DistributedLocks.GenerateDistributedLockAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKDLOCK001", + "Distributed Lock host must be partial", + "Type '{0}' is marked with [GenerateDistributedLock] but is not declared as partial", + "PatternKit.Generators.DistributedLocks", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidConfiguration = new( + "PKDLOCK002", + "Distributed Lock configuration is invalid", + "Distributed Lock '{0}' must have non-empty FactoryMethodName and LockName values and LeaseDurationMilliseconds > 0", + "PatternKit.Generators.DistributedLocks", + DiagnosticSeverity.Error, + true); + + private static readonly SymbolDisplayFormat TypeFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + AttributeName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(static attribute => + attribute.AttributeClass?.ToDisplayString() == AttributeName); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate(SourceProductionContext context, INamedTypeSymbol type, TypeDeclarationSyntax node, AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var keyType = attribute.ConstructorArguments.Length >= 1 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + if (keyType is null || keyType.TypeKind == TypeKind.Error) + return; + + var factoryMethodName = GetNamedString(attribute, "FactoryMethodName") ?? "Create"; + var lockName = GetNamedString(attribute, "LockName") ?? "distributed-lock"; + var leaseMs = GetNamedInt(attribute, "LeaseDurationMilliseconds") ?? 30000; + if (!IsValidIdentifier(factoryMethodName) || string.IsNullOrWhiteSpace(lockName) || leaseMs <= 0) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidConfiguration, node.Identifier.GetLocation(), type.Name)); + return; + } + + context.AddSource($"{type.Name}.DistributedLock.g.cs", SourceText.From( + GenerateSource(type, keyType, factoryMethodName, lockName, leaseMs), + Encoding.UTF8)); + } + + private static string GenerateSource(INamedTypeSymbol type, INamedTypeSymbol keyType, string factoryMethodName, string lockName, int leaseDurationMilliseconds) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var keyTypeName = keyType.ToDisplayString(TypeFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + var indent = string.Empty; + foreach (var containingType in GetContainingTypes(type)) + { + AppendTypeDeclaration(sb, containingType, indent); + sb.Append(indent).AppendLine("{"); + indent += " "; + } + + AppendTypeDeclaration(sb, type, indent); + sb.Append(indent).AppendLine("{"); + var memberIndent = indent + " "; + var bodyIndent = memberIndent + " "; + sb.Append(memberIndent).Append("public static global::PatternKit.Cloud.DistributedLocks.DistributedLock<").Append(keyTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.Append(memberIndent).AppendLine("{"); + sb.Append(bodyIndent).Append("return global::PatternKit.Cloud.DistributedLocks.DistributedLock<").Append(keyTypeName).Append(">.Create(\"").Append(Escape(lockName)).AppendLine("\")"); + sb.Append(bodyIndent).Append(" .LeaseDuration(global::System.TimeSpan.FromMilliseconds(").Append(leaseDurationMilliseconds).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 "); + if (type.IsRecord) + sb.Append(type.TypeKind == TypeKind.Struct ? "record struct" : "record class"); + else + sb.Append(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 int? GetNamedInt(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as int?; + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + 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 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/DistributedLockDemo/OrderAllocationDistributedLockDemoTests.cs b/test/PatternKit.Examples.Tests/DistributedLockDemo/OrderAllocationDistributedLockDemoTests.cs new file mode 100644 index 00000000..5f6a6f98 --- /dev/null +++ b/test/PatternKit.Examples.Tests/DistributedLockDemo/OrderAllocationDistributedLockDemoTests.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Cloud.DistributedLocks; +using PatternKit.Examples.DistributedLockDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.DistributedLockDemo; + +[Feature("Order allocation distributed lock demo")] +public sealed class OrderAllocationDistributedLockDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent and generated distributed lock paths protect order allocation")] + [Fact] + public Task Fluent_And_Generated_Distributed_Lock_Paths_Protect_Order_Allocation() + => Given("fluent and generated order allocation lock workflows", () => new + { + Fluent = OrderAllocationDistributedLockDemoRunner.RunFluent(), + Generated = new OrderAllocationLockWorkflow(GeneratedOrderAllocationDistributedLock.Create()) + .Allocate(new("ORDER-200", "allocator-b", 2)) + }) + .Then("both workflows acquire block contenders and release the order lock", result => + { + ScenarioExpect.True(result.Fluent.Acquired); + ScenarioExpect.True(result.Fluent.BlockedWhileActive); + ScenarioExpect.True(result.Fluent.Released); + ScenarioExpect.True(result.Generated.Acquired); + ScenarioExpect.True(result.Generated.BlockedWhileActive); + ScenarioExpect.True(result.Generated.Released); + }) + .AssertPassed(); + + [Scenario("Distributed lock example imports through IServiceCollection")] + [Fact] + public Task Distributed_Lock_Example_Imports_Through_IServiceCollection() + => Given("a service collection with the order allocation distributed lock demo", () => + { + var services = new ServiceCollection(); + services.AddOrderAllocationDistributedLockDemo(); + return services.BuildServiceProvider(); + }) + .When("the runner is resolved and executed", provider => new + { + Lock = provider.GetRequiredService>(), + Summary = provider.GetRequiredService().RunGenerated() + }) + .Then("the container owns the generated lock and workflow", result => + { + ScenarioExpect.Equal("order-allocation-lock", result.Lock.Name); + ScenarioExpect.True(result.Summary.Acquired); + ScenarioExpect.True(result.Summary.Released); + }) + .AssertPassed(); + + [Scenario("Order allocation workflow reports blocked resources")] + [Fact] + public Task Order_Allocation_Workflow_Reports_Blocked_Resources() + => Given("an order allocation workflow with an active lease", () => + { + var mutex = GeneratedOrderAllocationDistributedLock.Create(); + mutex.TryAcquire("ORDER-300", "allocator-a"); + return new OrderAllocationLockWorkflow(mutex); + }) + .When("another worker attempts to allocate the same order", workflow => new + { + Workflow = workflow, + Summary = workflow.Allocate(new("ORDER-300", "allocator-b", 1)) + }) + .Then("the workflow reports the allocation as blocked", summary => + { + ScenarioExpect.False(summary.Summary.Acquired); + ScenarioExpect.False(summary.Summary.Released); + ScenarioExpect.True(summary.Summary.BlockedWhileActive); + }) + .And("null requests are rejected", result => + ScenarioExpect.Throws(() => result.Workflow.Allocate(null!))) + .AssertPassed(); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs index 2abef9df..6ca3bc38 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("476 pattern route results", ctx.ResultsGuide)) + ScenarioExpect.Contains("480 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 == "DistributedLock") + return "Distributed Lock / Lease"; + if (patternName == "EventSourcing") return "Event Sourcing"; diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 4260eaef..32792250 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -103,6 +103,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Backends for Frontends", "Ambassador", "Leader Election", + "Distributed Lock / Lease", "Scheduler Agent Supervisor", "CQRS", "Aggregate Root", @@ -176,7 +177,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(20, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); + ScenarioExpect.Equal(21, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); ScenarioExpect.Equal(28, 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 69b71325..3e305032 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -20,6 +20,7 @@ using PatternKit.Generators.ContextMaps; using PatternKit.Generators.DataMapping; using PatternKit.Generators.Decorator; +using PatternKit.Generators.DistributedLocks; using PatternKit.Generators.DomainEvents; using PatternKit.Generators.DomainServices; using PatternKit.Generators.EventCarriedStateTransfer; @@ -135,6 +136,7 @@ private enum TestTrigger { typeof(GenerateDataMapperAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(DataMapperToDataAttribute), AttributeTargets.Method, false, false }, { typeof(DataMapperToDomainAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateDistributedLockAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GenerateDomainEventDispatcherAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(DomainEventHandlerAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateDomainServiceRegistryAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, @@ -416,6 +418,24 @@ public void AntiCorruption_Attributes_Expose_Defaults_And_Validation() ScenarioExpect.IsType(new AntiCorruptionTranslatorAttribute()); } + [Scenario("Distributed Lock Attributes Expose Defaults And Configuration")] + [Fact] + public void Distributed_Lock_Attributes_Expose_Defaults_And_Configuration() + { + var distributedLock = new GenerateDistributedLockAttribute(typeof(string)) + { + FactoryMethodName = "BuildLock", + LockName = "orders-lock", + LeaseDurationMilliseconds = 5000 + }; + + ScenarioExpect.Equal(typeof(string), distributedLock.KeyType); + ScenarioExpect.Equal("BuildLock", distributedLock.FactoryMethodName); + ScenarioExpect.Equal("orders-lock", distributedLock.LockName); + ScenarioExpect.Equal(5000, distributedLock.LeaseDurationMilliseconds); + ScenarioExpect.Throws(() => new GenerateDistributedLockAttribute(null!)); + } + [Scenario("Activity Tracker Attributes Expose Defaults And Configuration")] [Fact] public void ActivityTracker_Attributes_Expose_Defaults_And_Configuration() diff --git a/test/PatternKit.Generators.Tests/DistributedLockGeneratorTests.cs b/test/PatternKit.Generators.Tests/DistributedLockGeneratorTests.cs new file mode 100644 index 00000000..5d761b32 --- /dev/null +++ b/test/PatternKit.Generators.Tests/DistributedLockGeneratorTests.cs @@ -0,0 +1,197 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Cloud.DistributedLocks; +using PatternKit.Generators.DistributedLocks; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Distributed Lock generator")] +public sealed partial class DistributedLockGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates distributed lock factory")] + [Fact] + public Task Generates_Distributed_Lock_Factory() + => Given("a distributed lock declaration", () => Compile(""" + using PatternKit.Generators.DistributedLocks; + namespace Demo; + [GenerateDistributedLock(typeof(string), FactoryMethodName = "Build", LockName = "orders-lock", LeaseDurationMilliseconds = 5000)] + public static partial class OrderLocks; + """)) + .Then("the generated source creates the configured lock", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("public static partial class OrderLocks", source); + ScenarioExpect.Contains("DistributedLock Build()", source); + ScenarioExpect.Contains("DistributedLock.Create(\"orders-lock\")", source); + ScenarioExpect.Contains(".LeaseDuration(global::System.TimeSpan.FromMilliseconds(5000))", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid distributed lock declarations")] + [Fact] + public Task Reports_Diagnostics_For_Invalid_Distributed_Lock_Declarations() + => Given("invalid distributed lock declarations", () => new[] + { + Compile(""" + using PatternKit.Generators.DistributedLocks; + [GenerateDistributedLock(typeof(string))] + public static class OrderLocks; + """), + Compile(""" + using PatternKit.Generators.DistributedLocks; + [GenerateDistributedLock(typeof(string), FactoryMethodName = "")] + public static partial class OrderLocks; + """), + Compile(""" + using PatternKit.Generators.DistributedLocks; + [GenerateDistributedLock(typeof(string), FactoryMethodName = "1x")] + public static partial class OrderLocks; + """), + Compile(""" + using PatternKit.Generators.DistributedLocks; + [GenerateDistributedLock(typeof(string), FactoryMethodName = "class")] + public static partial class OrderLocks; + """), + Compile(""" + using PatternKit.Generators.DistributedLocks; + [GenerateDistributedLock(typeof(string), LockName = " ")] + public static partial class OrderLocks; + """), + Compile(""" + using PatternKit.Generators.DistributedLocks; + [GenerateDistributedLock(typeof(string), LeaseDurationMilliseconds = 0)] + public static partial class OrderLocks; + """) + }) + .Then("diagnostics identify the invalid declarations", results => + { + ScenarioExpect.Contains(results[0].Diagnostics, diagnostic => diagnostic.Id == "PKDLOCK001"); + ScenarioExpect.Contains(results[1].Diagnostics, diagnostic => diagnostic.Id == "PKDLOCK002"); + ScenarioExpect.Contains(results[2].Diagnostics, diagnostic => diagnostic.Id == "PKDLOCK002"); + ScenarioExpect.Contains(results[3].Diagnostics, diagnostic => diagnostic.Id == "PKDLOCK002"); + ScenarioExpect.Contains(results[4].Diagnostics, diagnostic => diagnostic.Id == "PKDLOCK002"); + ScenarioExpect.Contains(results[5].Diagnostics, diagnostic => diagnostic.Id == "PKDLOCK002"); + }) + .AssertPassed(); + + [Scenario("Generates distributed lock defaults and nested host wrappers")] + [Fact] + public Task Generates_Distributed_Lock_Defaults_And_Nested_Host_Wrappers() + => Given("nested distributed lock declarations", () => Compile(""" + using PatternKit.Generators.DistributedLocks; + namespace Demo; + public partial class FulfillmentModule + { + private partial class Locks + { + [GenerateDistributedLock(typeof(System.Guid), LockName = "order\\\"lock")] + private sealed partial class OrderLocks; + + [GenerateDistributedLock(typeof(System.Guid))] + protected partial class ProtectedLocks; + + [GenerateDistributedLock(typeof(System.Guid))] + private protected partial class PrivateProtectedLocks; + + [GenerateDistributedLock(typeof(System.Guid))] + protected internal partial class ProtectedInternalLocks; + } + } + """)) + .Then("generated sources preserve containing partial type wrappers", result => + { + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Equal(4, result.GeneratedSources.Count); + var source = string.Join(Environment.NewLine, result.GeneratedSources); + ScenarioExpect.Contains("public partial class FulfillmentModule", source); + ScenarioExpect.Contains("private partial class Locks", source); + ScenarioExpect.Contains("private sealed partial class OrderLocks", source); + ScenarioExpect.Contains("protected partial class ProtectedLocks", source); + ScenarioExpect.Contains("private protected partial class PrivateProtectedLocks", source); + ScenarioExpect.Contains("protected internal partial class ProtectedInternalLocks", source); + ScenarioExpect.Contains("DistributedLock.Create(\"order\\\\\\\"lock\")", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Skips distributed lock generation for malformed key type")] + [Fact] + public Task Skips_Distributed_Lock_Generation_For_Malformed_Key_Type() + => Given("a distributed lock declaration with an unresolved key type", () => Compile(""" + using PatternKit.Generators.DistributedLocks; + [GenerateDistributedLock(typeof(MissingKey))] + public static partial class MissingLocks; + """)) + .Then("no generated source is produced by the generator", result => + { + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Empty(result.GeneratedSources); + ScenarioExpect.False(result.EmitSuccess); + }) + .AssertPassed(); + + [Scenario("Generates distributed lock factory for abstract internal hosts")] + [Fact] + public Task Generates_Distributed_Lock_Factory_For_Abstract_Internal_Hosts() + => Given("an abstract internal distributed lock host", () => Compile(""" + using PatternKit.Generators.DistributedLocks; + [GenerateDistributedLock(typeof(int))] + internal abstract partial class InternalOrderLocks; + """)) + .Then("the generated source preserves the host shape", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("internal abstract partial class InternalOrderLocks", source); + ScenarioExpect.Contains("DistributedLock.Create(\"distributed-lock\")", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Generates distributed lock factory for record hosts")] + [Fact] + public Task Generates_Distributed_Lock_Factory_For_Record_Hosts() + => Given("record distributed lock hosts", () => Compile(""" + using PatternKit.Generators.DistributedLocks; + public partial record class RecordOrderLocks + { + [GenerateDistributedLock(typeof(int))] + public partial record struct StructOrderLocks; + } + """)) + .Then("the generated source preserves record host shapes", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("public partial record class RecordOrderLocks", source); + ScenarioExpect.Contains("public partial record struct StructOrderLocks", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "DistributedLockGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(DistributedLock<>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new DistributedLockGenerator(), 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/Cloud/DistributedLocks/DistributedLockTests.cs b/test/PatternKit.Tests/Cloud/DistributedLocks/DistributedLockTests.cs new file mode 100644 index 00000000..949792e2 --- /dev/null +++ b/test/PatternKit.Tests/Cloud/DistributedLocks/DistributedLockTests.cs @@ -0,0 +1,167 @@ +using PatternKit.Cloud.DistributedLocks; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Cloud.DistributedLocks; + +[Feature("Distributed Lock")] +public sealed class DistributedLockTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Distributed lock acquires renews and releases leases")] + [Fact] + public Task Distributed_Lock_Acquires_Renews_And_Releases_Leases() + => Given("a distributed lock with a fixed clock", () => + { + var now = DateTimeOffset.Parse("2026-05-31T00:00:00Z"); + var mutex = DistributedLock.Create("orders-lock") + .LeaseDuration(TimeSpan.FromSeconds(5)) + .WithClock(() => now) + .Build(); + return new { Lock = mutex, Advance = new Action(delta => now = now.Add(delta)) }; + }) + .When("an owner acquires renews and releases a resource lease", ctx => + { + var acquired = ctx.Lock.TryAcquire("order-1", "worker-a"); + ctx.Advance(TimeSpan.FromSeconds(2)); + var renewed = ctx.Lock.Renew(acquired.Lease!); + var released = ctx.Lock.Release(renewed.Lease!); + return new { acquired, renewed, released, ctx.Lock }; + }) + .Then("each lock transition succeeds and clears the gate", result => + { + ScenarioExpect.True(result.acquired.Acquired); + ScenarioExpect.Equal("orders-lock", result.acquired.LockName); + ScenarioExpect.Equal("order-1", result.acquired.ResourceKey); + ScenarioExpect.Equal("worker-a", result.acquired.OwnerId); + ScenarioExpect.True(result.renewed.Renewed); + ScenarioExpect.True(result.renewed.Lease!.ExpiresAt > result.acquired.Lease!.ExpiresAt); + ScenarioExpect.True(result.released.Released); + ScenarioExpect.Equal(result.renewed.Lease.ExpiresAt, result.released.Lease!.ExpiresAt); + ScenarioExpect.False(result.Lock.IsBlocked); + ScenarioExpect.Equal(0, result.Lock.ActiveCount); + var state = result.Lock.GetState(); + ScenarioExpect.Equal("orders-lock", state.LockName); + ScenarioExpect.False(state.IsBlocked); + ScenarioExpect.Equal(0, state.ActiveCount); + ScenarioExpect.Empty(state.ActiveLeases); + }) + .AssertPassed(); + + [Scenario("Distributed lock captures a single clock value for lease timestamps")] + [Fact] + public Task Distributed_Lock_Captures_A_Single_Clock_Value_For_Lease_Timestamps() + => Given("a distributed lock with an advancing clock", () => + { + var now = DateTimeOffset.Parse("2026-05-31T00:00:00Z"); + return DistributedLock.Create("orders-lock") + .LeaseDuration(TimeSpan.FromSeconds(5)) + .WithClock(() => + { + var value = now; + now = now.AddSeconds(1); + return value; + }) + .Build(); + }) + .When("a lease is acquired and renewed", mutex => + { + var acquired = mutex.TryAcquire("order-1", "worker-a"); + var renewed = mutex.Renew(acquired.Lease!); + return new { acquired, renewed }; + }) + .Then("the expiration timestamps are derived from the same captured instant", result => + { + ScenarioExpect.Equal(result.acquired.Lease!.AcquiredAt.AddSeconds(5), result.acquired.Lease.ExpiresAt); + ScenarioExpect.Equal(result.acquired.Lease.AcquiredAt.AddSeconds(6), result.renewed.Lease!.ExpiresAt); + }) + .AssertPassed(); + + [Scenario("Distributed lock blocks contention until lease expiry")] + [Fact] + public Task Distributed_Lock_Blocks_Contention_Until_Lease_Expiry() + => Given("a distributed lock with two owners", () => + { + var now = DateTimeOffset.Parse("2026-05-31T00:00:00Z"); + var mutex = DistributedLock.Create("orders-lock") + .LeaseDuration(TimeSpan.FromSeconds(1)) + .WithClock(() => now) + .Build(); + return new { Lock = mutex, Advance = new Action(delta => now = now.Add(delta)) }; + }) + .When("a second owner contends before and after expiry", ctx => + { + var first = ctx.Lock.TryAcquire("order-1", "worker-a"); + var blocked = ctx.Lock.TryAcquire("order-1", "worker-b"); + ctx.Advance(TimeSpan.FromSeconds(2)); + var afterExpiry = ctx.Lock.TryAcquire("order-1", "worker-b"); + return new { first, blocked, afterExpiry, ctx.Lock }; + }) + .Then("contention fails while the lease is active and succeeds after expiry", result => + { + ScenarioExpect.True(result.first.Acquired); + ScenarioExpect.True(result.blocked.Failed); + ScenarioExpect.Contains("worker-a", result.blocked.Exception!.Message); + ScenarioExpect.True(result.afterExpiry.Acquired); + ScenarioExpect.Equal("worker-b", result.Lock.Snapshot().Single().OwnerId); + }) + .AssertPassed(); + + [Scenario("Distributed lock validates inputs and stale leases")] + [Fact] + public Task Distributed_Lock_Validates_Inputs_And_Stale_Leases() + => Given("invalid distributed lock inputs", () => true) + .Then("invalid configuration and owner values are rejected", _ => + { + ScenarioExpect.Throws(() => DistributedLock.Create("").Build()); + ScenarioExpect.Throws(() => DistributedLock.Create().LeaseDuration(TimeSpan.Zero).Build()); + ScenarioExpect.Throws(() => DistributedLock.Create().WithClock(null!)); + ScenarioExpect.Throws(() => DistributedLock.Create().WithKeyComparer(null!)); + ScenarioExpect.Throws(() => DistributedLock.Create().Build().TryAcquire("order-1", "")); + ScenarioExpect.Throws(() => DistributedLock.Create().Build().TryAcquire("order-1", "worker-a", TimeSpan.Zero)); + }) + .And("stale or mismatched leases cannot renew or release active locks", _ => + { + var mutex = DistributedLock.Create().Build(); + ScenarioExpect.Throws(() => mutex.Renew(null!)); + ScenarioExpect.Throws(() => mutex.Release(null!)); + var acquired = mutex.TryAcquire("order-1", "worker-a"); + var mismatched = new DistributedLockRecord("order-1", "worker-b", acquired.Lease!.Token, acquired.Lease.AcquiredAt, acquired.Lease.ExpiresAt); + ScenarioExpect.True(mutex.Renew(mismatched).Failed); + ScenarioExpect.True(mutex.Release(mismatched).Failed); + ScenarioExpect.True(mutex.Release(acquired.Lease).Released); + ScenarioExpect.True(mutex.Renew(acquired.Lease).Failed); + ScenarioExpect.True(mutex.Release(acquired.Lease).Failed); + }) + .And("records states and results guard required values", _ => + { + ScenarioExpect.Throws(() => new DistributedLockRecord("order-1", "", "token", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow)); + ScenarioExpect.Throws(() => new DistributedLockRecord("order-1", "owner", "", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow)); + ScenarioExpect.Throws(() => DistributedLockResult.Failure("lock", "order-1", "owner", null!)); + ScenarioExpect.Throws(() => new DistributedLockState("lock", false, 0, null!)); + }) + .AssertPassed(); + + [Scenario("Distributed lock honors custom key comparers")] + [Fact] + public Task Distributed_Lock_Honors_Custom_Key_Comparers() + => Given("a distributed lock with a case-insensitive key comparer", () => + DistributedLock.Create("orders-lock") + .WithKeyComparer(StringComparer.OrdinalIgnoreCase) + .Build()) + .When("a lease is acquired with one key casing and released with the returned lease", mutex => + { + var acquired = mutex.TryAcquire("ORDER-1", "worker-a"); + var blocked = mutex.TryAcquire("order-1", "worker-b"); + var released = mutex.Release(acquired.Lease!); + return new { acquired, blocked, released, mutex }; + }) + .Then("the comparer controls contention and release succeeds", result => + { + ScenarioExpect.True(result.acquired.Acquired); + ScenarioExpect.True(result.blocked.Failed); + ScenarioExpect.True(result.released.Released); + ScenarioExpect.False(result.mutex.IsLocked("order-1")); + }) + .AssertPassed(); +}