diff --git a/README.md b/README.md index bc0a06f3..59974570 100644 --- a/README.md +++ b/README.md @@ -473,13 +473,13 @@ var cachedRemoteProxy = Proxy.Create(id => remoteProxy.Execute(id)) --- ## Patterns Table -PatternKit currently tracks 108 production-readiness patterns. Each catalog pattern is represented in tests, documentation, real-world examples, IoC integration, and the BenchmarkDotNet coverage matrix. +PatternKit currently tracks 109 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 | 23 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Feature Toggle, Identity Map, Manual Task Gate, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object | | Behavioral | 11 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor | -| Cloud Architecture | 17 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig | +| Cloud Architecture | 18 | 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, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig | | Creational | 5 | Abstract Factory, Builder, Factory Method, Prototype, Singleton | | Enterprise Integration | 41 | Aggregator, Canonical Data Model, 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 | 3 | Idempotent Receiver, Inbox, Outbox | @@ -519,6 +519,8 @@ BenchmarkDotNet guidance is documented in [docs/guides/benchmarks.md](docs/guide | Bridge | Execution | 91.848 ns | 664 B | 30.004 ns | 160 B | Generated bridge forwarding was faster and allocated less for notice rendering. | | Bulkhead | Construction | 20.56 ns | 216 B | 20.48 ns | 216 B | Effectively equivalent for this microbenchmark. | | Bulkhead | Execution | 102.70 ns | 592 B | 106.11 ns | 592 B | Same allocation; fluent was slightly faster for the shipping allocation workflow. | +| Cache Stampede Protection | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Cache Stampede Protection | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Cache-Aside | Construction | 19.91 ns | 200 B | 19.85 ns | 200 B | Effectively equivalent for this microbenchmark. | | Cache-Aside | Execution | 216.50 ns | 1,048 B | 208.60 ns | 1,048 B | Same allocation; generated was slightly faster for the miss-then-hit workflow. | | Canonical Data Model | Construction | 75.482 ns | 632 B | 59.947 ns | 496 B | Generated reduced construction time and allocation in this microbenchmark. | diff --git a/benchmarks/PatternKit.Benchmarks/Cloud/CacheStampedeProtectionBenchmarks.cs b/benchmarks/PatternKit.Benchmarks/Cloud/CacheStampedeProtectionBenchmarks.cs new file mode 100644 index 00000000..788b9ded --- /dev/null +++ b/benchmarks/PatternKit.Benchmarks/Cloud/CacheStampedeProtectionBenchmarks.cs @@ -0,0 +1,31 @@ +using BenchmarkDotNet.Attributes; +using PatternKit.Cloud.CacheStampedeProtection; +using PatternKit.Examples.CacheStampedeProtectionDemo; + +namespace PatternKit.Benchmarks.Cloud; + +[BenchmarkCategory("Cloud", "CacheStampedeProtection")] +public class CacheStampedeProtectionBenchmarks +{ + private static readonly ProductAvailabilityRequest Request = new("SKU-100", "us"); + + [Benchmark(Baseline = true, Description = "Fluent: create cache stampede protection policy")] + [BenchmarkCategory("Fluent", "Construction")] + public CacheStampedeProtectionPolicy Fluent_CreatePolicy() + => ProductCatalogStampedeProtectionPolicies.CreateFluent(); + + [Benchmark(Description = "Generated: create cache stampede protection policy")] + [BenchmarkCategory("Generated", "Construction")] + public CacheStampedeProtectionPolicy Generated_CreatePolicy() + => GeneratedProductCatalogStampedeProtectionPolicy.CreateGenerated(); + + [Benchmark(Description = "Fluent: share product catalog load")] + [BenchmarkCategory("Fluent", "Execution")] + public IReadOnlyList Fluent_ShareProductCatalogLoad() + => ProductCatalogStampedeProtectionDemoRunner.RunFluentAsync(Request).AsTask().GetAwaiter().GetResult(); + + [Benchmark(Description = "Generated: share product catalog load")] + [BenchmarkCategory("Generated", "Execution")] + public IReadOnlyList Generated_ShareProductCatalogLoad() + => ProductCatalogStampedeProtectionDemoRunner.RunGeneratedStaticAsync(Request).AsTask().GetAwaiter().GetResult(); +} diff --git a/docs/examples/product-catalog-cache-stampede-protection.md b/docs/examples/product-catalog-cache-stampede-protection.md new file mode 100644 index 00000000..bb018883 --- /dev/null +++ b/docs/examples/product-catalog-cache-stampede-protection.md @@ -0,0 +1,23 @@ +# Product Catalog Cache Stampede Protection + +This example protects product availability reads from duplicate origin loads when two requests miss the same catalog key at the same time. + +```csharp +var request = new ProductAvailabilityRequest("SKU-100", "us"); +var results = await ProductCatalogStampedeProtectionDemoRunner.RunFluentAsync(request); +``` + +The generated route uses the same workflow through a generated policy factory: + +```csharp +var policy = GeneratedProductCatalogStampedeProtectionPolicy.CreateGenerated(); +var service = new ProductCatalogStampedeProtectionService(policy, origin); +``` + +Import the demo into a host with: + +```csharp +services.AddProductCatalogStampedeProtectionDemo(); +``` + +The registration provides `CacheStampedeProtectionPolicy`, `ProductCatalogOrigin`, `ProductCatalogStampedeProtectionService`, and `ProductCatalogStampedeProtectionDemoRunner`. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index d1a1e2bc..a4ce05cb 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -280,6 +280,9 @@ - name: Product Catalog Cache-Aside href: product-catalog-cache-aside.md +- name: Product Catalog Cache Stampede Protection + href: product-catalog-cache-stampede-protection.md + - name: Product Search Rate Limiting href: product-search-rate-limiting.md diff --git a/docs/generators/cache-stampede-protection.md b/docs/generators/cache-stampede-protection.md new file mode 100644 index 00000000..de593a53 --- /dev/null +++ b/docs/generators/cache-stampede-protection.md @@ -0,0 +1,18 @@ +# Cache Stampede Protection Generator + +The Cache Stampede Protection generator creates a strongly typed factory for `CacheStampedeProtectionPolicy` from a partial host type. + +```csharp +using PatternKit.Generators.CacheStampedeProtection; + +[GenerateCacheStampedeProtection(typeof(ProductAvailabilitySnapshot), FactoryMethodName = "CreateGenerated", PolicyName = "product-catalog-single-flight")] +public static partial class GeneratedProductCatalogStampedeProtectionPolicy; +``` + +Generated usage: + +```csharp +var policy = GeneratedProductCatalogStampedeProtectionPolicy.CreateGenerated(); +``` + +The host type must be partial. `FactoryMethodName` and `PolicyName` must be non-empty when provided. diff --git a/docs/generators/index.md b/docs/generators/index.md index 84d9dbd9..fb8ec278 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -132,6 +132,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Health Endpoint Monitoring**](health-endpoint-monitoring.md) | Typed service health endpoint factories | `[GenerateHealthEndpoint]` | | [**Priority Queue**](priority-queue.md) | Business-priority queue factories | `[GeneratePriorityQueue]` | | [**Cache-Aside**](cache-aside.md) | Read-through cache policy factories with TTL and cache predicates | `[GenerateCacheAsidePolicy]` | +| [**Cache Stampede Protection**](cache-stampede-protection.md) | Keyed single-flight policy factories for suppressing duplicate cache-miss loads | `[GenerateCacheStampedeProtection]` | | [**Rate Limiting**](rate-limiting.md) | Key-partitioned fixed-window rate limit policy factories | `[GenerateRateLimitPolicy]` | | [**External Configuration Store**](external-configuration-store.md) | Typed centralized configuration loaders | `[GenerateExternalConfigurationStore]` | | [**Gateway Aggregation**](gateway-aggregation.md) | API gateway response composition factories | `[GenerateGatewayAggregation]` | diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index fd5d5b3c..0777eecb 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -31,6 +31,9 @@ - name: Cache-Aside href: cache-aside.md +- name: Cache Stampede Protection + href: cache-stampede-protection.md + - name: Canonical Data Model href: canonical-data-model.md diff --git a/docs/guides/benchmark-results.md b/docs/guides/benchmark-results.md index 3ac27ffa..03830a30 100644 --- a/docs/guides/benchmark-results.md +++ b/docs/guides/benchmark-results.md @@ -39,6 +39,8 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 | Bridge | Execution | 91.848 ns | 664 B | 30.004 ns | 160 B | Generated bridge forwarding was faster and allocated less for notice rendering. | | Bulkhead | Construction | 20.56 ns | 216 B | 20.48 ns | 216 B | Effectively equivalent for this microbenchmark. | | Bulkhead | Execution | 102.70 ns | 592 B | 106.11 ns | 592 B | Same allocation; fluent was slightly faster for the shipping allocation workflow. | +| Cache Stampede Protection | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Cache Stampede Protection | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Cache-Aside | Construction | 19.91 ns | 200 B | 19.85 ns | 200 B | Effectively equivalent for this microbenchmark. | | Cache-Aside | Execution | 216.50 ns | 1,048 B | 208.60 ns | 1,048 B | Same allocation; generated was slightly faster for the miss-then-hit workflow. | | Canonical Data Model | Construction | 75.482 ns | 632 B | 59.947 ns | 496 B | Generated reduced construction time and allocation in this microbenchmark. | @@ -234,19 +236,19 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 ## Coverage Matrix Summary -The coverage matrix currently publishes 108 catalog patterns and 432 pattern route results. Each pattern has four BenchmarkDotNet routes: fluent construction, fluent execution, source-generated construction, and source-generated execution. The reusable hosting integration matrix publishes 9 reusable hosting integration route results for package-level `IServiceCollection` registrations. +The coverage matrix currently publishes 109 catalog patterns and 436 pattern route results. Each pattern has four BenchmarkDotNet routes: fluent construction, fluent execution, source-generated construction, and source-generated execution. The reusable hosting integration matrix publishes 9 reusable hosting integration route results for package-level `IServiceCollection` registrations. | Category | Patterns | Published route results | | --- | ---: | ---: | | Application Architecture | 23 | 92 | | Behavioral | 11 | 44 | -| Cloud Architecture | 17 | 68 | +| Cloud Architecture | 18 | 72 | | Creational | 5 | 20 | | Enterprise Integration | 41 | 164 | | Messaging Reliability | 3 | 12 | | Structural | 7 | 28 | -The generator matrix currently publishes 103 generator source route results. +The generator matrix currently publishes 104 generator source route results. ## Hosting Integration Matrix Results @@ -301,9 +303,10 @@ The generator matrix currently publishes 103 generator source route results. | Behavioral | Template Method | Covered | Covered | Covered | Covered | | Behavioral | Visitor | Covered | Covered | Covered | Covered | | Cloud Architecture | Ambassador | Covered | Covered | Covered | Covered | -| Cloud Architecture | Backends for Frontends | Covered | Covered | Covered | Covered | -| Cloud Architecture | Bulkhead | Covered | Covered | Covered | Covered | -| Cloud Architecture | Cache-Aside | Covered | Covered | Covered | Covered | +| Cloud Architecture | Backends for Frontends | Covered | Covered | Covered | Covered | +| 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 | Gateway Aggregation | Covered | Covered | Covered | Covered | @@ -389,8 +392,9 @@ The generator matrix currently publishes 103 generator source route results. | BackendsForFrontendsGenerator | `src/PatternKit.Generators/BackendsForFrontends/BackendsForFrontendsGenerator.cs` | Covered | | BridgeGenerator | `src/PatternKit.Generators/Bridge/BridgeGenerator.cs` | Covered | | BuilderGenerator | `src/PatternKit.Generators/Builders/BuilderGenerator.cs` | Covered | -| BulkheadPolicyGenerator | `src/PatternKit.Generators/Bulkhead/BulkheadPolicyGenerator.cs` | Covered | -| CacheAsidePolicyGenerator | `src/PatternKit.Generators/CacheAside/CacheAsidePolicyGenerator.cs` | Covered | +| BulkheadPolicyGenerator | `src/PatternKit.Generators/Bulkhead/BulkheadPolicyGenerator.cs` | Covered | +| CacheAsidePolicyGenerator | `src/PatternKit.Generators/CacheAside/CacheAsidePolicyGenerator.cs` | Covered | +| CacheStampedeProtectionGenerator | `src/PatternKit.Generators/CacheStampedeProtection/CacheStampedeProtectionGenerator.cs` | Covered | | CanonicalDataModelGenerator | `src/PatternKit.Generators/CanonicalDataModel/CanonicalDataModelGenerator.cs` | Covered | | ChainGenerator | `src/PatternKit.Generators/Chain/ChainGenerator.cs` | Covered | | CircuitBreakerPolicyGenerator | `src/PatternKit.Generators/CircuitBreaker/CircuitBreakerPolicyGenerator.cs` | Covered | diff --git a/docs/guides/benchmarks.md b/docs/guides/benchmarks.md index f1592b32..e1f16764 100644 --- a/docs/guides/benchmarks.md +++ b/docs/guides/benchmarks.md @@ -56,6 +56,8 @@ The following numbers were captured on Windows 11, Intel Core i9-14900K, .NET SD | Bridge | Execution | 91.848 ns | 664 B | 30.004 ns | 160 B | Generated bridge forwarding was faster and allocated less for notice rendering. | | Bulkhead | Construction | 20.56 ns | 216 B | 20.48 ns | 216 B | Effectively equivalent for this microbenchmark. | | Bulkhead | Execution | 102.70 ns | 592 B | 106.11 ns | 592 B | Same allocation; fluent was slightly faster for the shipping allocation workflow. | +| Cache Stampede Protection | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Cache Stampede Protection | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Cache-Aside | Construction | 19.91 ns | 200 B | 19.85 ns | 200 B | Effectively equivalent for this microbenchmark. | | Cache-Aside | Execution | 216.50 ns | 1,048 B | 208.60 ns | 1,048 B | Same allocation; generated was slightly faster for the miss-then-hit workflow. | | Canonical Data Model | Construction | 75.482 ns | 632 B | 59.947 ns | 496 B | Generated reduced construction time and allocation in this microbenchmark. | diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 2ca9f034..cc27c6be 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -90,6 +90,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Cloud Architecture | Retry | `RetryPolicy` | Retry generator | | Cloud Architecture | Circuit Breaker | `CircuitBreakerPolicy` | Circuit Breaker generator | | Cloud Architecture | Bulkhead | `BulkheadPolicy` | Bulkhead generator | +| Cloud Architecture | Cache Stampede Protection | `CacheStampedeProtectionPolicy` | Cache Stampede Protection generator | | Cloud Architecture | Queue-Based Load Leveling | `QueueLoadLevelingPolicy` | Queue Load Leveling generator | | Cloud Architecture | Health Endpoint Monitoring | `HealthEndpoint` | Health Endpoint Monitoring generator | | Cloud Architecture | Priority Queue | `PriorityQueuePolicy` | Priority Queue generator | diff --git a/docs/index.md b/docs/index.md index c145ede7..e3d36b2a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,13 +66,13 @@ if (parser.Execute("123", out var value)) ## 📚 Available Patterns -PatternKit covers 108 production-readiness patterns with fluent APIs, source-generated routes where applicable, IoC integration examples, TinyBDD coverage, and BenchmarkDotNet coverage-matrix validation: +PatternKit covers 109 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 | 23 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Feature Toggle, Identity Map, Manual Task Gate, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object | | Behavioral | 11 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor | -| Cloud Architecture | 17 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig | +| Cloud Architecture | 18 | 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, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig | | Creational | 5 | Abstract Factory, Builder, Factory Method, Prototype, Singleton | | Enterprise Integration | 41 | Aggregator, Canonical Data Model, 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 | 3 | Idempotent Receiver, Inbox, Outbox | diff --git a/docs/patterns/cloud/cache-stampede-protection.md b/docs/patterns/cloud/cache-stampede-protection.md new file mode 100644 index 00000000..24731cb1 --- /dev/null +++ b/docs/patterns/cloud/cache-stampede-protection.md @@ -0,0 +1,25 @@ +# Cache Stampede Protection + +Cache Stampede Protection coordinates concurrent cache misses so only one origin load runs for a key while followers await the same result. Use it around expensive catalog, configuration, entitlement, pricing, or profile loads that can receive bursts of identical requests after expiration. + +`CacheStampedeProtectionPolicy` provides the fluent path: + +```csharp +var policy = CacheStampedeProtectionPolicy + .Create("product-catalog-single-flight") + .Build(); + +var result = await policy.GetOrLoadAsync( + "us:SKU-100", + ct => origin.LoadAsync(request, ct), + cancellationToken); +``` + +The first caller owns the load. Concurrent callers for the same key receive `SharedFlight = true` and the same loaded value once the origin call completes. + +Use the source-generated path for reusable policy factories: + +```csharp +[GenerateCacheStampedeProtection(typeof(ProductAvailabilitySnapshot), FactoryMethodName = "CreateGenerated", PolicyName = "product-catalog-single-flight")] +public static partial class GeneratedProductCatalogStampedeProtectionPolicy; +``` diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index fd05c254..7b50eae5 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -381,6 +381,8 @@ href: cloud/circuit-breaker.md - name: Bulkhead href: cloud/bulkhead.md + - name: Cache Stampede Protection + href: cloud/cache-stampede-protection.md - name: Queue-Based Load Leveling href: cloud/queue-load-leveling.md - name: Health Endpoint Monitoring diff --git a/src/PatternKit.Core/Cloud/CacheStampedeProtection/CacheStampedeProtectionPolicy.cs b/src/PatternKit.Core/Cloud/CacheStampedeProtection/CacheStampedeProtectionPolicy.cs new file mode 100644 index 00000000..8ed7c851 --- /dev/null +++ b/src/PatternKit.Core/Cloud/CacheStampedeProtection/CacheStampedeProtectionPolicy.cs @@ -0,0 +1,154 @@ +namespace PatternKit.Cloud.CacheStampedeProtection; + +/// +/// Coordinates keyed cache misses so only one loader per key runs at a time while followers await the shared result. +/// +public sealed class CacheStampedeProtectionPolicy +{ + private readonly object _gate = new(); + private readonly Dictionary>>> _inFlight = new(StringComparer.Ordinal); + + private CacheStampedeProtectionPolicy(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Cache stampede protection policy name is required.", nameof(name)); + + Name = name; + } + + public string Name { get; } + + public int InFlightCount + { + get + { + lock (_gate) + return _inFlight.Count; + } + } + + public static Builder Create(string name = "cache-stampede-protection") => new(name); + + public ValueTask> GetOrLoadAsync( + string key, + Func> loader, + CancellationToken cancellationToken = default) + { + ValidateKey(key); + if (loader is null) + throw new ArgumentNullException(nameof(loader)); + + cancellationToken.ThrowIfCancellationRequested(); + + Lazy>> lazy; + var shared = false; + lock (_gate) + { + if (!_inFlight.TryGetValue(key, out lazy!)) + { + lazy = new Lazy>>( + () => LoadAndReleaseAsync(key, loader), + LazyThreadSafetyMode.ExecutionAndPublication); + _inFlight.Add(key, lazy); + } + else + { + shared = true; + } + } + + return AwaitResultAsync(lazy.Value, shared, cancellationToken); + } + + private async Task> LoadAndReleaseAsync( + string key, + Func> loader) + { + try + { + var value = await loader(CancellationToken.None).ConfigureAwait(false); + return new CacheStampedeProtectionResult(key, value, sharedFlight: false); + } + finally + { + lock (_gate) + _inFlight.Remove(key); + } + } + + private static async ValueTask> AwaitResultAsync( + Task> task, + bool shared, + CancellationToken cancellationToken) + { + if (cancellationToken.CanBeCanceled) + await WhenCompletedOrCanceledAsync(task, cancellationToken).ConfigureAwait(false); + + var result = await task.ConfigureAwait(false); + return shared ? result.AsSharedFlight() : result; + } + + private static Task WhenCompletedOrCanceledAsync(Task task, CancellationToken cancellationToken) + { + if (task.IsCompleted) + return task; + + var cancellation = new TaskCompletionSource(); + var registration = cancellationToken.Register(static state => + { + var source = (TaskCompletionSource)state!; + source.TrySetCanceled(); + }, cancellation); + + return CompleteWhenAnyAsync(task, cancellation.Task, registration); + } + + private static async Task CompleteWhenAnyAsync(Task task, Task cancellation, CancellationTokenRegistration registration) + { + try + { + var completed = await Task.WhenAny(task, cancellation).ConfigureAwait(false); + await completed.ConfigureAwait(false); + } + finally + { + registration.Dispose(); + } + } + + private static void ValidateKey(string key) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("Cache stampede protection key is required.", nameof(key)); + } + + public sealed class Builder + { + private readonly string _name; + + internal Builder(string name) + => _name = name; + + public CacheStampedeProtectionPolicy Build() + => new(_name); + } +} + +public sealed class CacheStampedeProtectionResult +{ + public CacheStampedeProtectionResult(string key, TResult value, bool sharedFlight) + { + Key = key; + Value = value; + SharedFlight = sharedFlight; + } + + public string Key { get; } + + public TResult Value { get; } + + public bool SharedFlight { get; } + + internal CacheStampedeProtectionResult AsSharedFlight() + => SharedFlight ? this : new(Key, Value, sharedFlight: true); +} diff --git a/src/PatternKit.Examples/CacheStampedeProtectionDemo/ProductCatalogStampedeProtectionDemo.cs b/src/PatternKit.Examples/CacheStampedeProtectionDemo/ProductCatalogStampedeProtectionDemo.cs new file mode 100644 index 00000000..aa1bdd25 --- /dev/null +++ b/src/PatternKit.Examples/CacheStampedeProtectionDemo/ProductCatalogStampedeProtectionDemo.cs @@ -0,0 +1,96 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Cloud.CacheStampedeProtection; +using PatternKit.Generators.CacheStampedeProtection; + +namespace PatternKit.Examples.CacheStampedeProtectionDemo; + +public sealed record ProductAvailabilityRequest(string Sku, string Region); + +public sealed record ProductAvailabilitySnapshot(string Sku, string Region, int Available, DateTimeOffset LoadedAt); + +public sealed record ProductAvailabilitySummary(ProductAvailabilitySnapshot Snapshot, bool SharedFlight, int OriginLoadCount); + +public static partial class ProductCatalogStampedeProtectionPolicies +{ + public static CacheStampedeProtectionPolicy CreateFluent() + => CacheStampedeProtectionPolicy.Create("product-catalog-single-flight").Build(); +} + +[GenerateCacheStampedeProtection(typeof(ProductAvailabilitySnapshot), FactoryMethodName = "CreateGenerated", PolicyName = "product-catalog-single-flight")] +public static partial class GeneratedProductCatalogStampedeProtectionPolicy; + +public sealed class ProductCatalogOrigin +{ + private int _loads; + + public int LoadCount => Volatile.Read(ref _loads); + + public async ValueTask LoadAsync(ProductAvailabilityRequest request, CancellationToken cancellationToken) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + + Interlocked.Increment(ref _loads); + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + return new ProductAvailabilitySnapshot(request.Sku, request.Region, 42, DateTimeOffset.UtcNow); + } +} + +public sealed class ProductCatalogStampedeProtectionService( + CacheStampedeProtectionPolicy policy, + ProductCatalogOrigin origin) +{ + public async ValueTask GetAvailabilityAsync(ProductAvailabilityRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + + var key = $"{request.Region}:{request.Sku}"; + var result = await policy.GetOrLoadAsync( + key, + ct => origin.LoadAsync(request, ct), + cancellationToken).ConfigureAwait(false); + + return new(result.Value, result.SharedFlight, origin.LoadCount); + } +} + +public sealed class ProductCatalogStampedeProtectionDemoRunner(ProductCatalogStampedeProtectionService service) +{ + public async ValueTask> RunGeneratedAsync(ProductAvailabilityRequest request) + { + var first = service.GetAvailabilityAsync(request).AsTask(); + var second = service.GetAvailabilityAsync(request).AsTask(); + return await Task.WhenAll(first, second).ConfigureAwait(false); + } + + public static async ValueTask> RunFluentAsync(ProductAvailabilityRequest request) + { + var origin = new ProductCatalogOrigin(); + var service = new ProductCatalogStampedeProtectionService(ProductCatalogStampedeProtectionPolicies.CreateFluent(), origin); + var first = service.GetAvailabilityAsync(request).AsTask(); + var second = service.GetAvailabilityAsync(request).AsTask(); + return await Task.WhenAll(first, second).ConfigureAwait(false); + } + + public static async ValueTask> RunGeneratedStaticAsync(ProductAvailabilityRequest request) + { + var origin = new ProductCatalogOrigin(); + var service = new ProductCatalogStampedeProtectionService(GeneratedProductCatalogStampedeProtectionPolicy.CreateGenerated(), origin); + var first = service.GetAvailabilityAsync(request).AsTask(); + var second = service.GetAvailabilityAsync(request).AsTask(); + return await Task.WhenAll(first, second).ConfigureAwait(false); + } +} + +public static class ProductCatalogStampedeProtectionServiceCollectionExtensions +{ + public static IServiceCollection AddProductCatalogStampedeProtectionDemo(this IServiceCollection services) + { + services.AddSingleton(static _ => GeneratedProductCatalogStampedeProtectionPolicy.CreateGenerated()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 358288e2..b089991b 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -12,6 +12,7 @@ using PatternKit.Behavioral.TypeDispatcher; using PatternKit.Cloud.Bulkhead; using PatternKit.Cloud.CacheAside; +using PatternKit.Cloud.CacheStampedeProtection; using PatternKit.Cloud.CircuitBreaker; using PatternKit.Cloud.HealthEndpointMonitoring; using PatternKit.Cloud.PriorityQueue; @@ -32,6 +33,7 @@ using PatternKit.Examples.BoundedContextDemo; using PatternKit.Examples.BulkheadDemo; using PatternKit.Examples.CacheAsideDemo; +using PatternKit.Examples.CacheStampedeProtectionDemo; using PatternKit.Examples.CanonicalDataModelDemo; using PatternKit.Examples.Chain; using PatternKit.Examples.Chain.ConfigDriven; @@ -238,6 +240,7 @@ public sealed record FulfillmentQueueLoadLevelingExample(QueueLoadLevelingPolicy public sealed record FulfillmentHealthEndpointExample(HealthEndpoint Endpoint, FulfillmentHealthEndpointService Service); public sealed record FulfillmentPriorityQueueExample(PriorityQueuePolicy Queue, FulfillmentPriorityQueueService Service); public sealed record ProductCatalogCacheAsideExample(CacheAsidePolicy Policy, ProductCatalogCacheAsideService Service); +public sealed record ProductCatalogStampedeProtectionExample(CacheStampedeProtectionPolicy Policy, ProductCatalogStampedeProtectionDemoRunner Runner); public sealed record ProductSearchRateLimitingExample(RateLimitPolicy Policy, ProductSearchRateLimitService Service); public sealed record TenantExternalConfigurationStoreExample(TenantExternalConfigurationStoreDemoRunner Runner, TenantExternalConfigurationService Service); public sealed record CustomerDashboardGatewayAggregationExample(CustomerDashboardGatewayAggregationDemoRunner Runner, CustomerDashboardGatewayService Service); @@ -354,6 +357,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddFulfillmentHealthEndpointExample() .AddFulfillmentPriorityQueueExample() .AddProductCatalogCacheAsideExample() + .AddProductCatalogStampedeProtectionExample() .AddProductSearchRateLimitingExample() .AddTenantExternalConfigurationStoreExample() .AddCustomerDashboardGatewayAggregationExample() @@ -1222,6 +1226,15 @@ public static IServiceCollection AddProductCatalogCacheAsideExample(this IServic return services.RegisterExample("Product Catalog Cache-Aside", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddProductCatalogStampedeProtectionExample(this IServiceCollection services) + { + services.AddProductCatalogStampedeProtectionDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService())); + return services.RegisterExample("Product Catalog Cache Stampede Protection", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddProductSearchRateLimitingExample(this IServiceCollection services) { services.AddProductSearchRateLimitingDemo(); diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 17a8acf4..a11ca04c 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -864,6 +864,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, ["Cache-Aside"], ["read-through cache miss handling", "source-generated policy factory", "DI composition"]), + Descriptor( + "Product Catalog Cache Stampede Protection", + "src/PatternKit.Examples/CacheStampedeProtectionDemo/ProductCatalogStampedeProtectionDemo.cs", + "test/PatternKit.Examples.Tests/CacheStampedeProtectionDemo/ProductCatalogStampedeProtectionDemoTests.cs", + "docs/examples/product-catalog-cache-stampede-protection.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["Cache Stampede Protection"], + ["keyed single-flight load coordination", "source-generated policy factory", "DI composition"]), Descriptor( "Product Search Rate Limiting", "src/PatternKit.Examples/RateLimitingDemo/ProductSearchRateLimitingDemo.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 05ceee53..2f7b531c 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -1026,6 +1026,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/CacheAsideDemo/ProductCatalogCacheAsideDemoTests.cs", ["fluent cache-aside policy", "generated cache-aside policy", "DI-importable product catalog read model example"]), + Pattern("Cache Stampede Protection", PatternFamily.CloudArchitecture, + "docs/patterns/cloud/cache-stampede-protection.md", + "src/PatternKit.Core/Cloud/CacheStampedeProtection/CacheStampedeProtectionPolicy.cs", + "test/PatternKit.Tests/Cloud/CacheStampedeProtection/CacheStampedeProtectionPolicyTests.cs", + "docs/generators/cache-stampede-protection.md", + "src/PatternKit.Generators/CacheStampedeProtection/CacheStampedeProtectionGenerator.cs", + "test/PatternKit.Generators.Tests/CacheStampedeProtectionGeneratorTests.cs", + null, + "docs/examples/product-catalog-cache-stampede-protection.md", + "src/PatternKit.Examples/CacheStampedeProtectionDemo/ProductCatalogStampedeProtectionDemo.cs", + "test/PatternKit.Examples.Tests/CacheStampedeProtectionDemo/ProductCatalogStampedeProtectionDemoTests.cs", + ["fluent keyed single-flight policy", "generated cache stampede protection factory", "DI-importable product catalog example"]), + Pattern("Rate Limiting", PatternFamily.CloudArchitecture, "docs/patterns/cloud/rate-limiting.md", "src/PatternKit.Core/Cloud/RateLimiting/RateLimitPolicy.cs", diff --git a/src/PatternKit.Generators.Abstractions/CacheStampedeProtection/CacheStampedeProtectionAttributes.cs b/src/PatternKit.Generators.Abstractions/CacheStampedeProtection/CacheStampedeProtectionAttributes.cs new file mode 100644 index 00000000..caad4b69 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/CacheStampedeProtection/CacheStampedeProtectionAttributes.cs @@ -0,0 +1,11 @@ +namespace PatternKit.Generators.CacheStampedeProtection; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateCacheStampedeProtectionAttribute(Type resultType) : Attribute +{ + public Type ResultType { get; } = resultType ?? throw new ArgumentNullException(nameof(resultType)); + + public string FactoryMethodName { get; set; } = "Create"; + + public string PolicyName { get; set; } = "cache-stampede-protection"; +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index fdb9d26e..b3ed0811 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -433,3 +433,5 @@ PKTM001 | PatternKit.Generators.Timeouts | Error | Timeout Manager host must be PKTM002 | PatternKit.Generators.Timeouts | Error | Timeout Manager key type is invalid. PKMTG001 | PatternKit.Generators.ManualTaskGates | Error | Manual Task Gate host must be partial. PKMTG002 | PatternKit.Generators.ManualTaskGates | Error | Manual Task Gate configuration is invalid. +PKCSP001 | PatternKit.Generators.CacheStampedeProtection | Error | Cache Stampede Protection host must be partial. +PKCSP002 | PatternKit.Generators.CacheStampedeProtection | Error | Cache Stampede Protection configuration is invalid. diff --git a/src/PatternKit.Generators/CacheStampedeProtection/CacheStampedeProtectionGenerator.cs b/src/PatternKit.Generators/CacheStampedeProtection/CacheStampedeProtectionGenerator.cs new file mode 100644 index 00000000..ad2bc1de --- /dev/null +++ b/src/PatternKit.Generators/CacheStampedeProtection/CacheStampedeProtectionGenerator.cs @@ -0,0 +1,154 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace PatternKit.Generators.CacheStampedeProtection; + +[Generator] +public sealed class CacheStampedeProtectionGenerator : IIncrementalGenerator +{ + private const string AttributeName = "PatternKit.Generators.CacheStampedeProtection.GenerateCacheStampedeProtectionAttribute"; + + 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( + "PKCSP001", + "Cache Stampede Protection host must be partial", + "Type '{0}' is marked with [GenerateCacheStampedeProtection] but is not declared as partial", + "PatternKit.Generators.CacheStampedeProtection", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidConfiguration = new( + "PKCSP002", + "Cache Stampede Protection configuration is invalid", + "Cache Stampede Protection '{0}' must have non-empty FactoryMethodName and PolicyName values", + "PatternKit.Generators.CacheStampedeProtection", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + AttributeName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(static attribute => + attribute.AttributeClass?.ToDisplayString() == AttributeName); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate(SourceProductionContext context, INamedTypeSymbol type, TypeDeclarationSyntax node, AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var resultType = attribute.ConstructorArguments.Length >= 1 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + if (resultType is null || resultType.TypeKind == TypeKind.Error) + return; + + var factoryMethodName = GetNamedString(attribute, "FactoryMethodName") ?? "Create"; + var policyName = GetNamedString(attribute, "PolicyName") ?? "cache-stampede-protection"; + if (string.IsNullOrWhiteSpace(factoryMethodName) || string.IsNullOrWhiteSpace(policyName)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidConfiguration, node.Identifier.GetLocation(), type.Name)); + return; + } + + context.AddSource($"{type.Name}.CacheStampedeProtection.g.cs", SourceText.From( + GenerateSource(type, resultType, factoryMethodName, policyName), + Encoding.UTF8)); + } + + private static string GenerateSource(INamedTypeSymbol type, INamedTypeSymbol resultType, string factoryMethodName, string policyName) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var resultTypeName = resultType.ToDisplayString(TypeFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + 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.CacheStampedeProtection.CacheStampedeProtectionPolicy<").Append(resultTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.Append(memberIndent).AppendLine("{"); + sb.Append(bodyIndent).Append("return global::PatternKit.Cloud.CacheStampedeProtection.CacheStampedeProtectionPolicy<").Append(resultTypeName).Append(">.Create(\"").Append(Escape(policyName)).AppendLine("\").Build();"); + sb.Append(memberIndent).AppendLine("}"); + sb.Append(indent).AppendLine("}"); + while (indent.Length > 0) + { + indent = indent.Substring(0, indent.Length - 4); + sb.Append(indent).AppendLine("}"); + } + + return sb.ToString(); + } + + private static IReadOnlyList GetContainingTypes(INamedTypeSymbol type) + { + var stack = new Stack(); + for (var current = type.ContainingType; current is not null; current = current.ContainingType) + stack.Push(current); + return stack.ToArray(); + } + + private static void AppendTypeDeclaration(StringBuilder sb, INamedTypeSymbol type, string indent) + { + sb.Append(indent).Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + if (type.IsStatic) + sb.Append("static "); + else if (type.IsAbstract && type.TypeKind == TypeKind.Class) + sb.Append("abstract "); + else if (type.IsSealed && type.TypeKind == TypeKind.Class) + sb.Append("sealed "); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + } + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; +} diff --git a/test/PatternKit.Examples.Tests/CacheStampedeProtectionDemo/ProductCatalogStampedeProtectionDemoTests.cs b/test/PatternKit.Examples.Tests/CacheStampedeProtectionDemo/ProductCatalogStampedeProtectionDemoTests.cs new file mode 100644 index 00000000..4178a77e --- /dev/null +++ b/test/PatternKit.Examples.Tests/CacheStampedeProtectionDemo/ProductCatalogStampedeProtectionDemoTests.cs @@ -0,0 +1,80 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Cloud.CacheStampedeProtection; +using PatternKit.Examples.CacheStampedeProtectionDemo; +using PatternKit.Examples.DependencyInjection; +using TinyBDD; + +namespace PatternKit.Examples.Tests.CacheStampedeProtectionDemo; + +public sealed class ProductCatalogStampedeProtectionDemoTests +{ + [Scenario("Fluent cache stampede protection shares product catalog loads")] + [Fact] + public async Task Fluent_Cache_Stampede_Protection_Shares_Product_Catalog_Loads() + { + var results = await ProductCatalogStampedeProtectionDemoRunner.RunFluentAsync(CreateRequest()); + + ScenarioExpect.Equal(2, results.Count); + ScenarioExpect.Equal(1, results.Max(static result => result.OriginLoadCount)); + ScenarioExpect.Contains(results, static result => result.SharedFlight); + } + + [Scenario("Generated cache stampede protection matches fluent behavior")] + [Fact] + public async Task Generated_Cache_Stampede_Protection_Matches_Fluent_Behavior() + { + var request = CreateRequest(); + + var fluent = await ProductCatalogStampedeProtectionDemoRunner.RunFluentAsync(request); + var generated = await ProductCatalogStampedeProtectionDemoRunner.RunGeneratedStaticAsync(request); + + ScenarioExpect.Equal(fluent.Count, generated.Count); + ScenarioExpect.Equal(1, generated.Max(static result => result.OriginLoadCount)); + ScenarioExpect.Contains(generated, static result => result.SharedFlight); + } + + [Scenario("Product catalog stampede protection service validates requests")] + [Fact] + public async Task Product_Catalog_Stampede_Protection_Service_Validates_Requests() + { + var service = new ProductCatalogStampedeProtectionService( + ProductCatalogStampedeProtectionPolicies.CreateFluent(), + new ProductCatalogOrigin()); + + await ScenarioExpect.ThrowsAsync(async () => await service.GetAvailabilityAsync(null!)); + await ScenarioExpect.ThrowsAsync(async () => await new ProductCatalogOrigin().LoadAsync(null!, CancellationToken.None)); + } + + [Scenario("ServiceCollection imports cache stampede protection example")] + [Fact] + public async Task ServiceCollection_Imports_Cache_Stampede_Protection_Example() + { + var services = new ServiceCollection(); + services.AddProductCatalogStampedeProtectionDemo(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var runner = provider.GetRequiredService(); + var results = await runner.RunGeneratedAsync(CreateRequest()); + + ScenarioExpect.Equal(1, results.Max(static result => result.OriginLoadCount)); + ScenarioExpect.NotNull(provider.GetRequiredService>()); + } + + [Scenario("Aggregate examples import cache stampede protection example")] + [Fact] + public async Task Aggregate_Examples_Import_Cache_Stampede_Protection_Example() + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var example = provider.GetRequiredService(); + var results = await example.Runner.RunGeneratedAsync(CreateRequest()); + + ScenarioExpect.Equal(1, results.Max(static result => result.OriginLoadCount)); + ScenarioExpect.NotNull(example.Policy); + } + + private static ProductAvailabilityRequest CreateRequest() + => new("SKU-100", "us"); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs index 64e3b7c2..0fc07555 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("432 pattern route results", ctx.ResultsGuide)) + ScenarioExpect.Contains("436 pattern route results", ctx.ResultsGuide)) .AssertPassed(); [Scenario("Published benchmark results include reusable hosting integrations")] diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index b6609419..016be567 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -89,6 +89,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Health Endpoint Monitoring", "Priority Queue", "Cache-Aside", + "Cache Stampede Protection", "Rate Limiting", "External Configuration Store", "Gateway Aggregation", @@ -164,7 +165,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() { ScenarioExpect.Equal(41, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); - ScenarioExpect.Equal(17, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); + ScenarioExpect.Equal(18, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); ScenarioExpect.Equal(23, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Generators.Tests/AbstractionsTests.cs b/test/PatternKit.Generators.Tests/AbstractionsTests.cs index 381f9730..6dc38ef5 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsTests.cs @@ -8,6 +8,39 @@ namespace PatternKit.Generators.Tests; /// public class AbstractionsTests { + #region GenerateCacheStampedeProtectionAttribute Tests + + [Scenario("GenerateCacheStampedeProtectionAttribute Constructor Sets Properties")] + [Fact] + public void GenerateCacheStampedeProtectionAttribute_Constructor_Sets_Properties() + { + var attr = new PatternKit.Generators.CacheStampedeProtection.GenerateCacheStampedeProtectionAttribute(typeof(string)) + { + FactoryMethodName = "CreateCatalogSingleFlight", + PolicyName = "catalog-single-flight" + }; + + ScenarioExpect.Equal(typeof(string), attr.ResultType); + ScenarioExpect.Equal("CreateCatalogSingleFlight", attr.FactoryMethodName); + ScenarioExpect.Equal("catalog-single-flight", attr.PolicyName); + ScenarioExpect.Throws(() => new PatternKit.Generators.CacheStampedeProtection.GenerateCacheStampedeProtectionAttribute(null!)); + } + + [Scenario("GenerateCacheStampedeProtectionAttribute Has Correct AttributeUsage")] + [Fact] + public void GenerateCacheStampedeProtectionAttribute_Has_Correct_AttributeUsage() + { + var usage = typeof(PatternKit.Generators.CacheStampedeProtection.GenerateCacheStampedeProtectionAttribute) + .GetCustomAttributes(typeof(AttributeUsageAttribute), false) + .Cast() + .Single(); + + ScenarioExpect.Equal(AttributeTargets.Class | AttributeTargets.Struct, usage.ValidOn); + ScenarioExpect.False(usage.Inherited); + } + + #endregion + #region GenerateManualTaskGateAttribute Tests [Scenario("GenerateManualTaskGateAttribute Constructor Sets Properties")] diff --git a/test/PatternKit.Generators.Tests/CacheStampedeProtectionGeneratorTests.cs b/test/PatternKit.Generators.Tests/CacheStampedeProtectionGeneratorTests.cs new file mode 100644 index 00000000..421aefe9 --- /dev/null +++ b/test/PatternKit.Generators.Tests/CacheStampedeProtectionGeneratorTests.cs @@ -0,0 +1,157 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Generators.CacheStampedeProtection; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Cache Stampede Protection generator")] +public sealed partial class CacheStampedeProtectionGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates cache stampede protection factory")] + [Fact] + public Task Generates_Cache_Stampede_Protection_Factory() + => Given("a cache stampede protection declaration", () => Compile(""" + using PatternKit.Generators.CacheStampedeProtection; + namespace Demo; + [GenerateCacheStampedeProtection(typeof(string), FactoryMethodName = "Build", PolicyName = "catalog-single-flight")] + public static partial class CatalogSingleFlight; + """)) + .Then("the generated source creates the configured policy", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("public static partial class CatalogSingleFlight", source); + ScenarioExpect.Contains("CacheStampedeProtectionPolicy Build()", source); + ScenarioExpect.Contains("CacheStampedeProtectionPolicy.Create(\"catalog-single-flight\").Build()", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid cache stampede protection declarations")] + [Fact] + public Task Reports_Diagnostics_For_Invalid_Cache_Stampede_Protection_Declarations() + => Given("invalid cache stampede protection declarations", () => new[] + { + Compile(""" + using PatternKit.Generators.CacheStampedeProtection; + [GenerateCacheStampedeProtection(typeof(string))] + public static class CatalogSingleFlight; + """), + Compile(""" + using PatternKit.Generators.CacheStampedeProtection; + [GenerateCacheStampedeProtection(typeof(string), FactoryMethodName = "")] + public static partial class CatalogSingleFlight; + """), + Compile(""" + using PatternKit.Generators.CacheStampedeProtection; + [GenerateCacheStampedeProtection(typeof(string), PolicyName = " ")] + public static partial class CatalogSingleFlight; + """) + }) + .Then("diagnostics identify the invalid declarations", results => + { + ScenarioExpect.Contains(results[0].Diagnostics, diagnostic => diagnostic.Id == "PKCSP001"); + ScenarioExpect.Contains(results[1].Diagnostics, diagnostic => diagnostic.Id == "PKCSP002"); + ScenarioExpect.Contains(results[2].Diagnostics, diagnostic => diagnostic.Id == "PKCSP002"); + }) + .AssertPassed(); + + [Scenario("Generates cache stampede protection defaults and nested host wrappers")] + [Fact] + public Task Generates_Cache_Stampede_Protection_Defaults_And_Nested_Host_Wrappers() + => Given("nested cache stampede protection declarations", () => Compile(""" + using PatternKit.Generators.CacheStampedeProtection; + namespace Demo; + public static partial class CatalogModule + { + internal abstract partial class Policies + { + [GenerateCacheStampedeProtection(typeof(System.Guid), PolicyName = "single\\\"flight")] + private sealed partial class SingleFlight; + } + } + """)) + .Then("generated sources preserve containing partial type wrappers", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("public static partial class CatalogModule", source); + ScenarioExpect.Contains("internal abstract partial class Policies", source); + ScenarioExpect.Contains("private sealed partial class SingleFlight", source); + ScenarioExpect.Contains("CacheStampedeProtectionPolicy.Create(\"single\\\\\\\"flight\")", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Generates cache stampede protection factories for protected nested hosts")] + [Fact] + public Task Generates_Cache_Stampede_Protection_Factories_For_Protected_Nested_Hosts() + => Given("protected nested cache stampede protection declarations", () => Compile(""" + using PatternKit.Generators.CacheStampedeProtection; + namespace Demo; + public abstract partial class CatalogModule + { + [GenerateCacheStampedeProtection(typeof(string), FactoryMethodName = "CreateProtected")] + protected partial class ProtectedPolicy; + + [GenerateCacheStampedeProtection(typeof(string), FactoryMethodName = "CreatePrivateProtected")] + private protected partial class PrivateProtectedPolicy; + + [GenerateCacheStampedeProtection(typeof(string), FactoryMethodName = "CreateProtectedInternal")] + protected internal partial class ProtectedInternalPolicy; + } + """)) + .Then("generated sources preserve protected accessibility modifiers", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = string.Join(Environment.NewLine, result.GeneratedSources); + ScenarioExpect.Contains("protected partial class ProtectedPolicy", source); + ScenarioExpect.Contains("private protected partial class PrivateProtectedPolicy", source); + ScenarioExpect.Contains("protected internal partial class ProtectedInternalPolicy", source); + ScenarioExpect.Contains("CreateProtected()", source); + ScenarioExpect.Contains("CreatePrivateProtected()", source); + ScenarioExpect.Contains("CreateProtectedInternal()", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Skips cache stampede protection generation for malformed result type")] + [Fact] + public Task Skips_Cache_Stampede_Protection_Generation_For_Malformed_Result_Type() + => Given("a cache stampede protection declaration with an unresolved result type", () => Compile(""" + using PatternKit.Generators.CacheStampedeProtection; + [GenerateCacheStampedeProtection(typeof(MissingResult))] + public static partial class MissingSingleFlight; + """)) + .Then("no generated source is produced by the generator", result => + { + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Empty(result.GeneratedSources); + ScenarioExpect.False(result.EmitSuccess); + }) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "CacheStampedeProtectionGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(PatternKit.Cloud.CacheStampedeProtection.CacheStampedeProtectionPolicy<>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new CacheStampedeProtectionGenerator(), 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/CacheStampedeProtection/CacheStampedeProtectionPolicyTests.cs b/test/PatternKit.Tests/Cloud/CacheStampedeProtection/CacheStampedeProtectionPolicyTests.cs new file mode 100644 index 00000000..c09d3201 --- /dev/null +++ b/test/PatternKit.Tests/Cloud/CacheStampedeProtection/CacheStampedeProtectionPolicyTests.cs @@ -0,0 +1,134 @@ +using PatternKit.Cloud.CacheStampedeProtection; +using TinyBDD; + +namespace PatternKit.Tests.Cloud.CacheStampedeProtection; + +public sealed class CacheStampedeProtectionPolicyTests +{ + [Scenario("Cache stampede protection shares concurrent loads by key")] + [Fact] + public async Task Cache_Stampede_Protection_Shares_Concurrent_Loads_By_Key() + { + var policy = CacheStampedeProtectionPolicy.Create("products").Build(); + var release = new TaskCompletionSource(); + var loadCount = 0; + + async ValueTask Loader(CancellationToken _) + { + Interlocked.Increment(ref loadCount); + await release.Task; + return "catalog"; + } + + var first = policy.GetOrLoadAsync("sku-1", Loader).AsTask(); + var second = policy.GetOrLoadAsync("sku-1", Loader).AsTask(); + + ScenarioExpect.Equal(1, policy.InFlightCount); + release.SetResult(true); + + var results = await Task.WhenAll(first, second); + + ScenarioExpect.Equal(1, loadCount); + ScenarioExpect.Equal("catalog", results[0].Value); + ScenarioExpect.False(results[0].SharedFlight); + ScenarioExpect.True(results[1].SharedFlight); + ScenarioExpect.Equal(0, policy.InFlightCount); + } + + [Scenario("Cache stampede protection isolates different keys")] + [Fact] + public async Task Cache_Stampede_Protection_Isolates_Different_Keys() + { + var policy = CacheStampedeProtectionPolicy.Create().Build(); + var loadCount = 0; + + ValueTask Loader(CancellationToken _) + => new($"value-{Interlocked.Increment(ref loadCount)}"); + + var first = await policy.GetOrLoadAsync("sku-1", Loader); + var second = await policy.GetOrLoadAsync("sku-2", Loader); + + ScenarioExpect.Equal("cache-stampede-protection", policy.Name); + ScenarioExpect.Equal("sku-1", first.Key); + ScenarioExpect.Equal("sku-2", second.Key); + ScenarioExpect.Equal(2, loadCount); + ScenarioExpect.False(first.SharedFlight); + ScenarioExpect.False(second.SharedFlight); + } + + [Scenario("Cache stampede protection releases failed loads")] + [Fact] + public async Task Cache_Stampede_Protection_Releases_Failed_Loads() + { + var policy = CacheStampedeProtectionPolicy.Create().Build(); + var attempts = 0; + + async ValueTask Loader(CancellationToken _) + { + await Task.Yield(); + if (Interlocked.Increment(ref attempts) == 1) + throw new InvalidOperationException("origin unavailable"); + return "recovered"; + } + + await ScenarioExpect.ThrowsAsync(async () => await policy.GetOrLoadAsync("sku-1", Loader)); + var recovered = await policy.GetOrLoadAsync("sku-1", Loader); + + ScenarioExpect.Equal("recovered", recovered.Value); + ScenarioExpect.Equal(2, attempts); + ScenarioExpect.Equal(0, policy.InFlightCount); + } + + [Scenario("Cache stampede protection honors cancellation and validates configuration")] + [Fact] + public async Task Cache_Stampede_Protection_Honors_Cancellation_And_Validates_Configuration() + { + var policy = CacheStampedeProtectionPolicy.Create().Build(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + ScenarioExpect.Throws(() => CacheStampedeProtectionPolicy.Create("").Build()); + await ScenarioExpect.ThrowsAsync(async () => await policy.GetOrLoadAsync("", static _ => new ValueTask("value"))); + await ScenarioExpect.ThrowsAsync(async () => await policy.GetOrLoadAsync("sku-1", null!)); + await ScenarioExpect.ThrowsAsync(async () => await policy.GetOrLoadAsync("sku-1", static _ => new ValueTask("value"), cts.Token)); + } + + [Scenario("Cache stampede protection coordinates cancellable waits")] + [Fact] + public async Task Cache_Stampede_Protection_Coordinates_Cancellable_Waits() + { + var policy = CacheStampedeProtectionPolicy.Create().Build(); + using var activeTokenSource = new CancellationTokenSource(); + var release = new TaskCompletionSource(); + + async ValueTask Loader(CancellationToken _) + { + await release.Task; + return "catalog"; + } + + var pending = policy.GetOrLoadAsync("sku-1", Loader, activeTokenSource.Token).AsTask(); + release.SetResult(true); + var completed = await pending; + var fastPath = await policy.GetOrLoadAsync("sku-2", static _ => new ValueTask("ready"), activeTokenSource.Token); + + using var cancelledWait = new CancellationTokenSource(); + var blocked = new TaskCompletionSource(); + var cancelled = policy.GetOrLoadAsync("sku-3", async _ => + { + await blocked.Task; + return "late"; + }, cancelledWait.Token).AsTask(); + var follower = policy.GetOrLoadAsync("sku-3", static _ => new ValueTask("unused")).AsTask(); + + cancelledWait.Cancel(); + await ScenarioExpect.ThrowsAsync(async () => await cancelled); + blocked.SetResult(true); + var followed = await follower; + + ScenarioExpect.Equal("catalog", completed.Value); + ScenarioExpect.Equal("ready", fastPath.Value); + ScenarioExpect.Equal("late", followed.Value); + ScenarioExpect.Equal(0, policy.InFlightCount); + } +}