diff --git a/README.md b/README.md index d8a5360..ea3d9b5 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ NetEvolve Pulse delivers a high-performance CQRS mediator with an interceptor-en - Typed CQRS mediator with single-handler enforcement for commands and queries, plus fan-out event dispatch - Interceptor pipeline for logging, metrics, tracing, validation, retries, and other cross-cutting concerns via `IMediatorConfigurator` +- **Distributed query caching** via `ICacheableQuery` and `AddQueryCaching()` — transparent `IDistributedCache` integration with configurable `JsonSerializerOptions` and absolute/sliding expiration via `QueryCachingOptions` - **Outbox pattern** with background processor for reliable event delivery via `AddOutbox()` - OpenTelemetry-friendly hooks through `AddActivityAndMetrics()` and TimeProvider-aware flows for deterministic testing and scheduling - Minimal DI setup with `services.AddPulse(...)`, scoped lifetimes, and opt-in configurators per application diff --git a/src/NetEvolve.Pulse.Dapr/DaprMediatorConfiguratorExtensions.cs b/src/NetEvolve.Pulse.Dapr/DaprMediatorConfiguratorExtensions.cs index 89fe14b..e1a6aee 100644 --- a/src/NetEvolve.Pulse.Dapr/DaprMediatorConfiguratorExtensions.cs +++ b/src/NetEvolve.Pulse.Dapr/DaprMediatorConfiguratorExtensions.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse; +namespace NetEvolve.Pulse; using System.Linq; using Microsoft.Extensions.DependencyInjection; diff --git a/src/NetEvolve.Pulse.Dapr/DaprMessageTransport.cs b/src/NetEvolve.Pulse.Dapr/DaprMessageTransport.cs index 36276dc..cb6348f 100644 --- a/src/NetEvolve.Pulse.Dapr/DaprMessageTransport.cs +++ b/src/NetEvolve.Pulse.Dapr/DaprMessageTransport.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse; +namespace NetEvolve.Pulse; using System.Text.Json; using Dapr.Client; diff --git a/src/NetEvolve.Pulse.Dapr/DaprMessageTransportOptions.cs b/src/NetEvolve.Pulse.Dapr/DaprMessageTransportOptions.cs index 8c555ee..f82b13c 100644 --- a/src/NetEvolve.Pulse.Dapr/DaprMessageTransportOptions.cs +++ b/src/NetEvolve.Pulse.Dapr/DaprMessageTransportOptions.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse; +namespace NetEvolve.Pulse; using NetEvolve.Pulse.Extensibility; diff --git a/src/NetEvolve.Pulse.EntityFramework/Configurations/MySqlOutboxMessageConfiguration.cs b/src/NetEvolve.Pulse.EntityFramework/Configurations/MySqlOutboxMessageConfiguration.cs index 6a37b0c..7b9aefd 100644 --- a/src/NetEvolve.Pulse.EntityFramework/Configurations/MySqlOutboxMessageConfiguration.cs +++ b/src/NetEvolve.Pulse.EntityFramework/Configurations/MySqlOutboxMessageConfiguration.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Configurations; +namespace NetEvolve.Pulse.Configurations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; diff --git a/src/NetEvolve.Pulse.EntityFramework/EntityFrameworkEventOutbox.cs b/src/NetEvolve.Pulse.EntityFramework/EntityFrameworkEventOutbox.cs index 590cd68..3f14c57 100644 --- a/src/NetEvolve.Pulse.EntityFramework/EntityFrameworkEventOutbox.cs +++ b/src/NetEvolve.Pulse.EntityFramework/EntityFrameworkEventOutbox.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse; +namespace NetEvolve.Pulse; using System.Text.Json; using Microsoft.EntityFrameworkCore; diff --git a/src/NetEvolve.Pulse.EntityFramework/EntityFrameworkMediatorConfiguratorExtensions.cs b/src/NetEvolve.Pulse.EntityFramework/EntityFrameworkMediatorConfiguratorExtensions.cs index 88fa15b..b19e6f4 100644 --- a/src/NetEvolve.Pulse.EntityFramework/EntityFrameworkMediatorConfiguratorExtensions.cs +++ b/src/NetEvolve.Pulse.EntityFramework/EntityFrameworkMediatorConfiguratorExtensions.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse; +namespace NetEvolve.Pulse; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; diff --git a/src/NetEvolve.Pulse.EntityFramework/EntityFrameworkOutboxRepository.cs b/src/NetEvolve.Pulse.EntityFramework/EntityFrameworkOutboxRepository.cs index 6f640f7..f73545b 100644 --- a/src/NetEvolve.Pulse.EntityFramework/EntityFrameworkOutboxRepository.cs +++ b/src/NetEvolve.Pulse.EntityFramework/EntityFrameworkOutboxRepository.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse; +namespace NetEvolve.Pulse; using Microsoft.EntityFrameworkCore; using NetEvolve.Pulse.Extensibility; diff --git a/src/NetEvolve.Pulse.EntityFramework/EntityFrameworkOutboxTransactionScope.cs b/src/NetEvolve.Pulse.EntityFramework/EntityFrameworkOutboxTransactionScope.cs index c49d532..639eeca 100644 --- a/src/NetEvolve.Pulse.EntityFramework/EntityFrameworkOutboxTransactionScope.cs +++ b/src/NetEvolve.Pulse.EntityFramework/EntityFrameworkOutboxTransactionScope.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse; +namespace NetEvolve.Pulse; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; diff --git a/src/NetEvolve.Pulse.EntityFramework/IOutboxDbContext.cs b/src/NetEvolve.Pulse.EntityFramework/IOutboxDbContext.cs index fd8f86c..dfed503 100644 --- a/src/NetEvolve.Pulse.EntityFramework/IOutboxDbContext.cs +++ b/src/NetEvolve.Pulse.EntityFramework/IOutboxDbContext.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse; +namespace NetEvolve.Pulse; using Microsoft.EntityFrameworkCore; using NetEvolve.Pulse.Configurations; diff --git a/src/NetEvolve.Pulse.Extensibility/CacheExpirationMode.cs b/src/NetEvolve.Pulse.Extensibility/CacheExpirationMode.cs new file mode 100644 index 0000000..ef0e7d7 --- /dev/null +++ b/src/NetEvolve.Pulse.Extensibility/CacheExpirationMode.cs @@ -0,0 +1,23 @@ +namespace NetEvolve.Pulse.Extensibility; + +/// +/// Specifies how cached entries created by the distributed cache interceptor should expire. +/// +/// +/// +public enum CacheExpirationMode +{ + /// + /// The cache entry expires at an absolute point in time calculated as + /// the current instant plus . + /// This is the default mode. + /// + Absolute = 0, + + /// + /// The cache entry expiry window is reset each time the entry is accessed. + /// The entry expires after elapses + /// since the last access. + /// + Sliding = 1, +} diff --git a/src/NetEvolve.Pulse.Extensibility/ICacheableQuery.cs b/src/NetEvolve.Pulse.Extensibility/ICacheableQuery.cs new file mode 100644 index 0000000..c32f86c --- /dev/null +++ b/src/NetEvolve.Pulse.Extensibility/ICacheableQuery.cs @@ -0,0 +1,53 @@ +namespace NetEvolve.Pulse.Extensibility; + +/// +/// Represents a query that supports distributed caching of its response. +/// Queries implementing this interface are eligible for transparent caching via +/// DistributedCacheQueryInterceptor. +/// +/// The type of data returned by the query. +/// +/// Opt-In Caching: +/// Caching is opt-in per query type. Only queries that implement +/// will be cached by the interceptor. Queries that do not implement this interface always reach the handler. +/// Cache Key: +/// The property uniquely identifies the cached entry. Ensure it is deterministic +/// and unique per logical query result (e.g., include query parameters in the key). +/// Expiry: +/// When is , the entry is stored without an explicit expiry, +/// relying on the cache's default eviction policy. Provide a value to set an +/// absolute expiration relative to the time of caching. +/// +/// +/// +/// public record GetCustomerByIdQuery(string CustomerId) +/// : ICacheableQuery<CustomerDetailsDto> +/// { +/// public string? CorrelationId { get; set; } +/// public string CacheKey => $"customer:{CustomerId}"; +/// public TimeSpan? Expiry => TimeSpan.FromMinutes(5); +/// } +/// +/// +/// +public interface ICacheableQuery : IQuery +{ + /// + /// Gets the key used to store and retrieve the response from the distributed cache. + /// + /// + /// The key must be unique for each distinct query result. Include relevant query parameters + /// to avoid cache collisions between different query instances. + /// + string CacheKey { get; } + + /// + /// Gets the duration after which the cached entry expires, or to use the cache's default. + /// + /// + /// When , the entry is stored without an explicit absolute expiration. + /// When a value is provided, it is used as the absolute expiration + /// relative to the time of caching. + /// + TimeSpan? Expiry { get; } +} diff --git a/src/NetEvolve.Pulse.Extensibility/IEventDispatcher.cs b/src/NetEvolve.Pulse.Extensibility/IEventDispatcher.cs index 61ef2f0..303eda1 100644 --- a/src/NetEvolve.Pulse.Extensibility/IEventDispatcher.cs +++ b/src/NetEvolve.Pulse.Extensibility/IEventDispatcher.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Extensibility; +namespace NetEvolve.Pulse.Extensibility; /// /// Defines the contract for dispatching events to their registered handlers. diff --git a/src/NetEvolve.Pulse.Extensibility/IEventOutbox.cs b/src/NetEvolve.Pulse.Extensibility/IEventOutbox.cs index 5a64d0b..ceac253 100644 --- a/src/NetEvolve.Pulse.Extensibility/IEventOutbox.cs +++ b/src/NetEvolve.Pulse.Extensibility/IEventOutbox.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Extensibility; +namespace NetEvolve.Pulse.Extensibility; /// /// Defines the contract for an outbox that stores events for reliable delivery. diff --git a/src/NetEvolve.Pulse.Extensibility/IMediatorConfigurator.cs b/src/NetEvolve.Pulse.Extensibility/IMediatorConfigurator.cs index af31991..78df7a2 100644 --- a/src/NetEvolve.Pulse.Extensibility/IMediatorConfigurator.cs +++ b/src/NetEvolve.Pulse.Extensibility/IMediatorConfigurator.cs @@ -142,6 +142,53 @@ public interface IMediatorConfigurator /// Prometheus Documentation IMediatorConfigurator AddActivityAndMetrics(); + /// + /// Registers the distributed cache interceptor for queries. + /// Queries implementing are transparently served from + /// IDistributedCache when a cache entry exists, + /// or stored after a cache miss. + /// + /// + /// An optional delegate that configures . + /// When , default options are used. + /// + /// The current configurator instance for method chaining. + /// + /// Opt-In: + /// Only queries that implement participate in caching. + /// All other queries pass through to the handler unchanged. + /// Prerequisite: + /// An IDistributedCache implementation must be + /// registered in the DI container (e.g. via services.AddDistributedMemoryCache()). + /// When no cache is registered the interceptor falls through without error. + /// Custom Serialization: + /// Use to supply custom + /// for cache serialization. + /// Expiration Mode: + /// Use to choose between + /// absolute (, default) and + /// sliding () cache expiration. + /// + /// + /// + /// services.AddDistributedMemoryCache(); + /// services.AddPulse(config => + /// { + /// config.AddQueryCaching(options => + /// { + /// options.ExpirationMode = CacheExpirationMode.Sliding; + /// options.JsonSerializerOptions = new JsonSerializerOptions + /// { + /// PropertyNamingPolicy = JsonNamingPolicy.CamelCase + /// }; + /// }); + /// }); + /// + /// + /// + /// + IMediatorConfigurator AddQueryCaching(Action? configure = null); + /// /// Configures a custom event dispatcher to control how events are dispatched to their handlers. /// This allows customization of the execution strategy (parallel, sequential, rate-limited, etc.). diff --git a/src/NetEvolve.Pulse.Extensibility/IMessageTransport.cs b/src/NetEvolve.Pulse.Extensibility/IMessageTransport.cs index 091722c..01be6cc 100644 --- a/src/NetEvolve.Pulse.Extensibility/IMessageTransport.cs +++ b/src/NetEvolve.Pulse.Extensibility/IMessageTransport.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Extensibility; +namespace NetEvolve.Pulse.Extensibility; /// /// Defines the contract for transporting outbox messages to external systems or handlers. diff --git a/src/NetEvolve.Pulse.Extensibility/IOutboxRepository.cs b/src/NetEvolve.Pulse.Extensibility/IOutboxRepository.cs index a579aad..aab745e 100644 --- a/src/NetEvolve.Pulse.Extensibility/IOutboxRepository.cs +++ b/src/NetEvolve.Pulse.Extensibility/IOutboxRepository.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Extensibility; +namespace NetEvolve.Pulse.Extensibility; /// /// Defines the contract for outbox message persistence operations. diff --git a/src/NetEvolve.Pulse.Extensibility/IOutboxTransactionScope.cs b/src/NetEvolve.Pulse.Extensibility/IOutboxTransactionScope.cs index 511bb8f..a75477f 100644 --- a/src/NetEvolve.Pulse.Extensibility/IOutboxTransactionScope.cs +++ b/src/NetEvolve.Pulse.Extensibility/IOutboxTransactionScope.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Extensibility; +namespace NetEvolve.Pulse.Extensibility; /// /// Defines the contract for managing transaction scope when storing outbox messages. diff --git a/src/NetEvolve.Pulse.Extensibility/IPrioritizedEventHandler.cs b/src/NetEvolve.Pulse.Extensibility/IPrioritizedEventHandler.cs index 94f31ae..cf6cd07 100644 --- a/src/NetEvolve.Pulse.Extensibility/IPrioritizedEventHandler.cs +++ b/src/NetEvolve.Pulse.Extensibility/IPrioritizedEventHandler.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Extensibility; +namespace NetEvolve.Pulse.Extensibility; /// /// Defines a handler for processing events that supports priority-based ordering. diff --git a/src/NetEvolve.Pulse.Extensibility/OutboxMessage.cs b/src/NetEvolve.Pulse.Extensibility/OutboxMessage.cs index c6e4a67..581b982 100644 --- a/src/NetEvolve.Pulse.Extensibility/OutboxMessage.cs +++ b/src/NetEvolve.Pulse.Extensibility/OutboxMessage.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Extensibility; +namespace NetEvolve.Pulse.Extensibility; /// /// Represents a message stored in the outbox for reliable delivery. diff --git a/src/NetEvolve.Pulse.Extensibility/OutboxMessageSchema.cs b/src/NetEvolve.Pulse.Extensibility/OutboxMessageSchema.cs index 969135e..981bc30 100644 --- a/src/NetEvolve.Pulse.Extensibility/OutboxMessageSchema.cs +++ b/src/NetEvolve.Pulse.Extensibility/OutboxMessageSchema.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Extensibility; +namespace NetEvolve.Pulse.Extensibility; using System.Diagnostics.CodeAnalysis; diff --git a/src/NetEvolve.Pulse.Extensibility/OutboxMessageStatus.cs b/src/NetEvolve.Pulse.Extensibility/OutboxMessageStatus.cs index b1c9bf6..20dd00d 100644 --- a/src/NetEvolve.Pulse.Extensibility/OutboxMessageStatus.cs +++ b/src/NetEvolve.Pulse.Extensibility/OutboxMessageStatus.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Extensibility; +namespace NetEvolve.Pulse.Extensibility; /// /// Represents the processing status of an outbox message. diff --git a/src/NetEvolve.Pulse.Extensibility/QueryCachingOptions.cs b/src/NetEvolve.Pulse.Extensibility/QueryCachingOptions.cs new file mode 100644 index 0000000..46365a0 --- /dev/null +++ b/src/NetEvolve.Pulse.Extensibility/QueryCachingOptions.cs @@ -0,0 +1,73 @@ +namespace NetEvolve.Pulse.Extensibility; + +using System.Text.Json; + +/// +/// Configuration options for the distributed query caching interceptor. +/// +/// +/// Configure these options when calling . +/// +/// +/// +public sealed class QueryCachingOptions +{ + /// + /// Gets or sets the used when + /// serializing responses to the cache and deserializing them back. + /// + /// + /// Defaults to . + /// Provide custom options when your response types require non-default converters, + /// naming policies, or other serialization settings. + /// + public JsonSerializerOptions JsonSerializerOptions { get; set; } = JsonSerializerOptions.Default; + + /// + /// Gets or sets how the value is interpreted + /// when storing entries in the distributed cache. + /// + /// + /// + /// + /// + /// (default) — sets + /// DistributedCacheEntryOptions.AbsoluteExpirationRelativeToNow. + /// + /// + /// + /// + /// — sets + /// DistributedCacheEntryOptions.SlidingExpiration, resetting the + /// expiry window on each cache access. + /// + /// + /// + /// When is , + /// is used if set; otherwise no expiry is applied and the + /// cache's default eviction policy is used. + /// + public CacheExpirationMode ExpirationMode { get; set; } = CacheExpirationMode.Absolute; + + /// + /// Gets or sets the fallback expiry duration used when + /// returns . + /// + /// + /// When (default), entries whose query returns a + /// are stored without an explicit expiration, + /// relying on the cache's default eviction policy. + /// When a value is provided, it is applied according to + /// in the same way as a per-query expiry would be. + /// + /// + /// + /// services.AddPulse(config => config.AddQueryCaching(options => + /// { + /// // All queries without an explicit Expiry get a 10-minute TTL + /// options.DefaultExpiry = TimeSpan.FromMinutes(10); + /// })); + /// + /// + public TimeSpan? DefaultExpiry { get; set; } +} diff --git a/src/NetEvolve.Pulse.Extensibility/README.md b/src/NetEvolve.Pulse.Extensibility/README.md index 5c8cef0..38f75fc 100644 --- a/src/NetEvolve.Pulse.Extensibility/README.md +++ b/src/NetEvolve.Pulse.Extensibility/README.md @@ -12,6 +12,7 @@ NetEvolve.Pulse.Extensibility delivers the core contracts for building CQRS medi - Strongly typed handler interfaces with single-handler guarantees for commands and queries - Interceptor interfaces for cross-cutting concerns (logging, validation, metrics, caching) - Fluent mediator configuration via `IMediatorConfigurator` and extension methods +- **`ICacheableQuery`** — opt-in caching contract that pairs with the `AddQueryCaching()` interceptor in `NetEvolve.Pulse` - **Outbox pattern contracts** including `IEventOutbox`, `IOutboxRepository`, and `IMessageTransport` - Designed for framework-agnostic use while pairing seamlessly with NetEvolve.Pulse - Test-friendly primitives including `Void` responses and TimeProvider awareness @@ -113,6 +114,59 @@ services.AddPulse(config => }); ``` +### Cacheable Queries (`ICacheableQuery`) + +Opt specific queries into transparent `IDistributedCache` caching by implementing `ICacheableQuery` instead of `IQuery`. The interface adds two properties that control the cache entry: + +```csharp +using NetEvolve.Pulse.Extensibility; + +// Implement ICacheableQuery on the queries you want cached +public record GetCustomerByIdQuery(Guid CustomerId) + : ICacheableQuery +{ + public string? CorrelationId { get; set; } + + // Unique cache key — include all parameters that distinguish results + public string CacheKey => $"customer:{CustomerId}"; + + // null = rely on cache default; TimeSpan = expiry duration + public TimeSpan? Expiry => TimeSpan.FromMinutes(10); +} + +public record CustomerDetailsDto(Guid Id, string Name, string Email); +``` + +To activate caching, register an `IDistributedCache` implementation and call `AddQueryCaching()` during Pulse setup (requires `NetEvolve.Pulse`). Use `QueryCachingOptions` to configure serialization and expiration behavior: + +```csharp +services.AddDistributedMemoryCache(); +services.AddPulse(config => config.AddQueryCaching(options => +{ + // Absolute (default) or Sliding expiration + options.ExpirationMode = CacheExpirationMode.Sliding; + + // Fallback expiry for queries that return null from ICacheableQuery.Expiry + options.DefaultExpiry = TimeSpan.FromMinutes(10); + + // Custom JSON serializer options + options.JsonSerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; +})); +``` + +`QueryCachingOptions` properties: + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `JsonSerializerOptions` | `JsonSerializerOptions` | `JsonSerializerOptions.Default` | Options used for cache serialization and deserialization | +| `ExpirationMode` | `CacheExpirationMode` | `Absolute` | Whether `Expiry` is treated as absolute or sliding expiration | +| `DefaultExpiry` | `TimeSpan?` | `null` | Fallback expiry used when `ICacheableQuery.Expiry` returns `null` | + +Queries that do **not** implement `ICacheableQuery` always reach the handler unchanged. When `IDistributedCache` is not registered the interceptor falls through silently. + ## Configuration ```csharp diff --git a/src/NetEvolve.Pulse.Polly/Interceptors/PollyEventInterceptor.cs b/src/NetEvolve.Pulse.Polly/Interceptors/PollyEventInterceptor.cs index 2c03c7f..cb607c1 100644 --- a/src/NetEvolve.Pulse.Polly/Interceptors/PollyEventInterceptor.cs +++ b/src/NetEvolve.Pulse.Polly/Interceptors/PollyEventInterceptor.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Interceptors; +namespace NetEvolve.Pulse.Interceptors; using System; using Microsoft.Extensions.DependencyInjection; diff --git a/src/NetEvolve.Pulse.Polly/Interceptors/PollyRequestInterceptor.cs b/src/NetEvolve.Pulse.Polly/Interceptors/PollyRequestInterceptor.cs index e5a20f9..b2b6c0d 100644 --- a/src/NetEvolve.Pulse.Polly/Interceptors/PollyRequestInterceptor.cs +++ b/src/NetEvolve.Pulse.Polly/Interceptors/PollyRequestInterceptor.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Interceptors; +namespace NetEvolve.Pulse.Interceptors; using System; using Microsoft.Extensions.DependencyInjection; diff --git a/src/NetEvolve.Pulse.Polly/PollyMediatorConfiguratorExtensions.cs b/src/NetEvolve.Pulse.Polly/PollyMediatorConfiguratorExtensions.cs index 970295d..80a8c44 100644 --- a/src/NetEvolve.Pulse.Polly/PollyMediatorConfiguratorExtensions.cs +++ b/src/NetEvolve.Pulse.Polly/PollyMediatorConfiguratorExtensions.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse; +namespace NetEvolve.Pulse; using System.Linq; using Microsoft.Extensions.DependencyInjection; diff --git a/src/NetEvolve.Pulse.SqlServer/SqlServerEventOutbox.cs b/src/NetEvolve.Pulse.SqlServer/SqlServerEventOutbox.cs index 8f893e9..282153a 100644 --- a/src/NetEvolve.Pulse.SqlServer/SqlServerEventOutbox.cs +++ b/src/NetEvolve.Pulse.SqlServer/SqlServerEventOutbox.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse; +namespace NetEvolve.Pulse; using System.Diagnostics.CodeAnalysis; using System.Text.Json; diff --git a/src/NetEvolve.Pulse.SqlServer/SqlServerMediatorConfiguratorExtensions.cs b/src/NetEvolve.Pulse.SqlServer/SqlServerMediatorConfiguratorExtensions.cs index cfe2115..3808892 100644 --- a/src/NetEvolve.Pulse.SqlServer/SqlServerMediatorConfiguratorExtensions.cs +++ b/src/NetEvolve.Pulse.SqlServer/SqlServerMediatorConfiguratorExtensions.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse; +namespace NetEvolve.Pulse; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; diff --git a/src/NetEvolve.Pulse.SqlServer/SqlServerOutboxRepository.cs b/src/NetEvolve.Pulse.SqlServer/SqlServerOutboxRepository.cs index f5c94b1..842e181 100644 --- a/src/NetEvolve.Pulse.SqlServer/SqlServerOutboxRepository.cs +++ b/src/NetEvolve.Pulse.SqlServer/SqlServerOutboxRepository.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse; +namespace NetEvolve.Pulse; using System.Data; using System.Diagnostics.CodeAnalysis; diff --git a/src/NetEvolve.Pulse.SqlServer/SqlServerOutboxTransactionScope.cs b/src/NetEvolve.Pulse.SqlServer/SqlServerOutboxTransactionScope.cs index 4fb16f1..ff6ea66 100644 --- a/src/NetEvolve.Pulse.SqlServer/SqlServerOutboxTransactionScope.cs +++ b/src/NetEvolve.Pulse.SqlServer/SqlServerOutboxTransactionScope.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse; +namespace NetEvolve.Pulse; using Microsoft.Data.SqlClient; using NetEvolve.Pulse.Extensibility; diff --git a/src/NetEvolve.Pulse/Dispatchers/ParallelEventDispatcher.cs b/src/NetEvolve.Pulse/Dispatchers/ParallelEventDispatcher.cs index 2b530ef..5055b84 100644 --- a/src/NetEvolve.Pulse/Dispatchers/ParallelEventDispatcher.cs +++ b/src/NetEvolve.Pulse/Dispatchers/ParallelEventDispatcher.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Dispatchers; +namespace NetEvolve.Pulse.Dispatchers; using System.Collections.Concurrent; using NetEvolve.Pulse.Extensibility; diff --git a/src/NetEvolve.Pulse/Dispatchers/PrioritizedEventDispatcher.cs b/src/NetEvolve.Pulse/Dispatchers/PrioritizedEventDispatcher.cs index 563ccf3..bb8782c 100644 --- a/src/NetEvolve.Pulse/Dispatchers/PrioritizedEventDispatcher.cs +++ b/src/NetEvolve.Pulse/Dispatchers/PrioritizedEventDispatcher.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Dispatchers; +namespace NetEvolve.Pulse.Dispatchers; using NetEvolve.Pulse.Extensibility; diff --git a/src/NetEvolve.Pulse/Dispatchers/RateLimitedEventDispatcher.cs b/src/NetEvolve.Pulse/Dispatchers/RateLimitedEventDispatcher.cs index e635b28..c8bf686 100644 --- a/src/NetEvolve.Pulse/Dispatchers/RateLimitedEventDispatcher.cs +++ b/src/NetEvolve.Pulse/Dispatchers/RateLimitedEventDispatcher.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Dispatchers; +namespace NetEvolve.Pulse.Dispatchers; using NetEvolve.Pulse.Extensibility; diff --git a/src/NetEvolve.Pulse/Dispatchers/SequentialEventDispatcher.cs b/src/NetEvolve.Pulse/Dispatchers/SequentialEventDispatcher.cs index a1c818a..81c9126 100644 --- a/src/NetEvolve.Pulse/Dispatchers/SequentialEventDispatcher.cs +++ b/src/NetEvolve.Pulse/Dispatchers/SequentialEventDispatcher.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Dispatchers; +namespace NetEvolve.Pulse.Dispatchers; using NetEvolve.Pulse.Extensibility; diff --git a/src/NetEvolve.Pulse/Dispatchers/TransactionalEventDispatcher.cs b/src/NetEvolve.Pulse/Dispatchers/TransactionalEventDispatcher.cs index ec8ed6e..50febfb 100644 --- a/src/NetEvolve.Pulse/Dispatchers/TransactionalEventDispatcher.cs +++ b/src/NetEvolve.Pulse/Dispatchers/TransactionalEventDispatcher.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Dispatchers; +namespace NetEvolve.Pulse.Dispatchers; using NetEvolve.Pulse.Extensibility; diff --git a/src/NetEvolve.Pulse/HandlerRegistrationExtensions.cs b/src/NetEvolve.Pulse/HandlerRegistrationExtensions.cs index fa931c3..5ca67fb 100644 --- a/src/NetEvolve.Pulse/HandlerRegistrationExtensions.cs +++ b/src/NetEvolve.Pulse/HandlerRegistrationExtensions.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse; +namespace NetEvolve.Pulse; using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; diff --git a/src/NetEvolve.Pulse/Interceptors/DistributedCacheQueryInterceptor.cs b/src/NetEvolve.Pulse/Interceptors/DistributedCacheQueryInterceptor.cs new file mode 100644 index 0000000..3e3c0ca --- /dev/null +++ b/src/NetEvolve.Pulse/Interceptors/DistributedCacheQueryInterceptor.cs @@ -0,0 +1,119 @@ +namespace NetEvolve.Pulse.Interceptors; + +using System.Text.Json; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NetEvolve.Pulse.Extensibility; + +/// +/// An interceptor that transparently caches query responses in . +/// Only queries implementing are eligible for caching. +/// Queries that do not implement the interface always reach the handler unchanged. +/// +/// The type of query to intercept, which must implement . +/// The type of response produced by the query. +/// +/// Cache Hit: +/// When a cached entry is found for the query's , +/// the handler is skipped and the deserialized response is returned directly. +/// Cache Miss: +/// When no cached entry exists, the handler is invoked and the response is serialized to JSON +/// and stored in the cache before being returned to the caller. +/// No Cache Registered: +/// When is not registered in the DI container, the interceptor +/// falls through to the handler without error. +/// Expiry: +/// The effective expiry is determined by first checking ; +/// when it is , is used as a fallback. +/// If both are the entry is stored without an explicit expiration. +/// The resolved expiry is applied as absolute or sliding based on . +/// Serialization: +/// Responses are serialized using System.Text.Json with the options supplied via +/// . Defaults to +/// when no custom options are configured. +/// +/// +/// +internal sealed class DistributedCacheQueryInterceptor : IQueryInterceptor + where TQuery : IQuery +{ + private readonly IServiceProvider _serviceProvider; + private readonly QueryCachingOptions _options; + + /// + /// Initializes a new instance of the class. + /// + /// The service provider used to resolve . + /// The caching options. + public DistributedCacheQueryInterceptor(IServiceProvider serviceProvider, IOptions options) + { + _serviceProvider = serviceProvider; + _options = options.Value; + } + + /// + public async Task HandleAsync( + TQuery request, + Func> handler, + CancellationToken cancellationToken = default + ) + { + // Only cacheable queries are eligible + if (request is not ICacheableQuery cacheableQuery) + { + return await handler(request, cancellationToken).ConfigureAwait(false); + } + + // Fall through when IDistributedCache is not registered + var cache = _serviceProvider.GetService(); + if (cache is null) + { + return await handler(request, cancellationToken).ConfigureAwait(false); + } + + var cacheKey = cacheableQuery.CacheKey; + var jsonOptions = _options.JsonSerializerOptions; + + var cachedBytes = await cache.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false); + if (cachedBytes is not null) + { + return JsonSerializer.Deserialize(cachedBytes, jsonOptions)!; + } + + var response = await handler(request, cancellationToken).ConfigureAwait(false); + + var serialized = JsonSerializer.SerializeToUtf8Bytes(response, jsonOptions); + + var entryOptions = GetCacheEntryOptions(cacheableQuery); + + await cache.SetAsync(cacheKey, serialized, entryOptions, cancellationToken).ConfigureAwait(false); + + return response; + } + + /// + /// Determines the appropriate cache entry options based on the query's expiry and the configured expiration mode. + /// + /// The cacheable query. + /// The cache entry options. + private DistributedCacheEntryOptions GetCacheEntryOptions(ICacheableQuery cacheableQuery) + { + var entryOptions = new DistributedCacheEntryOptions(); + + var expiry = cacheableQuery.Expiry ?? _options.DefaultExpiry; + if (expiry.HasValue) + { + if (_options.ExpirationMode == CacheExpirationMode.Sliding) + { + entryOptions.SlidingExpiration = expiry; + } + else + { + entryOptions.AbsoluteExpirationRelativeToNow = expiry; + } + } + + return entryOptions; + } +} diff --git a/src/NetEvolve.Pulse/Internals/MediatorConfigurator.cs b/src/NetEvolve.Pulse/Internals/MediatorConfigurator.cs index 79a9f17..9adc42e 100644 --- a/src/NetEvolve.Pulse/Internals/MediatorConfigurator.cs +++ b/src/NetEvolve.Pulse/Internals/MediatorConfigurator.cs @@ -41,6 +41,22 @@ public IMediatorConfigurator AddActivityAndMetrics() return this; } + /// + public IMediatorConfigurator AddQueryCaching(Action? configure = null) + { + _ = Services.AddOptions(); + if (configure is not null) + { + _ = Services.Configure(configure); + } + + Services.TryAddEnumerable( + ServiceDescriptor.Scoped(typeof(IRequestInterceptor<,>), typeof(DistributedCacheQueryInterceptor<,>)) + ); + + return this; + } + /// public IMediatorConfigurator UseDefaultEventDispatcher< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TDispatcher diff --git a/src/NetEvolve.Pulse/Outbox/InMemoryMessageTransport.cs b/src/NetEvolve.Pulse/Outbox/InMemoryMessageTransport.cs index a4cfd47..7c4ac7c 100644 --- a/src/NetEvolve.Pulse/Outbox/InMemoryMessageTransport.cs +++ b/src/NetEvolve.Pulse/Outbox/InMemoryMessageTransport.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Outbox; +namespace NetEvolve.Pulse.Outbox; using System.Collections.Concurrent; using System.Linq.Expressions; diff --git a/src/NetEvolve.Pulse/Outbox/OutboxEventStore.cs b/src/NetEvolve.Pulse/Outbox/OutboxEventStore.cs index 753f5d2..325c687 100644 --- a/src/NetEvolve.Pulse/Outbox/OutboxEventStore.cs +++ b/src/NetEvolve.Pulse/Outbox/OutboxEventStore.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Outbox; +namespace NetEvolve.Pulse.Outbox; using System.Text.Json; using Microsoft.Extensions.Options; diff --git a/src/NetEvolve.Pulse/Outbox/OutboxMediatorConfiguratorExtensions.cs b/src/NetEvolve.Pulse/Outbox/OutboxMediatorConfiguratorExtensions.cs index 7c79ace..a4943dd 100644 --- a/src/NetEvolve.Pulse/Outbox/OutboxMediatorConfiguratorExtensions.cs +++ b/src/NetEvolve.Pulse/Outbox/OutboxMediatorConfiguratorExtensions.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Outbox; +namespace NetEvolve.Pulse.Outbox; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; diff --git a/src/NetEvolve.Pulse/Outbox/OutboxOptions.cs b/src/NetEvolve.Pulse/Outbox/OutboxOptions.cs index 49d5200..68cc1fa 100644 --- a/src/NetEvolve.Pulse/Outbox/OutboxOptions.cs +++ b/src/NetEvolve.Pulse/Outbox/OutboxOptions.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Outbox; +namespace NetEvolve.Pulse.Outbox; using System.Text.Json; using NetEvolve.Pulse.Extensibility; diff --git a/src/NetEvolve.Pulse/Outbox/OutboxProcessorOptions.cs b/src/NetEvolve.Pulse/Outbox/OutboxProcessorOptions.cs index 30648cd..994a0e6 100644 --- a/src/NetEvolve.Pulse/Outbox/OutboxProcessorOptions.cs +++ b/src/NetEvolve.Pulse/Outbox/OutboxProcessorOptions.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Outbox; +namespace NetEvolve.Pulse.Outbox; using NetEvolve.Pulse.Extensibility; diff --git a/src/NetEvolve.Pulse/README.md b/src/NetEvolve.Pulse/README.md index b0140a3..cc79dd7 100644 --- a/src/NetEvolve.Pulse/README.md +++ b/src/NetEvolve.Pulse/README.md @@ -11,6 +11,7 @@ NetEvolve.Pulse is a high-performance CQRS mediator for ASP.NET Core that wires - Typed CQRS mediator with single-handler enforcement for commands and queries plus fan-out events - Minimal DI integration via `services.AddPulse(...)` with scoped lifetimes for handlers and interceptors - Configurable interceptor pipeline (logging, metrics, tracing, validation) via `IMediatorConfigurator` +- **Distributed query caching** — register `ICacheableQuery` per query and enable transparent `IDistributedCache` caching with `AddQueryCaching()` - **Outbox pattern** with background processor for reliable event delivery via `AddOutbox()` - Parallel event dispatch for efficient domain event broadcasting - TimeProvider-aware for deterministic testing and scheduling scenarios @@ -139,6 +140,53 @@ services.AddPulse(config => }); ``` +### Distributed Query Caching + +Enable transparent `IDistributedCache` caching for queries. Any query that implements `ICacheableQuery` (from `NetEvolve.Pulse.Extensibility`) is served from the cache on subsequent invocations; all other queries pass through unchanged. + +```csharp +// 1. Register an IDistributedCache implementation +services.AddDistributedMemoryCache(); // or Redis, SQL Server, etc. + +// 2. Enable the caching interceptor (with optional options) +services.AddPulse(config => config.AddQueryCaching(options => +{ + // Choose between absolute (default) and sliding expiration + options.ExpirationMode = CacheExpirationMode.Sliding; + + // Supply custom JsonSerializerOptions for cache serialization + options.JsonSerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; +})); + +// 3. Implement ICacheableQuery on the queries you want cached +public record GetProductQuery(Guid Id) : ICacheableQuery +{ + public string? CorrelationId { get; set; } + + // Unique per query result — include all discriminating parameters + public string CacheKey => $"product:{Id}"; + + // null = no explicit expiry (relies on cache defaults); or provide a TimeSpan + public TimeSpan? Expiry => TimeSpan.FromMinutes(5); +} +``` + +Behavior summary: + +| Scenario | Result | +| --- | --- | +| Query implements `ICacheableQuery` and cache entry exists | Cached response returned; handler skipped | +| Query implements `ICacheableQuery` and no cache entry | Handler invoked; response stored in cache | +| Query does **not** implement `ICacheableQuery` | Handler always invoked; cache never consulted | +| `IDistributedCache` not registered in DI | Interceptor falls through; handler invoked without error | +| `Expiry = null` and `DefaultExpiry = null` | Entry stored without explicit expiry; cache default eviction policy applies | +| `Expiry = null` and `DefaultExpiry` is set | `DefaultExpiry` value is applied using the configured `ExpirationMode` | +| `ExpirationMode = Absolute` (default) | `Expiry` (or `DefaultExpiry`) is applied as absolute expiry relative to now | +| `ExpirationMode = Sliding` | `Expiry` (or `DefaultExpiry`) window resets on each cache access | + ### Outbox Pattern Configuration The outbox pattern ensures reliable event delivery by persisting events before dispatching: diff --git a/tests/NetEvolve.Pulse.EntityFramework.Tests.Integration/Fixtures/SqlServerContainerFixture.cs b/tests/NetEvolve.Pulse.EntityFramework.Tests.Integration/Fixtures/SqlServerContainerFixture.cs index ed71483..78e5387 100644 --- a/tests/NetEvolve.Pulse.EntityFramework.Tests.Integration/Fixtures/SqlServerContainerFixture.cs +++ b/tests/NetEvolve.Pulse.EntityFramework.Tests.Integration/Fixtures/SqlServerContainerFixture.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.EntityFramework.Tests.Integration.Fixtures; +namespace NetEvolve.Pulse.EntityFramework.Tests.Integration.Fixtures; using System.Diagnostics.CodeAnalysis; using DotNet.Testcontainers.Builders; diff --git a/tests/NetEvolve.Pulse.EntityFramework.Tests.Integration/Fixtures/TestOutboxDbContext.cs b/tests/NetEvolve.Pulse.EntityFramework.Tests.Integration/Fixtures/TestOutboxDbContext.cs index 24839f4..027eef0 100644 --- a/tests/NetEvolve.Pulse.EntityFramework.Tests.Integration/Fixtures/TestOutboxDbContext.cs +++ b/tests/NetEvolve.Pulse.EntityFramework.Tests.Integration/Fixtures/TestOutboxDbContext.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.EntityFramework.Tests.Integration.Fixtures; +namespace NetEvolve.Pulse.EntityFramework.Tests.Integration.Fixtures; using Microsoft.EntityFrameworkCore; using NetEvolve.Pulse.Configurations; diff --git a/tests/NetEvolve.Pulse.EntityFramework.Tests.Integration/OutboxMessageConfigurationFactoryTests.cs b/tests/NetEvolve.Pulse.EntityFramework.Tests.Integration/OutboxMessageConfigurationFactoryTests.cs index b645bed..9c99af3 100644 --- a/tests/NetEvolve.Pulse.EntityFramework.Tests.Integration/OutboxMessageConfigurationFactoryTests.cs +++ b/tests/NetEvolve.Pulse.EntityFramework.Tests.Integration/OutboxMessageConfigurationFactoryTests.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.EntityFramework.Tests.Integration; +namespace NetEvolve.Pulse.EntityFramework.Tests.Integration; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; diff --git a/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkEventOutboxTests.cs b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkEventOutboxTests.cs index 11d9b7b..96ffccd 100644 --- a/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkEventOutboxTests.cs +++ b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkEventOutboxTests.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.EntityFramework.Tests.Unit; +namespace NetEvolve.Pulse.EntityFramework.Tests.Unit; using System; using System.Threading.Tasks; diff --git a/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkMediatorConfiguratorExtensionsTests.cs b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkMediatorConfiguratorExtensionsTests.cs index b7a32a0..7ddbb41 100644 --- a/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkMediatorConfiguratorExtensionsTests.cs +++ b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkMediatorConfiguratorExtensionsTests.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.EntityFramework.Tests.Unit; +namespace NetEvolve.Pulse.EntityFramework.Tests.Unit; using System; using System.Linq; @@ -138,6 +138,9 @@ private sealed class MediatorConfiguratorStub : IMediatorConfigurator public IMediatorConfigurator AddActivityAndMetrics() => throw new NotImplementedException(); + public IMediatorConfigurator AddQueryCaching(Action? configure = null) => + throw new NotImplementedException(); + public IMediatorConfigurator UseDefaultEventDispatcher( ServiceLifetime lifetime = ServiceLifetime.Singleton ) diff --git a/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkOutboxRepositoryTests.cs b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkOutboxRepositoryTests.cs index 85b83ac..4e775a7 100644 --- a/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkOutboxRepositoryTests.cs +++ b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkOutboxRepositoryTests.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.EntityFramework.Tests.Unit; +namespace NetEvolve.Pulse.EntityFramework.Tests.Unit; using System; using System.Threading.Tasks; diff --git a/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkOutboxTransactionScopeTests.cs b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkOutboxTransactionScopeTests.cs index 1d2e060..0e3059a 100644 --- a/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkOutboxTransactionScopeTests.cs +++ b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkOutboxTransactionScopeTests.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.EntityFramework.Tests.Unit; +namespace NetEvolve.Pulse.EntityFramework.Tests.Unit; using System; using System.Threading.Tasks; diff --git a/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/OutboxMessageConfigurationFactoryTests.cs b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/OutboxMessageConfigurationFactoryTests.cs index 337a725..00c2fc6 100644 --- a/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/OutboxMessageConfigurationFactoryTests.cs +++ b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/OutboxMessageConfigurationFactoryTests.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.EntityFramework.Tests.Unit; +namespace NetEvolve.Pulse.EntityFramework.Tests.Unit; using System; using System.Threading.Tasks; diff --git a/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/TestDbContext.cs b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/TestDbContext.cs index 3ab8b5d..dc19ed8 100644 --- a/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/TestDbContext.cs +++ b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/TestDbContext.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.EntityFramework.Tests.Unit; +namespace NetEvolve.Pulse.EntityFramework.Tests.Unit; using Microsoft.EntityFrameworkCore; using NetEvolve.Pulse; diff --git a/tests/NetEvolve.Pulse.Polly.Tests.Unit/PollyMediatorConfiguratorExtensionsTests.cs b/tests/NetEvolve.Pulse.Polly.Tests.Unit/PollyMediatorConfiguratorExtensionsTests.cs index d008a2f..9186c88 100644 --- a/tests/NetEvolve.Pulse.Polly.Tests.Unit/PollyMediatorConfiguratorExtensionsTests.cs +++ b/tests/NetEvolve.Pulse.Polly.Tests.Unit/PollyMediatorConfiguratorExtensionsTests.cs @@ -384,6 +384,9 @@ private sealed class MediatorConfiguratorStub : IMediatorConfigurator public IMediatorConfigurator AddActivityAndMetrics() => throw new NotImplementedException(); + public IMediatorConfigurator AddQueryCaching(Action? configure = null) => + throw new NotImplementedException(); + public IMediatorConfigurator UseDefaultEventDispatcher( ServiceLifetime lifetime = ServiceLifetime.Singleton ) diff --git a/tests/NetEvolve.Pulse.SqlServer.Tests.Integration/SqlServerEventOutboxTests.cs b/tests/NetEvolve.Pulse.SqlServer.Tests.Integration/SqlServerEventOutboxTests.cs index c3dd93c..40aacfd 100644 --- a/tests/NetEvolve.Pulse.SqlServer.Tests.Integration/SqlServerEventOutboxTests.cs +++ b/tests/NetEvolve.Pulse.SqlServer.Tests.Integration/SqlServerEventOutboxTests.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.SqlServer.Tests.Integration; +namespace NetEvolve.Pulse.SqlServer.Tests.Integration; using System.Threading.Tasks; using Microsoft.Data.SqlClient; diff --git a/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerEventOutboxTests.cs b/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerEventOutboxTests.cs index 341cf66..4c5d4a2 100644 --- a/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerEventOutboxTests.cs +++ b/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerEventOutboxTests.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.SqlServer.Tests.Unit; +namespace NetEvolve.Pulse.SqlServer.Tests.Unit; using System; using System.Threading.Tasks; diff --git a/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerMediatorConfiguratorExtensionsTests.cs b/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerMediatorConfiguratorExtensionsTests.cs index d5dcd29..e05e3d4 100644 --- a/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerMediatorConfiguratorExtensionsTests.cs +++ b/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerMediatorConfiguratorExtensionsTests.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.SqlServer.Tests.Unit; +namespace NetEvolve.Pulse.SqlServer.Tests.Unit; using System; using System.Linq; @@ -168,6 +168,9 @@ private sealed class MediatorConfiguratorStub : IMediatorConfigurator public IMediatorConfigurator AddActivityAndMetrics() => throw new NotImplementedException(); + public IMediatorConfigurator AddQueryCaching(Action? configure = null) => + throw new NotImplementedException(); + public IMediatorConfigurator UseDefaultEventDispatcher( ServiceLifetime lifetime = ServiceLifetime.Singleton ) diff --git a/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerOutboxRepositoryTests.cs b/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerOutboxRepositoryTests.cs index 080a248..613efb8 100644 --- a/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerOutboxRepositoryTests.cs +++ b/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerOutboxRepositoryTests.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.SqlServer.Tests.Unit; +namespace NetEvolve.Pulse.SqlServer.Tests.Unit; using System; using System.Threading.Tasks; diff --git a/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerOutboxTransactionScopeTests.cs b/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerOutboxTransactionScopeTests.cs index fc051e5..64b89e5 100644 --- a/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerOutboxTransactionScopeTests.cs +++ b/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerOutboxTransactionScopeTests.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.SqlServer.Tests.Unit; +namespace NetEvolve.Pulse.SqlServer.Tests.Unit; using System.Threading.Tasks; using TUnit.Core; diff --git a/tests/NetEvolve.Pulse.Tests.Integration/DistributedCacheQueryInterceptorTests.cs b/tests/NetEvolve.Pulse.Tests.Integration/DistributedCacheQueryInterceptorTests.cs new file mode 100644 index 0000000..ce1c3ae --- /dev/null +++ b/tests/NetEvolve.Pulse.Tests.Integration/DistributedCacheQueryInterceptorTests.cs @@ -0,0 +1,290 @@ +namespace NetEvolve.Pulse.Tests.Integration; + +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using NetEvolve.Pulse.Extensibility; +using TUnit.Core; + +public sealed class DistributedCacheQueryInterceptorTests +{ + [Test] + public async Task QueryAsync_CacheableQuery_SecondInvocationServedFromCache() + { + var services = new ServiceCollection(); + _ = services.AddLogging(); + _ = services.AddDistributedMemoryCache(); + _ = services + .AddPulse(config => config.AddQueryCaching()) + .AddScoped, CachedValueQueryHandler>(); + + await using var provider = services.BuildServiceProvider(); + + await using var scope1 = provider.CreateAsyncScope(); + var mediator1 = scope1.ServiceProvider.GetRequiredService(); + var handler1 = + scope1.ServiceProvider.GetRequiredService>() + as CachedValueQueryHandler; + + var query = new CachedValueQuery("integration-key"); + + var firstResult = await mediator1.QueryAsync(query).ConfigureAwait(false); + + await using var scope2 = provider.CreateAsyncScope(); + var mediator2 = scope2.ServiceProvider.GetRequiredService(); + var handler2 = + scope2.ServiceProvider.GetRequiredService>() + as CachedValueQueryHandler; + + var secondResult = await mediator2.QueryAsync(query).ConfigureAwait(false); + + using (Assert.Multiple()) + { + _ = await Assert.That(firstResult).IsEqualTo("integration-value"); + _ = await Assert.That(secondResult).IsEqualTo("integration-value"); + _ = await Assert.That(handler1).IsNotNull(); + _ = await Assert.That(handler1!.CallCount).IsEqualTo(1); + _ = await Assert.That(handler2).IsNotNull(); + _ = await Assert.That(handler2!.CallCount).IsEqualTo(0); + } + } + + [Test] + public async Task QueryAsync_NonCacheableQuery_AlwaysReachesHandler() + { + var services = new ServiceCollection(); + _ = services.AddLogging(); + _ = services.AddDistributedMemoryCache(); + _ = services + .AddPulse(config => config.AddQueryCaching()) + .AddScoped, NonCachedQueryHandler>(); + + await using var provider = services.BuildServiceProvider(); + + await using var scope = provider.CreateAsyncScope(); + var mediator = scope.ServiceProvider.GetRequiredService(); + var handler = + scope.ServiceProvider.GetRequiredService>() as NonCachedQueryHandler; + + var query = new NonCachedQuery(); + + _ = await mediator.QueryAsync(query).ConfigureAwait(false); + _ = await mediator.QueryAsync(query).ConfigureAwait(false); + + _ = await Assert.That(handler).IsNotNull(); + _ = await Assert.That(handler!.CallCount).IsEqualTo(2); + } + + [Test] + public async Task QueryAsync_WithoutDistributedCache_FallsThroughToHandler() + { + var services = new ServiceCollection(); + _ = services.AddLogging(); + // Intentionally no IDistributedCache registration + _ = services + .AddPulse(config => config.AddQueryCaching()) + .AddScoped, CachedValueQueryHandler>(); + + await using var provider = services.BuildServiceProvider(); + + await using var scope = provider.CreateAsyncScope(); + var mediator = scope.ServiceProvider.GetRequiredService(); + var handler = + scope.ServiceProvider.GetRequiredService>() + as CachedValueQueryHandler; + + var query = new CachedValueQuery("no-cache-key"); + + var result = await mediator.QueryAsync(query).ConfigureAwait(false); + + using (Assert.Multiple()) + { + _ = await Assert.That(result).IsEqualTo("integration-value"); + _ = await Assert.That(handler).IsNotNull(); + _ = await Assert.That(handler!.CallCount).IsEqualTo(1); + } + } + + [Test] + public async Task QueryAsync_WithCustomJsonSerializerOptions_SerializesAndDeserializesCorrectly() + { + var services = new ServiceCollection(); + _ = services.AddLogging(); + _ = services.AddDistributedMemoryCache(); + _ = services + .AddPulse(config => + config.AddQueryCaching(options => + { + options.JsonSerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + }) + ) + .AddScoped, CachedValueQueryHandler>(); + + await using var provider = services.BuildServiceProvider(); + + await using var scope1 = provider.CreateAsyncScope(); + var mediator1 = scope1.ServiceProvider.GetRequiredService(); + + var query = new CachedValueQuery("custom-json-integration-key"); + var firstResult = await mediator1.QueryAsync(query).ConfigureAwait(false); + + await using var scope2 = provider.CreateAsyncScope(); + var mediator2 = scope2.ServiceProvider.GetRequiredService(); + var handler2 = + scope2.ServiceProvider.GetRequiredService>() + as CachedValueQueryHandler; + + var secondResult = await mediator2.QueryAsync(query).ConfigureAwait(false); + + using (Assert.Multiple()) + { + _ = await Assert.That(firstResult).IsEqualTo("integration-value"); + _ = await Assert.That(secondResult).IsEqualTo("integration-value"); + _ = await Assert.That(handler2).IsNotNull(); + _ = await Assert.That(handler2!.CallCount).IsEqualTo(0); + } + } + + [Test] + public async Task QueryAsync_WithSlidingExpiration_SecondInvocationServedFromCache() + { + var services = new ServiceCollection(); + _ = services.AddLogging(); + _ = services.AddDistributedMemoryCache(); + _ = services + .AddPulse(config => + config.AddQueryCaching(options => + { + options.ExpirationMode = CacheExpirationMode.Sliding; + }) + ) + .AddScoped, CachedValueQueryWithExpiryHandler>(); + + await using var provider = services.BuildServiceProvider(); + + await using var scope1 = provider.CreateAsyncScope(); + var mediator1 = scope1.ServiceProvider.GetRequiredService(); + + var query = new CachedValueQueryWithExpiry("sliding-integration-key"); + var firstResult = await mediator1.QueryAsync(query).ConfigureAwait(false); + + await using var scope2 = provider.CreateAsyncScope(); + var mediator2 = scope2.ServiceProvider.GetRequiredService(); + var handler2 = + scope2.ServiceProvider.GetRequiredService>() + as CachedValueQueryWithExpiryHandler; + + var secondResult = await mediator2.QueryAsync(query).ConfigureAwait(false); + + using (Assert.Multiple()) + { + _ = await Assert.That(firstResult).IsEqualTo("integration-value-with-expiry"); + _ = await Assert.That(secondResult).IsEqualTo("integration-value-with-expiry"); + _ = await Assert.That(handler2).IsNotNull(); + _ = await Assert.That(handler2!.CallCount).IsEqualTo(0); + } + } + + [Test] + public async Task QueryAsync_WithDefaultExpiry_AppliedWhenQueryExpiryIsNull() + { + var services = new ServiceCollection(); + _ = services.AddLogging(); + _ = services.AddDistributedMemoryCache(); + _ = services + .AddPulse(config => + config.AddQueryCaching(options => + { + options.DefaultExpiry = TimeSpan.FromMinutes(5); + }) + ) + .AddScoped, CachedValueQueryHandler>(); + + await using var provider = services.BuildServiceProvider(); + + await using var scope1 = provider.CreateAsyncScope(); + var mediator1 = scope1.ServiceProvider.GetRequiredService(); + var handler1 = + scope1.ServiceProvider.GetRequiredService>() + as CachedValueQueryHandler; + + var query = new CachedValueQuery("default-expiry-integration-key"); + var firstResult = await mediator1.QueryAsync(query).ConfigureAwait(false); + + await using var scope2 = provider.CreateAsyncScope(); + var mediator2 = scope2.ServiceProvider.GetRequiredService(); + var handler2 = + scope2.ServiceProvider.GetRequiredService>() + as CachedValueQueryHandler; + + var secondResult = await mediator2.QueryAsync(query).ConfigureAwait(false); + + using (Assert.Multiple()) + { + _ = await Assert.That(firstResult).IsEqualTo("integration-value"); + _ = await Assert.That(secondResult).IsEqualTo("integration-value"); + _ = await Assert.That(handler1).IsNotNull(); + _ = await Assert.That(handler1!.CallCount).IsEqualTo(1); + _ = await Assert.That(handler2).IsNotNull(); + _ = await Assert.That(handler2!.CallCount).IsEqualTo(0); + } + } + + // ── Private test types ─────────────────────────────────────────────────── + + private sealed record CachedValueQuery(string Key) : ICacheableQuery + { + public string? CorrelationId { get; set; } + public string CacheKey => Key; + public TimeSpan? Expiry => null; + } + + private sealed record CachedValueQueryWithExpiry(string Key) : ICacheableQuery + { + public string? CorrelationId { get; set; } + public string CacheKey => Key; + public TimeSpan? Expiry => TimeSpan.FromMinutes(5); + } + + private sealed class NonCachedQuery : IQuery + { + public string? CorrelationId { get; set; } + } + + private sealed class CachedValueQueryHandler : IQueryHandler + { + public int CallCount { get; private set; } + + public Task HandleAsync(CachedValueQuery query, CancellationToken cancellationToken = default) + { + CallCount++; + return Task.FromResult("integration-value"); + } + } + + private sealed class CachedValueQueryWithExpiryHandler : IQueryHandler + { + public int CallCount { get; private set; } + + public Task HandleAsync(CachedValueQueryWithExpiry query, CancellationToken cancellationToken = default) + { + CallCount++; + return Task.FromResult("integration-value-with-expiry"); + } + } + + private sealed class NonCachedQueryHandler : IQueryHandler + { + public int CallCount { get; private set; } + + public Task HandleAsync(NonCachedQuery query, CancellationToken cancellationToken = default) + { + CallCount++; + return Task.FromResult("non-cached-value"); + } + } +} diff --git a/tests/NetEvolve.Pulse.Tests.Integration/GlobalSuppressions.cs b/tests/NetEvolve.Pulse.Tests.Integration/GlobalSuppressions.cs index 4c7c96c..8e836a2 100644 --- a/tests/NetEvolve.Pulse.Tests.Integration/GlobalSuppressions.cs +++ b/tests/NetEvolve.Pulse.Tests.Integration/GlobalSuppressions.cs @@ -1,4 +1,4 @@ -// This file is used by Code Analysis to maintain SuppressMessage +// This file is used by Code Analysis to maintain SuppressMessage // attributes that are applied to this project. // Project-level suppressions either have no target or are given // a specific target and scoped to a namespace, type, member, etc. diff --git a/tests/NetEvolve.Pulse.Tests.Unit/AssemblyScanningExtensionsTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/AssemblyScanningExtensionsTests.cs index 968bf64..b17a80a 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/AssemblyScanningExtensionsTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/AssemblyScanningExtensionsTests.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Tests.Unit; +namespace NetEvolve.Pulse.Tests.Unit; using System.Diagnostics.CodeAnalysis; using System.Reflection; diff --git a/tests/NetEvolve.Pulse.Tests.Unit/HandlerRegistrationExtensionsTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/HandlerRegistrationExtensionsTests.cs index b15015a..3a8f9e8 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/HandlerRegistrationExtensionsTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/HandlerRegistrationExtensionsTests.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Tests.Unit; +namespace NetEvolve.Pulse.Tests.Unit; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/DistributedCacheQueryInterceptorTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/DistributedCacheQueryInterceptorTests.cs new file mode 100644 index 0000000..88cad74 --- /dev/null +++ b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/DistributedCacheQueryInterceptorTests.cs @@ -0,0 +1,347 @@ +namespace NetEvolve.Pulse.Tests.Unit.Interceptors; + +using System.Text.Json; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NetEvolve.Pulse.Extensibility; +using NetEvolve.Pulse.Interceptors; +using TUnit.Core; + +public class DistributedCacheQueryInterceptorTests +{ + private static IOptions DefaultOptions => Options.Create(new QueryCachingOptions()); + + [Test] + public async Task HandleAsync_QueryNotCacheable_AlwaysCallsHandler() + { + var services = new ServiceCollection(); + _ = services.AddDistributedMemoryCache(); + await using var provider = services.BuildServiceProvider(); + + var interceptor = new DistributedCacheQueryInterceptor(provider, DefaultOptions); + var query = new NonCacheableQuery(); + var handlerCallCount = 0; + + var result = await interceptor + .HandleAsync( + query, + (_, _) => + { + handlerCallCount++; + return Task.FromResult("handler-result"); + } + ) + .ConfigureAwait(false); + + using (Assert.Multiple()) + { + _ = await Assert.That(result).IsEqualTo("handler-result"); + _ = await Assert.That(handlerCallCount).IsEqualTo(1); + } + } + + [Test] + public async Task HandleAsync_CacheMiss_CallsHandlerAndStoresResult() + { + var services = new ServiceCollection(); + _ = services.AddDistributedMemoryCache(); + await using var provider = services.BuildServiceProvider(); + + var interceptor = new DistributedCacheQueryInterceptor(provider, DefaultOptions); + var query = new CacheableQuery("test-key"); + var handlerCallCount = 0; + + var result = await interceptor + .HandleAsync( + query, + (_, _) => + { + handlerCallCount++; + return Task.FromResult("cached-value"); + } + ) + .ConfigureAwait(false); + + using (Assert.Multiple()) + { + _ = await Assert.That(result).IsEqualTo("cached-value"); + _ = await Assert.That(handlerCallCount).IsEqualTo(1); + } + + // Verify the value was written to the cache + var cache = provider.GetRequiredService(); + var bytes = await cache.GetAsync("test-key").ConfigureAwait(false); + _ = await Assert.That(bytes).IsNotNull(); + var deserialised = JsonSerializer.Deserialize(bytes!); + _ = await Assert.That(deserialised).IsEqualTo("cached-value"); + } + + [Test] + public async Task HandleAsync_CacheHit_ReturnsCachedValueWithoutCallingHandler() + { + var services = new ServiceCollection(); + _ = services.AddDistributedMemoryCache(); + await using var provider = services.BuildServiceProvider(); + + // Pre-populate the cache + var cache = provider.GetRequiredService(); + var serialized = JsonSerializer.SerializeToUtf8Bytes("cached-result"); + await cache.SetAsync("hit-key", serialized).ConfigureAwait(false); + + var interceptor = new DistributedCacheQueryInterceptor(provider, DefaultOptions); + var query = new CacheableQuery("hit-key"); + var handlerCallCount = 0; + + var result = await interceptor + .HandleAsync( + query, + (_, _) => + { + handlerCallCount++; + return Task.FromResult("handler-result"); + } + ) + .ConfigureAwait(false); + + using (Assert.Multiple()) + { + _ = await Assert.That(result).IsEqualTo("cached-result"); + _ = await Assert.That(handlerCallCount).IsEqualTo(0); + } + } + + [Test] + public async Task HandleAsync_WithExpiry_StoresEntryWithAbsoluteExpiration() + { + var services = new ServiceCollection(); + _ = services.AddDistributedMemoryCache(); + await using var provider = services.BuildServiceProvider(); + + var interceptor = new DistributedCacheQueryInterceptor( + provider, + DefaultOptions + ); + var query = new CacheableQueryWithExpiry("expiry-key", TimeSpan.FromSeconds(60)); + + var result = await interceptor + .HandleAsync(query, (_, _) => Task.FromResult("expiry-value")) + .ConfigureAwait(false); + + _ = await Assert.That(result).IsEqualTo("expiry-value"); + + var cache = provider.GetRequiredService(); + var bytes = await cache.GetAsync("expiry-key").ConfigureAwait(false); + _ = await Assert.That(bytes).IsNotNull(); + } + + [Test] + public async Task HandleAsync_WithNullExpiry_StoresEntryWithoutExpiration() + { + var services = new ServiceCollection(); + _ = services.AddDistributedMemoryCache(); + await using var provider = services.BuildServiceProvider(); + + var interceptor = new DistributedCacheQueryInterceptor(provider, DefaultOptions); + var query = new CacheableQuery("no-expiry-key"); + + var result = await interceptor + .HandleAsync(query, (_, _) => Task.FromResult("no-expiry-value")) + .ConfigureAwait(false); + + _ = await Assert.That(result).IsEqualTo("no-expiry-value"); + + var cache = provider.GetRequiredService(); + var bytes = await cache.GetAsync("no-expiry-key").ConfigureAwait(false); + _ = await Assert.That(bytes).IsNotNull(); + } + + [Test] + public async Task HandleAsync_NoCacheRegistered_FallsThroughToHandler() + { + var services = new ServiceCollection(); + // Do NOT register IDistributedCache + await using var provider = services.BuildServiceProvider(); + + var interceptor = new DistributedCacheQueryInterceptor(provider, DefaultOptions); + var query = new CacheableQuery("some-key"); + var handlerCallCount = 0; + + var result = await interceptor + .HandleAsync( + query, + (_, _) => + { + handlerCallCount++; + return Task.FromResult("fallthrough-result"); + } + ) + .ConfigureAwait(false); + + using (Assert.Multiple()) + { + _ = await Assert.That(result).IsEqualTo("fallthrough-result"); + _ = await Assert.That(handlerCallCount).IsEqualTo(1); + } + } + + [Test] + public async Task HandleAsync_ExpiredCacheEntry_CallsHandlerAndRefreshesCache() + { + var services = new ServiceCollection(); + _ = services.AddDistributedMemoryCache(); + await using var provider = services.BuildServiceProvider(); + + var cache = provider.GetRequiredService(); + var serialized = JsonSerializer.SerializeToUtf8Bytes("stale-value"); + // Store with an already-expired entry (1 ms TTL) + await cache + .SetAsync( + "expired-key", + serialized, + new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(1) } + ) + .ConfigureAwait(false); + + await Task.Delay(50).ConfigureAwait(false); // Allow the entry to expire + + var interceptor = new DistributedCacheQueryInterceptor(provider, DefaultOptions); + var query = new CacheableQuery("expired-key"); + var handlerCallCount = 0; + + var result = await interceptor + .HandleAsync( + query, + (_, _) => + { + handlerCallCount++; + return Task.FromResult("fresh-value"); + } + ) + .ConfigureAwait(false); + + using (Assert.Multiple()) + { + _ = await Assert.That(result).IsEqualTo("fresh-value"); + _ = await Assert.That(handlerCallCount).IsEqualTo(1); + } + } + + [Test] + public async Task HandleAsync_SlidingExpirationMode_StoresEntryWithSlidingExpiration() + { + var services = new ServiceCollection(); + _ = services.AddDistributedMemoryCache(); + await using var provider = services.BuildServiceProvider(); + + var options = Options.Create(new QueryCachingOptions { ExpirationMode = CacheExpirationMode.Sliding }); + var interceptor = new DistributedCacheQueryInterceptor(provider, options); + var query = new CacheableQueryWithExpiry("sliding-key", TimeSpan.FromSeconds(60)); + + var result = await interceptor + .HandleAsync(query, (_, _) => Task.FromResult("sliding-value")) + .ConfigureAwait(false); + + _ = await Assert.That(result).IsEqualTo("sliding-value"); + + // Entry should still be accessible after being stored with sliding expiration + var cache = provider.GetRequiredService(); + var bytes = await cache.GetAsync("sliding-key").ConfigureAwait(false); + _ = await Assert.That(bytes).IsNotNull(); + } + + [Test] + public async Task HandleAsync_CustomJsonSerializerOptions_UsedForSerializationAndDeserialization() + { + var services = new ServiceCollection(); + _ = services.AddDistributedMemoryCache(); + await using var provider = services.BuildServiceProvider(); + + var customJsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + var options = Options.Create(new QueryCachingOptions { JsonSerializerOptions = customJsonOptions }); + + var interceptor = new DistributedCacheQueryInterceptor(provider, options); + var query = new CacheableQuery("custom-json-key"); + + var result = await interceptor + .HandleAsync(query, (_, _) => Task.FromResult("custom-json-value")) + .ConfigureAwait(false); + + _ = await Assert.That(result).IsEqualTo("custom-json-value"); + + // Second call should return from cache using the same custom options + var cachedResult = await interceptor + .HandleAsync(query, (_, _) => Task.FromResult("should-not-be-returned")) + .ConfigureAwait(false); + + _ = await Assert.That(cachedResult).IsEqualTo("custom-json-value"); + } + + [Test] + public async Task HandleAsync_DefaultExpiry_UsedWhenQueryExpiryIsNull() + { + var services = new ServiceCollection(); + _ = services.AddDistributedMemoryCache(); + await using var provider = services.BuildServiceProvider(); + + var options = Options.Create(new QueryCachingOptions { DefaultExpiry = TimeSpan.FromMinutes(5) }); + var interceptor = new DistributedCacheQueryInterceptor(provider, options); + var query = new CacheableQuery("default-expiry-key"); + + var result = await interceptor + .HandleAsync(query, (_, _) => Task.FromResult("default-expiry-value")) + .ConfigureAwait(false); + + _ = await Assert.That(result).IsEqualTo("default-expiry-value"); + + // Entry should be present (default expiry applied) + var cache = provider.GetRequiredService(); + var bytes = await cache.GetAsync("default-expiry-key").ConfigureAwait(false); + _ = await Assert.That(bytes).IsNotNull(); + } + + [Test] + public async Task HandleAsync_DefaultExpiry_NotUsedWhenQueryExpiryIsProvided() + { + var services = new ServiceCollection(); + _ = services.AddDistributedMemoryCache(); + await using var provider = services.BuildServiceProvider(); + + // DefaultExpiry set but query overrides with its own expiry value + var options = Options.Create(new QueryCachingOptions { DefaultExpiry = TimeSpan.FromMilliseconds(1) }); + var interceptor = new DistributedCacheQueryInterceptor(provider, options); + var query = new CacheableQueryWithExpiry("query-expiry-key", TimeSpan.FromMinutes(10)); + + var result = await interceptor + .HandleAsync(query, (_, _) => Task.FromResult("query-expiry-value")) + .ConfigureAwait(false); + + _ = await Assert.That(result).IsEqualTo("query-expiry-value"); + + // Entry should still be present because the query's own expiry (10 min) overrode DefaultExpiry (1 ms) + var cache = provider.GetRequiredService(); + var bytes = await cache.GetAsync("query-expiry-key").ConfigureAwait(false); + _ = await Assert.That(bytes).IsNotNull(); + } + + // ── Private test types ─────────────────────────────────────────────────── + + private sealed class NonCacheableQuery : IQuery + { + public string? CorrelationId { get; set; } + } + + private sealed record CacheableQuery(string Key) : ICacheableQuery + { + public string? CorrelationId { get; set; } + public string CacheKey => Key; + public TimeSpan? Expiry => null; + } + + private sealed record CacheableQueryWithExpiry(string Key, TimeSpan ExpiryValue) : ICacheableQuery + { + public string? CorrelationId { get; set; } + public string CacheKey => Key; + public TimeSpan? Expiry => ExpiryValue; + } +} diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Internals/MediatorConfiguratorTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Internals/MediatorConfiguratorTests.cs index aac1dc7..bf387d0 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/Internals/MediatorConfiguratorTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/Internals/MediatorConfiguratorTests.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Tests.Unit.Internals; +namespace NetEvolve.Pulse.Tests.Unit.Internals; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; diff --git a/tests/NetEvolve.Pulse.Tests.Unit/ServiceCollectionExtensionsTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/ServiceCollectionExtensionsTests.cs index e8bcd25..b9070f4 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/ServiceCollectionExtensionsTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/ServiceCollectionExtensionsTests.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Tests.Unit; +namespace NetEvolve.Pulse.Tests.Unit; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection;