diff --git a/README.md b/README.md index 59974570..9daa9037 100644 --- a/README.md +++ b/README.md @@ -473,13 +473,13 @@ var cachedRemoteProxy = Proxy.Create(id => remoteProxy.Execute(id)) --- ## Patterns Table -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. +PatternKit currently tracks 111 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 | 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 | +| Cloud Architecture | 20 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Cache Stampede Protection, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Read-Through Cache, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig, Write-Through Cache | | Creational | 5 | Abstract Factory, Builder, Factory Method, Prototype, Singleton | | 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 | @@ -523,6 +523,12 @@ BenchmarkDotNet guidance is documented in [docs/guides/benchmarks.md](docs/guide | 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. | +| Read-Through / Write-Through Cache | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Read-Through / Write-Through Cache | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Read-Through Cache | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix through the read/write-through cache route. | +| Read-Through Cache | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix through the read/write-through cache route. | +| Write-Through Cache | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix through the read/write-through cache route. | +| Write-Through Cache | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix through the read/write-through cache route. | | Canonical Data Model | Construction | 75.482 ns | 632 B | 59.947 ns | 496 B | Generated reduced construction time and allocation in this microbenchmark. | | Canonical Data Model | Execution | 116.680 ns | 832 B | 92.082 ns | 696 B | Generated reduced execution time and allocation for order normalization. | | Channel Adapter | Construction | 38.469 ns | 384 B | 38.681 ns | 384 B | Effectively equivalent for this microbenchmark. | diff --git a/benchmarks/PatternKit.Benchmarks/Cloud/ReadWriteThroughCacheBenchmarks.cs b/benchmarks/PatternKit.Benchmarks/Cloud/ReadWriteThroughCacheBenchmarks.cs new file mode 100644 index 00000000..ad4f8cf9 --- /dev/null +++ b/benchmarks/PatternKit.Benchmarks/Cloud/ReadWriteThroughCacheBenchmarks.cs @@ -0,0 +1,39 @@ +using BenchmarkDotNet.Attributes; +using PatternKit.Cloud.ReadWriteThroughCache; +using PatternKit.Examples.ReadWriteThroughCacheDemo; + +namespace PatternKit.Benchmarks.Cloud; + +[BenchmarkCategory("Cloud", "ReadWriteThroughCache")] +public class ReadWriteThroughCacheBenchmarks +{ + [Benchmark(Baseline = true, Description = "Fluent: create read/write-through cache policy")] + [BenchmarkCategory("Fluent", "Construction")] + public ReadWriteThroughCachePolicy Fluent_CreatePolicy() + => ProductCatalogReadWriteThroughPolicies.CreateFluent(); + + [Benchmark(Description = "Generated: create read/write-through cache policy")] + [BenchmarkCategory("Generated", "Construction")] + public ReadWriteThroughCachePolicy Generated_CreatePolicy() + => GeneratedProductCatalogReadWriteThroughPolicy.CreateGenerated(); + + [Benchmark(Description = "Fluent: read/write-through product catalog flow")] + [BenchmarkCategory("Fluent", "Execution")] + public IReadOnlyList Fluent_RunCatalogFlow() + { + var service = new ProductCatalogReadWriteThroughCacheService( + new ProductCatalogReadWriteRepository(new CatalogProduct("SKU-42", "Trail Jacket", 129m)), + ProductCatalogReadWriteThroughPolicies.CreateFluent()); + return new ProductCatalogReadWriteThroughDemoRunner(service).RunAsync().AsTask().GetAwaiter().GetResult(); + } + + [Benchmark(Description = "Generated: read/write-through product catalog flow")] + [BenchmarkCategory("Generated", "Execution")] + public IReadOnlyList Generated_RunCatalogFlow() + { + var service = new ProductCatalogReadWriteThroughCacheService( + new ProductCatalogReadWriteRepository(new CatalogProduct("SKU-42", "Trail Jacket", 129m)), + GeneratedProductCatalogReadWriteThroughPolicy.CreateGenerated()); + return new ProductCatalogReadWriteThroughDemoRunner(service).RunAsync().AsTask().GetAwaiter().GetResult(); + } +} diff --git a/docs/examples/product-catalog-read-write-through-cache.md b/docs/examples/product-catalog-read-write-through-cache.md new file mode 100644 index 00000000..3fc3fc8d --- /dev/null +++ b/docs/examples/product-catalog-read-write-through-cache.md @@ -0,0 +1,18 @@ +# Product Catalog Read-Through and Write-Through Cache + +This example models a product catalog repository where reads and writes are coordinated through one cache policy. + +- `ReadThroughAsync` loads missing products from the repository and caches them. +- `WriteThroughAsync` saves the product to the repository before updating the cache. +- `AddProductCatalogReadWriteThroughDemo()` registers the generated policy, repository, service, and runner with `IServiceCollection`. + +```csharp +var services = new ServiceCollection(); +services.AddProductCatalogReadWriteThroughDemo(); + +using var provider = services.BuildServiceProvider(validateScopes: true); +var runner = provider.GetRequiredService(); +var results = await runner.RunAsync(); +``` + +The fluent and source-generated paths produce the same `ReadWriteThroughCachePolicy` surface. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index a4ce05cb..30189f49 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -283,6 +283,9 @@ - name: Product Catalog Cache Stampede Protection href: product-catalog-cache-stampede-protection.md +- name: Product Catalog Read-Through and Write-Through Cache + href: product-catalog-read-write-through-cache.md + - name: Product Search Rate Limiting href: product-search-rate-limiting.md diff --git a/docs/generators/index.md b/docs/generators/index.md index fb8ec278..1371eec9 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -133,6 +133,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**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]` | +| [**Read-Through / Write-Through Cache**](read-write-through-cache.md) | Cache-backed repository policy factories with coordinated read and write paths | `[GenerateReadWriteThroughCachePolicy]` | | [**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/read-write-through-cache.md b/docs/generators/read-write-through-cache.md new file mode 100644 index 00000000..8a229b0d --- /dev/null +++ b/docs/generators/read-write-through-cache.md @@ -0,0 +1,14 @@ +# Read-Through and Write-Through Cache Generator + +The read/write-through cache generator creates a strongly typed `ReadWriteThroughCachePolicy` factory from `[GenerateReadWriteThroughCachePolicy]`. + +```csharp +[GenerateReadWriteThroughCachePolicy( + typeof(CatalogProduct), + FactoryMethodName = "CreateGenerated", + PolicyName = "product-catalog-read-write-through", + TimeToLiveMilliseconds = 300000)] +public static partial class GeneratedProductCatalogReadWriteThroughPolicy; +``` + +The generated factory returns the same runtime policy as the fluent API and applies the configured TTL. diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 0777eecb..8d85731d 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -34,6 +34,9 @@ - name: Cache Stampede Protection href: cache-stampede-protection.md +- name: Read-Through / Write-Through Cache + href: read-write-through-cache.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 03830a30..8969c44f 100644 --- a/docs/guides/benchmark-results.md +++ b/docs/guides/benchmark-results.md @@ -43,6 +43,12 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 | 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. | +| Read-Through / Write-Through Cache | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Read-Through / Write-Through Cache | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Read-Through Cache | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix through the read/write-through cache route. | +| Read-Through Cache | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix through the read/write-through cache route. | +| Write-Through Cache | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix through the read/write-through cache route. | +| Write-Through Cache | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix through the read/write-through cache route. | | Canonical Data Model | Construction | 75.482 ns | 632 B | 59.947 ns | 496 B | Generated reduced construction time and allocation in this microbenchmark. | | Canonical Data Model | Execution | 116.680 ns | 832 B | 92.082 ns | 696 B | Generated reduced execution time and allocation for order normalization. | | Channel Adapter | Construction | 38.469 ns | 384 B | 38.681 ns | 384 B | Effectively equivalent for this microbenchmark. | @@ -236,19 +242,19 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 ## Coverage Matrix Summary -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. +The coverage matrix currently publishes 111 catalog patterns and 444 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 | 18 | 72 | +| Cloud Architecture | 20 | 80 | | Creational | 5 | 20 | | Enterprise Integration | 41 | 164 | | Messaging Reliability | 3 | 12 | | Structural | 7 | 28 | -The generator matrix currently publishes 104 generator source route results. +The generator matrix currently publishes 105 generator source route results. ## Hosting Integration Matrix Results @@ -314,12 +320,14 @@ The generator matrix currently publishes 104 generator source route results. | Cloud Architecture | Health Endpoint Monitoring | Covered | Covered | Covered | Covered | | Cloud Architecture | Leader Election | Covered | Covered | Covered | Covered | | Cloud Architecture | Priority Queue | Covered | Covered | Covered | Covered | -| Cloud Architecture | Queue-Based Load Leveling | Covered | Covered | Covered | Covered | -| Cloud Architecture | Rate Limiting | Covered | Covered | Covered | Covered | -| Cloud Architecture | Retry | Covered | Covered | Covered | Covered | -| Cloud Architecture | Scheduler Agent Supervisor | Covered | Covered | Covered | Covered | -| Cloud Architecture | Sidecar | Covered | Covered | Covered | Covered | -| Cloud Architecture | Strangler Fig | Covered | Covered | Covered | Covered | +| Cloud Architecture | Queue-Based Load Leveling | Covered | Covered | Covered | Covered | +| Cloud Architecture | Rate Limiting | Covered | Covered | Covered | Covered | +| Cloud Architecture | Read-Through Cache | Covered | Covered | Covered | Covered | +| Cloud Architecture | Retry | Covered | Covered | Covered | Covered | +| Cloud Architecture | Scheduler Agent Supervisor | Covered | Covered | Covered | Covered | +| Cloud Architecture | Sidecar | Covered | Covered | Covered | Covered | +| Cloud Architecture | Strangler Fig | Covered | Covered | Covered | Covered | +| Cloud Architecture | Write-Through Cache | Covered | Covered | Covered | Covered | | Creational | Abstract Factory | Covered | Covered | Covered | Covered | | Creational | Builder | Covered | Covered | Covered | Covered | | Creational | Factory Method | Covered | Covered | Covered | Covered | @@ -395,7 +403,8 @@ The generator matrix currently publishes 104 generator source route results. | 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 | +| ReadWriteThroughCachePolicyGenerator | `src/PatternKit.Generators/ReadWriteThroughCache/ReadWriteThroughCachePolicyGenerator.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 | | ExternalConfigurationStoreGenerator | `src/PatternKit.Generators/Cloud/ExternalConfigurationStoreGenerator.cs` | Covered | diff --git a/docs/guides/benchmarks.md b/docs/guides/benchmarks.md index e1f16764..bbc9ab16 100644 --- a/docs/guides/benchmarks.md +++ b/docs/guides/benchmarks.md @@ -60,6 +60,12 @@ The following numbers were captured on Windows 11, Intel Core i9-14900K, .NET SD | 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. | +| Read-Through / Write-Through Cache | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Read-Through / Write-Through Cache | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Read-Through Cache | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix through the read/write-through cache route. | +| Read-Through Cache | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix through the read/write-through cache route. | +| Write-Through Cache | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix through the read/write-through cache route. | +| Write-Through Cache | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix through the read/write-through cache route. | | Canonical Data Model | Construction | 75.482 ns | 632 B | 59.947 ns | 496 B | Generated reduced construction time and allocation in this microbenchmark. | | Canonical Data Model | Execution | 116.680 ns | 832 B | 92.082 ns | 696 B | Generated reduced execution time and allocation for order normalization. | | Channel Adapter | Construction | 38.469 ns | 384 B | 38.681 ns | 384 B | Effectively equivalent for this microbenchmark. | diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index cc27c6be..3f7ad4ae 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -95,6 +95,8 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Cloud Architecture | Health Endpoint Monitoring | `HealthEndpoint` | Health Endpoint Monitoring generator | | Cloud Architecture | Priority Queue | `PriorityQueuePolicy` | Priority Queue generator | | Cloud Architecture | Cache-Aside | `CacheAsidePolicy` | Cache-Aside generator | +| Cloud Architecture | Read-Through Cache | `ReadWriteThroughCachePolicy` | Read/write-through cache generator | +| Cloud Architecture | Write-Through Cache | `ReadWriteThroughCachePolicy` | Read/write-through cache generator | | Cloud Architecture | Rate Limiting | `RateLimitPolicy` | Rate Limiting generator | | Cloud Architecture | External Configuration Store | `ExternalConfigurationStore` | External Configuration Store generator | | Cloud Architecture | Gateway Aggregation | `GatewayAggregation` | Gateway Aggregation generator | diff --git a/docs/index.md b/docs/index.md index e3d36b2a..bbc2316f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,13 +66,13 @@ if (parser.Execute("123", out var value)) ## 📚 Available Patterns -PatternKit covers 109 production-readiness patterns with fluent APIs, source-generated routes where applicable, IoC integration examples, TinyBDD coverage, and BenchmarkDotNet coverage-matrix validation: +PatternKit covers 111 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 | 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 | +| Cloud Architecture | 20 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Cache Stampede Protection, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Read-Through Cache, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig, Write-Through Cache | | Creational | 5 | Abstract Factory, Builder, Factory Method, Prototype, Singleton | | 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/read-write-through-cache.md b/docs/patterns/cloud/read-write-through-cache.md new file mode 100644 index 00000000..9a38b8e5 --- /dev/null +++ b/docs/patterns/cloud/read-write-through-cache.md @@ -0,0 +1,14 @@ +# Read-Through and Write-Through Cache + +PatternKit provides `ReadWriteThroughCachePolicy` in `PatternKit.Cloud.ReadWriteThroughCache`. + +Use it when the application should not hand-roll cache orchestration around repositories. `ReadThroughAsync` checks the cache first, loads from the origin on a miss, then stores the loaded value. `WriteThroughAsync` persists to the origin first and updates the cache only after the write succeeds. + +```csharp +var policy = ReadWriteThroughCachePolicy + .Create("product-catalog-read-write-through") + .WithTimeToLive(TimeSpan.FromMinutes(5)) + .Build(); +``` + +The product catalog example demonstrates fluent and source-generated policy creation, plus `IServiceCollection` registration for applications that want container-owned policies and services. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 7b50eae5..d5fad38b 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -391,6 +391,8 @@ href: cloud/priority-queue.md - name: Cache-Aside href: cloud/cache-aside.md + - name: Read-Through and Write-Through Cache + href: cloud/read-write-through-cache.md - name: Rate Limiting href: cloud/rate-limiting.md - name: External Configuration Store diff --git a/src/PatternKit.Core/Cloud/ReadWriteThroughCache/ReadWriteThroughCachePolicy.cs b/src/PatternKit.Core/Cloud/ReadWriteThroughCache/ReadWriteThroughCachePolicy.cs new file mode 100644 index 00000000..8f691c3b --- /dev/null +++ b/src/PatternKit.Core/Cloud/ReadWriteThroughCache/ReadWriteThroughCachePolicy.cs @@ -0,0 +1,136 @@ +using PatternKit.Cloud.CacheAside; + +namespace PatternKit.Cloud.ReadWriteThroughCache; + +public sealed class ReadWriteThroughCacheResult +{ + public ReadWriteThroughCacheResult(string key, TResult? value, bool found, bool cacheHit, bool written) + { + Key = key; + Value = value; + Found = found; + CacheHit = cacheHit; + Written = written; + } + + public string Key { get; } + public TResult? Value { get; } + public bool Found { get; } + public bool CacheHit { get; } + public bool CacheMiss => !CacheHit; + public bool Written { get; } + + public static ReadWriteThroughCacheResult Hit(string key, TResult value) + => new(key, value, found: true, cacheHit: true, written: false); + + public static ReadWriteThroughCacheResult Miss(string key, TResult? value, bool found) + => new(key, value, found, cacheHit: false, written: false); + + public static ReadWriteThroughCacheResult Write(string key, TResult value) + => new(key, value, found: true, cacheHit: false, written: true); +} + +public sealed class ReadWriteThroughCachePolicy +{ + private readonly ICacheAsideStore _store; + private readonly TimeSpan? _ttl; + + private ReadWriteThroughCachePolicy(string name, ICacheAsideStore store, TimeSpan? ttl) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Read/write-through cache policy name is required.", nameof(name)); + if (ttl is not null && ttl.Value < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(ttl), ttl, "Read/write-through cache TTL cannot be negative."); + + Name = name; + _store = store ?? throw new ArgumentNullException(nameof(store)); + _ttl = ttl; + } + + public string Name { get; } + public TimeSpan? TimeToLive => _ttl; + public ICacheAsideStore Store => _store; + + public static Builder Create(string name = "read-write-through-cache") => new(name); + + public async ValueTask> ReadThroughAsync( + string key, + Func> loader, + CancellationToken cancellationToken = default) + { + ValidateKey(key); + if (loader is null) + throw new ArgumentNullException(nameof(loader)); + + cancellationToken.ThrowIfCancellationRequested(); + if (_store.TryGet(key, out var cached)) + return ReadWriteThroughCacheResult.Hit(key, cached!); + + var value = await loader(cancellationToken).ConfigureAwait(false); + if (value is null) + return ReadWriteThroughCacheResult.Miss(key, default, found: false); + + _store.Set(key, value, _ttl); + return ReadWriteThroughCacheResult.Miss(key, value, found: true); + } + + public async ValueTask> WriteThroughAsync( + string key, + TResult value, + Func writer, + CancellationToken cancellationToken = default) + { + ValidateKey(key); + if (writer is null) + throw new ArgumentNullException(nameof(writer)); + + cancellationToken.ThrowIfCancellationRequested(); + await writer(value, cancellationToken).ConfigureAwait(false); + _store.Set(key, value, _ttl); + return ReadWriteThroughCacheResult.Write(key, value); + } + + public bool Invalidate(string key) + { + ValidateKey(key); + return _store.Remove(key); + } + + public void Clear() => _store.Clear(); + + private static void ValidateKey(string key) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("Cache key is required.", nameof(key)); + } + + public sealed class Builder + { + private readonly string _name; + private ICacheAsideStore? _store; + private TimeSpan? _ttl; + + internal Builder(string name) => _name = name; + + public Builder WithStore(ICacheAsideStore store) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + return this; + } + + public Builder WithTimeToLive(TimeSpan ttl) + { + _ttl = ttl; + return this; + } + + public Builder WithoutExpiration() + { + _ttl = null; + return this; + } + + public ReadWriteThroughCachePolicy Build() + => new(_name, _store ?? new InMemoryCacheAsideStore(), _ttl); + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index b089991b..b2ed8f43 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -18,6 +18,7 @@ using PatternKit.Cloud.PriorityQueue; using PatternKit.Cloud.QueueLoadLeveling; using PatternKit.Cloud.RateLimiting; +using PatternKit.Cloud.ReadWriteThroughCache; using PatternKit.Cloud.Retry; using PatternKit.Creational.AbstractFactory; using PatternKit.Creational.Prototype; @@ -70,6 +71,7 @@ using PatternKit.Examples.ProxyDemo; using PatternKit.Examples.QueueLoadLevelingDemo; using PatternKit.Examples.RateLimitingDemo; +using PatternKit.Examples.ReadWriteThroughCacheDemo; using PatternKit.Examples.RepositoryDemo; using PatternKit.Examples.RetryDemo; using PatternKit.Examples.SchedulerAgentSupervisorDemo; @@ -241,6 +243,7 @@ public sealed record FulfillmentHealthEndpointExample(HealthEndpoint Queue, FulfillmentPriorityQueueService Service); public sealed record ProductCatalogCacheAsideExample(CacheAsidePolicy Policy, ProductCatalogCacheAsideService Service); public sealed record ProductCatalogStampedeProtectionExample(CacheStampedeProtectionPolicy Policy, ProductCatalogStampedeProtectionDemoRunner Runner); +public sealed record ProductCatalogReadWriteThroughExample(ReadWriteThroughCachePolicy Policy, ProductCatalogReadWriteThroughDemoRunner 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); @@ -358,6 +361,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddFulfillmentPriorityQueueExample() .AddProductCatalogCacheAsideExample() .AddProductCatalogStampedeProtectionExample() + .AddProductCatalogReadWriteThroughExample() .AddProductSearchRateLimitingExample() .AddTenantExternalConfigurationStoreExample() .AddCustomerDashboardGatewayAggregationExample() @@ -1235,6 +1239,15 @@ public static IServiceCollection AddProductCatalogStampedeProtectionExample(this return services.RegisterExample("Product Catalog Cache Stampede Protection", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddProductCatalogReadWriteThroughExample(this IServiceCollection services) + { + services.AddProductCatalogReadWriteThroughDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService())); + return services.RegisterExample("Product Catalog Read-Through and Write-Through Cache", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); + } + 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 a11ca04c..2d35910f 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -872,6 +872,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["Cache Stampede Protection"], ["keyed single-flight load coordination", "source-generated policy factory", "DI composition"]), + Descriptor( + "Product Catalog Read-Through and Write-Through Cache", + "src/PatternKit.Examples/ReadWriteThroughCacheDemo/ProductCatalogReadWriteThroughCacheDemo.cs", + "test/PatternKit.Examples.Tests/ReadWriteThroughCacheDemo/ProductCatalogReadWriteThroughCacheDemoTests.cs", + "docs/examples/product-catalog-read-write-through-cache.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, + ["Read-Through Cache", "Write-Through Cache"], + ["cache-owned repository reads", "write-through persistence before cache update", "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 2f7b531c..499da929 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -1039,6 +1039,32 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/CacheStampedeProtectionDemo/ProductCatalogStampedeProtectionDemoTests.cs", ["fluent keyed single-flight policy", "generated cache stampede protection factory", "DI-importable product catalog example"]), + Pattern("Read-Through Cache", PatternFamily.CloudArchitecture, + "docs/patterns/cloud/read-write-through-cache.md", + "src/PatternKit.Core/Cloud/ReadWriteThroughCache/ReadWriteThroughCachePolicy.cs", + "test/PatternKit.Tests/Cloud/ReadWriteThroughCache/ReadWriteThroughCachePolicyTests.cs", + "docs/generators/read-write-through-cache.md", + "src/PatternKit.Generators/ReadWriteThroughCache/ReadWriteThroughCachePolicyGenerator.cs", + "test/PatternKit.Generators.Tests/ReadWriteThroughCachePolicyGeneratorTests.cs", + null, + "docs/examples/product-catalog-read-write-through-cache.md", + "src/PatternKit.Examples/ReadWriteThroughCacheDemo/ProductCatalogReadWriteThroughCacheDemo.cs", + "test/PatternKit.Examples.Tests/ReadWriteThroughCacheDemo/ProductCatalogReadWriteThroughCacheDemoTests.cs", + ["fluent read-through cache policy", "generated read/write-through policy factory", "DI-importable product catalog example"]), + + Pattern("Write-Through Cache", PatternFamily.CloudArchitecture, + "docs/patterns/cloud/read-write-through-cache.md", + "src/PatternKit.Core/Cloud/ReadWriteThroughCache/ReadWriteThroughCachePolicy.cs", + "test/PatternKit.Tests/Cloud/ReadWriteThroughCache/ReadWriteThroughCachePolicyTests.cs", + "docs/generators/read-write-through-cache.md", + "src/PatternKit.Generators/ReadWriteThroughCache/ReadWriteThroughCachePolicyGenerator.cs", + "test/PatternKit.Generators.Tests/ReadWriteThroughCachePolicyGeneratorTests.cs", + null, + "docs/examples/product-catalog-read-write-through-cache.md", + "src/PatternKit.Examples/ReadWriteThroughCacheDemo/ProductCatalogReadWriteThroughCacheDemo.cs", + "test/PatternKit.Examples.Tests/ReadWriteThroughCacheDemo/ProductCatalogReadWriteThroughCacheDemoTests.cs", + ["fluent write-through cache policy", "generated read/write-through policy 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.Examples/ReadWriteThroughCacheDemo/ProductCatalogReadWriteThroughCacheDemo.cs b/src/PatternKit.Examples/ReadWriteThroughCacheDemo/ProductCatalogReadWriteThroughCacheDemo.cs new file mode 100644 index 00000000..232b9e3c --- /dev/null +++ b/src/PatternKit.Examples/ReadWriteThroughCacheDemo/ProductCatalogReadWriteThroughCacheDemo.cs @@ -0,0 +1,93 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Cloud.ReadWriteThroughCache; +using PatternKit.Generators.ReadWriteThroughCache; + +namespace PatternKit.Examples.ReadWriteThroughCacheDemo; + +public sealed record CatalogProduct(string Sku, string Name, decimal Price); + +public sealed class ProductCatalogReadWriteRepository(params CatalogProduct[] products) +{ + private readonly Dictionary _products = products.ToDictionary(static p => p.Sku, StringComparer.OrdinalIgnoreCase); + + public int Reads { get; private set; } + public int Writes { get; private set; } + + public ValueTask FindAsync(string sku, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sku); + cancellationToken.ThrowIfCancellationRequested(); + Reads++; + return new(_products.TryGetValue(sku, out var product) ? product : null); + } + + public ValueTask SaveAsync(CatalogProduct product, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(product); + cancellationToken.ThrowIfCancellationRequested(); + Writes++; + _products[product.Sku] = product; + return ValueTask.CompletedTask; + } +} + +public sealed record ProductCatalogReadWriteSummary(CatalogProduct? Product, bool Found, bool CacheHit, bool Written, int OriginReads, int OriginWrites); + +public sealed class ProductCatalogReadWriteThroughCacheService( + ProductCatalogReadWriteRepository repository, + ReadWriteThroughCachePolicy policy) +{ + public async ValueTask FindAsync(string sku, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sku); + var result = await policy.ReadThroughAsync(sku, ct => repository.FindAsync(sku, ct), cancellationToken).ConfigureAwait(false); + return new(result.Value, result.Found, result.CacheHit, result.Written, repository.Reads, repository.Writes); + } + + public async ValueTask SaveAsync(CatalogProduct product, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(product); + var result = await policy.WriteThroughAsync(product.Sku, product, (value, ct) => repository.SaveAsync(value, ct), cancellationToken).ConfigureAwait(false); + return new(result.Value, result.Found, result.CacheHit, result.Written, repository.Reads, repository.Writes); + } +} + +public static partial class ProductCatalogReadWriteThroughPolicies +{ + public static ReadWriteThroughCachePolicy CreateFluent() + => ReadWriteThroughCachePolicy + .Create("product-catalog-read-write-through") + .WithTimeToLive(TimeSpan.FromMinutes(5)) + .Build(); +} + +[GenerateReadWriteThroughCachePolicy( + typeof(CatalogProduct), + FactoryMethodName = "CreateGenerated", + PolicyName = "product-catalog-read-write-through", + TimeToLiveMilliseconds = 300000)] +public static partial class GeneratedProductCatalogReadWriteThroughPolicy; + +public sealed class ProductCatalogReadWriteThroughDemoRunner(ProductCatalogReadWriteThroughCacheService service) +{ + public async ValueTask> RunAsync() + { + var first = await service.FindAsync("SKU-42").ConfigureAwait(false); + var second = await service.FindAsync("SKU-42").ConfigureAwait(false); + var write = await service.SaveAsync(new CatalogProduct("SKU-42", "Trail Jacket", 139m)).ConfigureAwait(false); + var afterWrite = await service.FindAsync("SKU-42").ConfigureAwait(false); + return [first, second, write, afterWrite]; + } +} + +public static class ProductCatalogReadWriteThroughServiceCollectionExtensions +{ + public static IServiceCollection AddProductCatalogReadWriteThroughDemo(this IServiceCollection services) + { + services.AddSingleton(static _ => GeneratedProductCatalogReadWriteThroughPolicy.CreateGenerated()); + services.AddSingleton(static _ => new ProductCatalogReadWriteRepository(new CatalogProduct("SKU-42", "Trail Jacket", 129m))); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Generators.Abstractions/ReadWriteThroughCache/ReadWriteThroughCacheAttributes.cs b/src/PatternKit.Generators.Abstractions/ReadWriteThroughCache/ReadWriteThroughCacheAttributes.cs new file mode 100644 index 00000000..cb6b0dd9 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/ReadWriteThroughCache/ReadWriteThroughCacheAttributes.cs @@ -0,0 +1,10 @@ +namespace PatternKit.Generators.ReadWriteThroughCache; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateReadWriteThroughCachePolicyAttribute(Type resultType) : Attribute +{ + public Type ResultType { get; } = resultType ?? throw new ArgumentNullException(nameof(resultType)); + public string FactoryMethodName { get; set; } = "Create"; + public string PolicyName { get; set; } = "read-write-through-cache"; + public int TimeToLiveMilliseconds { get; set; } +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index b3ed0811..f2bbca7b 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -435,3 +435,5 @@ PKMTG001 | PatternKit.Generators.ManualTaskGates | Error | Manual Task Gate host 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. +PKRWTC001 | PatternKit.Generators.ReadWriteThroughCache | Error | Read/write-through cache policy host must be partial. +PKRWTC002 | PatternKit.Generators.ReadWriteThroughCache | Error | Read/write-through cache policy configuration is invalid. diff --git a/src/PatternKit.Generators/ReadWriteThroughCache/ReadWriteThroughCachePolicyGenerator.cs b/src/PatternKit.Generators/ReadWriteThroughCache/ReadWriteThroughCachePolicyGenerator.cs new file mode 100644 index 00000000..511da0c5 --- /dev/null +++ b/src/PatternKit.Generators/ReadWriteThroughCache/ReadWriteThroughCachePolicyGenerator.cs @@ -0,0 +1,131 @@ +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace PatternKit.Generators.ReadWriteThroughCache; + +[Generator] +public sealed class ReadWriteThroughCachePolicyGenerator : IIncrementalGenerator +{ + private const string AttributeName = "PatternKit.Generators.ReadWriteThroughCache.GenerateReadWriteThroughCachePolicyAttribute"; + + 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( + "PKRWTC001", + "Read/write-through cache policy host must be partial", + "Type '{0}' is marked with [GenerateReadWriteThroughCachePolicy] but is not declared as partial", + "PatternKit.Generators.ReadWriteThroughCache", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidConfiguration = new( + "PKRWTC002", + "Read/write-through cache policy configuration is invalid", + "Read/write-through cache policy '{0}' must have non-empty FactoryMethodName and PolicyName values and TimeToLiveMilliseconds >= 0", + "PatternKit.Generators.ReadWriteThroughCache", + 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") ?? "read-write-through-cache"; + var ttl = GetNamedInt(attribute, "TimeToLiveMilliseconds") ?? 0; + if (string.IsNullOrWhiteSpace(factoryMethodName) || string.IsNullOrWhiteSpace(policyName) || ttl < 0) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidConfiguration, node.Identifier.GetLocation(), type.Name)); + return; + } + + context.AddSource($"{type.Name}.ReadWriteThroughCachePolicy.g.cs", SourceText.From(GenerateSource(type, resultType, factoryMethodName, policyName, ttl), Encoding.UTF8)); + } + + private static string GenerateSource(INamedTypeSymbol type, INamedTypeSymbol resultType, string factoryMethodName, string policyName, int ttl) + { + 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(); + } + + sb.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(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Cloud.ReadWriteThroughCache.ReadWriteThroughCachePolicy<").Append(resultTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" var builder = global::PatternKit.Cloud.ReadWriteThroughCache.ReadWriteThroughCachePolicy<").Append(resultTypeName).Append(">.Create(\"").Append(Escape(policyName)).AppendLine("\");"); + if (ttl > 0) + sb.Append(" builder.WithTimeToLive(global::System.TimeSpan.FromMilliseconds(").Append(ttl).AppendLine("));"); + else + sb.AppendLine(" builder.WithoutExpiration();"); + sb.AppendLine(" return builder.Build();"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; + + private 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?; +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs index 0fc07555..3b4c73d8 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("436 pattern route results", ctx.ResultsGuide)) + ScenarioExpect.Contains("444 pattern route results", ctx.ResultsGuide)) .AssertPassed(); [Scenario("Published benchmark results include reusable hosting integrations")] @@ -209,6 +209,9 @@ private static string HumanizeScenarioBenchmarkName(string benchmarkClassName) if (patternName == "CacheAside") return "Cache-Aside"; + if (patternName == "ReadWriteThroughCache") + return "Read-Through / Write-Through Cache"; + if (patternName == "ChainOfResponsibility") return "Chain of Responsibility"; diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 016be567..76c9d1c3 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -90,6 +90,8 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Priority Queue", "Cache-Aside", "Cache Stampede Protection", + "Read-Through Cache", + "Write-Through Cache", "Rate Limiting", "External Configuration Store", "Gateway Aggregation", @@ -165,7 +167,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(18, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); + ScenarioExpect.Equal(20, 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.Examples.Tests/ReadWriteThroughCacheDemo/ProductCatalogReadWriteThroughCacheDemoTests.cs b/test/PatternKit.Examples.Tests/ReadWriteThroughCacheDemo/ProductCatalogReadWriteThroughCacheDemoTests.cs new file mode 100644 index 00000000..ae88fe20 --- /dev/null +++ b/test/PatternKit.Examples.Tests/ReadWriteThroughCacheDemo/ProductCatalogReadWriteThroughCacheDemoTests.cs @@ -0,0 +1,68 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Cloud.ReadWriteThroughCache; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.ReadWriteThroughCacheDemo; +using TinyBDD; + +namespace PatternKit.Examples.Tests.ReadWriteThroughCacheDemo; + +public sealed class ProductCatalogReadWriteThroughCacheDemoTests +{ + [Scenario("Fluent and generated read write through cache policies coordinate catalog reads and writes")] + [Fact] + public async Task Fluent_And_Generated_ReadWriteThrough_Cache_Policies_Coordinate_Catalog_Reads_And_Writes() + { + var fluent = new ProductCatalogReadWriteThroughCacheService( + new ProductCatalogReadWriteRepository(new CatalogProduct("SKU-42", "Trail Jacket", 129m)), + ProductCatalogReadWriteThroughPolicies.CreateFluent()); + var generated = new ProductCatalogReadWriteThroughCacheService( + new ProductCatalogReadWriteRepository(new CatalogProduct("SKU-42", "Trail Jacket", 129m)), + GeneratedProductCatalogReadWriteThroughPolicy.CreateGenerated()); + + var first = await fluent.FindAsync("SKU-42"); + var second = await fluent.FindAsync("SKU-42"); + var write = await generated.SaveAsync(new CatalogProduct("SKU-42", "Trail Jacket", 139m)); + var afterWrite = await generated.FindAsync("SKU-42"); + + ScenarioExpect.False(first.CacheHit); + ScenarioExpect.True(second.CacheHit); + ScenarioExpect.Equal(1, second.OriginReads); + ScenarioExpect.True(write.Written); + ScenarioExpect.True(afterWrite.CacheHit); + ScenarioExpect.Equal(139m, afterWrite.Product!.Price); + } + + [Scenario("ServiceCollection imports read write through cache example")] + [Fact] + public async Task ServiceCollection_Imports_ReadWriteThrough_Cache_Example() + { + var services = new ServiceCollection(); + services.AddProductCatalogReadWriteThroughDemo(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var runner = provider.GetRequiredService(); + var results = await runner.RunAsync(); + + ScenarioExpect.NotNull(provider.GetRequiredService>()); + ScenarioExpect.Equal(4, results.Count); + ScenarioExpect.True(results[1].CacheHit); + ScenarioExpect.True(results[2].Written); + ScenarioExpect.True(results[3].CacheHit); + } + + [Scenario("Aggregate examples import read write through cache example")] + [Fact] + public async Task Aggregate_Examples_Import_ReadWriteThrough_Cache_Example() + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var example = provider.GetRequiredService(); + var results = await example.Runner.RunAsync(); + + ScenarioExpect.NotNull(example.Policy); + ScenarioExpect.True(results[1].CacheHit); + ScenarioExpect.True(results[2].Written); + } +} diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 1d3c65b4..69b71325 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -44,6 +44,7 @@ using PatternKit.Generators.Proxy; using PatternKit.Generators.QueueLoadLeveling; using PatternKit.Generators.RateLimiting; +using PatternKit.Generators.ReadWriteThroughCache; using PatternKit.Generators.Repository; using PatternKit.Generators.Retry; using PatternKit.Generators.SchedulerAgentSupervisor; @@ -96,6 +97,7 @@ private enum TestTrigger { typeof(GenerateBulkheadPolicyAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GenerateCacheAsidePolicyAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(CacheAsidePredicateAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateReadWriteThroughCachePolicyAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GenerateCanonicalDataModelAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(CanonicalDataModelMapperAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateEventCarriedStateTransferAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, @@ -351,6 +353,12 @@ public void CacheAside_Attributes_Expose_Defaults_And_Configuration() PolicyName = "products", TimeToLiveMilliseconds = 250 }; + var readWriteThrough = new GenerateReadWriteThroughCachePolicyAttribute(typeof(string)) + { + FactoryMethodName = "BuildProductReadWriteCache", + PolicyName = "products-read-write-through", + TimeToLiveMilliseconds = 500 + }; var externalConfig = new GenerateExternalConfigurationStoreAttribute(typeof(string)) { FactoryName = "BuildTenantConfig", @@ -363,6 +371,10 @@ public void CacheAside_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.Equal("BuildProductCache", cacheAside.FactoryMethodName); ScenarioExpect.Equal("products", cacheAside.PolicyName); ScenarioExpect.Equal(250, cacheAside.TimeToLiveMilliseconds); + ScenarioExpect.Equal(typeof(string), readWriteThrough.ResultType); + ScenarioExpect.Equal("BuildProductReadWriteCache", readWriteThrough.FactoryMethodName); + ScenarioExpect.Equal("products-read-write-through", readWriteThrough.PolicyName); + ScenarioExpect.Equal(500, readWriteThrough.TimeToLiveMilliseconds); ScenarioExpect.Equal(typeof(string), externalConfig.SettingsType); ScenarioExpect.Equal("BuildTenantConfig", externalConfig.FactoryName); ScenarioExpect.Equal("tenant-config", externalConfig.StoreName); @@ -370,6 +382,7 @@ public void CacheAside_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.Equal("Endpoint is required.", externalValidator.RejectionReason); ScenarioExpect.Equal(10, externalValidator.Order); ScenarioExpect.Throws(() => new GenerateCacheAsidePolicyAttribute(null!)); + ScenarioExpect.Throws(() => new GenerateReadWriteThroughCachePolicyAttribute(null!)); ScenarioExpect.Throws(() => new GenerateExternalConfigurationStoreAttribute(null!)); ScenarioExpect.Throws(() => new ExternalConfigurationValidatorAttribute("", 1)); ScenarioExpect.IsType(new CacheAsidePredicateAttribute()); diff --git a/test/PatternKit.Generators.Tests/AbstractionsTests.cs b/test/PatternKit.Generators.Tests/AbstractionsTests.cs index 6dc38ef5..9ca02ec6 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsTests.cs @@ -41,6 +41,41 @@ public void GenerateCacheStampedeProtectionAttribute_Has_Correct_AttributeUsage( #endregion + #region GenerateReadWriteThroughCachePolicyAttribute Tests + + [Scenario("GenerateReadWriteThroughCachePolicyAttribute Constructor Sets Properties")] + [Fact] + public void GenerateReadWriteThroughCachePolicyAttribute_Constructor_Sets_Properties() + { + var attr = new PatternKit.Generators.ReadWriteThroughCache.GenerateReadWriteThroughCachePolicyAttribute(typeof(string)) + { + FactoryMethodName = "CreateCatalogCache", + PolicyName = "catalog-read-write-through", + TimeToLiveMilliseconds = 500 + }; + + ScenarioExpect.Equal(typeof(string), attr.ResultType); + ScenarioExpect.Equal("CreateCatalogCache", attr.FactoryMethodName); + ScenarioExpect.Equal("catalog-read-write-through", attr.PolicyName); + ScenarioExpect.Equal(500, attr.TimeToLiveMilliseconds); + ScenarioExpect.Throws(() => new PatternKit.Generators.ReadWriteThroughCache.GenerateReadWriteThroughCachePolicyAttribute(null!)); + } + + [Scenario("GenerateReadWriteThroughCachePolicyAttribute Has Correct AttributeUsage")] + [Fact] + public void GenerateReadWriteThroughCachePolicyAttribute_Has_Correct_AttributeUsage() + { + var usage = typeof(PatternKit.Generators.ReadWriteThroughCache.GenerateReadWriteThroughCachePolicyAttribute) + .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/ReadWriteThroughCachePolicyGeneratorTests.cs b/test/PatternKit.Generators.Tests/ReadWriteThroughCachePolicyGeneratorTests.cs new file mode 100644 index 00000000..ed3dc49a --- /dev/null +++ b/test/PatternKit.Generators.Tests/ReadWriteThroughCachePolicyGeneratorTests.cs @@ -0,0 +1,123 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Generators.ReadWriteThroughCache; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class ReadWriteThroughCachePolicyGeneratorTests +{ + [Scenario("Generates read write through cache policy factory")] + [Fact] + public void Generates_ReadWriteThrough_Cache_Policy_Factory() + { + var result = Compile(""" + using PatternKit.Generators.ReadWriteThroughCache; + namespace Demo; + [GenerateReadWriteThroughCachePolicy(typeof(string), FactoryMethodName = "Build", PolicyName = "catalog-rwtc", TimeToLiveMilliseconds = 250)] + public static partial class ProductCache; + """); + + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("ReadWriteThroughCachePolicy Build()", source); + ScenarioExpect.Contains("ReadWriteThroughCachePolicy.Create(\"catalog-rwtc\")", source); + ScenarioExpect.Contains("WithTimeToLive(global::System.TimeSpan.FromMilliseconds(250))", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + } + + [Scenario("Generates read write through cache defaults and accessibility wrappers")] + [Fact] + public void Generates_ReadWriteThrough_Cache_Defaults_And_Accessibility_Wrappers() + { + var defaults = Compile(""" + using PatternKit.Generators.ReadWriteThroughCache; + namespace Demo; + [GenerateReadWriteThroughCachePolicy(typeof(int))] + internal partial struct InternalCache; + """); + var accessibility = Compile(""" + using PatternKit.Generators.ReadWriteThroughCache; + namespace Demo; + public partial class CatalogModule + { + [GenerateReadWriteThroughCachePolicy(typeof(string), FactoryMethodName = "CreateAbstract")] + public abstract partial class AbstractCache; + [GenerateReadWriteThroughCachePolicy(typeof(string), FactoryMethodName = "CreateSealed")] + private sealed partial class SealedCache; + [GenerateReadWriteThroughCachePolicy(typeof(string), FactoryMethodName = "CreateProtected")] + protected partial class ProtectedCache; + [GenerateReadWriteThroughCachePolicy(typeof(string), FactoryMethodName = "CreatePrivateProtected")] + private protected partial class PrivateProtectedCache; + [GenerateReadWriteThroughCachePolicy(typeof(string), FactoryMethodName = "CreateProtectedInternal")] + protected internal partial class ProtectedInternalCache; + } + """); + + ScenarioExpect.Empty(defaults.Diagnostics); + ScenarioExpect.Contains("internal partial struct InternalCache", ScenarioExpect.Single(defaults.GeneratedSources)); + ScenarioExpect.Contains("WithoutExpiration()", ScenarioExpect.Single(defaults.GeneratedSources)); + + var source = string.Join(Environment.NewLine, accessibility.GeneratedSources); + ScenarioExpect.Contains("public abstract partial class AbstractCache", source); + ScenarioExpect.Contains("private sealed partial class SealedCache", source); + ScenarioExpect.Contains("protected partial class ProtectedCache", source); + ScenarioExpect.Contains("private protected partial class PrivateProtectedCache", source); + ScenarioExpect.Contains("protected internal partial class ProtectedInternalCache", source); + } + + [Scenario("Reports diagnostics for invalid read write through cache declarations")] + [Fact] + public void Reports_Diagnostics_For_Invalid_ReadWriteThrough_Cache_Declarations() + { + var nonPartial = Compile(""" + using PatternKit.Generators.ReadWriteThroughCache; + [GenerateReadWriteThroughCachePolicy(typeof(string))] + public static class ProductCache; + """); + var invalid = Compile(""" + using PatternKit.Generators.ReadWriteThroughCache; + [GenerateReadWriteThroughCachePolicy(typeof(string), FactoryMethodName = "", PolicyName = " ", TimeToLiveMilliseconds = -1)] + public static partial class ProductCache; + """); + + ScenarioExpect.Contains(nonPartial.Diagnostics, static diagnostic => diagnostic.Id == "PKRWTC001"); + ScenarioExpect.Contains(invalid.Diagnostics, static diagnostic => diagnostic.Id == "PKRWTC002"); + } + + [Scenario("Skips read write through cache generation for malformed result type")] + [Fact] + public void Skips_ReadWriteThrough_Cache_Generation_For_Malformed_Result_Type() + { + var result = Compile(""" + using PatternKit.Generators.ReadWriteThroughCache; + [GenerateReadWriteThroughCachePolicy(typeof(MissingResult))] + public static partial class ProductCache; + """); + + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Empty(result.GeneratedSources); + ScenarioExpect.False(result.EmitSuccess); + } + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "ReadWriteThroughCachePolicyGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(PatternKit.Cloud.ReadWriteThroughCache.ReadWriteThroughCachePolicy<>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new ReadWriteThroughCachePolicyGenerator(), 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/ReadWriteThroughCache/ReadWriteThroughCachePolicyTests.cs b/test/PatternKit.Tests/Cloud/ReadWriteThroughCache/ReadWriteThroughCachePolicyTests.cs new file mode 100644 index 00000000..97f3a221 --- /dev/null +++ b/test/PatternKit.Tests/Cloud/ReadWriteThroughCache/ReadWriteThroughCachePolicyTests.cs @@ -0,0 +1,95 @@ +using PatternKit.Cloud.ReadWriteThroughCache; +using TinyBDD; + +namespace PatternKit.Tests.Cloud.ReadWriteThroughCache; + +public sealed class ReadWriteThroughCachePolicyTests +{ + [Scenario("Read-through cache loads misses and reuses cached values")] + [Fact] + public async Task ReadThrough_Cache_Loads_Misses_And_Reuses_Cached_Values() + { + var policy = ReadWriteThroughCachePolicy.Create("products").WithTimeToLive(TimeSpan.FromMinutes(1)).Build(); + var loads = 0; + + ValueTask Loader(CancellationToken _) + => new($"catalog-{Interlocked.Increment(ref loads)}"); + + var miss = await policy.ReadThroughAsync("sku-1", Loader); + var hit = await policy.ReadThroughAsync("sku-1", Loader); + + ScenarioExpect.Equal("products", policy.Name); + ScenarioExpect.Equal(TimeSpan.FromMinutes(1), policy.TimeToLive); + ScenarioExpect.False(miss.CacheHit); + ScenarioExpect.True(miss.Found); + ScenarioExpect.True(hit.CacheHit); + ScenarioExpect.Equal("catalog-1", hit.Value); + ScenarioExpect.Equal(1, loads); + } + + [Scenario("Write-through cache persists before updating cache")] + [Fact] + public async Task WriteThrough_Cache_Persists_Before_Updating_Cache() + { + var policy = ReadWriteThroughCachePolicy.Create().Build(); + var persisted = new List(); + + var written = await policy.WriteThroughAsync("sku-1", "new-value", (value, _) => + { + persisted.Add(value); + return ValueTask.CompletedTask; + }); + var read = await policy.ReadThroughAsync("sku-1", static _ => new ValueTask("origin")); + + ScenarioExpect.True(written.Written); + ScenarioExpect.Equal("new-value", written.Value); + ScenarioExpect.Equal(["new-value"], persisted); + ScenarioExpect.True(read.CacheHit); + ScenarioExpect.Equal("new-value", read.Value); + } + + [Scenario("Read/write-through cache releases misses and validates input")] + [Fact] + public async Task ReadWriteThrough_Cache_Releases_Misses_And_Validates_Input() + { + var policy = ReadWriteThroughCachePolicy.Create().WithoutExpiration().Build(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var missing = await policy.ReadThroughAsync("missing", static _ => new ValueTask((string?)null)); + + ScenarioExpect.False(missing.Found); + ScenarioExpect.False(missing.CacheHit); + ScenarioExpect.Throws(() => ReadWriteThroughCachePolicy.Create(" ").Build()); + await ScenarioExpect.ThrowsAsync(async () => await policy.ReadThroughAsync("", static _ => new ValueTask("value"))); + await ScenarioExpect.ThrowsAsync(async () => await policy.ReadThroughAsync("sku-1", null!)); + await ScenarioExpect.ThrowsAsync(async () => await policy.WriteThroughAsync("sku-1", "value", null!)); + await ScenarioExpect.ThrowsAsync(async () => await policy.ReadThroughAsync("sku-1", static _ => new ValueTask("value"), cts.Token)); + } + + [Scenario("Read write through cache exposes store operations and builder validation")] + [Fact] + public async Task ReadWriteThrough_Cache_Exposes_Store_Operations_And_Builder_Validation() + { + var store = new PatternKit.Cloud.CacheAside.InMemoryCacheAsideStore(); + var builder = ReadWriteThroughCachePolicy.Create("custom"); + var returned = builder.WithStore(store); + var policy = builder.Build(); + + var written = await policy.WriteThroughAsync("sku-1", "value", static (_, _) => ValueTask.CompletedTask); + var invalidated = policy.Invalidate("sku-1"); + await policy.WriteThroughAsync("sku-2", "other", static (_, _) => ValueTask.CompletedTask); + policy.Clear(); + var afterClear = await policy.ReadThroughAsync("sku-2", static _ => new ValueTask((string?)null)); + + ScenarioExpect.Same(builder, returned); + ScenarioExpect.Same(store, policy.Store); + ScenarioExpect.Equal("sku-1", written.Key); + ScenarioExpect.True(written.CacheMiss); + ScenarioExpect.True(invalidated); + ScenarioExpect.True(afterClear.CacheMiss); + ScenarioExpect.Throws(() => ReadWriteThroughCachePolicy.Create().WithTimeToLive(TimeSpan.FromMilliseconds(-1)).Build()); + ScenarioExpect.Throws(() => ReadWriteThroughCachePolicy.Create().WithStore(null!)); + await ScenarioExpect.ThrowsAsync(async () => await policy.WriteThroughAsync("", "value", static (_, _) => ValueTask.CompletedTask)); + } +}