diff --git a/README.md b/README.md index ca4bea5c..6a60fbbc 100644 --- a/README.md +++ b/README.md @@ -473,11 +473,11 @@ var cachedRemoteProxy = Proxy.Create(id => remoteProxy.Execute(id)) --- ## Patterns Table -PatternKit currently tracks 116 production-readiness patterns. Each catalog pattern is represented in tests, documentation, real-world examples, IoC integration, and the BenchmarkDotNet coverage matrix. +PatternKit currently tracks 117 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 | 26 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Eventual Consistency Monitor, Feature Toggle, Identity Map, Manual Task Gate, Materialized View, Repository, Service Layer, Snapshot / Checkpoint Management, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object, Workflow Orchestration | +| Application Architecture | 27 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Eventual Consistency Monitor, Feature Toggle, Identity Map, Lazy Load, Manual Task Gate, Materialized View, Repository, Service Layer, Snapshot / Checkpoint Management, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object, Workflow Orchestration | | Behavioral | 12 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Null Object, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 20 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Cache Stampede Protection, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Read-Through Cache, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig, Write-Through Cache | | Creational | 6 | Abstract Factory, Builder, Factory Method, Object Pool, Prototype, Singleton | diff --git a/benchmarks/PatternKit.Benchmarks/Application/LazyLoadBenchmarks.cs b/benchmarks/PatternKit.Benchmarks/Application/LazyLoadBenchmarks.cs new file mode 100644 index 00000000..53e6b288 --- /dev/null +++ b/benchmarks/PatternKit.Benchmarks/Application/LazyLoadBenchmarks.cs @@ -0,0 +1,41 @@ +using BenchmarkDotNet.Attributes; +using PatternKit.Application.LazyLoading; +using PatternKit.Examples.LazyLoadDemo; + +namespace PatternKit.Benchmarks.Application; + +[BenchmarkCategory("ApplicationArchitecture", "LazyLoad")] +public class LazyLoadBenchmarks +{ + private static readonly ICustomerProfileStore Store = new InMemoryCustomerProfileStore(new CustomerProfile(Guid.Empty, "Ada Lovelace", "Gold")); + + [Benchmark(Baseline = true, Description = "Fluent: create lazy load")] + [BenchmarkCategory("Fluent", "Construction")] + public LazyLoad Fluent_Create() + => CustomerProfileLazyLoadPolicies.CreateFluent(Store); + + [Benchmark(Description = "Generated: create lazy load")] + [BenchmarkCategory("Generated", "Construction")] + public LazyLoad Generated_Create() + { + GeneratedCustomerProfileLazyLoad.UseStore(Store); + return GeneratedCustomerProfileLazyLoad.CreateGenerated(); + } + + [Benchmark(Description = "Fluent: resolve lazy value")] + [BenchmarkCategory("Fluent", "Execution")] + public async ValueTask Fluent_Resolve() + { + var service = new CustomerProfileLazyLoadService(CustomerProfileLazyLoadPolicies.CreateFluent(Store)); + return await service.GetTierAsync().ConfigureAwait(false); + } + + [Benchmark(Description = "Generated: resolve lazy value")] + [BenchmarkCategory("Generated", "Execution")] + public async ValueTask Generated_Resolve() + { + GeneratedCustomerProfileLazyLoad.UseStore(Store); + var service = new CustomerProfileLazyLoadService(GeneratedCustomerProfileLazyLoad.CreateGenerated()); + return await service.GetTierAsync().ConfigureAwait(false); + } +} diff --git a/docs/examples/customer-profile-lazy-load.md b/docs/examples/customer-profile-lazy-load.md new file mode 100644 index 00000000..a23b66b9 --- /dev/null +++ b/docs/examples/customer-profile-lazy-load.md @@ -0,0 +1,24 @@ +# Customer Profile Lazy Load + +The customer profile lazy-load example shows a production-shaped deferred profile lookup. It includes a fluent lazy loader, a source-generated lazy loader factory, and an `IServiceCollection` extension that registers the loader and application service. + +Import it into a host: + +```csharp +services.AddCustomerProfileLazyLoadDemo(); +``` + +For reusable app-level registration without importing examples: + +```csharp +services.AddPatternKitLazyLoad( + (provider, ct) => + { + var store = provider.GetRequiredService(); + return store.LoadAsync(customerId, ct); + }, + "customer-profile", + builder => builder.WithTimeToLive(TimeSpan.FromMinutes(5))); +``` + +The example validates that the expensive load is deferred, cached, refreshable through invalidation, and importable through standard Microsoft dependency injection. diff --git a/docs/examples/index.md b/docs/examples/index.md index b29f71d7..8cffc98b 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -57,6 +57,9 @@ Welcome! This section collects small, focused demos that show **how to compose b * **Checkout Backpressure** A Generic Host importable checkout admission gate with fluent and source-generated routes, `IServiceCollection` registration, and explicit saturation behavior. See [Checkout Backpressure](checkout-backpressure.md). +* **Customer Profile Lazy Load** + A Generic Host importable deferred profile lookup with fluent and source-generated routes, `IServiceCollection` registration, TTL caching, and invalidation. See [Customer Profile Lazy Load](customer-profile-lazy-load.md). + * **Minimal Web Request Router** A tiny "API gateway" that separates **first-match middleware** (side effects/logging/auth) from **first-match routes** and **content negotiation**. A crisp example of Strategy patterns in an HTTP-ish setting. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 33d851aa..bcb1de5e 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -208,6 +208,9 @@ - name: Order Identity Map Pattern href: order-identity-map-pattern.md +- name: Customer Profile Lazy Load + href: customer-profile-lazy-load.md + - name: Order Transaction Script Pattern href: order-transaction-script-pattern.md diff --git a/docs/generators/index.md b/docs/generators/index.md index 5b5d60c5..c3c88778 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -73,6 +73,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Unit of Work**](unit-of-work.md) | Ordered commit and rollback units | `[GenerateUnitOfWork]` | | [**Data Mapper**](data-mapper.md) | Domain/data model mapper factories | `[GenerateDataMapper]` | | [**Identity Map**](identity-map.md) | Scoped object identity caches from key selectors | `[GenerateIdentityMap]` | +| [**Lazy Load**](lazy-load.md) | Deferred value factories with caching, TTL, and invalidation | `[GenerateLazyLoad]` | | [**Materialized View**](materialized-view.md) | Event projection read-model factories from handlers | `[GenerateMaterializedView]` | | [**Transaction Script**](transaction-script.md) | Typed application workflow factories | `[GenerateTransactionScript]` | | [**Service Layer**](service-layer.md) | Application operation boundary factories | `[GenerateServiceLayerOperation]` | @@ -234,6 +235,10 @@ public static partial class InventoryStateTransfer { } [GenerateEventNotification(typeof(OrderAccepted), typeof(string))] public static partial class OrderAcceptedNotification { } +// Lazy load - deferred value factory +[GenerateLazyLoad(typeof(CustomerProfile), LoaderMethodName = "LoadProfileAsync", TimeToLiveMilliseconds = 300000)] +public static partial class CustomerProfileLazyLoad { } + // Claim check - external payload storage reference [GenerateClaimCheck(typeof(LargeOrderDocument), StoreName = "document-archive")] public static partial class LargeDocumentClaims { } diff --git a/docs/generators/lazy-load.md b/docs/generators/lazy-load.md new file mode 100644 index 00000000..e17c3e76 --- /dev/null +++ b/docs/generators/lazy-load.md @@ -0,0 +1,31 @@ +# Lazy Load Generator + +`LazyLoadGenerator` emits a typed `LazyLoad` factory from a loader method. + +```csharp +[GenerateLazyLoad( + typeof(CustomerProfile), + FactoryMethodName = "CreateProfile", + LoaderMethodName = "LoadProfileAsync", + LazyLoadName = "customer-profile", + TimeToLiveMilliseconds = 300000)] +public static partial class CustomerProfileLazyLoad +{ + public static ValueTask LoadProfileAsync(CancellationToken cancellationToken) + => store.LoadAsync(customerId, cancellationToken); +} +``` + +## Diagnostics + +| ID | Severity | Message | +| --- | --- | --- | +| `PKLL001` | Error | The host type must be partial. | +| `PKLL002` | Error | Time-to-live values must be non-negative. | +| `PKLL003` | Error | Factory and loader method names must be valid C# identifiers. | + +Register generated lazy loaders with normal .NET hosts by calling the generated factory from the service registration: + +```csharp +services.AddSingleton(_ => CustomerProfileLazyLoad.CreateProfile()); +``` diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 7fec36dd..95a6f458 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -103,6 +103,9 @@ - name: Identity Map href: identity-map.md +- name: Lazy Load + href: lazy-load.md + - name: Materialized View href: materialized-view.md diff --git a/docs/guides/benchmark-results.md b/docs/guides/benchmark-results.md index ef18eb68..cf28e0b8 100644 --- a/docs/guides/benchmark-results.md +++ b/docs/guides/benchmark-results.md @@ -51,6 +51,8 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 | Bulkhead | Execution | 102.70 ns | 592 B | 106.11 ns | 592 B | Same allocation; fluent was slightly faster for the shipping allocation workflow. | | Backpressure | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Backpressure | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Lazy Load | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Lazy Load | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | 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. | @@ -254,11 +256,11 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 ## Coverage Matrix Summary -The coverage matrix currently publishes 116 catalog patterns and 464 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 11 reusable hosting integration route results for package-level `IServiceCollection` registrations. +The coverage matrix currently publishes 117 catalog patterns and 468 pattern route results. Each pattern has four BenchmarkDotNet routes: fluent construction, fluent execution, source-generated construction, and source-generated execution. The reusable hosting integration matrix publishes 12 reusable hosting integration route results for package-level `IServiceCollection` registrations. | Category | Patterns | Published route results | | --- | ---: | ---: | -| Application Architecture | 26 | 104 | +| Application Architecture | 27 | 108 | | Behavioral | 12 | 48 | | Cloud Architecture | 20 | 80 | | Creational | 6 | 24 | @@ -266,7 +268,7 @@ The coverage matrix currently publishes 116 catalog patterns and 464 pattern rou | Messaging Reliability | 4 | 16 | | Structural | 7 | 28 | -The generator matrix currently publishes 111 generator source route results. +The generator matrix currently publishes 112 generator source route results. ## Hosting Integration Matrix Results @@ -276,6 +278,7 @@ The generator matrix currently publishes 111 generator source route results. | Bulkhead | `IServiceCollection` | `AddPatternKitBulkheadPolicy` | `src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs` | `test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs` | `docs/guides/hosting-extensions.md` | | Circuit Breaker | `IServiceCollection` | `AddPatternKitCircuitBreakerPolicy` | `src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs` | `test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs` | `docs/guides/hosting-extensions.md` | | Guaranteed Delivery | `IServiceCollection` | `AddPatternKitGuaranteedDelivery` | `src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs` | `test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs` | `docs/guides/hosting-extensions.md` | +| Lazy Load | `IServiceCollection` | `AddPatternKitLazyLoad` | `src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs` | `test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs` | `docs/guides/hosting-extensions.md` | | Message Channel | `IServiceCollection` | `AddPatternKitMessageChannel` | `src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs` | `test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs` | `docs/guides/hosting-extensions.md` | | Message Store | `IServiceCollection` | `AddPatternKitMessageStore` | `src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs` | `test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs` | `docs/guides/hosting-extensions.md` | | Null Object | `IServiceCollection` | `AddPatternKitNullObject` | `src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs` | `test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs` | `docs/guides/hosting-extensions.md` | @@ -304,9 +307,10 @@ The generator matrix currently publishes 111 generator source route results. | Application Architecture | Domain Event | Covered | Covered | Covered | Covered | | Application Architecture | Domain Service | Covered | Covered | Covered | Covered | | Application Architecture | Event Sourcing | Covered | Covered | Covered | Covered | -| Application Architecture | Feature Toggle | Covered | Covered | Covered | Covered | -| Application Architecture | Identity Map | Covered | Covered | Covered | Covered | -| Application Architecture | Materialized View | Covered | Covered | Covered | Covered | +| Application Architecture | Feature Toggle | Covered | Covered | Covered | Covered | +| Application Architecture | Identity Map | Covered | Covered | Covered | Covered | +| Application Architecture | Lazy Load | Covered | Covered | Covered | Covered | +| Application Architecture | Materialized View | Covered | Covered | Covered | Covered | | Application Architecture | Repository | Covered | Covered | Covered | Covered | | Application Architecture | Service Layer | Covered | Covered | Covered | Covered | | Application Architecture | Specification | Covered | Covered | Covered | Covered | @@ -452,8 +456,9 @@ The generator matrix currently publishes 111 generator source route results. | GatewayAggregationGenerator | `src/PatternKit.Generators/GatewayAggregation/GatewayAggregationGenerator.cs` | Covered | | GatewayRoutingGenerator | `src/PatternKit.Generators/GatewayRouting/GatewayRoutingGenerator.cs` | Covered | | HealthEndpointMonitoringGenerator | `src/PatternKit.Generators/HealthEndpointMonitoring/HealthEndpointMonitoringGenerator.cs` | Covered | -| IdentityMapGenerator | `src/PatternKit.Generators/IdentityMap/IdentityMapGenerator.cs` | Covered | -| InterpreterGenerator | `src/PatternKit.Generators/Interpreter/InterpreterGenerator.cs` | Covered | +| IdentityMapGenerator | `src/PatternKit.Generators/IdentityMap/IdentityMapGenerator.cs` | Covered | +| LazyLoadGenerator | `src/PatternKit.Generators/LazyLoading/LazyLoadGenerator.cs` | Covered | +| InterpreterGenerator | `src/PatternKit.Generators/Interpreter/InterpreterGenerator.cs` | Covered | | IteratorGenerator | `src/PatternKit.Generators/Iterator/IteratorGenerator.cs` | Covered | | LeaderElectionGenerator | `src/PatternKit.Generators/LeaderElection/LeaderElectionGenerator.cs` | Covered | | MaterializedViewGenerator | `src/PatternKit.Generators/MaterializedViews/MaterializedViewGenerator.cs` | Covered | diff --git a/docs/guides/hosting-extensions.md b/docs/guides/hosting-extensions.md index d73457ff..2ff8fb0f 100644 --- a/docs/guides/hosting-extensions.md +++ b/docs/guides/hosting-extensions.md @@ -71,6 +71,10 @@ services .WithCapacity(8) .WithMode(BackpressureMode.Wait) .WithWaitTimeout(TimeSpan.FromMilliseconds(50))) + .AddPatternKitLazyLoad( + (_, _) => new ValueTask(new ServiceReply(true)), + "inventory-lazy", + lazy => lazy.WithTimeToLive(TimeSpan.FromMinutes(5))) .AddPatternKitRateLimitPolicy( "inventory-rate-limit", rateLimit => rateLimit @@ -122,6 +126,7 @@ Every catalog pattern is importable through the production example catalog. The | Circuit Breaker | `AddPatternKitCircuitBreakerPolicy` | Register named circuit breakers with shared state. | | Bulkhead | `AddPatternKitBulkheadPolicy` | Register concurrency and queue isolation policies. | | Backpressure | `AddPatternKitBackpressurePolicy` | Register admission gates for saturated work boundaries. | +| Lazy Load | `AddPatternKitLazyLoad` | Register deferred value loaders with cache, TTL, and invalidation support. | | Rate Limiting | `AddPatternKitRateLimitPolicy` | Register per-key rate windows. | | Queue-Based Load Leveling | `AddPatternKitQueueLoadLevelingPolicy` | Register queue-backed worker policies. | | Priority Queue | `AddPatternKitPriorityQueue` | Register priority-ordered work queues. | diff --git a/docs/index.md b/docs/index.md index b4587ab3..9db05552 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,11 +66,11 @@ if (parser.Execute("123", out var value)) ## 📚 Available Patterns -PatternKit covers 116 production-readiness patterns with fluent APIs, source-generated routes where applicable, IoC integration examples, TinyBDD coverage, and BenchmarkDotNet coverage-matrix validation: +PatternKit covers 117 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 | 26 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Eventual Consistency Monitor, Feature Toggle, Identity Map, Manual Task Gate, Materialized View, Repository, Service Layer, Snapshot / Checkpoint Management, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object, Workflow Orchestration | +| Application Architecture | 27 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Eventual Consistency Monitor, Feature Toggle, Identity Map, Lazy Load, Manual Task Gate, Materialized View, Repository, Service Layer, Snapshot / Checkpoint Management, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object, Workflow Orchestration | | Behavioral | 12 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Null Object, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 20 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Cache Stampede Protection, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Read-Through Cache, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig, Write-Through Cache | | Creational | 6 | Abstract Factory, Builder, Factory Method, Object Pool, Prototype, Singleton | diff --git a/docs/patterns/application/lazy-load.md b/docs/patterns/application/lazy-load.md new file mode 100644 index 00000000..70698e34 --- /dev/null +++ b/docs/patterns/application/lazy-load.md @@ -0,0 +1,37 @@ +# Lazy Load + +Lazy Load defers expensive work until the value is actually needed. Use it for profile data, reference data, large aggregates, or remote calls that should not run during object construction or host startup. + +The PatternKit runtime gives the deferred value an explicit name, single-flight loading, optional caching, invalidation, cancellation, and TTL-based refresh. + +## Fluent Path + +```csharp +var profile = LazyLoad.Create("customer-profile") + .LoadWith(ct => store.LoadAsync(customerId, ct)) + .WithTimeToLive(TimeSpan.FromMinutes(5)) + .Build(); + +var loaded = await profile.GetAsync(cancellationToken); +``` + +`GetAsync` returns a `LazyLoadResult` so the caller can see the value, whether this call loaded it, whether it came from cache, and when the cached value was originally loaded. Repeated calls share the cached value until `Invalidate` is called or the TTL expires. + +## DI Usage + +Register lazy loaders as scoped or singleton services depending on ownership of the cached value: + +```csharp +services.AddPatternKitLazyLoad( + (provider, ct) => + { + var store = provider.GetRequiredService(); + return store.LoadAsync(customerId, ct); + }, + "customer-profile", + builder => builder.WithTimeToLive(TimeSpan.FromMinutes(5))); +``` + +Use scoped registration for request-owned data and singleton registration for process-wide reference data. + +See [Customer Profile Lazy Load](../../examples/customer-profile-lazy-load.md). diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index ac43d1e3..3f7289e0 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -435,6 +435,8 @@ href: application/data-mapper.md - name: Identity Map href: application/identity-map.md + - name: Lazy Load + href: application/lazy-load.md - name: Transaction Script href: application/transaction-script.md - name: Service Layer diff --git a/src/PatternKit.Core/Application/LazyLoading/LazyLoad.cs b/src/PatternKit.Core/Application/LazyLoading/LazyLoad.cs new file mode 100644 index 00000000..1fc93746 --- /dev/null +++ b/src/PatternKit.Core/Application/LazyLoading/LazyLoad.cs @@ -0,0 +1,139 @@ +namespace PatternKit.Application.LazyLoading; + +public sealed class LazyLoadResult +{ + public LazyLoadResult(TValue value, bool loaded, bool cached, DateTimeOffset loadedAt) + { + Value = value; + Loaded = loaded; + Cached = cached; + LoadedAt = loadedAt; + } + + public TValue Value { get; } + public bool Loaded { get; } + public bool Cached { get; } + public DateTimeOffset LoadedAt { get; } +} + +public sealed class LazyLoad +{ + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly Func> _loader; + private readonly Func _utcNow; + private LazyLoadResult? _current; + private int _version; + + private LazyLoad(string name, Func> loader, bool cacheEnabled, TimeSpan? timeToLive, Func utcNow) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Lazy load name is required.", nameof(name)); + + Name = name; + _loader = loader ?? throw new ArgumentNullException(nameof(loader)); + CacheEnabled = cacheEnabled; + TimeToLive = timeToLive; + _utcNow = utcNow ?? throw new ArgumentNullException(nameof(utcNow)); + } + + public string Name { get; } + public bool CacheEnabled { get; } + public TimeSpan? TimeToLive { get; } + public bool IsLoaded => TryGetCurrent(out _); + + public static Builder Create(string name = "lazy-load") => new(name); + + public async ValueTask> GetAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (TryGetCurrent(out var current)) + return new(current.Value, false, true, current.LoadedAt); + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (TryGetCurrent(out current)) + return new(current.Value, false, true, current.LoadedAt); + + var version = Volatile.Read(ref _version); + var value = await _loader(cancellationToken).ConfigureAwait(false); + var loaded = new LazyLoadResult(value, true, false, _utcNow()); + if (CacheEnabled && version == Volatile.Read(ref _version)) + Volatile.Write(ref _current, loaded); + + return loaded; + } + finally + { + _gate.Release(); + } + } + + public void Invalidate() + { + Interlocked.Increment(ref _version); + Volatile.Write(ref _current, null); + } + + private bool TryGetCurrent(out LazyLoadResult current) + { + var candidate = Volatile.Read(ref _current); + if (candidate is not null && !IsExpired(candidate)) + { + current = candidate; + return true; + } + + current = null!; + return false; + } + + private bool IsExpired(LazyLoadResult current) + => TimeToLive is { } ttl && _utcNow() - current.LoadedAt >= ttl; + + public sealed class Builder + { + private readonly string _name; + private Func>? _loader; + private bool _cacheEnabled = true; + private TimeSpan? _timeToLive; + private Func _utcNow = () => DateTimeOffset.UtcNow; + + internal Builder(string name) => _name = name; + + public Builder LoadWith(Func> loader) + { + _loader = loader ?? throw new ArgumentNullException(nameof(loader)); + return this; + } + + public Builder DisableCache() + { + _cacheEnabled = false; + return this; + } + + public Builder WithTimeToLive(TimeSpan timeToLive) + { + if (timeToLive <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(timeToLive), timeToLive, "Lazy load time to live must be positive."); + + _timeToLive = timeToLive; + return this; + } + + public Builder WithClock(Func utcNow) + { + _utcNow = utcNow ?? throw new ArgumentNullException(nameof(utcNow)); + return this; + } + + public LazyLoad Build() + { + if (_loader is null) + throw new InvalidOperationException("Lazy load requires a loader."); + + return new(_name, _loader, _cacheEnabled, _timeToLive, _utcNow); + } + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index c8890eed..bfe9c610 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -63,6 +63,7 @@ using PatternKit.Examples.Generators.Visitors; using PatternKit.Examples.HealthEndpointMonitoringDemo; using PatternKit.Examples.IdentityMapDemo; +using PatternKit.Examples.LazyLoadDemo; using PatternKit.Examples.LeaderElectionDemo; using PatternKit.Examples.ManualTaskGateDemo; using PatternKit.Examples.MaterializedViewDemo; @@ -227,6 +228,7 @@ public sealed record OrderRepositoryPatternExample(OrderRepositoryDemoRunner Run public sealed record CheckoutUnitOfWorkPatternExample(CheckoutUnitOfWorkDemoRunner Runner, CheckoutUnitOfWorkWorkflow Workflow); public sealed record OrderDataMapperPatternExample(OrderDataMapperDemoRunner Runner, OrderDataMapperWorkflow Workflow); public sealed record OrderIdentityMapPatternExample(OrderIdentityMapDemoRunner Runner); +public sealed record CustomerProfileLazyLoadPatternExample(CustomerProfileLazyLoadService Service); public sealed record OrderTransactionScriptPatternExample(OrderTransactionScriptDemoRunner Runner); public sealed record CustomerServiceLayerPatternExample(CustomerServiceLayerDemoRunner Runner); public sealed record OrderDomainEventPatternExample(OrderDomainEventDemoRunner Runner); @@ -351,6 +353,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddCheckoutUnitOfWorkPatternExample() .AddOrderDataMapperPatternExample() .AddOrderIdentityMapPatternExample() + .AddCustomerProfileLazyLoadPatternExample() .AddOrderTransactionScriptPatternExample() .AddCustomerServiceLayerPatternExample() .AddOrderDomainEventPatternExample() @@ -1038,6 +1041,13 @@ public static IServiceCollection AddOrderIdentityMapPatternExample(this IService return services.RegisterExample("Order Identity Map Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddCustomerProfileLazyLoadPatternExample(this IServiceCollection services) + { + services.AddCustomerProfileLazyLoadDemo(); + services.AddSingleton(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Customer Profile Lazy Load", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddOrderTransactionScriptPatternExample(this IServiceCollection services) { services.AddOrderTransactionScriptDemo(); diff --git a/src/PatternKit.Examples/LazyLoadDemo/CustomerProfileLazyLoadDemo.cs b/src/PatternKit.Examples/LazyLoadDemo/CustomerProfileLazyLoadDemo.cs new file mode 100644 index 00000000..5471fe77 --- /dev/null +++ b/src/PatternKit.Examples/LazyLoadDemo/CustomerProfileLazyLoadDemo.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.LazyLoading; +using PatternKit.Generators.LazyLoading; + +namespace PatternKit.Examples.LazyLoadDemo; + +public sealed record CustomerProfile(Guid CustomerId, string Name, string Tier); + +public interface ICustomerProfileStore +{ + ValueTask LoadAsync(Guid customerId, CancellationToken cancellationToken = default); +} + +public sealed class InMemoryCustomerProfileStore(CustomerProfile profile) : ICustomerProfileStore +{ + public ValueTask LoadAsync(Guid customerId, CancellationToken cancellationToken = default) + => new(profile with { CustomerId = customerId }); +} + +public sealed class CustomerProfileLazyLoadService(LazyLoad profile) +{ + public async ValueTask GetTierAsync(CancellationToken cancellationToken = default) + { + var loaded = await profile.GetAsync(cancellationToken).ConfigureAwait(false); + return loaded.Value.Tier; + } + + public void Refresh() => profile.Invalidate(); +} + +public static class CustomerProfileLazyLoadPolicies +{ + private static readonly Guid CustomerId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + public static LazyLoad CreateFluent(ICustomerProfileStore store) + => LazyLoad.Create("customer-profile") + .LoadWith(ct => store.LoadAsync(CustomerId, ct)) + .WithTimeToLive(TimeSpan.FromMinutes(5)) + .Build(); +} + +[GenerateLazyLoad( + typeof(CustomerProfile), + FactoryMethodName = nameof(CreateGenerated), + LoaderMethodName = nameof(LoadGeneratedAsync), + LazyLoadName = "customer-profile", + TimeToLiveMilliseconds = 300000)] +public static partial class GeneratedCustomerProfileLazyLoad +{ + private static readonly Guid CustomerId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static ICustomerProfileStore? _store; + + public static void UseStore(ICustomerProfileStore store) => _store = store; + + public static ValueTask LoadGeneratedAsync(CancellationToken cancellationToken) + => (_store ?? throw new InvalidOperationException("Customer profile store is not configured.")) + .LoadAsync(CustomerId, cancellationToken); +} + +public static class CustomerProfileLazyLoadServiceCollectionExtensions +{ + public static IServiceCollection AddCustomerProfileLazyLoadDemo(this IServiceCollection services) + { + services.AddSingleton(_ => new InMemoryCustomerProfileStore(new CustomerProfile(Guid.Empty, "Ada Lovelace", "Gold"))); + services.AddSingleton(provider => CustomerProfileLazyLoadPolicies.CreateFluent(provider.GetRequiredService())); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index e97ae6e6..dfa117d4 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -592,6 +592,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["IdentityMap"], ["request-scoped identity reuse", "source-generated map factory", "DI composition"]), + Descriptor( + "Customer Profile Lazy Load", + "src/PatternKit.Examples/LazyLoadDemo/CustomerProfileLazyLoadDemo.cs", + "test/PatternKit.Examples.Tests/LazyLoadDemo/CustomerProfileLazyLoadDemoTests.cs", + "docs/examples/customer-profile-lazy-load.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["Lazy Load"], + ["deferred profile relationship", "source-generated lazy-load factory", "DI composition"]), Descriptor( "Order Transaction Script Pattern", "src/PatternKit.Examples/TransactionScriptDemo/OrderTransactionScriptDemo.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitHostingIntegrationCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitHostingIntegrationCatalog.cs index 0ec2d0dc..506c177f 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitHostingIntegrationCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitHostingIntegrationCatalog.cs @@ -61,6 +61,7 @@ public sealed class PatternKitHostingIntegrationCatalog : IPatternKitHostingInte ["Queue-Based Load Leveling"] = "AddPatternKitQueueLoadLevelingPolicy", ["Priority Queue"] = "AddPatternKitPriorityQueue", ["Backpressure"] = "AddPatternKitBackpressurePolicy", + ["Lazy Load"] = "AddPatternKitLazyLoad", ["Null Object"] = "AddPatternKitNullObject" }; diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 4412d205..333ff3af 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -1376,6 +1376,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/IdentityMapDemo/OrderIdentityMapDemoTests.cs", ["fluent scoped identity map", "generated identity-map factory", "DI-importable request scope example"]), + Pattern("Lazy Load", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/lazy-load.md", + "src/PatternKit.Core/Application/LazyLoading/LazyLoad.cs", + "test/PatternKit.Tests/Application/LazyLoading/LazyLoadTests.cs", + "docs/generators/lazy-load.md", + "src/PatternKit.Generators/LazyLoading/LazyLoadGenerator.cs", + "test/PatternKit.Generators.Tests/LazyLoadGeneratorTests.cs", + null, + "docs/examples/customer-profile-lazy-load.md", + "src/PatternKit.Examples/LazyLoadDemo/CustomerProfileLazyLoadDemo.cs", + "test/PatternKit.Examples.Tests/LazyLoadDemo/CustomerProfileLazyLoadDemoTests.cs", + ["fluent deferred value loader", "generated lazy load factory", "DI-importable customer profile example"]), + Pattern("Transaction Script", PatternFamily.ApplicationArchitecture, "docs/patterns/application/transaction-script.md", "src/PatternKit.Core/Application/TransactionScript/TransactionScript.cs", diff --git a/src/PatternKit.Generators.Abstractions/LazyLoading/LazyLoadAttributes.cs b/src/PatternKit.Generators.Abstractions/LazyLoading/LazyLoadAttributes.cs new file mode 100644 index 00000000..8f0d8e0c --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/LazyLoading/LazyLoadAttributes.cs @@ -0,0 +1,12 @@ +namespace PatternKit.Generators.LazyLoading; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateLazyLoadAttribute(Type valueType) : Attribute +{ + public Type ValueType { get; } = valueType ?? throw new ArgumentNullException(nameof(valueType)); + public string FactoryMethodName { get; set; } = "Create"; + public string LoaderMethodName { get; set; } = "LoadAsync"; + public string LazyLoadName { get; set; } = "lazy-load"; + public bool CacheEnabled { get; set; } = true; + public int TimeToLiveMilliseconds { get; set; } +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 5c99db14..beb5f72c 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -458,3 +458,6 @@ PKBP001 | PatternKit.Generators.Backpressure | Error | Backpressure policy host PKBP002 | PatternKit.Generators.Backpressure | Error | Backpressure policy configuration is invalid. PKBP003 | PatternKit.Generators.Backpressure | Error | Backpressure factory method name is invalid. PKBP004 | PatternKit.Generators.Backpressure | Error | Backpressure mode is invalid. +PKLL001 | PatternKit.Generators.LazyLoading | Error | Lazy load host must be partial. +PKLL002 | PatternKit.Generators.LazyLoading | Error | Lazy load configuration is invalid. +PKLL003 | PatternKit.Generators.LazyLoading | Error | Lazy load member name is invalid. diff --git a/src/PatternKit.Generators/LazyLoading/LazyLoadGenerator.cs b/src/PatternKit.Generators/LazyLoading/LazyLoadGenerator.cs new file mode 100644 index 00000000..6663f7ac --- /dev/null +++ b/src/PatternKit.Generators/LazyLoading/LazyLoadGenerator.cs @@ -0,0 +1,205 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace PatternKit.Generators.LazyLoading; + +[Generator] +public sealed class LazyLoadGenerator : IIncrementalGenerator +{ + private const string GenerateLazyLoadAttributeName = "PatternKit.Generators.LazyLoading.GenerateLazyLoadAttribute"; + + 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( + "PKLL001", + "Lazy load host must be partial", + "Type '{0}' is marked with [GenerateLazyLoad] but is not declared as partial", + "PatternKit.Generators.LazyLoading", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidConfiguration = new( + "PKLL002", + "Lazy load configuration is invalid", + "Lazy load '{0}' must have TimeToLiveMilliseconds >= 0", + "PatternKit.Generators.LazyLoading", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidIdentifier = new( + "PKLL003", + "Lazy load member name is invalid", + "Lazy load '{0}' has an invalid member name '{1}'", + "PatternKit.Generators.LazyLoading", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + GenerateLazyLoadAttributeName, + 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 a => + a.AttributeClass?.ToDisplayString() == GenerateLazyLoadAttributeName); + 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 valueType = attribute.ConstructorArguments.Length >= 1 + ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol + : null; + if (valueType is null) + return; + + var ttl = GetNamedInt(attribute, "TimeToLiveMilliseconds") ?? 0; + if (ttl < 0) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidConfiguration, node.Identifier.GetLocation(), type.Name)); + return; + } + + var factoryMethodName = GetNamedString(attribute, "FactoryMethodName") ?? "Create"; + if (!IsIdentifier(factoryMethodName)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidIdentifier, node.Identifier.GetLocation(), type.Name, factoryMethodName)); + return; + } + + var loaderMethodName = GetNamedString(attribute, "LoaderMethodName") ?? "LoadAsync"; + if (!IsIdentifier(loaderMethodName)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidIdentifier, node.Identifier.GetLocation(), type.Name, loaderMethodName)); + return; + } + + var name = GetNamedString(attribute, "LazyLoadName") ?? "lazy-load"; + var cacheEnabled = GetNamedBool(attribute, "CacheEnabled") ?? true; + context.AddSource($"{type.Name}.LazyLoad.g.cs", SourceText.From( + GenerateSource(type, valueType, factoryMethodName, loaderMethodName, name, cacheEnabled, ttl), + Encoding.UTF8)); + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol valueType, + string factoryMethodName, + string loaderMethodName, + string name, + bool cacheEnabled, + int ttl) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var valueTypeName = valueType.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 containingTypes = GetContainingTypes(type); + var indentLevel = 0; + foreach (var containingType in containingTypes) + { + AppendTypeDeclaration(sb, containingType, indentLevel); + sb.AppendLine(); + sb.AppendLine(new string(' ', indentLevel * 4) + "{"); + indentLevel++; + } + + AppendTypeDeclaration(sb, type, indentLevel); + sb.AppendLine(); + var indent = new string(' ', indentLevel * 4); + sb.AppendLine(indent + "{"); + var memberIndent = indent + " "; + var bodyIndent = memberIndent + " "; + sb.Append(memberIndent).Append("public static global::PatternKit.Application.LazyLoading.LazyLoad<").Append(valueTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.AppendLine(memberIndent + "{"); + sb.Append(bodyIndent).Append("var builder = global::PatternKit.Application.LazyLoading.LazyLoad<").Append(valueTypeName).Append(">.Create(\"").Append(Escape(name)).AppendLine("\")"); + sb.Append(bodyIndent).Append(" .LoadWith(").Append(loaderMethodName).AppendLine(");"); + if (!cacheEnabled) + sb.AppendLine(bodyIndent + "builder.DisableCache();"); + if (ttl > 0) + sb.Append(bodyIndent).Append("builder.WithTimeToLive(global::System.TimeSpan.FromMilliseconds(").Append(ttl).AppendLine("));"); + sb.AppendLine(bodyIndent + "return builder.Build();"); + sb.AppendLine(memberIndent + "}"); + sb.AppendLine(indent + "}"); + for (var i = containingTypes.Length - 1; i >= 0; i--) + sb.AppendLine(new string(' ', i * 4) + "}"); + + return sb.ToString(); + } + + private static INamedTypeSymbol[] GetContainingTypes(INamedTypeSymbol type) + { + var containingTypes = new Stack(); + for (var current = type.ContainingType; current is not null; current = current.ContainingType) + containingTypes.Push(current); + + return containingTypes.ToArray(); + } + + private static void AppendTypeDeclaration(StringBuilder sb, INamedTypeSymbol type, int indentLevel) + { + sb.Append(new string(' ', indentLevel * 4)); + 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); + } + + private static bool IsIdentifier(string value) + => SyntaxFacts.IsValidIdentifier(value) && SyntaxFacts.GetKeywordKind(value) == SyntaxKind.None; + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static int? GetNamedInt(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as int?; + + private static bool? GetNamedBool(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as bool?; +} diff --git a/src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs b/src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs index 0279ec49..a6819942 100644 --- a/src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs +++ b/src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.LazyLoading; using PatternKit.Behavioral.NullObject; using PatternKit.Cloud.Bulkhead; using PatternKit.Cloud.CircuitBreaker; @@ -213,6 +214,29 @@ public static IServiceCollection AddPatternKitBackpressurePolicy( }); } + public static IServiceCollection AddPatternKitLazyLoad( + this IServiceCollection services, + Func> loader, + string name = "lazy-load", + Action.Builder>? configure = null, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + if (services is null) + throw new ArgumentNullException(nameof(services)); + if (loader is null) + throw new ArgumentNullException(nameof(loader)); + + return services.AddPatternKitService( + lifetime, + provider => + { + var builder = LazyLoad.Create(name) + .LoadWith(cancellationToken => loader(provider, cancellationToken)); + configure?.Invoke(builder); + return builder.Build(); + }); + } + public static IServiceCollection AddPatternKitNullObject( this IServiceCollection services, TContract instance, diff --git a/test/PatternKit.Examples.Tests/LazyLoadDemo/CustomerProfileLazyLoadDemoTests.cs b/test/PatternKit.Examples.Tests/LazyLoadDemo/CustomerProfileLazyLoadDemoTests.cs new file mode 100644 index 00000000..6976d94c --- /dev/null +++ b/test/PatternKit.Examples.Tests/LazyLoadDemo/CustomerProfileLazyLoadDemoTests.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.LazyLoading; +using PatternKit.Examples.LazyLoadDemo; +using TinyBDD; + +namespace PatternKit.Examples.Tests.LazyLoadDemo; + +public sealed class CustomerProfileLazyLoadDemoTests +{ + [Scenario("Customer profile lazy load works through fluent and generated policies")] + [Fact] + public async Task Customer_Profile_Lazy_Load_Works_Through_Fluent_And_Generated_Policies() + { + var store = new InMemoryCustomerProfileStore(new CustomerProfile(Guid.Empty, "Ada Lovelace", "Gold")); + var fluent = CustomerProfileLazyLoadPolicies.CreateFluent(store); + GeneratedCustomerProfileLazyLoad.UseStore(store); + var generated = GeneratedCustomerProfileLazyLoad.CreateGenerated(); + + var fluentResult = await fluent.GetAsync(); + var generatedResult = await generated.GetAsync(); + + ScenarioExpect.True(fluentResult.Loaded); + ScenarioExpect.True(generatedResult.Loaded); + ScenarioExpect.Equal("Gold", generatedResult.Value.Tier); + } + + [Scenario("Customer profile lazy load is importable through IServiceCollection")] + [Fact] + public async Task Customer_Profile_Lazy_Load_Is_Importable_Through_ServiceCollection() + { + using var provider = new ServiceCollection() + .AddCustomerProfileLazyLoadDemo() + .BuildServiceProvider(); + + var lazy = provider.GetRequiredService>(); + var service = provider.GetRequiredService(); + + var first = await service.GetTierAsync(); + var second = await service.GetTierAsync(); + + ScenarioExpect.Equal("customer-profile", lazy.Name); + ScenarioExpect.Equal("Gold", first); + ScenarioExpect.Equal("Gold", second); + ScenarioExpect.True(lazy.IsLoaded); + } + + [Scenario("Generated customer profile lazy load reports missing store")] + [Fact] + public async Task Generated_Customer_Profile_Lazy_Load_Reports_Missing_Store() + { + var store = new InMemoryCustomerProfileStore(new CustomerProfile(Guid.Empty, "Ada Lovelace", "Gold")); + GeneratedCustomerProfileLazyLoad.UseStore(null!); + var generated = GeneratedCustomerProfileLazyLoad.CreateGenerated(); + + try + { + await ScenarioExpect.ThrowsAsync(() => generated.GetAsync().AsTask()); + } + finally + { + GeneratedCustomerProfileLazyLoad.UseStore(store); + } + } +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs index 50fe2c81..3521ba0e 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("464 pattern route results", ctx.ResultsGuide)) + ScenarioExpect.Contains("468 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 f8e7480f..e5561d69 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -114,6 +114,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Unit of Work", "Data Mapper", "Identity Map", + "Lazy Load", "Transaction Script", "Service Layer", "Domain Event", @@ -174,7 +175,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() ScenarioExpect.Equal(41, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(4, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(20, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); - ScenarioExpect.Equal(26, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(27, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); @@ -254,6 +255,7 @@ public Task Hosting_Integration_Catalog_Audits_Every_Pattern() "Bulkhead", "Circuit Breaker", "Guaranteed Delivery", + "Lazy Load", "Message Channel", "Message Store", "Null Object", diff --git a/test/PatternKit.Generators.Tests/LazyLoadGeneratorTests.cs b/test/PatternKit.Generators.Tests/LazyLoadGeneratorTests.cs new file mode 100644 index 00000000..2d873712 --- /dev/null +++ b/test/PatternKit.Generators.Tests/LazyLoadGeneratorTests.cs @@ -0,0 +1,168 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.LazyLoading; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Lazy Load generator")] +public sealed partial class LazyLoadGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates lazy load factory")] + [Fact] + public Task Generates_Lazy_Load_Factory() + => Given("a configured lazy load declaration", () => Compile(""" + using PatternKit.Generators.LazyLoading; + using System.Threading; + using System.Threading.Tasks; + namespace Demo; + [GenerateLazyLoad(typeof(string), FactoryMethodName = "Build", LoaderMethodName = "FetchAsync", LazyLoadName = "profile", TimeToLiveMilliseconds = 250)] + public static partial class ProfileLazyLoad + { + public static ValueTask FetchAsync(CancellationToken ct) => new("customer"); + } + """)) + .Then("generated source creates the configured lazy load", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Equal("ProfileLazyLoad.LazyLoad.g.cs", source.HintName); + ScenarioExpect.Contains("Build()", source.Source); + ScenarioExpect.Contains("LazyLoad.Create(\"profile\")", source.Source); + ScenarioExpect.Contains(".LoadWith(FetchAsync)", source.Source); + ScenarioExpect.Contains("WithTimeToLive(global::System.TimeSpan.FromMilliseconds(250))", source.Source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid lazy load declarations")] + [Theory] + [InlineData("public static class LazyHost { public static ValueTask LoadAsync(CancellationToken ct) => new(\"x\"); }", "PKLL001")] + [InlineData("public static partial class LazyHost { public static ValueTask LoadAsync(CancellationToken ct) => new(\"x\"); }", "PKLL002", "TimeToLiveMilliseconds = -1")] + [InlineData("public static partial class LazyHost { public static ValueTask LoadAsync(CancellationToken ct) => new(\"x\"); }", "PKLL003", "FactoryMethodName = \"class\"")] + [InlineData("public static partial class LazyHost { public static ValueTask LoadAsync(CancellationToken ct) => new(\"x\"); }", "PKLL003", "LoaderMethodName = \"1bad\"")] + public Task Reports_Diagnostics_For_Invalid_Lazy_Load_Declarations(string declaration, string diagnosticId, string configuration = "") + => Given("an invalid lazy load declaration", () => Compile($$""" + using PatternKit.Generators.LazyLoading; + using System.Threading; + using System.Threading.Tasks; + [GenerateLazyLoad(typeof(string){{(string.IsNullOrWhiteSpace(configuration) ? "" : ", " + configuration)}})] + {{declaration}} + """)) + .Then("the expected diagnostic is reported", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == diagnosticId)) + .AssertPassed(); + + [Scenario("Generates lazy load defaults and host shapes")] + [Fact] + public Task Generates_Lazy_Load_Defaults_And_Host_Shapes() + => Given("lazy load declarations with default names and host shapes", () => Compile(""" + using PatternKit.Generators.LazyLoading; + using System.Threading; + using System.Threading.Tasks; + namespace Demo; + + [GenerateLazyLoad(typeof(string))] + internal abstract partial class AbstractLazy + { + public static ValueTask LoadAsync(CancellationToken ct) => new("a"); + } + + [GenerateLazyLoad(typeof(string), LazyLoadName = "tenant\\\"profile", CacheEnabled = false)] + public sealed partial class SealedLazy + { + public static ValueTask LoadAsync(CancellationToken ct) => new("s"); + } + + [GenerateLazyLoad(typeof(int))] + internal partial struct StructLazy + { + public static ValueTask LoadAsync(CancellationToken ct) => new(1); + } + """)) + .Then("generated sources preserve host shape and configured defaults", result => + { + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Equal(3, result.GeneratedSources.Count); + var combined = string.Join("\n", result.GeneratedSources.Select(static source => source.Source)); + ScenarioExpect.Contains("internal abstract partial class AbstractLazy", combined); + ScenarioExpect.Contains("public sealed partial class SealedLazy", combined); + ScenarioExpect.Contains("internal partial struct StructLazy", combined); + ScenarioExpect.Contains("Create(\"lazy-load\")", combined); + ScenarioExpect.Contains("Create(\"tenant\\\\\\\"profile\")", combined); + ScenarioExpect.Contains("builder.DisableCache();", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Skips malformed lazy load value type")] + [Fact] + public Task Skips_Malformed_Lazy_Load_Value_Type() + => Given("a lazy load declaration with a null value type", () => Compile(""" + using PatternKit.Generators.LazyLoading; + [GenerateLazyLoad(null!)] + public static partial class LazyHost; + """)) + .Then("no source is generated", result => + ScenarioExpect.Empty(result.GeneratedSources)) + .AssertPassed(); + + [Scenario("Lazy load attribute exposes generator configuration")] + [Fact] + public void Lazy_Load_Attribute_Exposes_Generator_Configuration() + { + var attribute = new GenerateLazyLoadAttribute(typeof(string)) + { + FactoryMethodName = "CreateProfile", + LoaderMethodName = "LoadProfileAsync", + LazyLoadName = "profile", + CacheEnabled = false, + TimeToLiveMilliseconds = 42 + }; + + ScenarioExpect.Equal(typeof(string), attribute.ValueType); + ScenarioExpect.Equal("CreateProfile", attribute.FactoryMethodName); + ScenarioExpect.Equal("LoadProfileAsync", attribute.LoaderMethodName); + ScenarioExpect.Equal("profile", attribute.LazyLoadName); + ScenarioExpect.False(attribute.CacheEnabled); + ScenarioExpect.Equal(42, attribute.TimeToLiveMilliseconds); + } + + private static GeneratorResult Compile(string source) + { + var compilation = CreateCompilation(source, "LazyLoadGeneratorTests"); + _ = RoslynTestHelpers.Run(compilation, new LazyLoadGenerator(), 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 => new GeneratedSource(source.HintName, source.SourceText.ToString())).ToArray(), + emit.Success, + emit.Diagnostics.Select(static diagnostic => diagnostic.ToString()).ToArray()); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: + [ + MetadataReference.CreateFromFile(GetAbstractionsAssemblyPath()), + MetadataReference.CreateFromFile(typeof(PatternKit.Application.LazyLoading.LazyLoad<>).Assembly.Location) + ]); + + private static string GetAbstractionsAssemblyPath() + => Path.Combine( + Path.GetDirectoryName(typeof(LazyLoadGenerator).Assembly.Location)!, + "PatternKit.Generators.Abstractions.dll"); + + private sealed record GeneratorResult( + IReadOnlyList Diagnostics, + IReadOnlyList GeneratedSources, + bool EmitSuccess, + IReadOnlyList EmitDiagnostics); + + private sealed record GeneratedSource(string HintName, string Source); +} diff --git a/test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs b/test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs index fbfe3827..fe2d962a 100644 --- a/test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs +++ b/test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.LazyLoading; using PatternKit.Behavioral.NullObject; using PatternKit.Cloud.Bulkhead; using PatternKit.Cloud.CircuitBreaker; @@ -79,6 +80,9 @@ public Task Cloud_Resilience_Primitives_Register_Through_IServiceCollection() .AddPatternKitBackpressurePolicy( "inventory-backpressure", builder => builder.WithCapacity(2).WithMode(BackpressureMode.Wait)) + .AddPatternKitLazyLoad( + (_, _) => new ValueTask(new ServiceReply(true)), + "inventory-lazy") .AddPatternKitRateLimitPolicy( "inventory-rate-limit", builder => builder.WithPermitLimit(1).WithWindow(TimeSpan.FromMinutes(1))) @@ -99,6 +103,7 @@ public Task Cloud_Resilience_Primitives_Register_Through_IServiceCollection() var breaker = provider.GetRequiredService>(); var bulkhead = provider.GetRequiredService>(); var backpressure = provider.GetRequiredService>(); + var lazy = provider.GetRequiredService>(); var rateLimit = provider.GetRequiredService>(); var leveling = provider.GetRequiredService>(); var priority = provider.GetRequiredService>(); @@ -107,6 +112,7 @@ public Task Cloud_Resilience_Primitives_Register_Through_IServiceCollection() var breakerResult = breaker.Execute(static () => new ServiceReply(true)); var bulkheadResult = bulkhead.Execute(static () => new ServiceReply(true)); var backpressureResult = backpressure.Execute(static () => new ServiceReply(true)); + var lazyResult = lazy.GetAsync().AsTask().GetAwaiter().GetResult(); var rateLimitResult = rateLimit.Execute("tenant-a", static () => new ServiceReply(true)); var levelingResult = leveling.Execute(static () => new ServiceReply(true)); priority.Enqueue(new("slow", 1)); @@ -118,6 +124,7 @@ public Task Cloud_Resilience_Primitives_Register_Through_IServiceCollection() breaker, bulkhead, backpressure, + lazy, rateLimit, leveling, priority, @@ -125,6 +132,7 @@ public Task Cloud_Resilience_Primitives_Register_Through_IServiceCollection() breakerResult, bulkheadResult, backpressureResult, + lazyResult, rateLimitResult, levelingResult, next); @@ -140,6 +148,8 @@ public Task Cloud_Resilience_Primitives_Register_Through_IServiceCollection() ScenarioExpect.True(result.BulkheadResult.Succeeded); ScenarioExpect.Equal("inventory-backpressure", result.Backpressure.Name); ScenarioExpect.True(result.BackpressureResult.Accepted); + ScenarioExpect.Equal("inventory-lazy", result.Lazy.Name); + ScenarioExpect.True(result.LazyResult.Value.Available); ScenarioExpect.Equal("inventory-rate-limit", result.RateLimit.Name); ScenarioExpect.True(result.RateLimitResult.Allowed); ScenarioExpect.Equal("inventory-leveling", result.Leveling.Name); @@ -205,6 +215,10 @@ public Task Hosting_Extensions_Validate_Registration_Input() () => inputs.Services.AddPatternKitPriorityQueue(null!)), ScenarioExpect.Throws( () => inputs.MissingServices!.AddPatternKitBackpressurePolicy()), + ScenarioExpect.Throws( + () => inputs.MissingServices!.AddPatternKitLazyLoad((_, _) => new ValueTask(new ServiceReply(true)))), + ScenarioExpect.Throws( + () => inputs.Services.AddPatternKitLazyLoad(null!)), ScenarioExpect.Throws( () => inputs.MissingServices!.AddPatternKitNullObject(new SilentNotificationSink())), ScenarioExpect.Throws( @@ -222,6 +236,8 @@ public Task Hosting_Extensions_Validate_Registration_Input() ScenarioExpect.Equal("services", results.MissingServicesException.ParamName); ScenarioExpect.Equal("prioritySelector", results.PrioritySelectorException.ParamName); ScenarioExpect.Equal("services", results.BackpressureMissingServicesException.ParamName); + ScenarioExpect.Equal("services", results.LazyLoadMissingServicesException.ParamName); + ScenarioExpect.Equal("loader", results.LazyLoadLoaderException.ParamName); ScenarioExpect.Equal("services", results.NullObjectInstanceMissingServicesException.ParamName); ScenarioExpect.Equal("services", results.NullObjectFactoryMissingServicesException.ParamName); ScenarioExpect.Equal("instance", results.NullObjectInstanceException.ParamName); @@ -260,6 +276,8 @@ private sealed record InvalidRegistrationResults( ArgumentNullException MissingServicesException, ArgumentNullException PrioritySelectorException, ArgumentNullException BackpressureMissingServicesException, + ArgumentNullException LazyLoadMissingServicesException, + ArgumentNullException LazyLoadLoaderException, ArgumentNullException NullObjectInstanceMissingServicesException, ArgumentNullException NullObjectFactoryMissingServicesException, ArgumentNullException NullObjectInstanceException, @@ -280,6 +298,7 @@ private sealed record CloudRegistrationResult( CircuitBreakerPolicy Breaker, BulkheadPolicy Bulkhead, BackpressurePolicy Backpressure, + LazyLoad Lazy, RateLimitPolicy RateLimit, QueueLoadLevelingPolicy Leveling, PriorityQueuePolicy Priority, @@ -287,6 +306,7 @@ private sealed record CloudRegistrationResult( CircuitBreakerResult BreakerResult, BulkheadResult BulkheadResult, BackpressureResult BackpressureResult, + LazyLoadResult LazyResult, RateLimitResult RateLimitResult, QueueLoadLevelingResult LevelingResult, PriorityQueueDequeueResult Next); diff --git a/test/PatternKit.Tests/Application/LazyLoading/LazyLoadTests.cs b/test/PatternKit.Tests/Application/LazyLoading/LazyLoadTests.cs new file mode 100644 index 00000000..2dcb4615 --- /dev/null +++ b/test/PatternKit.Tests/Application/LazyLoading/LazyLoadTests.cs @@ -0,0 +1,146 @@ +using PatternKit.Application.LazyLoading; +using TinyBDD; + +namespace PatternKit.Tests.Application.LazyLoading; + +public sealed class LazyLoadTests +{ + [Scenario("Lazy load defers and caches expensive values")] + [Fact] + public async Task Lazy_Load_Defers_And_Caches_Expensive_Values() + { + var calls = 0; + var loader = LazyLoad.Create("profile") + .LoadWith(_ => + { + calls++; + return new ValueTask("customer"); + }) + .Build(); + + ScenarioExpect.False(loader.IsLoaded); + var first = await loader.GetAsync(); + var second = await loader.GetAsync(); + + ScenarioExpect.True(first.Loaded); + ScenarioExpect.False(second.Loaded); + ScenarioExpect.True(second.Cached); + ScenarioExpect.Equal("customer", second.Value); + ScenarioExpect.Equal(1, calls); + ScenarioExpect.True(loader.IsLoaded); + } + + [Scenario("Lazy load invalidates cached values")] + [Fact] + public async Task Lazy_Load_Invalidates_Cached_Values() + { + var calls = 0; + var loader = LazyLoad.Create("counter") + .LoadWith(_ => new ValueTask(++calls)) + .Build(); + + var first = await loader.GetAsync(); + loader.Invalidate(); + var second = await loader.GetAsync(); + + ScenarioExpect.Equal(1, first.Value); + ScenarioExpect.Equal(2, second.Value); + ScenarioExpect.True(second.Loaded); + } + + [Scenario("Lazy load invalidation wins over in-flight load")] + [Fact] + public async Task Lazy_Load_Invalidation_Wins_Over_In_Flight_Load() + { + var firstLoad = new TaskCompletionSource(); + var calls = 0; + var loader = LazyLoad.Create("in-flight") + .LoadWith(_ => + { + calls++; + return calls == 1 + ? new ValueTask(firstLoad.Task) + : new ValueTask(calls); + }) + .Build(); + + var pending = loader.GetAsync(); + loader.Invalidate(); + firstLoad.SetResult(1); + + var first = await pending; + var second = await loader.GetAsync(); + + ScenarioExpect.Equal(1, first.Value); + ScenarioExpect.True(first.Loaded); + ScenarioExpect.Equal(2, second.Value); + ScenarioExpect.True(second.Loaded); + } + + [Scenario("Lazy load can disable caching")] + [Fact] + public async Task Lazy_Load_Can_Disable_Caching() + { + var calls = 0; + var loader = LazyLoad.Create("uncached") + .LoadWith(_ => new ValueTask(++calls)) + .DisableCache() + .Build(); + + var first = await loader.GetAsync(); + var second = await loader.GetAsync(); + + ScenarioExpect.Equal(1, first.Value); + ScenarioExpect.Equal(2, second.Value); + ScenarioExpect.False(loader.IsLoaded); + } + + [Scenario("Lazy load reloads expired values")] + [Fact] + public async Task Lazy_Load_Reloads_Expired_Values() + { + var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-01-01T00:00:00Z")); + var calls = 0; + var loader = LazyLoad.Create("ttl") + .LoadWith(_ => new ValueTask(++calls)) + .WithTimeToLive(TimeSpan.FromSeconds(1)) + .WithClock(time.GetUtcNow) + .Build(); + + var first = await loader.GetAsync(); + time.Advance(TimeSpan.FromSeconds(2)); + var second = await loader.GetAsync(); + + ScenarioExpect.Equal(1, first.Value); + ScenarioExpect.Equal(2, second.Value); + ScenarioExpect.True(second.Loaded); + } + + [Scenario("Lazy load preserves cancellation and validation")] + [Fact] + public async Task Lazy_Load_Preserves_Cancellation_And_Validation() + { + ScenarioExpect.Throws(() => LazyLoad.Create("").LoadWith(_ => new("")).Build()); + ScenarioExpect.Throws(() => LazyLoad.Create().LoadWith(null!)); + ScenarioExpect.Throws(() => LazyLoad.Create().LoadWith(_ => new("")).WithClock(null!)); + ScenarioExpect.Throws(() => LazyLoad.Create().LoadWith(_ => new("")).WithTimeToLive(TimeSpan.Zero)); + ScenarioExpect.Throws(() => LazyLoad.Create().Build()); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var loader = LazyLoad.Create() + .LoadWith(_ => new ValueTask("never")) + .Build(); + + await ScenarioExpect.ThrowsAsync(() => loader.GetAsync(cts.Token).AsTask()); + } + + private sealed class FakeTimeProvider(DateTimeOffset now) + { + private DateTimeOffset _now = now; + + public DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan delta) => _now += delta; + } +}