From ea0c54e2898c2499c1a52e9f258e5352ec68ea23 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sat, 2 May 2026 01:44:11 +0100 Subject: [PATCH 01/50] v2: cache provider abstraction, MediatR + Redis sub-packages, multi-TFM, stampede protection - Replace MemoryCache-specific API with pluggable ICleverCacheStore abstraction - MemoryCacheStore (default), DistributedCacheStore (IDistributedCache/JSON) - CleverCacheEntryOptions replaces MemoryCacheEntryOptions - Fluent DI: UseMemoryCache(), UseDistributedCache(), UseCustomStore() - Extract MediatR integration into separate CleverCache.MediatR NuGet package - Main package no longer has any MediatR dependency - Add CleverCache.Redis NuGet package - UseRedisCache(string) and UseRedisCache(Action) convenience methods - Central Package Management (Directory.Build.props + Directory.Packages.props) - Shared metadata (authors, repo URL, license) in one place - Per-TFM conditional package versions - Multi-target net9.0 and net10.0 with stable packages only - net9.0: EF Core 9.0.9, Extensions.Caching 9.0.9, Redis 9.0.9 - net10.0: EF Core 10.0.7, Extensions.Caching 10.0.7, Redis 10.0.7 - Cache stampede protection via AsyncKeyedLock 8.0.2 - Per-key locking (not global) prevents nested cached call deadlocks - Double-checked locking pattern on both sync and async paths - Updated ReadMe with corrected usage examples, cache options, custom store guide Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 3 + .../AutoCacheAttribute.cs | 3 +- .../AutoCacheBehaviour.cs | 17 +- .../CleverCache.MediatR.csproj | 18 ++ .../MediatRServiceConfigurationExt.cs | 7 +- CleverCache.MediatR/NuGetReadMe.md | 19 ++ CleverCache.Redis/CleverCache.Redis.csproj | 18 ++ .../CleverCacheOptionsRedisExtensions.cs | 30 +++ CleverCache.Redis/NuGetReadMe.md | 19 ++ CleverCache.csproj | 33 ++-- CleverCache.sln | 40 ++++ .../ServiceCollectionExtensions.cs | 8 +- Directory.Build.props | 13 ++ Directory.Packages.props | 28 +++ Extensions/CleverCacheExtensions.cs | 40 ++-- GlobalUsings.cs | 1 - ICleverCache.cs | 12 +- ICleverCacheStore.cs | 25 +++ Implementations/CleverCacheService.cs | 52 ++++++ Implementations/DistributedCacheStore.cs | 61 ++++++ Implementations/FakeCache.cs | 62 ++----- Implementations/MemoryCache.cs | 56 ------ Implementations/MemoryCacheStore.cs | 49 +++++ Models/CleverCacheEntryOptions.cs | 8 + Models/CleverCacheOptions.cs | 67 ++++++- Models/FakeCacheEntry.cs | 21 --- NuGetReadMe.md | 21 +++ ReadMe.md | 175 ++++++++++++++++-- 28 files changed, 685 insertions(+), 221 deletions(-) rename {Mediatr => CleverCache.MediatR}/AutoCacheAttribute.cs (76%) rename {Mediatr => CleverCache.MediatR}/AutoCacheBehaviour.cs (59%) create mode 100644 CleverCache.MediatR/CleverCache.MediatR.csproj rename {Mediatr => CleverCache.MediatR}/MediatRServiceConfigurationExt.cs (60%) create mode 100644 CleverCache.MediatR/NuGetReadMe.md create mode 100644 CleverCache.Redis/CleverCache.Redis.csproj create mode 100644 CleverCache.Redis/CleverCacheOptionsRedisExtensions.cs create mode 100644 CleverCache.Redis/NuGetReadMe.md create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props create mode 100644 ICleverCacheStore.cs create mode 100644 Implementations/CleverCacheService.cs create mode 100644 Implementations/DistributedCacheStore.cs delete mode 100644 Implementations/MemoryCache.cs create mode 100644 Implementations/MemoryCacheStore.cs create mode 100644 Models/CleverCacheEntryOptions.cs delete mode 100644 Models/FakeCacheEntry.cs create mode 100644 NuGetReadMe.md diff --git a/.gitignore b/.gitignore index 6845257..974bcb9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ /obj /bin + +/*/bin/* +/*/obj/* \ No newline at end of file diff --git a/Mediatr/AutoCacheAttribute.cs b/CleverCache.MediatR/AutoCacheAttribute.cs similarity index 76% rename from Mediatr/AutoCacheAttribute.cs rename to CleverCache.MediatR/AutoCacheAttribute.cs index 43190dc..b409371 100644 --- a/Mediatr/AutoCacheAttribute.cs +++ b/CleverCache.MediatR/AutoCacheAttribute.cs @@ -1,4 +1,5 @@ -namespace CleverCache.Mediatr; +namespace CleverCache.Mediatr; + public class AutoCacheAttribute(params Type[] types) : Attribute { public Type[] Types { get; } = types; diff --git a/Mediatr/AutoCacheBehaviour.cs b/CleverCache.MediatR/AutoCacheBehaviour.cs similarity index 59% rename from Mediatr/AutoCacheBehaviour.cs rename to CleverCache.MediatR/AutoCacheBehaviour.cs index ab58870..ba2b56d 100644 --- a/Mediatr/AutoCacheBehaviour.cs +++ b/CleverCache.MediatR/AutoCacheBehaviour.cs @@ -1,7 +1,8 @@ -using System.Reflection; +using System.Reflection; using MediatR; namespace CleverCache.Mediatr; + internal class AutoCacheBehaviour(ICleverCache cache) : IPipelineBehavior where TRequest : class @@ -10,35 +11,25 @@ public async Task Handle(TRequest request, RequestHandlerDelegate(); - // No types to use if (attribute is null) { return await next(cancellationToken); } - // Try to get the result from cache var result = await cache.GetOrCreateAsync( attribute.Types, request, - async _ => - { - var result = await next(cancellationToken); - return result; - } - // Call next only once when the cache is not available + () => next(cancellationToken) ); - // If cache has the result, return it if (result is not null) { return result; } - // If result is null, remove from cache and call next delegate cache.Remove(request); - return await next(cancellationToken); // Only call next here if cache miss + return await next(cancellationToken); } } diff --git a/CleverCache.MediatR/CleverCache.MediatR.csproj b/CleverCache.MediatR/CleverCache.MediatR.csproj new file mode 100644 index 0000000..7c15124 --- /dev/null +++ b/CleverCache.MediatR/CleverCache.MediatR.csproj @@ -0,0 +1,18 @@ + + + CleverCache.MediatR + 2.0.0 + MediatR pipeline integration for CleverCache — automatic caching of MediatR queries via the [AutoCache] attribute. + MediatR,CleverCache,Cache,Automatic Cache + NuGetReadMe.md + + + + + + + + + + + diff --git a/Mediatr/MediatRServiceConfigurationExt.cs b/CleverCache.MediatR/MediatRServiceConfigurationExt.cs similarity index 60% rename from Mediatr/MediatRServiceConfigurationExt.cs rename to CleverCache.MediatR/MediatRServiceConfigurationExt.cs index d4fd1ca..3845fdc 100644 --- a/Mediatr/MediatRServiceConfigurationExt.cs +++ b/CleverCache.MediatR/MediatRServiceConfigurationExt.cs @@ -1,4 +1,9 @@ -namespace CleverCache.Mediatr; +using MediatR; +using MediatR.Registration; +using Microsoft.Extensions.DependencyInjection; + +namespace CleverCache.Mediatr; + public static class MediatRServiceConfigurationExt { public static void AddCleverCache(this MediatRServiceConfiguration cfg) diff --git a/CleverCache.MediatR/NuGetReadMe.md b/CleverCache.MediatR/NuGetReadMe.md new file mode 100644 index 0000000..9ef268a --- /dev/null +++ b/CleverCache.MediatR/NuGetReadMe.md @@ -0,0 +1,19 @@ +# CleverCache.MediatR + +MediatR pipeline integration for [CleverCache](https://www.nuget.org/packages/CleverCache) — automatically caches MediatR query results using the `[AutoCache]` attribute with zero changes to your handlers. + +For full documentation see the [GitHub repository](https://github.com/chunty/CleverCache). + +## Quick start + +```csharp +// 1. Register the pipeline behaviour +services.AddMediatR(cfg => +{ + cfg.AddCleverCache(); +}); + +// 2. Decorate any query with [AutoCache] +[AutoCache([typeof(MyEntity)])] +public record GetMyQuery(int Id) : IRequest; +``` diff --git a/CleverCache.Redis/CleverCache.Redis.csproj b/CleverCache.Redis/CleverCache.Redis.csproj new file mode 100644 index 0000000..68f5eb5 --- /dev/null +++ b/CleverCache.Redis/CleverCache.Redis.csproj @@ -0,0 +1,18 @@ + + + CleverCache.Redis + 2.0.0 + Redis integration for CleverCache — adds UseRedisCache() convenience registration backed by StackExchange.Redis. + Redis,StackExchange.Redis,CleverCache,Cache,Automatic Cache + NuGetReadMe.md + + + + + + + + + + + diff --git a/CleverCache.Redis/CleverCacheOptionsRedisExtensions.cs b/CleverCache.Redis/CleverCacheOptionsRedisExtensions.cs new file mode 100644 index 0000000..ee80d9a --- /dev/null +++ b/CleverCache.Redis/CleverCacheOptionsRedisExtensions.cs @@ -0,0 +1,30 @@ +using CleverCache.Implementations; +using CleverCache.Models; +using Microsoft.Extensions.Caching.StackExchangeRedis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CleverCache.Redis; + +public static class CleverCacheOptionsRedisExtensions +{ + /// + /// Configures CleverCache to use Redis as the cache backend via StackExchange.Redis. + /// + /// The CleverCache options. + /// The Redis connection string (e.g. "localhost:6379"). + public static CleverCacheOptions UseRedisCache(this CleverCacheOptions options, string connectionString) => + options.UseRedisCache(redis => redis.Configuration = connectionString); + + /// + /// Configures CleverCache to use Redis as the cache backend via StackExchange.Redis. + /// + /// The CleverCache options. + /// A delegate to configure the Redis cache options. + public static CleverCacheOptions UseRedisCache(this CleverCacheOptions options, Action configure) => + options.UseCustomStoreRegistration(services => + { + services.AddStackExchangeRedisCache(configure); + services.TryAddSingleton(); + }); +} diff --git a/CleverCache.Redis/NuGetReadMe.md b/CleverCache.Redis/NuGetReadMe.md new file mode 100644 index 0000000..43f3c36 --- /dev/null +++ b/CleverCache.Redis/NuGetReadMe.md @@ -0,0 +1,19 @@ +# CleverCache.Redis + +Redis integration for [CleverCache](https://www.nuget.org/packages/CleverCache) — adds a `UseRedisCache()` convenience method backed by `StackExchange.Redis`. + +For full documentation see the [GitHub repository](https://github.com/chunty/CleverCache). + +## Quick start + +```csharp +// Simple connection string +builder.Services.AddCleverCache(o => o.UseRedisCache("localhost:6379")); + +// Full Redis options +builder.Services.AddCleverCache(o => o.UseRedisCache(redis => +{ + redis.Configuration = "localhost:6379"; + redis.InstanceName = "MyApp:"; +})); +``` diff --git a/CleverCache.csproj b/CleverCache.csproj index fc0dad3..34ba92f 100644 --- a/CleverCache.csproj +++ b/CleverCache.csproj @@ -1,30 +1,21 @@  - net9.0 - enable - enable CleverCache - 1.0.13 - Chris Hunt - TappetyClick - Like MemoryCache but better! You never need to worry about clearing cache again. - MemoryCache,Automatic Cache - ReadMe.md - - MIT - - https://github.com/chunty/CleverCache - git - - https://github.com/chunty/CleverCache + 2.0.0 + Like MemoryCache but better! Automatically invalidates cache by entity type, supports memory cache, distributed cache, or a custom provider. + MemoryCache,DistributedCache,Automatic Cache,Cache Invalidation + NuGetReadMe.md + + $(DefaultItemExcludes);CleverCache.MediatR\**;CleverCache.Redis\** - - - - + + + + + - + diff --git a/CleverCache.sln b/CleverCache.sln index 77712e8..51b4357 100644 --- a/CleverCache.sln +++ b/CleverCache.sln @@ -5,16 +5,56 @@ VisualStudioVersion = 17.12.35527.113 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleverCache", "CleverCache.csproj", "{2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleverCache.MediatR", "CleverCache.MediatR\CleverCache.MediatR.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleverCache.Redis", "CleverCache.Redis\CleverCache.Redis.csproj", "{C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Debug|x64.ActiveCfg = Debug|Any CPU + {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Debug|x64.Build.0 = Debug|Any CPU + {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Debug|x86.ActiveCfg = Debug|Any CPU + {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Debug|x86.Build.0 = Debug|Any CPU {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Release|Any CPU.ActiveCfg = Release|Any CPU {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Release|Any CPU.Build.0 = Release|Any CPU + {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Release|x64.ActiveCfg = Release|Any CPU + {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Release|x64.Build.0 = Release|Any CPU + {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Release|x86.ActiveCfg = Release|Any CPU + {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Release|x86.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Debug|x64.ActiveCfg = Debug|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Debug|x64.Build.0 = Debug|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Debug|x86.ActiveCfg = Debug|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Debug|x86.Build.0 = Debug|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Release|Any CPU.Build.0 = Release|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Release|x64.ActiveCfg = Release|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Release|x64.Build.0 = Release|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Release|x86.ActiveCfg = Release|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/DependencyInjection/ServiceCollectionExtensions.cs b/DependencyInjection/ServiceCollectionExtensions.cs index 8a281ff..8008713 100644 --- a/DependencyInjection/ServiceCollectionExtensions.cs +++ b/DependencyInjection/ServiceCollectionExtensions.cs @@ -11,15 +11,15 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddCleverCache(this IServiceCollection services, Action? options = null) { - // Register the Smart Cache Options var localOptions = new CleverCacheOptions(); options?.Invoke(localOptions); services.TryAddSingleton(localOptions); - services.AddMemoryCache(); + // Register chosen store (defaults to memory) + localOptions.StoreRegistration?.Invoke(services); - // Register ICleverCache - services.TryAddSingleton(); + // Register ICleverCache backed by the store + services.TryAddSingleton(); // Register the Smart Cache Interceptor as Service services.TryAddScoped(); diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..98f8b85 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,13 @@ + + + net9.0;net10.0 + enable + enable + Chris Hunt + TappetyClick + MIT + https://github.com/chunty/CleverCache + git + https://github.com/chunty/CleverCache + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..90bd3f8 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,28 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Extensions/CleverCacheExtensions.cs b/Extensions/CleverCacheExtensions.cs index 7421560..8d6b857 100644 --- a/Extensions/CleverCacheExtensions.cs +++ b/Extensions/CleverCacheExtensions.cs @@ -12,11 +12,11 @@ public static class CleverCacheExtensions /// The cache instance. /// The key of the entry to look for or create. /// The factory that creates the value associated with this key if the key does not exist in the cache. - /// The options to be applied to the if the key does not exist in the cache. + /// The options to be applied to the cache entry if the key does not exist in the cache. /// The value associated with this key. public static TItem? GetOrCreate(this ICleverCache cache, object key, - Func factory, - MemoryCacheEntryOptions? createOptions = null) where T : class => + Func factory, + CleverCacheEntryOptions? createOptions = null) where T : class => cache.GetOrCreate(typeof(T), key, factory, createOptions); /// @@ -27,10 +27,10 @@ public static class CleverCacheExtensions /// The type of the object the cache key belongs to. /// The key of the entry to look for or create. /// The factory that creates the value associated with this key if the key does not exist in the cache. - /// The options to be applied to the if the key does not exist in the cache. + /// The options to be applied to the cache entry if the key does not exist in the cache. /// The value associated with this key. - public static TItem? GetOrCreate(this ICleverCache cache, Type type, object key, Func factory, - MemoryCacheEntryOptions? createOptions = null) => + public static TItem? GetOrCreate(this ICleverCache cache, Type type, object key, Func factory, + CleverCacheEntryOptions? createOptions = null) => cache.GetOrCreate([type], key, factory, createOptions); /// @@ -41,11 +41,11 @@ public static class CleverCacheExtensions /// The cache instance. /// The key of the entry to look for or create. /// The factory task that creates the value associated with this key if the key does not exist in the cache. - /// The options to be applied to the if the key does not exist in the cache. + /// The options to be applied to the cache entry if the key does not exist in the cache. /// The task object representing the asynchronous operation. public static async Task GetOrCreateAsync(this ICleverCache cache, object key, - Func> factory, - MemoryCacheEntryOptions? createOptions = null) where T : class => + Func> factory, + CleverCacheEntryOptions? createOptions = null) where T : class => await cache.GetOrCreateAsync(typeof(T), key, factory, createOptions); /// @@ -56,26 +56,12 @@ public static class CleverCacheExtensions /// The type of the object the cache key belongs to. /// The key of the entry to look for or create. /// The factory task that creates the value associated with this key if the key does not exist in the cache. - /// The options to be applied to the if the key does not exist in the cache. + /// The options to be applied to the cache entry if the key does not exist in the cache. /// The task object representing the asynchronous operation. public static async Task GetOrCreateAsync(this ICleverCache cache, Type type, object key, - Func> factory, - MemoryCacheEntryOptions? createOptions = null) => + Func> factory, + CleverCacheEntryOptions? createOptions = null) => await cache.GetOrCreateAsync([type], key, factory, createOptions); - - /// - /// Asynchronously gets the value associated with this key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found. - /// - /// The type of the object to get. - /// The cache instance. - /// An array of types the cache key belongs to. - /// The key of the entry to look for or create. - /// The factory task that creates the value associated with this key if the key does not exist in the cache. - /// The options to be applied to the if the key does not exist in the cache. - /// The task object representing the asynchronous operation. - public static Task GetOrCreateAsync(this ICleverCache cache, Type[] types, - object key, - Func> factory, - MemoryCacheEntryOptions? createOptions = null) => throw new NotImplementedException(); } + diff --git a/GlobalUsings.cs b/GlobalUsings.cs index b4951ce..9497c1b 100644 --- a/GlobalUsings.cs +++ b/GlobalUsings.cs @@ -1,7 +1,6 @@ // Global using directives global using Microsoft.EntityFrameworkCore; -global using Microsoft.Extensions.Caching.Memory; global using Microsoft.Extensions.DependencyInjection; global using CleverCache.Extensions; global using CleverCache.Interceptors; diff --git a/ICleverCache.cs b/ICleverCache.cs index bfd4ad4..94f4a13 100644 --- a/ICleverCache.cs +++ b/ICleverCache.cs @@ -8,13 +8,13 @@ public interface ICleverCache : ICacheEntryManager /// An array types the cache key belongs to. /// The key of the entry to look for or create. /// The factory that creates the value associated with this key if the key does not exist in the cache. - /// The options to be applied to the if the key does not exist in the cache. + /// The options to be applied to the cache entry if the key does not exist in the cache. /// The value associated with this key. TItem? GetOrCreate( Type[] types, object key, - Func factory, - MemoryCacheEntryOptions? createOptions = null); + Func factory, + CleverCacheEntryOptions? createOptions = null); /// /// Asynchronously gets the value associated with this key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found. @@ -23,13 +23,13 @@ public interface ICleverCache : ICacheEntryManager /// An array types the cache key belongs to. /// The key of the entry to look for or create. /// The factory task that creates the value associated with this key if the key does not exist in the cache. - /// The options to be applied to the if the key does not exist in the cache. + /// The options to be applied to the cache entry if the key does not exist in the cache. /// The task object representing the asynchronous operation. Task GetOrCreateAsync( Type[] types, object key, - Func> factory, - MemoryCacheEntryOptions? createOptions = null); + Func> factory, + CleverCacheEntryOptions? createOptions = null); /// /// Removes the object associated with the given key. diff --git a/ICleverCacheStore.cs b/ICleverCacheStore.cs new file mode 100644 index 0000000..51fb5b6 --- /dev/null +++ b/ICleverCacheStore.cs @@ -0,0 +1,25 @@ +namespace CleverCache; + +/// +/// Abstraction over the underlying cache backend. Implement this interface to plug in a custom cache provider. +/// +public interface ICleverCacheStore +{ + /// Attempts to retrieve a cached value by key. + bool TryGet(object key, out TItem? value); + + /// Asynchronously attempts to retrieve a cached value by key. + Task<(bool Hit, TItem? Value)> TryGetAsync(object key, CancellationToken cancellationToken = default); + + /// Stores a value in the cache. + void Set(object key, TItem value, CleverCacheEntryOptions? options = null); + + /// Asynchronously stores a value in the cache. + Task SetAsync(object key, TItem value, CleverCacheEntryOptions? options = null, CancellationToken cancellationToken = default); + + /// Removes a cached entry by key. + void Remove(object key); + + /// Asynchronously removes a cached entry by key. + Task RemoveAsync(object key, CancellationToken cancellationToken = default); +} diff --git a/Implementations/CleverCacheService.cs b/Implementations/CleverCacheService.cs new file mode 100644 index 0000000..7765021 --- /dev/null +++ b/Implementations/CleverCacheService.cs @@ -0,0 +1,52 @@ +using AsyncKeyedLock; + +namespace CleverCache.Implementations; + +/// +public class CleverCacheService(ICleverCacheStore store) : CacheEntryManager, ICleverCache +{ + private readonly AsyncKeyedLocker _locker = new(); + + public TItem? GetOrCreate(Type[] types, object key, Func factory, CleverCacheEntryOptions? options = null) + { + if (store.TryGet(key, out var hit)) return hit; + + using var _ = _locker.Lock(key); + + // Double-check: another thread may have populated the cache while we waited for the lock + if (store.TryGet(key, out hit)) return hit; + + AddKeyToTypes(types, key); + var value = factory(); + store.Set(key, value, options); + return value; + } + + public async Task GetOrCreateAsync(Type[] types, object key, Func> factory, CleverCacheEntryOptions? options = null) + { + var (found, cached) = await store.TryGetAsync(key).ConfigureAwait(false); + if (found) return cached; + + using var _ = await _locker.LockAsync(key).ConfigureAwait(false); + + // Double-check: another thread may have populated the cache while we waited for the lock + (found, cached) = await store.TryGetAsync(key).ConfigureAwait(false); + if (found) return cached; + + AddKeyToTypes(types, key); + var value = await factory().ConfigureAwait(false); + await store.SetAsync(key, value, options).ConfigureAwait(false); + return value; + } + + public void RemoveByType(Type type) + { + foreach (var k in SnapshotKeysFor(type)) + { + store.Remove(k); + } + } + + public void Remove(object key) => store.Remove(key); +} + diff --git a/Implementations/DistributedCacheStore.cs b/Implementations/DistributedCacheStore.cs new file mode 100644 index 0000000..ac24b12 --- /dev/null +++ b/Implementations/DistributedCacheStore.cs @@ -0,0 +1,61 @@ +using System.Text.Json; +using Microsoft.Extensions.Caching.Distributed; + +namespace CleverCache.Implementations; + +public class DistributedCacheStore(IDistributedCache distributedCache) : ICleverCacheStore +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public bool TryGet(object key, out TItem? value) + { + var bytes = distributedCache.Get(ToStringKey(key)); + if (bytes is null) + { + value = default; + return false; + } + + value = JsonSerializer.Deserialize(bytes, JsonOptions); + return true; + } + + public async Task<(bool Hit, TItem? Value)> TryGetAsync(object key, CancellationToken cancellationToken = default) + { + var bytes = await distributedCache.GetAsync(ToStringKey(key), cancellationToken).ConfigureAwait(false); + if (bytes is null) return (false, default); + + var value = JsonSerializer.Deserialize(bytes, JsonOptions); + return (true, value); + } + + public void Set(object key, TItem value, CleverCacheEntryOptions? options = null) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(value, JsonOptions); + distributedCache.Set(ToStringKey(key), bytes, ToDistributedOptions(options)); + } + + public async Task SetAsync(object key, TItem value, CleverCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(value, JsonOptions); + await distributedCache.SetAsync(ToStringKey(key), bytes, ToDistributedOptions(options), cancellationToken).ConfigureAwait(false); + } + + public void Remove(object key) => distributedCache.Remove(ToStringKey(key)); + + public async Task RemoveAsync(object key, CancellationToken cancellationToken = default) => + await distributedCache.RemoveAsync(ToStringKey(key), cancellationToken).ConfigureAwait(false); + + private static string ToStringKey(object key) => + $"{typeof(TItem).FullName}:{JsonSerializer.Serialize(key, JsonOptions)}"; + + private static DistributedCacheEntryOptions ToDistributedOptions(CleverCacheEntryOptions? options) => + options is null + ? new DistributedCacheEntryOptions() + : new DistributedCacheEntryOptions + { + AbsoluteExpiration = options.AbsoluteExpiration, + AbsoluteExpirationRelativeToNow = options.AbsoluteExpirationRelativeToNow, + SlidingExpiration = options.SlidingExpiration + }; +} diff --git a/Implementations/FakeCache.cs b/Implementations/FakeCache.cs index 2794ad5..a6b1451 100644 --- a/Implementations/FakeCache.cs +++ b/Implementations/FakeCache.cs @@ -5,66 +5,30 @@ /// public class FakeCache : ICleverCache { - /// - /// Adds a dependent cache type. - /// - /// The type of the cache. - /// The dependent type of the cache. + /// public void AddDependentCache(Type type, Type dependentType) { } - /// - /// Adds the specified key to the cache entry type. - /// - /// An array types the cache key belongs to. - /// The key of the cache entry to add. + /// public void AddKeyToTypes(Type[] types, object key) { } - /// - /// Removes all cache entries of the specified type. - /// - /// The type of the objects to remove cache entries for. + /// public void RemoveByType(Type type) { } - /// - /// Gets the value associated with this key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found. - /// - /// The type of the object to get. - /// An array types the cache key belongs to. - /// The key of the entry to look for or create. - /// The factory that creates the value associated with this key if the key does not exist in the cache. - /// The options to be applied to the if the key does not exist in the cache. - /// The value associated with this key. + /// public TItem? GetOrCreate( Type[] types, object key, - Func factory, - MemoryCacheEntryOptions? createOptions = null) - { - ICacheEntry cacheEntry = new FakeCacheEntry(); - return factory(cacheEntry); - } + Func factory, + CleverCacheEntryOptions? createOptions = null) => factory(); - /// - /// Asynchronously gets the value associated with this key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found. - /// - /// The type of the object to get. - /// An array types the cache key belongs to. - /// The key of the entry to look for or create. - /// The factory task that creates the value associated with this key if the key does not exist in the cache. - /// The options to be applied to the if the key does not exist in the cache. - /// The task object representing the asynchronous operation. - public async Task GetOrCreateAsync(Type[] types, + /// + public async Task GetOrCreateAsync( + Type[] types, object key, - Func> factory, - MemoryCacheEntryOptions? createOptions = null) - { - ICacheEntry cacheEntry = new FakeCacheEntry(); - return await factory(cacheEntry); - } + Func> factory, + CleverCacheEntryOptions? createOptions = null) => await factory(); - /// - /// Removes the object associated with the given key. - /// - /// An object identifying the entry. + /// public void Remove(object key) { } } + diff --git a/Implementations/MemoryCache.cs b/Implementations/MemoryCache.cs deleted file mode 100644 index 321dfa7..0000000 --- a/Implementations/MemoryCache.cs +++ /dev/null @@ -1,56 +0,0 @@ -namespace CleverCache.Implementations; - -/// -public class CleverMemoryCache(IMemoryCache memoryCache) : CacheEntryManager, ICleverCache -{ - public TItem? GetOrCreate(Type[] types, object key, Func factory, MemoryCacheEntryOptions? options = null) - { - if (memoryCache.TryGetValue(key, out var hit)) return (TItem?)hit; - - using var entry = GetCacheEntry(types, key, options); - return SetEntryValue(factory(entry), entry); - } - - public async Task GetOrCreateAsync(Type[] types, object key, Func> factory, MemoryCacheEntryOptions? options = null) - { - if (memoryCache.TryGetValue(key, out var hit)) return (TItem?)hit; - - using var entry = GetCacheEntry(types, key, options); - return SetEntryValue(await factory(entry).ConfigureAwait(false), entry); - } - - public void RemoveByType(Type type) - { - // snapshot avoids races - foreach (var k in SnapshotKeysFor(type)) - { - memoryCache.Remove(k); - } - } - - public void Remove(object key) => memoryCache.Remove(key); - - private static TItem? SetEntryValue(TItem value, ICacheEntry entry) - { - entry.Value = value; - return (TItem?)entry.Value; - } - - private ICacheEntry GetCacheEntry(Type[] types, object key, MemoryCacheEntryOptions? options) - { - ICacheEntry? entry = null; - try - { - AddKeyToTypes(types, key); - entry = memoryCache.CreateEntry(key); - if (options is not null) entry.SetOptions(options); - return entry; - } - catch - { - entry?.Dispose(); - throw; - } - } - -} diff --git a/Implementations/MemoryCacheStore.cs b/Implementations/MemoryCacheStore.cs new file mode 100644 index 0000000..8a63cdb --- /dev/null +++ b/Implementations/MemoryCacheStore.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace CleverCache.Implementations; + +public class MemoryCacheStore(IMemoryCache memoryCache) : ICleverCacheStore +{ + public bool TryGet(object key, out TItem? value) + { + if (memoryCache.TryGetValue(key, out var hit)) + { + value = (TItem?)hit; + return true; + } + + value = default; + return false; + } + + public Task<(bool Hit, TItem? Value)> TryGetAsync(object key, CancellationToken cancellationToken = default) + { + var hit = TryGet(key, out var value); + return Task.FromResult((hit, value)); + } + + public void Set(object key, TItem value, CleverCacheEntryOptions? options = null) + { + using var entry = memoryCache.CreateEntry(key); + entry.Value = value; + + if (options is null) return; + entry.AbsoluteExpiration = options.AbsoluteExpiration; + entry.AbsoluteExpirationRelativeToNow = options.AbsoluteExpirationRelativeToNow; + entry.SlidingExpiration = options.SlidingExpiration; + } + + public Task SetAsync(object key, TItem value, CleverCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + { + Set(key, value, options); + return Task.CompletedTask; + } + + public void Remove(object key) => memoryCache.Remove(key); + + public Task RemoveAsync(object key, CancellationToken cancellationToken = default) + { + Remove(key); + return Task.CompletedTask; + } +} diff --git a/Models/CleverCacheEntryOptions.cs b/Models/CleverCacheEntryOptions.cs new file mode 100644 index 0000000..939ed4b --- /dev/null +++ b/Models/CleverCacheEntryOptions.cs @@ -0,0 +1,8 @@ +namespace CleverCache.Models; + +public class CleverCacheEntryOptions +{ + public DateTimeOffset? AbsoluteExpiration { get; set; } + public TimeSpan? AbsoluteExpirationRelativeToNow { get; set; } + public TimeSpan? SlidingExpiration { get; set; } +} diff --git a/Models/CleverCacheOptions.cs b/Models/CleverCacheOptions.cs index fbba01e..c12661e 100644 --- a/Models/CleverCacheOptions.cs +++ b/Models/CleverCacheOptions.cs @@ -1,4 +1,10 @@ -namespace CleverCache.Models; +using CleverCache.Implementations; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CleverCache.Models; public class CleverCacheOptions(CleverCacheScanOptions? scanOptions = null, HashSet? dependentCaches = null, @@ -7,5 +13,60 @@ public class CleverCacheOptions(CleverCacheScanOptions? scanOptions = null, // ReSharper disable once IdentifierTypo public CleverCacheScanOptions Scanning { get; set; } = scanOptions ?? new CleverCacheScanOptions(); public HashSet DependentCaches { get; set; } = dependentCaches ?? []; - public bool DisableAllScanning { get; set; } = disableAllScanning; // Don't do any scanning to set up -} \ No newline at end of file + public bool DisableAllScanning { get; set; } = disableAllScanning; + + internal Action StoreRegistration { get; private set; } = services => + { + services.AddMemoryCache(); + services.TryAddSingleton(); + }; + + /// Uses the built-in backend (default). + public CleverCacheOptions UseMemoryCache() + { + StoreRegistration = services => + { + services.AddMemoryCache(); + services.TryAddSingleton(); + }; + return this; + } + + /// + /// Uses the built-in backend. + /// Requires IDistributedCache to be registered (e.g. via AddStackExchangeRedisCache or AddDistributedMemoryCache). + /// + public CleverCacheOptions UseDistributedCache() + { + StoreRegistration = services => + services.TryAddSingleton(); + return this; + } + + /// Registers a custom implementation. + public CleverCacheOptions UseCustomStore() where TStore : class, ICleverCacheStore + { + StoreRegistration = services => + services.TryAddSingleton(); + return this; + } + + /// Registers a custom via a factory. + public CleverCacheOptions UseCustomStore(Func factory) + { + StoreRegistration = services => + services.TryAddSingleton(factory); + return this; + } + + /// + /// Provides direct control over the service registrations used for the cache store. + /// Intended for use by extension packages (e.g. CleverCache.Redis) that need to register + /// additional services alongside the . + /// + public CleverCacheOptions UseCustomStoreRegistration(Action registration) + { + StoreRegistration = registration; + return this; + } +} diff --git a/Models/FakeCacheEntry.cs b/Models/FakeCacheEntry.cs deleted file mode 100644 index 15275d6..0000000 --- a/Models/FakeCacheEntry.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.Extensions.Primitives; - -namespace CleverCache.Models; - -public class FakeCacheEntry : ICacheEntry -{ - public void Dispose() - { - GC.SuppressFinalize(this); - } - - public object Key { get; } = Guid.NewGuid(); - public object? Value { get; set; } - public DateTimeOffset? AbsoluteExpiration { get; set; } - public TimeSpan? AbsoluteExpirationRelativeToNow { get; set; } - public TimeSpan? SlidingExpiration { get; set; } - public IList ExpirationTokens { get; } = []; - public IList PostEvictionCallbacks { get; } = []; - public CacheItemPriority Priority { get; set; } - public long? Size { get; set; } -} \ No newline at end of file diff --git a/NuGetReadMe.md b/NuGetReadMe.md new file mode 100644 index 0000000..495c6d9 --- /dev/null +++ b/NuGetReadMe.md @@ -0,0 +1,21 @@ +# CleverCache + +Automatic cache invalidation for .NET — tracks entity changes via EF Core and clears related cache entries automatically. + +Supports **memory cache** (default), **distributed cache** (`IDistributedCache`), or a **custom provider**. + +For full documentation, examples, and configuration options see the [GitHub repository](https://github.com/chunty/CleverCache). + +## Quick start + +```csharp +// Memory cache (default) +builder.Services.AddCleverCache(); + +// Distributed cache (e.g. Redis) +builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost"); +builder.Services.AddCleverCache(o => o.UseDistributedCache()); + +// Custom provider +builder.Services.AddCleverCache(o => o.UseCustomStore()); +``` diff --git a/ReadMe.md b/ReadMe.md index dcf5b3b..1b067db 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -12,8 +12,7 @@ With a small amount of configuration **CleverCache** will automatically track ch and reset the cache for any entity if an entity of that type is create, updated or deleted, and - if required, any related entity where data is also part of the same cache entry. ->_BONUS:_ If you're using Mediatr, CleverCache can automatically cache results but using a pipeline behaviour with minimal changes -to your existing code. +>_BONUS:_ MediatR users can install the separate [`CleverCache.MediatR`](https://www.nuget.org/packages/clevercache.mediatr) package for automatic query caching with zero handler changes. ## Installing CleverCache You should install CleverCache with NuGet: @@ -27,6 +26,50 @@ dotnet add package CleverCache Either commands, from Package Manager Console or .NET Core CLI, will download and install CleverCache and all required dependencies. +## Cache provider + +By default CleverCache uses `IMemoryCache`. You can switch to a distributed cache, use the dedicated Redis package, or plug in your own provider: + +```csharp +// Memory cache (default) +builder.Services.AddCleverCache(); + +// Redis — install CleverCache.Redis, then: +builder.Services.AddCleverCache(o => o.UseRedisCache("localhost:6379")); + +// Any IDistributedCache backend — register it first, then: +builder.Services.AddDistributedMemoryCache(); // or AddStackExchangeRedisCache, etc. +builder.Services.AddCleverCache(o => o.UseDistributedCache()); + +// Custom provider — implement ICleverCacheStore +builder.Services.AddCleverCache(o => o.UseCustomStore()); +// or via a factory: +builder.Services.AddCleverCache(o => o.UseCustomStore(sp => new MyStore(sp.GetRequiredService()))); +``` + +### Cache entry options + +All `GetOrCreate` / `GetOrCreateAsync` overloads accept an optional `CleverCacheEntryOptions`: + +```csharp +var options = new CleverCacheEntryOptions +{ + // Expire 10 minutes after the entry was created + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10), + + // Or expire at a specific point in time + AbsoluteExpiration = DateTimeOffset.UtcNow.AddHours(1), + + // Extend lifetime on each read (memory cache only) + SlidingExpiration = TimeSpan.FromMinutes(5), +}; + +var result = await cache.GetOrCreateAsync>( + key, + async () => await db.Results.ToListAsync(), + options); +``` + ## Get Started 1. Register the services: @@ -83,21 +126,25 @@ CleverCache and all required dependencies. ``` ## Usage -You create cache in the same way you would when using MemoryCache, but specify an additional type parameter as shown below -to associate a given type with a cache key: +You create cache entries in the same way you would with MemoryCache, but specify an additional type parameter to associate a given type with a cache key: ```csharp - var myItem = await cache.GetOrCreateAsync( - typeof(MyEntityType), - cacheKey, - _ => - { - //return ; - } - ) ?? []; +// Generic shorthand — associate with a single type +var myItems = await cache.GetOrCreateAsync>( + cacheKey, + async () => await db.MyItems.ToListAsync() +) ?? []; ``` -The interceptor tracks when any instance of `MyEntityType` is added, changed or deleted and will clear all -cache keys associated with that type. +The interceptor tracks when any instance of `MyEntityType` is added, changed or deleted and clears all cache keys associated with that type. + +You can also supply cache entry options: +```csharp +var myItems = await cache.GetOrCreateAsync>( + cacheKey, + async () => await db.MyItems.ToListAsync(), + new CleverCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) } +) ?? []; +``` ## Dependent Caches Often you have information in a cache entry that contains data from multiple entity types @@ -141,11 +188,14 @@ This will automatically register any keys for `ThingOne` with `ThingTwo` and `Th so changes to any object of these types will clear the cache key. You can also reverse these mappings by using `reverse: true` in the attribute. This will register `ThingTwo` and `ThingThree` with `ThingOne` -## Auto caching mediatr queries -This is a really powerful tool that enables you to quickly add caching to your mediatr queries without any changes -to your handlers. +## Auto caching MediatR queries +This is available via the separate **[CleverCache.MediatR](https://www.nuget.org/packages/clevercache.mediatr)** package — install it to keep your main project free of the MediatR dependency. -Add the following to your mediatr setup: +``` +Install-Package CleverCache.MediatR +``` + +Add the following to your MediatR setup: ```csharp services.AddMediatR(cfg => @@ -163,6 +213,95 @@ public record MyQuery : IRequest; This uses the mediatr request as the cache key so you can use the same query with different parameters and it will cache each one separately. +## Redis cache + +Install the dedicated **[CleverCache.Redis](https://www.nuget.org/packages/clevercache.redis)** package to add Redis support without bringing the StackExchange.Redis dependency into your main project. + +``` +Install-Package CleverCache.Redis +``` + +```csharp +// Simple connection string +builder.Services.AddCleverCache(o => o.UseRedisCache("localhost:6379")); + +// Full Redis options +builder.Services.AddCleverCache(o => o.UseRedisCache(redis => +{ + redis.Configuration = "localhost:6379"; + redis.InstanceName = "MyApp:"; +})); +``` + +## Custom cache store + +Implement `ICleverCacheStore` to plug in any backing store: + +```csharp +public interface ICleverCacheStore +{ + bool TryGet(object key, out TItem? value); + Task<(bool Hit, TItem? Value)> TryGetAsync(object key, CancellationToken cancellationToken = default); + void Set(object key, TItem value, CleverCacheEntryOptions? options = null); + Task SetAsync(object key, TItem value, CleverCacheEntryOptions? options = null, CancellationToken cancellationToken = default); + void Remove(object key); + Task RemoveAsync(object key, CancellationToken cancellationToken = default); +} +``` + +Example — a simple in-memory dictionary store: + +```csharp +public class DictionaryCacheStore : ICleverCacheStore +{ + private readonly Dictionary _store = new(); + + private static string Key(object key) => $"{typeof(TItem).FullName}:{key}"; + + public bool TryGet(object key, out TItem? value) + { + if (_store.TryGetValue(Key(key), out var hit)) + { + value = (TItem?)hit; + return true; + } + value = default; + return false; + } + + public Task<(bool Hit, TItem? Value)> TryGetAsync(object key, CancellationToken cancellationToken = default) + { + var found = TryGet(key, out var value); + return Task.FromResult((found, value)); + } + + public void Set(object key, TItem value, CleverCacheEntryOptions? options = null) + => _store[Key(key)] = value; + + public Task SetAsync(object key, TItem value, CleverCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + { + Set(key, value, options); + return Task.CompletedTask; + } + + public void Remove(object key) => _store.Remove(Key(key)); + + public Task RemoveAsync(object key, CancellationToken cancellationToken = default) + { + Remove(key); + return Task.CompletedTask; + } +} +``` + +Register it with: + +```csharp +builder.Services.AddCleverCache(o => o.UseCustomStore()); +// or via a factory: +builder.Services.AddCleverCache(o => o.UseCustomStore(sp => new DictionaryCacheStore())); +``` + ## Unit testing Unit testing methods that use cache is generally fiddly, to help with this **CleverCache** is shipped with a `FakeCache` implementation which you can use in your test. The implementation never caches and always calls your From 119cd11b8acc51df5573de21d5069dae9076ef0b Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sat, 2 May 2026 02:11:54 +0100 Subject: [PATCH 02/50] Add unit tests and fix DistributedCacheStore key encoding - Add CleverCache.Tests project with 33 tests covering: - CleverCacheService: cache hit/miss, remove, stampede protection, and deadlock regression test for nested cached calls - CacheEntryManager: type-to-key tracking, dependencies, cyclic guard - MemoryCacheStore and DistributedCacheStore: get/set/remove basics - FakeCache: always-calls-factory behaviour - AutoCacheBehaviour: MediatR pipeline attribute handling - Fix DistributedCacheStore key encoding: remove type-name prefix so Remove(key) correctly matches keys stored via Set/SetAsync - Add InternalsVisibleTo(CleverCache.Tests) in CleverCache.MediatR.csproj Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CleverCache.MediatR.csproj | 5 + CleverCache.Tests/AutoCacheBehaviourTests.cs | 71 +++++++++ CleverCache.Tests/CacheEntryManagerTests.cs | 81 ++++++++++ CleverCache.Tests/CleverCache.Tests.csproj | 20 +++ CleverCache.Tests/CleverCacheServiceTests.cs | 146 ++++++++++++++++++ .../DistributedCacheStoreTests.cs | 73 +++++++++ CleverCache.Tests/FakeCacheTests.cs | 54 +++++++ CleverCache.Tests/GlobalUsings.cs | 2 + CleverCache.Tests/MemoryCacheStoreTests.cs | 82 ++++++++++ CleverCache.csproj | 2 +- CleverCache.sln | 14 ++ Directory.Packages.props | 4 + Implementations/DistributedCacheStore.cs | 16 +- 13 files changed, 561 insertions(+), 9 deletions(-) create mode 100644 CleverCache.Tests/AutoCacheBehaviourTests.cs create mode 100644 CleverCache.Tests/CacheEntryManagerTests.cs create mode 100644 CleverCache.Tests/CleverCache.Tests.csproj create mode 100644 CleverCache.Tests/CleverCacheServiceTests.cs create mode 100644 CleverCache.Tests/DistributedCacheStoreTests.cs create mode 100644 CleverCache.Tests/FakeCacheTests.cs create mode 100644 CleverCache.Tests/GlobalUsings.cs create mode 100644 CleverCache.Tests/MemoryCacheStoreTests.cs diff --git a/CleverCache.MediatR/CleverCache.MediatR.csproj b/CleverCache.MediatR/CleverCache.MediatR.csproj index 7c15124..031465e 100644 --- a/CleverCache.MediatR/CleverCache.MediatR.csproj +++ b/CleverCache.MediatR/CleverCache.MediatR.csproj @@ -15,4 +15,9 @@ + + + <_Parameter1>CleverCache.Tests + + diff --git a/CleverCache.Tests/AutoCacheBehaviourTests.cs b/CleverCache.Tests/AutoCacheBehaviourTests.cs new file mode 100644 index 0000000..f894db5 --- /dev/null +++ b/CleverCache.Tests/AutoCacheBehaviourTests.cs @@ -0,0 +1,71 @@ +using CleverCache.Mediatr; +using MediatR; +using Moq; + +namespace CleverCache.Tests; + +[AutoCache([typeof(CachedEntity)])] +file record CachedQuery(int Id) : IRequest; + +file record UncachedQuery(int Id) : IRequest; + +file class CachedEntity; + +public class AutoCacheBehaviourTests +{ + [Fact] + public async Task Handle_NoAttribute_AlwaysCallsNext() + { + var cacheMock = new Mock(); + var sut = new AutoCacheBehaviour(cacheMock.Object); + var callCount = 0; + RequestHandlerDelegate next = _ => { callCount++; return Task.FromResult("result"); }; + + await sut.Handle(new UncachedQuery(1), next, CancellationToken.None); + await sut.Handle(new UncachedQuery(1), next, CancellationToken.None); + + Assert.Equal(2, callCount); + cacheMock.Verify(c => c.GetOrCreateAsync( + It.IsAny(), It.IsAny(), It.IsAny>>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WithAttribute_CacheMiss_CallsNextAndCaches() + { + var cacheMock = new Mock(); + cacheMock + .Setup(c => c.GetOrCreateAsync( + It.IsAny(), It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns>, CleverCacheEntryOptions?>((_, _, factory, _) => factory()); + + var sut = new AutoCacheBehaviour(cacheMock.Object); + var callCount = 0; + RequestHandlerDelegate next = _ => { callCount++; return Task.FromResult("fresh"); }; + + var result = await sut.Handle(new CachedQuery(1), next, CancellationToken.None); + + Assert.Equal("fresh", result); + Assert.Equal(1, callCount); + cacheMock.Verify(c => c.GetOrCreateAsync( + It.IsAny(), It.IsAny(), It.IsAny>>(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithAttribute_CacheHit_DoesNotCallNext() + { + var cacheMock = new Mock(); + cacheMock + .Setup(c => c.GetOrCreateAsync( + It.IsAny(), It.IsAny(), It.IsAny>>(), It.IsAny())) + .ReturnsAsync("cached-value"); // returns cached directly, never invokes factory + + var sut = new AutoCacheBehaviour(cacheMock.Object); + var callCount = 0; + RequestHandlerDelegate next = _ => { callCount++; return Task.FromResult("fresh"); }; + + var result = await sut.Handle(new CachedQuery(1), next, CancellationToken.None); + + Assert.Equal("cached-value", result); + Assert.Equal(0, callCount); + } +} diff --git a/CleverCache.Tests/CacheEntryManagerTests.cs b/CleverCache.Tests/CacheEntryManagerTests.cs new file mode 100644 index 0000000..70d4330 --- /dev/null +++ b/CleverCache.Tests/CacheEntryManagerTests.cs @@ -0,0 +1,81 @@ +namespace CleverCache.Tests; + +// Concrete subclass so we can test the abstract CacheEntryManager via its public API +file class TestCacheManager : CacheEntryManager +{ + public object[] KeysFor(Type type) => SnapshotKeysFor(type); +} + +public class CacheEntryManagerTests +{ + [Fact] + public void AddKeyToTypes_AssociatesKeyWithType() + { + var mgr = new TestCacheManager(); + + mgr.AddKeyToTypes([typeof(string)], "myKey"); + + Assert.Contains("myKey", mgr.KeysFor(typeof(string))); + } + + [Fact] + public void AddKeyToTypes_MultipleTypes_KeyAssociatedWithAll() + { + var mgr = new TestCacheManager(); + + mgr.AddKeyToTypes([typeof(string), typeof(int)], "sharedKey"); + + Assert.Contains("sharedKey", mgr.KeysFor(typeof(string))); + Assert.Contains("sharedKey", mgr.KeysFor(typeof(int))); + } + + [Fact] + public void AddDependentCache_KeyForPrimaryAlsoRegisteredUnderDependent() + { + var mgr = new TestCacheManager(); + mgr.AddDependentCache(typeof(string), typeof(int)); + + mgr.AddKeyToTypes([typeof(string)], "key1"); + + Assert.Contains("key1", mgr.KeysFor(typeof(string))); + Assert.Contains("key1", mgr.KeysFor(typeof(int))); + } + + [Fact] + public void AddKeyToTypes_TransitiveDependency_KeyRegisteredUnderAllTypes() + { + // A -> B -> C: key for A should appear under A, B, and C + var mgr = new TestCacheManager(); + mgr.AddDependentCache(typeof(string), typeof(int)); + mgr.AddDependentCache(typeof(int), typeof(bool)); + + mgr.AddKeyToTypes([typeof(string)], "transitiveKey"); + + Assert.Contains("transitiveKey", mgr.KeysFor(typeof(string))); + Assert.Contains("transitiveKey", mgr.KeysFor(typeof(int))); + Assert.Contains("transitiveKey", mgr.KeysFor(typeof(bool))); + } + + [Fact] + public void AddKeyToTypes_CyclicDependency_DoesNotInfiniteLoop() + { + // A -> B -> A: should terminate cleanly + var mgr = new TestCacheManager(); + mgr.AddDependentCache(typeof(string), typeof(int)); + mgr.AddDependentCache(typeof(int), typeof(string)); + + var ex = Record.Exception(() => mgr.AddKeyToTypes([typeof(string)], "cycleKey")); + + Assert.Null(ex); + Assert.Contains("cycleKey", mgr.KeysFor(typeof(string))); + Assert.Contains("cycleKey", mgr.KeysFor(typeof(int))); + } + + [Fact] + public void SnapshotKeysFor_UnknownType_ReturnsEmpty() + { + var mgr = new TestCacheManager(); + + Assert.Empty(mgr.KeysFor(typeof(double))); + } +} diff --git a/CleverCache.Tests/CleverCache.Tests.csproj b/CleverCache.Tests/CleverCache.Tests.csproj new file mode 100644 index 0000000..5656eea --- /dev/null +++ b/CleverCache.Tests/CleverCache.Tests.csproj @@ -0,0 +1,20 @@ + + + CleverCache.Tests + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/CleverCache.Tests/CleverCacheServiceTests.cs b/CleverCache.Tests/CleverCacheServiceTests.cs new file mode 100644 index 0000000..63debae --- /dev/null +++ b/CleverCache.Tests/CleverCacheServiceTests.cs @@ -0,0 +1,146 @@ +using CleverCache.Implementations; +using Microsoft.Extensions.Caching.Memory; +using Moq; + +namespace CleverCache.Tests; + +public class CleverCacheServiceTests +{ + private static CleverCacheService CreateService(ICleverCacheStore? store = null) + => new(store ?? new MemoryCacheStore(new MemoryCache(new MemoryCacheOptions()))); + + [Fact] + public void GetOrCreate_CacheMiss_InvokesFactory() + { + var sut = CreateService(); + var callCount = 0; + + var result = sut.GetOrCreate([typeof(string)], "key1", () => { callCount++; return 42; }); + + Assert.Equal(42, result); + Assert.Equal(1, callCount); + } + + [Fact] + public void GetOrCreate_CacheHit_DoesNotInvokeFactoryAgain() + { + var sut = CreateService(); + var callCount = 0; + + sut.GetOrCreate([typeof(string)], "key1", () => { callCount++; return 42; }); + var result = sut.GetOrCreate([typeof(string)], "key1", () => { callCount++; return 99; }); + + Assert.Equal(42, result); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task GetOrCreateAsync_CacheMiss_InvokesFactory() + { + var sut = CreateService(); + var callCount = 0; + + var result = await sut.GetOrCreateAsync([typeof(string)], "key1", + async () => { callCount++; await Task.Yield(); return 42; }); + + Assert.Equal(42, result); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task GetOrCreateAsync_CacheHit_DoesNotInvokeFactoryAgain() + { + var sut = CreateService(); + var callCount = 0; + + await sut.GetOrCreateAsync([typeof(string)], "key1", + async () => { callCount++; await Task.Yield(); return 42; }); + var result = await sut.GetOrCreateAsync([typeof(string)], "key1", + async () => { callCount++; await Task.Yield(); return 99; }); + + Assert.Equal(42, result); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task Remove_AllowsFactoryToBeCalledAgain() + { + var sut = CreateService(); + var callCount = 0; + + await sut.GetOrCreateAsync([typeof(string)], "key1", async () => { callCount++; await Task.Yield(); return 1; }); + sut.Remove("key1"); + await sut.GetOrCreateAsync([typeof(string)], "key1", async () => { callCount++; await Task.Yield(); return 2; }); + + Assert.Equal(2, callCount); + } + + [Fact] + public async Task RemoveByType_RemovesAllKeysForType() + { + var sut = CreateService(); + var callCount = 0; + + await sut.GetOrCreateAsync([typeof(string)], "key1", async () => { await Task.Yield(); return 1; }); + await sut.GetOrCreateAsync([typeof(string)], "key2", async () => { await Task.Yield(); return 2; }); + + sut.RemoveByType(typeof(string)); + + await sut.GetOrCreateAsync([typeof(string)], "key1", async () => { callCount++; await Task.Yield(); return 99; }); + await sut.GetOrCreateAsync([typeof(string)], "key2", async () => { callCount++; await Task.Yield(); return 99; }); + + Assert.Equal(2, callCount); + } + + /// + /// Regression test: with the old global SemaphoreSlim, a factory that triggered another + /// cached call (different key) would deadlock indefinitely. Per-key locking must not deadlock. + /// + [Fact] + public async Task GetOrCreateAsync_NestedCachedCallWithDifferentKey_DoesNotDeadlock() + { + var sut = CreateService(); + + var outerCompleted = false; + + var task = sut.GetOrCreateAsync([typeof(string)], "outer-key", async () => + { + // Simulate a nested cached call with a DIFFERENT key — the original deadlock scenario + var inner = await sut.GetOrCreateAsync([typeof(int)], "inner-key", + async () => { await Task.Yield(); return 99; }); + + outerCompleted = true; + return $"result:{inner}"; + }); + + // If deadlocked, this will throw after the timeout rather than hang the test suite + var completed = await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(5))); + + Assert.Same(task, completed); // task finished, not the timeout + Assert.True(outerCompleted); + Assert.Equal("result:99", await task); + } + + [Fact] + public async Task GetOrCreateAsync_ConcurrentRequestsSameKey_FactoryCalledOnce() + { + var sut = CreateService(); + var callCount = 0; + var tcs = new TaskCompletionSource(); + + // 20 concurrent calls all waiting on the same factory + var tasks = Enumerable.Range(0, 20).Select(_ => + sut.GetOrCreateAsync([typeof(string)], "stampede-key", async () => + { + await tcs.Task; // all wait here until released + Interlocked.Increment(ref callCount); + return 42; + })).ToArray(); + + tcs.SetResult(); // release all waiters simultaneously + var results = await Task.WhenAll(tasks); + + Assert.Equal(1, callCount); + Assert.All(results, r => Assert.Equal(42, r)); + } +} diff --git a/CleverCache.Tests/DistributedCacheStoreTests.cs b/CleverCache.Tests/DistributedCacheStoreTests.cs new file mode 100644 index 0000000..d677698 --- /dev/null +++ b/CleverCache.Tests/DistributedCacheStoreTests.cs @@ -0,0 +1,73 @@ +using CleverCache.Implementations; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace CleverCache.Tests; + +public class DistributedCacheStoreTests +{ + private static DistributedCacheStore CreateStore() + { + var cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); + return new DistributedCacheStore(cache); + } + + [Fact] + public void TryGet_MissingKey_ReturnsFalse() + { + var store = CreateStore(); + + var found = store.TryGet("missing", out var value); + + Assert.False(found); + Assert.Null(value); + } + + [Fact] + public void Set_ThenTryGet_ReturnsDeserializedValue() + { + var store = CreateStore(); + + store.Set("key1", "distributed-hello"); + var found = store.TryGet("key1", out var value); + + Assert.True(found); + Assert.Equal("distributed-hello", value); + } + + [Fact] + public void Remove_AfterSet_TryGetReturnsFalse() + { + var store = CreateStore(); + store.Set("key1", 42); + + store.Remove("key1"); + + Assert.False(store.TryGet("key1", out _)); + } + + [Fact] + public async Task SetAsync_ThenTryGetAsync_ReturnsValue() + { + var store = CreateStore(); + + await store.SetAsync("key1", "async-value"); + var (found, value) = await store.TryGetAsync("key1"); + + Assert.True(found); + Assert.Equal("async-value", value); + } + + [Fact] + public async Task RemoveAsync_AfterSetAsync_TryGetReturnsFalse() + { + var store = CreateStore(); + await store.SetAsync("key1", 99); + + await store.RemoveAsync("key1"); + + var (found, _) = await store.TryGetAsync("key1"); + Assert.False(found); + } +} diff --git a/CleverCache.Tests/FakeCacheTests.cs b/CleverCache.Tests/FakeCacheTests.cs new file mode 100644 index 0000000..b0483fa --- /dev/null +++ b/CleverCache.Tests/FakeCacheTests.cs @@ -0,0 +1,54 @@ +using CleverCache.Implementations; + +namespace CleverCache.Tests; + +public class FakeCacheTests +{ + [Fact] + public void GetOrCreate_AlwaysCallsFactory() + { + var fake = new FakeCache(); + var callCount = 0; + + fake.GetOrCreate([typeof(string)], "key", () => { callCount++; return 1; }); + fake.GetOrCreate([typeof(string)], "key", () => { callCount++; return 2; }); + + Assert.Equal(2, callCount); + } + + [Fact] + public async Task GetOrCreateAsync_AlwaysCallsFactory() + { + var fake = new FakeCache(); + var callCount = 0; + + await fake.GetOrCreateAsync([typeof(string)], "key", async () => { callCount++; await Task.Yield(); return 1; }); + await fake.GetOrCreateAsync([typeof(string)], "key", async () => { callCount++; await Task.Yield(); return 2; }); + + Assert.Equal(2, callCount); + } + + [Fact] + public void Remove_DoesNotThrow() + { + var fake = new FakeCache(); + var ex = Record.Exception(() => fake.Remove("any-key")); + Assert.Null(ex); + } + + [Fact] + public void RemoveByType_DoesNotThrow() + { + var fake = new FakeCache(); + var ex = Record.Exception(() => fake.RemoveByType(typeof(string))); + Assert.Null(ex); + } + + [Fact] + public void AddKeyToTypes_DoesNotThrow() + { + var fake = new FakeCache(); + var ex = Record.Exception(() => fake.AddKeyToTypes([typeof(string)], "key")); + Assert.Null(ex); + } +} diff --git a/CleverCache.Tests/GlobalUsings.cs b/CleverCache.Tests/GlobalUsings.cs new file mode 100644 index 0000000..8fbbaef --- /dev/null +++ b/CleverCache.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Xunit; +global using CleverCache.Models; diff --git a/CleverCache.Tests/MemoryCacheStoreTests.cs b/CleverCache.Tests/MemoryCacheStoreTests.cs new file mode 100644 index 0000000..d91c114 --- /dev/null +++ b/CleverCache.Tests/MemoryCacheStoreTests.cs @@ -0,0 +1,82 @@ +using CleverCache.Implementations; +using Microsoft.Extensions.Caching.Memory; + +namespace CleverCache.Tests; + +public class MemoryCacheStoreTests +{ + private static MemoryCacheStore CreateStore() + => new(new MemoryCache(new MemoryCacheOptions())); + + [Fact] + public void TryGet_MissingKey_ReturnsFalse() + { + var store = CreateStore(); + + var found = store.TryGet("missing", out var value); + + Assert.False(found); + Assert.Null(value); + } + + [Fact] + public void Set_ThenTryGet_ReturnsValue() + { + var store = CreateStore(); + + store.Set("key1", "hello"); + var found = store.TryGet("key1", out var value); + + Assert.True(found); + Assert.Equal("hello", value); + } + + [Fact] + public void Remove_AfterSet_TryGetReturnsFalse() + { + var store = CreateStore(); + store.Set("key1", 42); + + store.Remove("key1"); + + Assert.False(store.TryGet("key1", out _)); + } + + [Fact] + public async Task TryGetAsync_MissingKey_ReturnsFalse() + { + var store = CreateStore(); + + var (found, value) = await store.TryGetAsync("missing"); + + Assert.False(found); + Assert.Null(value); + } + + [Fact] + public async Task SetAsync_ThenTryGetAsync_ReturnsValue() + { + var store = CreateStore(); + + await store.SetAsync("key1", "async-hello"); + var (found, value) = await store.TryGetAsync("key1"); + + Assert.True(found); + Assert.Equal("async-hello", value); + } + + [Fact] + public void Set_WithOptions_StoresValue() + { + var store = CreateStore(); + var options = new CleverCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) + }; + + store.Set("key1", 99, options); + + Assert.True(store.TryGet("key1", out var val)); + Assert.Equal(99, val); + } +} diff --git a/CleverCache.csproj b/CleverCache.csproj index 34ba92f..1cbce2a 100644 --- a/CleverCache.csproj +++ b/CleverCache.csproj @@ -6,7 +6,7 @@ MemoryCache,DistributedCache,Automatic Cache,Cache Invalidation NuGetReadMe.md - $(DefaultItemExcludes);CleverCache.MediatR\**;CleverCache.Redis\** + $(DefaultItemExcludes);CleverCache.MediatR\**;CleverCache.Redis\**;CleverCache.Tests\** diff --git a/CleverCache.sln b/CleverCache.sln index 51b4357..7f6ad1f 100644 --- a/CleverCache.sln +++ b/CleverCache.sln @@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleverCache.MediatR", "Clev EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleverCache.Redis", "CleverCache.Redis\CleverCache.Redis.csproj", "{C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleverCache.Tests", "CleverCache.Tests\CleverCache.Tests.csproj", "{261F2368-C237-4CF0-A4C0-50E650D64412}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -55,6 +57,18 @@ Global {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Release|x64.Build.0 = Release|Any CPU {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Release|x86.ActiveCfg = Release|Any CPU {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Release|x86.Build.0 = Release|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Debug|Any CPU.Build.0 = Debug|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Debug|x64.ActiveCfg = Debug|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Debug|x64.Build.0 = Debug|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Debug|x86.ActiveCfg = Debug|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Debug|x86.Build.0 = Debug|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Release|Any CPU.ActiveCfg = Release|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Release|Any CPU.Build.0 = Release|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Release|x64.ActiveCfg = Release|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Release|x64.Build.0 = Release|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Release|x86.ActiveCfg = Release|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Directory.Packages.props b/Directory.Packages.props index 90bd3f8..4b4866f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,10 @@ + + + + diff --git a/Implementations/DistributedCacheStore.cs b/Implementations/DistributedCacheStore.cs index ac24b12..48f6e41 100644 --- a/Implementations/DistributedCacheStore.cs +++ b/Implementations/DistributedCacheStore.cs @@ -9,7 +9,7 @@ public class DistributedCacheStore(IDistributedCache distributedCache) : IClever public bool TryGet(object key, out TItem? value) { - var bytes = distributedCache.Get(ToStringKey(key)); + var bytes = distributedCache.Get(ToStringKey(key)); if (bytes is null) { value = default; @@ -22,7 +22,7 @@ public bool TryGet(object key, out TItem? value) public async Task<(bool Hit, TItem? Value)> TryGetAsync(object key, CancellationToken cancellationToken = default) { - var bytes = await distributedCache.GetAsync(ToStringKey(key), cancellationToken).ConfigureAwait(false); + var bytes = await distributedCache.GetAsync(ToStringKey(key), cancellationToken).ConfigureAwait(false); if (bytes is null) return (false, default); var value = JsonSerializer.Deserialize(bytes, JsonOptions); @@ -32,22 +32,22 @@ public bool TryGet(object key, out TItem? value) public void Set(object key, TItem value, CleverCacheEntryOptions? options = null) { var bytes = JsonSerializer.SerializeToUtf8Bytes(value, JsonOptions); - distributedCache.Set(ToStringKey(key), bytes, ToDistributedOptions(options)); + distributedCache.Set(ToStringKey(key), bytes, ToDistributedOptions(options)); } public async Task SetAsync(object key, TItem value, CleverCacheEntryOptions? options = null, CancellationToken cancellationToken = default) { var bytes = JsonSerializer.SerializeToUtf8Bytes(value, JsonOptions); - await distributedCache.SetAsync(ToStringKey(key), bytes, ToDistributedOptions(options), cancellationToken).ConfigureAwait(false); + await distributedCache.SetAsync(ToStringKey(key), bytes, ToDistributedOptions(options), cancellationToken).ConfigureAwait(false); } - public void Remove(object key) => distributedCache.Remove(ToStringKey(key)); + public void Remove(object key) => distributedCache.Remove(ToStringKey(key)); public async Task RemoveAsync(object key, CancellationToken cancellationToken = default) => - await distributedCache.RemoveAsync(ToStringKey(key), cancellationToken).ConfigureAwait(false); + await distributedCache.RemoveAsync(ToStringKey(key), cancellationToken).ConfigureAwait(false); - private static string ToStringKey(object key) => - $"{typeof(TItem).FullName}:{JsonSerializer.Serialize(key, JsonOptions)}"; + private static string ToStringKey(object key) => + JsonSerializer.Serialize(key, JsonOptions); private static DistributedCacheEntryOptions ToDistributedOptions(CleverCacheEntryOptions? options) => options is null From 0b1b7a7bd054d35114cb4b19cd665d5baf3869b5 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sat, 2 May 2026 12:45:27 +0100 Subject: [PATCH 03/50] Remove ICacheEntryManager - inline members onto ICleverCache ICacheEntryManager was an early draft of the cache provider abstraction, now superseded by ICleverCacheStore. Its two members (AddDependentCache, AddKeyToTypes) are moved directly onto ICleverCache where they always appeared via inheritance. - Delete ICacheKeyManager.cs - ICleverCache: remove : ICacheEntryManager, add members directly - CacheEntryManager: make internal (implementation detail) - CleverCacheService: make internal (users always inject ICleverCache) - CacheEntryManagerExtensions: extend ICleverCache instead of ICacheEntryManager - CleverCache.csproj: add InternalsVisibleTo(CleverCache.Tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CacheKeyManager.cs | 2 +- CleverCache.csproj | 5 +++++ Extensions/CacheEntryManagerExtensions.cs | 8 ++++---- ICacheKeyManager.cs | 18 ------------------ ICleverCache.cs | 12 +++++++++++- Implementations/CleverCacheService.cs | 2 +- 6 files changed, 22 insertions(+), 25 deletions(-) delete mode 100644 ICacheKeyManager.cs diff --git a/CacheKeyManager.cs b/CacheKeyManager.cs index 48454cc..2cd6b19 100644 --- a/CacheKeyManager.cs +++ b/CacheKeyManager.cs @@ -1,7 +1,7 @@ namespace CleverCache; using System.Collections.Concurrent; -public abstract class CacheEntryManager: ICacheEntryManager +internal abstract class CacheEntryManager { // type -> set of keys (thread-safe “set” via ConcurrentDictionary) private readonly ConcurrentDictionary> _keysByType = new(); diff --git a/CleverCache.csproj b/CleverCache.csproj index 1cbce2a..0b1b043 100644 --- a/CleverCache.csproj +++ b/CleverCache.csproj @@ -18,4 +18,9 @@ + + + <_Parameter1>CleverCache.Tests + + diff --git a/Extensions/CacheEntryManagerExtensions.cs b/Extensions/CacheEntryManagerExtensions.cs index 1313de6..a490a6f 100644 --- a/Extensions/CacheEntryManagerExtensions.cs +++ b/Extensions/CacheEntryManagerExtensions.cs @@ -1,7 +1,7 @@ namespace CleverCache.Extensions; /// -/// Provides extension methods for the interface. +/// Provides extension methods for . /// public static class CacheEntryManagerExtensions { @@ -11,7 +11,7 @@ public static class CacheEntryManagerExtensions /// The type of the cache. /// The cache instance. /// The dependent type of the cache. - public static void AddDependentCache(this ICacheEntryManager cache, Type dependentType) => cache.AddDependentCache(typeof(T), dependentType); + public static void AddDependentCache(this ICleverCache cache, Type dependentType) => cache.AddDependentCache(typeof(T), dependentType); /// /// Adds the specified key to the cache entry type. @@ -19,7 +19,7 @@ public static class CacheEntryManagerExtensions /// The type of the object the cache key belongs to. /// The cache instance. /// The key of the cache entry to add. - public static void AddKeyToType(this ICacheEntryManager cache, object key) where T : class => cache.AddKeyToType(typeof(T), key); + public static void AddKeyToType(this ICleverCache cache, object key) where T : class => cache.AddKeyToType(typeof(T), key); /// /// Adds the specified key to the cache entry type. @@ -27,5 +27,5 @@ public static class CacheEntryManagerExtensions /// The cache instance. /// The type of the object the cache key belongs to. /// The key of the cache entry to add. - public static void AddKeyToType(this ICacheEntryManager cache, Type type, object key) => cache.AddKeyToTypes([type], key); + public static void AddKeyToType(this ICleverCache cache, Type type, object key) => cache.AddKeyToTypes([type], key); } diff --git a/ICacheKeyManager.cs b/ICacheKeyManager.cs deleted file mode 100644 index 48e95de..0000000 --- a/ICacheKeyManager.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace CleverCache; - -public interface ICacheEntryManager -{ - /// - /// Adds a dependent cache type. - /// - /// The type of the cache. - /// The dependent type of the cache. - void AddDependentCache(Type type, Type dependentType); - - /// - /// Adds the specified key to the cache entry type. - /// - /// An array types the cache key belongs to. - /// The key of the cache entry to add. - void AddKeyToTypes(Type[] types, object key); -} \ No newline at end of file diff --git a/ICleverCache.cs b/ICleverCache.cs index 94f4a13..4ea9461 100644 --- a/ICleverCache.cs +++ b/ICleverCache.cs @@ -1,7 +1,17 @@ namespace CleverCache; -public interface ICleverCache : ICacheEntryManager +public interface ICleverCache { + /// + /// Adds a dependent cache type so that invalidating also invalidates . + /// + void AddDependentCache(Type type, Type dependentType); + + /// + /// Associates the specified key with one or more cache types. + /// + void AddKeyToTypes(Type[] types, object key); + /// /// Gets the value associated with this key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found. /// diff --git a/Implementations/CleverCacheService.cs b/Implementations/CleverCacheService.cs index 7765021..b0a3cd5 100644 --- a/Implementations/CleverCacheService.cs +++ b/Implementations/CleverCacheService.cs @@ -3,7 +3,7 @@ namespace CleverCache.Implementations; /// -public class CleverCacheService(ICleverCacheStore store) : CacheEntryManager, ICleverCache +internal class CleverCacheService(ICleverCacheStore store) : CacheEntryManager, ICleverCache { private readonly AsyncKeyedLocker _locker = new(); From f22b82fa563cdca3b84373a52810037581d34af6 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sat, 2 May 2026 20:30:05 +0100 Subject: [PATCH 04/50] Cleanup: remove dead code, fix typo, fix base exception class - Delete CacheTypeMap.cs - unused record, dead code - Rename DependantCachesAttribute to DependentCachesAttribute (typo fix) - Remove commented-out Attribute value from DependentCacheNavigationScanMode - MissingInterceptorException: inherit from Exception not ApplicationException Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Attributes/DependentCachesAttribute.cs | 2 +- Exceptions/MissingInterceptorException.cs | 2 +- Extensions/DbContextExtensions.cs | 2 +- Models/CacheTypeMap.cs | 3 --- Models/DependentCacheNavigationScanMode.cs | 3 +-- 5 files changed, 4 insertions(+), 8 deletions(-) delete mode 100644 Models/CacheTypeMap.cs diff --git a/Attributes/DependentCachesAttribute.cs b/Attributes/DependentCachesAttribute.cs index 672e6ab..04edfd4 100644 --- a/Attributes/DependentCachesAttribute.cs +++ b/Attributes/DependentCachesAttribute.cs @@ -1,7 +1,7 @@ namespace CleverCache.Attributes; [AttributeUsage(AttributeTargets.Class)] -public class DependantCachesAttribute( +public class DependentCachesAttribute( Type[] types, DependentCacheNavigationScanMode navigationScanMode = DependentCacheNavigationScanMode.None, bool reverse = false diff --git a/Exceptions/MissingInterceptorException.cs b/Exceptions/MissingInterceptorException.cs index a8ba658..4ff779d 100644 --- a/Exceptions/MissingInterceptorException.cs +++ b/Exceptions/MissingInterceptorException.cs @@ -1,4 +1,4 @@ namespace CleverCache.Exceptions; internal class MissingInterceptorException() : - ApplicationException("CleverCache requires the ClearSmartMemoryCacheInterceptor to be added to the database context."); \ No newline at end of file + Exception("CleverCache requires the ClearSmartMemoryCacheInterceptor to be added to the database context."); \ No newline at end of file diff --git a/Extensions/DbContextExtensions.cs b/Extensions/DbContextExtensions.cs index 0a1412d..8d1ef9e 100644 --- a/Extensions/DbContextExtensions.cs +++ b/Extensions/DbContextExtensions.cs @@ -44,7 +44,7 @@ private static void ProcessAttribute(DbContext dbContext, IEntityType entityType { // Check if this has attribute var type = entityType.ClrType; - var attribute = type.GetCustomAttribute(); + var attribute = type.GetCustomAttribute(); if (attribute is null) { return; diff --git a/Models/CacheTypeMap.cs b/Models/CacheTypeMap.cs deleted file mode 100644 index d6b5032..0000000 --- a/Models/CacheTypeMap.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace CleverCache.Models; - -public record CacheTypeMap(Type Type, object Key); \ No newline at end of file diff --git a/Models/DependentCacheNavigationScanMode.cs b/Models/DependentCacheNavigationScanMode.cs index f527a36..8b17690 100644 --- a/Models/DependentCacheNavigationScanMode.cs +++ b/Models/DependentCacheNavigationScanMode.cs @@ -4,6 +4,5 @@ public enum DependentCacheNavigationScanMode { None, Direct, - Recursive/*, - Attribute*/ + Recursive } \ No newline at end of file From 2a95ba06ebfb3e8a57aa81245f62f68f0d5b99a1 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 09:23:44 +0100 Subject: [PATCH 05/50] Move FakeCache to root CleverCache namespace, improve testing docs FakeCache was in CleverCache.Implementations alongside internal classes, making it awkward for users to discover and import. - FakeCache.cs: change namespace to CleverCache - FakeCacheTests.cs: remove now-unnecessary using CleverCache.Implementations - ReadMe.md: rewrite testing section - fix typos, clarify MediatR note, add guidance on using Mock for interaction verification Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CleverCache.Tests/FakeCacheTests.cs | 2 -- Implementations/FakeCache.cs | 2 +- ReadMe.md | 18 ++++++++++++------ 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/CleverCache.Tests/FakeCacheTests.cs b/CleverCache.Tests/FakeCacheTests.cs index b0483fa..9b8b0d7 100644 --- a/CleverCache.Tests/FakeCacheTests.cs +++ b/CleverCache.Tests/FakeCacheTests.cs @@ -1,5 +1,3 @@ -using CleverCache.Implementations; - namespace CleverCache.Tests; public class FakeCacheTests diff --git a/Implementations/FakeCache.cs b/Implementations/FakeCache.cs index a6b1451..7e948ce 100644 --- a/Implementations/FakeCache.cs +++ b/Implementations/FakeCache.cs @@ -1,4 +1,4 @@ -namespace CleverCache.Implementations; +namespace CleverCache; /// /// A fake implementation of the ICleverCache interface for testing purposes. diff --git a/ReadMe.md b/ReadMe.md index 1b067db..32f330e 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -303,17 +303,23 @@ builder.Services.AddCleverCache(o => o.UseCustomStore(sp => new DictionaryCacheS ``` ## Unit testing -Unit testing methods that use cache is generally fiddly, to help with this **CleverCache** is shipped with a -`FakeCache` implementation which you can use in your test. The implementation never caches and always calls your -underlying method retrieve your data. For example when using `Moq.AutoMocker` you would do this: +Unit testing methods that use cache is generally fiddly. To help with this, **CleverCache** ships with a +`FakeCache` implementation. It never caches and always calls your underlying factory, so the cache is +completely transparent in your tests. + ```csharp var mocker = new AutoMocker(); mocker.Use(new FakeCache()); var sut = mocker.CreateInstance(); -// Run unit tests as normall +// Run unit tests as normal var result = sut.GetDoorCount(); ``` -Now can unit test the `GetDoorCount` method without the cache getting in the way. +Now you can unit test the `GetDoorCount` method without the cache getting in the way. + +If you need to **verify cache interactions** (e.g. assert the factory was called exactly once, or that +a specific key was used), use `Mock` directly instead — `ICleverCache` is a plain interface +and works with any mocking library. -Note: If you're using the `Mediatr` automatic caching you don't need this. +> **Note:** If you are *only* using the MediatR automatic caching (`[AutoCache]`) and never injecting +> `ICleverCache` into your own services, you don't need `FakeCache` at all. From 9e8ccee0a58816ccfd5f13e63098e8ac38236574 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 09:29:22 +0100 Subject: [PATCH 06/50] README: clarify EF Core dependency and manual invalidation fallback Automatic cache invalidation requires EF Core (SaveChangesInterceptor). Add prominent callout explaining: - Invalidation fires on SaveChanges/SaveChangesAsync via change tracker - Raw SQL / stored procs / external writes won't auto-invalidate; use RemoveByType() manually - Without EF Core at all, CleverCache still adds value - RemoveByType() handles the full dependency tree so callers don't need to track cascades Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ReadMe.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ReadMe.md b/ReadMe.md index 32f330e..7026e35 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -12,6 +12,16 @@ With a small amount of configuration **CleverCache** will automatically track ch and reset the cache for any entity if an entity of that type is create, updated or deleted, and - if required, any related entity where data is also part of the same cache entry. +> **Automatic invalidation requires Entity Framework Core.** CleverCache hooks into EF Core as a +> `SaveChangesInterceptor` — cache entries are cleared automatically when `SaveChanges` or +> `SaveChangesAsync` completes. If you write data outside EF Core's change tracker (raw SQL via +> `ExecuteUpdate`/`ExecuteDelete`, stored procedures, or external services) those writes won't +> trigger automatic invalidation — call `cache.RemoveByType()` manually instead. +> +> **No EF Core at all?** You can still use CleverCache for manual invalidation. `RemoveByType()` +> understands your dependent-cache tree, so one call handles all the cascades — you just trigger it +> yourself rather than it happening automatically on save. + >_BONUS:_ MediatR users can install the separate [`CleverCache.MediatR`](https://www.nuget.org/packages/clevercache.mediatr) package for automatic query caching with zero handler changes. ## Installing CleverCache From 213167db3bd020b3b78302614dbe5b2458a681c5 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 09:32:17 +0100 Subject: [PATCH 07/50] README: give MediatR callout proper prominence Move MediatR from buried one-liner to a dedicated callout above the EF Core requirements note - it's a key selling point and should be seen immediately after the intro, not lost after a large blockquote. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ReadMe.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ReadMe.md b/ReadMe.md index 7026e35..cbb7d26 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -12,6 +12,10 @@ With a small amount of configuration **CleverCache** will automatically track ch and reset the cache for any entity if an entity of that type is create, updated or deleted, and - if required, any related entity where data is also part of the same cache entry. +> 🚀 **MediatR users:** Install [`CleverCache.MediatR`](https://www.nuget.org/packages/clevercache.mediatr) +> for **automatic query caching with zero changes to your handlers** — just add `[AutoCache]` to your +> query class and CleverCache handles the rest, including automatic invalidation when your data changes. + > **Automatic invalidation requires Entity Framework Core.** CleverCache hooks into EF Core as a > `SaveChangesInterceptor` — cache entries are cleared automatically when `SaveChanges` or > `SaveChangesAsync` completes. If you write data outside EF Core's change tracker (raw SQL via @@ -22,8 +26,6 @@ any related entity where data is also part of the same cache entry. > understands your dependent-cache tree, so one call handles all the cascades — you just trigger it > yourself rather than it happening automatically on save. ->_BONUS:_ MediatR users can install the separate [`CleverCache.MediatR`](https://www.nuget.org/packages/clevercache.mediatr) package for automatic query caching with zero handler changes. - ## Installing CleverCache You should install CleverCache with NuGet: ``` From 0ccbaeddc4869f650cf20aad62f05500244d2492 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 09:33:48 +0100 Subject: [PATCH 08/50] README: promote MediatR to its own section heading Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ReadMe.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ReadMe.md b/ReadMe.md index cbb7d26..d73c1ac 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -12,9 +12,13 @@ With a small amount of configuration **CleverCache** will automatically track ch and reset the cache for any entity if an entity of that type is create, updated or deleted, and - if required, any related entity where data is also part of the same cache entry. -> 🚀 **MediatR users:** Install [`CleverCache.MediatR`](https://www.nuget.org/packages/clevercache.mediatr) -> for **automatic query caching with zero changes to your handlers** — just add `[AutoCache]` to your -> query class and CleverCache handles the rest, including automatic invalidation when your data changes. +## 🚀 MediatR users + +Install [`CleverCache.MediatR`](https://www.nuget.org/packages/clevercache.mediatr) +for **automatic query caching with zero changes to your handlers** — just add `[AutoCache]` to your +query class and CleverCache handles the rest, including automatic invalidation when your data changes. + +[Jump to MediatR docs ↓](#auto-caching-mediatr-queries) > **Automatic invalidation requires Entity Framework Core.** CleverCache hooks into EF Core as a > `SaveChangesInterceptor` — cache entries are cleared automatically when `SaveChanges` or From 9f69568639f320377518af2177d91970d53e01eb Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 09:46:05 +0100 Subject: [PATCH 09/50] Add InvalidatesCache attribute and bulk operation InvalidateCaches extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 1 - [InvalidatesCache] for MediatR commands (CleverCache.MediatR): - InvalidatesCacheAttribute: decorate commands with types to invalidate - InvalidateCacheBehaviour: clears declared types after handler succeeds; skips invalidation if handler throws - Both behaviours auto-registered by cfg.AddCleverCache() - NuGetReadMe updated with command invalidation quick start Part 2 - Fluent InvalidateCaches() for EF bulk ops (main CleverCache): - BulkOperationExtensions: InvalidateCaches(this int, ...) and InvalidateCaches(this Task, ...) — no Async suffix needed since receiver types differ; generic overloads avoid typeof() - Returns row count passthrough for drop-in compatibility Tests: 11 new tests, 44 total (up from 33) Docs: README bulk operations section + MediatR [InvalidatesCache] section Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../InvalidateCacheBehaviour.cs | 26 ++++++ .../InvalidatesCacheAttribute.cs | 7 ++ .../MediatRServiceConfigurationExt.cs | 1 + CleverCache.MediatR/NuGetReadMe.md | 12 ++- .../BulkOperationExtensionsTests.cs | 72 +++++++++++++++ .../InvalidateCacheBehaviourTests.cs | 87 +++++++++++++++++++ Extensions/BulkOperationExtensions.cs | 44 ++++++++++ ReadMe.md | 57 ++++++++++++ 8 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 CleverCache.MediatR/InvalidateCacheBehaviour.cs create mode 100644 CleverCache.MediatR/InvalidatesCacheAttribute.cs create mode 100644 CleverCache.Tests/BulkOperationExtensionsTests.cs create mode 100644 CleverCache.Tests/InvalidateCacheBehaviourTests.cs create mode 100644 Extensions/BulkOperationExtensions.cs diff --git a/CleverCache.MediatR/InvalidateCacheBehaviour.cs b/CleverCache.MediatR/InvalidateCacheBehaviour.cs new file mode 100644 index 0000000..399016b --- /dev/null +++ b/CleverCache.MediatR/InvalidateCacheBehaviour.cs @@ -0,0 +1,26 @@ +using System.Reflection; +using MediatR; + +namespace CleverCache.Mediatr; + +internal class InvalidateCacheBehaviour(ICleverCache cache) + : IPipelineBehavior + where TRequest : class +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + var attribute = typeof(TRequest).GetCustomAttribute(); + + if (attribute is null) + return await next(cancellationToken); + + var result = await next(cancellationToken); + + foreach (var type in attribute.Types) + cache.RemoveByType(type); + + return result; + } +} diff --git a/CleverCache.MediatR/InvalidatesCacheAttribute.cs b/CleverCache.MediatR/InvalidatesCacheAttribute.cs new file mode 100644 index 0000000..b8104cc --- /dev/null +++ b/CleverCache.MediatR/InvalidatesCacheAttribute.cs @@ -0,0 +1,7 @@ +namespace CleverCache.Mediatr; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public class InvalidatesCacheAttribute(params Type[] types) : Attribute +{ + public Type[] Types { get; } = types; +} diff --git a/CleverCache.MediatR/MediatRServiceConfigurationExt.cs b/CleverCache.MediatR/MediatRServiceConfigurationExt.cs index 3845fdc..92af708 100644 --- a/CleverCache.MediatR/MediatRServiceConfigurationExt.cs +++ b/CleverCache.MediatR/MediatRServiceConfigurationExt.cs @@ -8,6 +8,7 @@ public static class MediatRServiceConfigurationExt { public static void AddCleverCache(this MediatRServiceConfiguration cfg) { + cfg.AddOpenBehavior(typeof(InvalidateCacheBehaviour<,>)); cfg.AddOpenBehavior(typeof(AutoCacheBehaviour<,>)); } } diff --git a/CleverCache.MediatR/NuGetReadMe.md b/CleverCache.MediatR/NuGetReadMe.md index 9ef268a..11af1fd 100644 --- a/CleverCache.MediatR/NuGetReadMe.md +++ b/CleverCache.MediatR/NuGetReadMe.md @@ -1,19 +1,25 @@ # CleverCache.MediatR -MediatR pipeline integration for [CleverCache](https://www.nuget.org/packages/CleverCache) — automatically caches MediatR query results using the `[AutoCache]` attribute with zero changes to your handlers. +MediatR pipeline integration for [CleverCache](https://www.nuget.org/packages/CleverCache) — automatically caches MediatR query results and invalidates cache on commands, with zero changes to your handlers. For full documentation see the [GitHub repository](https://github.com/chunty/CleverCache). ## Quick start ```csharp -// 1. Register the pipeline behaviour +// 1. Register the pipeline behaviours services.AddMediatR(cfg => { cfg.AddCleverCache(); }); -// 2. Decorate any query with [AutoCache] +// 2. Cache query results — decorate any query with [AutoCache] [AutoCache([typeof(MyEntity)])] public record GetMyQuery(int Id) : IRequest; + +// 3. Invalidate on commands — decorate any command with [InvalidatesCache] +[InvalidatesCache(typeof(MyEntity))] +public record DeleteMyCommand(int Id) : IRequest; ``` + +Cache is cleared after the command handler completes successfully. A failed handler leaves the cache untouched. diff --git a/CleverCache.Tests/BulkOperationExtensionsTests.cs b/CleverCache.Tests/BulkOperationExtensionsTests.cs new file mode 100644 index 0000000..a287567 --- /dev/null +++ b/CleverCache.Tests/BulkOperationExtensionsTests.cs @@ -0,0 +1,72 @@ +using CleverCache.Extensions; +using Moq; + +namespace CleverCache.Tests; + +file class BulkEntity; +file class OtherBulkEntity; + +public class BulkOperationExtensionsTests +{ + [Fact] + public void InvalidateCaches_CallsRemoveByTypeForEachType() + { + var cacheMock = new Mock(); + + 42.InvalidateCaches(cacheMock.Object, typeof(BulkEntity), typeof(OtherBulkEntity)); + + cacheMock.Verify(c => c.RemoveByType(typeof(BulkEntity)), Times.Once); + cacheMock.Verify(c => c.RemoveByType(typeof(OtherBulkEntity)), Times.Once); + } + + [Fact] + public void InvalidateCaches_ReturnsRowCount() + { + var cacheMock = new Mock(); + + var result = 7.InvalidateCaches(cacheMock.Object, typeof(BulkEntity)); + + Assert.Equal(7, result); + } + + [Fact] + public void InvalidateCaches_Generic_CallsRemoveByTypeForT() + { + var cacheMock = new Mock(); + + 5.InvalidateCaches(cacheMock.Object); + + cacheMock.Verify(c => c.RemoveByType(typeof(BulkEntity)), Times.Once); + } + + [Fact] + public async Task InvalidateCaches_Async_CallsRemoveByTypeForEachType() + { + var cacheMock = new Mock(); + + await Task.FromResult(10).InvalidateCaches(cacheMock.Object, typeof(BulkEntity), typeof(OtherBulkEntity)); + + cacheMock.Verify(c => c.RemoveByType(typeof(BulkEntity)), Times.Once); + cacheMock.Verify(c => c.RemoveByType(typeof(OtherBulkEntity)), Times.Once); + } + + [Fact] + public async Task InvalidateCaches_Async_ReturnsRowCount() + { + var cacheMock = new Mock(); + + var result = await Task.FromResult(3).InvalidateCaches(cacheMock.Object, typeof(BulkEntity)); + + Assert.Equal(3, result); + } + + [Fact] + public async Task InvalidateCaches_Async_Generic_CallsRemoveByTypeForT() + { + var cacheMock = new Mock(); + + await Task.FromResult(1).InvalidateCaches(cacheMock.Object); + + cacheMock.Verify(c => c.RemoveByType(typeof(BulkEntity)), Times.Once); + } +} diff --git a/CleverCache.Tests/InvalidateCacheBehaviourTests.cs b/CleverCache.Tests/InvalidateCacheBehaviourTests.cs new file mode 100644 index 0000000..85fe69a --- /dev/null +++ b/CleverCache.Tests/InvalidateCacheBehaviourTests.cs @@ -0,0 +1,87 @@ +using CleverCache.Mediatr; +using MediatR; +using Moq; + +namespace CleverCache.Tests; + +[InvalidatesCache(typeof(InvalidatedEntity), typeof(DependentEntity))] +file record DeleteCommand(int Id) : IRequest; + +file record NoInvalidationCommand(int Id) : IRequest; + +file class InvalidatedEntity; +file class DependentEntity; + +public class InvalidateCacheBehaviourTests +{ + [Fact] + public async Task Handle_NoAttribute_DoesNotInvalidate() + { + var cacheMock = new Mock(); + var sut = new InvalidateCacheBehaviour(cacheMock.Object); + RequestHandlerDelegate next = _ => Task.FromResult(true); + + await sut.Handle(new NoInvalidationCommand(1), next, CancellationToken.None); + + cacheMock.Verify(c => c.RemoveByType(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WithAttribute_InvalidatesAllDeclaredTypes() + { + var cacheMock = new Mock(); + var sut = new InvalidateCacheBehaviour(cacheMock.Object); + RequestHandlerDelegate next = _ => Task.FromResult(true); + + await sut.Handle(new DeleteCommand(1), next, CancellationToken.None); + + cacheMock.Verify(c => c.RemoveByType(typeof(InvalidatedEntity)), Times.Once); + cacheMock.Verify(c => c.RemoveByType(typeof(DependentEntity)), Times.Once); + } + + [Fact] + public async Task Handle_WithAttribute_InvalidatesAfterNextCompletes() + { + var cacheMock = new Mock(); + var order = new List(); + var sut = new InvalidateCacheBehaviour(cacheMock.Object); + + cacheMock.Setup(c => c.RemoveByType(It.IsAny())) + .Callback(_ => order.Add("invalidate")); + + RequestHandlerDelegate next = _ => + { + order.Add("handler"); + return Task.FromResult(true); + }; + + await sut.Handle(new DeleteCommand(1), next, CancellationToken.None); + + Assert.Equal(["handler", "invalidate", "invalidate"], order); + } + + [Fact] + public async Task Handle_WithAttribute_ReturnsHandlerResult() + { + var cacheMock = new Mock(); + var sut = new InvalidateCacheBehaviour(cacheMock.Object); + RequestHandlerDelegate next = _ => Task.FromResult(true); + + var result = await sut.Handle(new DeleteCommand(1), next, CancellationToken.None); + + Assert.True(result); + } + + [Fact] + public async Task Handle_HandlerThrows_DoesNotInvalidate() + { + var cacheMock = new Mock(); + var sut = new InvalidateCacheBehaviour(cacheMock.Object); + RequestHandlerDelegate next = _ => Task.FromException(new InvalidOperationException("db error")); + + await Assert.ThrowsAsync(() => + sut.Handle(new DeleteCommand(1), next, CancellationToken.None)); + + cacheMock.Verify(c => c.RemoveByType(It.IsAny()), Times.Never); + } +} diff --git a/Extensions/BulkOperationExtensions.cs b/Extensions/BulkOperationExtensions.cs new file mode 100644 index 0000000..3f1deb7 --- /dev/null +++ b/Extensions/BulkOperationExtensions.cs @@ -0,0 +1,44 @@ +namespace CleverCache.Extensions; + +/// +/// Fluent cache invalidation helpers to use after EF Core bulk operations +/// (ExecuteDelete, ExecuteUpdate) which bypass the change tracker and therefore +/// do not trigger automatic CleverCache invalidation. +/// +public static class BulkOperationExtensions +{ + /// + /// Invalidates the cache for the specified types and returns the row count. + /// Use after ExecuteDelete / ExecuteUpdate to keep the cache consistent. + /// + public static int InvalidateCaches(this int rowCount, ICleverCache cache, params Type[] types) + { + foreach (var type in types) + cache.RemoveByType(type); + return rowCount; + } + + /// + /// Invalidates the cache for and returns the row count. + /// + public static int InvalidateCaches(this int rowCount, ICleverCache cache) + => rowCount.InvalidateCaches(cache, typeof(T)); + + /// + /// Awaits the bulk operation task, invalidates the cache for the specified types, and returns the row count. + /// Use after ExecuteDeleteAsync / ExecuteUpdateAsync to keep the cache consistent. + /// + public static async Task InvalidateCaches(this Task task, ICleverCache cache, params Type[] types) + { + var rowCount = await task.ConfigureAwait(false); + foreach (var type in types) + cache.RemoveByType(type); + return rowCount; + } + + /// + /// Awaits the bulk operation task, invalidates the cache for , and returns the row count. + /// + public static Task InvalidateCaches(this Task task, ICleverCache cache) + => task.InvalidateCaches(cache, typeof(T)); +} diff --git a/ReadMe.md b/ReadMe.md index d73c1ac..29a5e99 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -229,6 +229,18 @@ public record MyQuery : IRequest; This uses the mediatr request as the cache key so you can use the same query with different parameters and it will cache each one separately. +### Auto-invalidating on MediatR commands + +Add `[InvalidatesCache]` to any command to automatically clear the specified cache types after the +command handler completes successfully: + +```csharp +[InvalidatesCache(typeof(Order), typeof(OrderLine))] +public record DeleteOrderCommand(int OrderId) : IRequest; +``` + +Cache is only cleared if the handler completes without throwing — a failed command leaves the cache untouched. + ## Redis cache Install the dedicated **[CleverCache.Redis](https://www.nuget.org/packages/clevercache.redis)** package to add Redis support without bringing the StackExchange.Redis dependency into your main project. @@ -318,6 +330,51 @@ builder.Services.AddCleverCache(o => o.UseCustomStore()); builder.Services.AddCleverCache(o => o.UseCustomStore(sp => new DictionaryCacheStore())); ``` +## Bulk operations and non-EF writes + +EF Core's `ExecuteDelete` and `ExecuteUpdate` (and any other writes that bypass the change tracker — +stored procedures, raw SQL, external services) do **not** trigger the `SaveChangesInterceptor`, so +CleverCache won't automatically invalidate affected entries. Two workarounds are available: + +### Option 1 — Fluent `.InvalidateCaches()` (any project) + +Chain `.InvalidateCaches()` after any operation that returns `int` (rows affected): + +```csharp +// Sync +context.Orders.Where(o => o.IsDeleted) + .ExecuteDelete() + .InvalidateCaches(cache, typeof(Order)); + +// Async +await context.Orders.Where(o => o.IsDeleted) + .ExecuteDeleteAsync() + .InvalidateCaches(cache, typeof(Order)); + +// Generic shorthand — no typeof needed +await context.Orders.Where(o => o.IsDeleted) + .ExecuteDeleteAsync() + .InvalidateCaches(cache); + +// Works after ExecuteUpdate too +await context.Orders.Where(o => o.Status == "pending") + .ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, "complete")) + .InvalidateCaches(cache); +``` + +The call passes through the row count so existing code that uses the return value still compiles. + +### Option 2 — `[InvalidatesCache]` on MediatR commands (CleverCache.MediatR) + +If you're using CQRS with MediatR, decorate the command instead — no manual cache calls needed: + +```csharp +[InvalidatesCache(typeof(Order), typeof(OrderLine))] +public record DeleteOrderCommand(int OrderId) : IRequest; +``` + +See the [MediatR section ↑](#auto-caching-mediatr-queries) for setup. + ## Unit testing Unit testing methods that use cache is generally fiddly. To help with this, **CleverCache** ships with a `FakeCache` implementation. It never caches and always calls your underlying factory, so the cache is From 32f396f76b6e1b209b2ad194e587533663530edf Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 11:32:48 +0100 Subject: [PATCH 10/50] docs, assembly scanning, and bug fixes - Trim README to quick-start + wiki links - Add 8 wiki pages (Getting-Started, Cache-Providers, Caching-Data, Dependent-Caches, MediatR-Integration, Bulk-Operations, Unit-Testing, Home) - Improve XML docs on ICleverCache, CacheEntryManagerExtensions, AutoCacheAttribute, InvalidatesCacheAttribute, DependentCachesAttribute - Add PackageProjectUrl to all 3 production csproj files - Fix duplicate namespace bug in DependentCachesAttribute.cs - Add ScanAssemblyContaining() and ScanAssemblies() to CleverCacheOptions for attribute-based dependency registration at AddCleverCache() time - Update CleverCacheService constructor to initialise dependency tree from CleverCacheOptions.DependentCaches - Fix UseCleverCache() resolving scoped DbContext from root provider - Add AssemblyScanningTests (5 tests); 49/49 passing on net9 and net10 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Attributes/DependentCachesAttribute.cs | 21 + CleverCache.MediatR/AutoCacheAttribute.cs | 13 + .../CleverCache.MediatR.csproj | 1 + .../InvalidatesCacheAttribute.cs | 12 + CleverCache.MediatR/NuGetReadMe.md | 2 +- CleverCache.Redis/CleverCache.Redis.csproj | 1 + CleverCache.Redis/NuGetReadMe.md | 2 +- CleverCache.Tests/AssemblyScanningTests.cs | 84 ++++ CleverCache.Tests/CleverCacheServiceTests.cs | 2 +- CleverCache.csproj | 1 + .../ApplicationBuilderExtensions.cs | 4 +- Extensions/CacheEntryManagerExtensions.cs | 21 +- ICleverCache.cs | 33 +- Implementations/CleverCacheService.cs | 28 +- Models/CleverCacheOptions.cs | 40 +- NuGetReadMe.md | 2 +- ReadMe.md | 401 ++---------------- wiki/Bulk-Operations.md | 106 +++++ wiki/Cache-Providers.md | 122 ++++++ wiki/Caching-Data.md | 72 ++++ wiki/Dependent-Caches.md | 126 ++++++ wiki/Getting-Started.md | 84 ++++ wiki/Home.md | 27 ++ wiki/MediatR-Integration.md | 98 +++++ wiki/Unit-Testing.md | 46 ++ 25 files changed, 951 insertions(+), 398 deletions(-) create mode 100644 CleverCache.Tests/AssemblyScanningTests.cs create mode 100644 wiki/Bulk-Operations.md create mode 100644 wiki/Cache-Providers.md create mode 100644 wiki/Caching-Data.md create mode 100644 wiki/Dependent-Caches.md create mode 100644 wiki/Getting-Started.md create mode 100644 wiki/Home.md create mode 100644 wiki/MediatR-Integration.md create mode 100644 wiki/Unit-Testing.md diff --git a/Attributes/DependentCachesAttribute.cs b/Attributes/DependentCachesAttribute.cs index 04edfd4..211691c 100644 --- a/Attributes/DependentCachesAttribute.cs +++ b/Attributes/DependentCachesAttribute.cs @@ -1,5 +1,21 @@ namespace CleverCache.Attributes; +/// +/// Declares cache dependency relationships for an entity type. +/// Applied at startup by app.UseCleverCache<TContext>(), which registers the +/// declared dependencies so that invalidating this type also invalidates the dependent types. +/// +/// +/// +/// // Invalidating Order also invalidates OrderLine and OrderNote +/// [DependentCaches([typeof(OrderLine), typeof(OrderNote)])] +/// public class Order { } +/// +/// // reverse: true also invalidates Order when OrderLine changes +/// [DependentCaches([typeof(OrderLine)], reverse: true)] +/// public class Order { } +/// +/// [AttributeUsage(AttributeTargets.Class)] public class DependentCachesAttribute( Type[] types, @@ -7,7 +23,12 @@ public class DependentCachesAttribute( bool reverse = false ) : Attribute { + /// The entity types that should also be invalidated when this type's entries are evicted. public Type[] DependantTypes { get; } = types ?? []; + + /// Controls whether navigation properties are scanned to discover additional dependent types. public DependentCacheNavigationScanMode NavigationScanMode { get; set; } = navigationScanMode; + + /// When true, also registers the inverse dependency so that invalidating any dependent type also invalidates this type. public bool Reverse { get; set; } = reverse; } \ No newline at end of file diff --git a/CleverCache.MediatR/AutoCacheAttribute.cs b/CleverCache.MediatR/AutoCacheAttribute.cs index b409371..1d88710 100644 --- a/CleverCache.MediatR/AutoCacheAttribute.cs +++ b/CleverCache.MediatR/AutoCacheAttribute.cs @@ -1,6 +1,19 @@ namespace CleverCache.Mediatr; +/// +/// Marks a MediatR query so that its result is automatically cached by the CleverCache pipeline behaviour. +/// The MediatR request object is used as the cache key, so the same query with different parameters +/// gets its own cache entry. +/// Cache entries are evicted automatically when any of the specified entity types changes via EF Core. +/// +/// +/// +/// [AutoCache([typeof(Order)])] +/// public record GetOrdersQuery(int CustomerId) : IRequest<List<Order>>; +/// +/// public class AutoCacheAttribute(params Type[] types) : Attribute { + /// The entity types this cache entry is associated with. The entry is evicted when any of these types is invalidated. public Type[] Types { get; } = types; } diff --git a/CleverCache.MediatR/CleverCache.MediatR.csproj b/CleverCache.MediatR/CleverCache.MediatR.csproj index 031465e..f6417f7 100644 --- a/CleverCache.MediatR/CleverCache.MediatR.csproj +++ b/CleverCache.MediatR/CleverCache.MediatR.csproj @@ -2,6 +2,7 @@ CleverCache.MediatR 2.0.0 + https://github.com/chunty/CleverCache/wiki/MediatR-Integration MediatR pipeline integration for CleverCache — automatic caching of MediatR queries via the [AutoCache] attribute. MediatR,CleverCache,Cache,Automatic Cache NuGetReadMe.md diff --git a/CleverCache.MediatR/InvalidatesCacheAttribute.cs b/CleverCache.MediatR/InvalidatesCacheAttribute.cs index b8104cc..bcc974b 100644 --- a/CleverCache.MediatR/InvalidatesCacheAttribute.cs +++ b/CleverCache.MediatR/InvalidatesCacheAttribute.cs @@ -1,7 +1,19 @@ namespace CleverCache.Mediatr; +/// +/// Marks a MediatR command so that the specified cache types are automatically invalidated +/// after the command handler completes successfully. +/// Cache is only cleared on success — a handler that throws leaves the cache untouched. +/// +/// +/// +/// [InvalidatesCache(typeof(Order), typeof(OrderLine))] +/// public record DeleteOrderCommand(int OrderId) : IRequest; +/// +/// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] public class InvalidatesCacheAttribute(params Type[] types) : Attribute { + /// The entity types whose cache entries will be evicted after the command succeeds. public Type[] Types { get; } = types; } diff --git a/CleverCache.MediatR/NuGetReadMe.md b/CleverCache.MediatR/NuGetReadMe.md index 11af1fd..c52fa0b 100644 --- a/CleverCache.MediatR/NuGetReadMe.md +++ b/CleverCache.MediatR/NuGetReadMe.md @@ -2,7 +2,7 @@ MediatR pipeline integration for [CleverCache](https://www.nuget.org/packages/CleverCache) — automatically caches MediatR query results and invalidates cache on commands, with zero changes to your handlers. -For full documentation see the [GitHub repository](https://github.com/chunty/CleverCache). +For full documentation see the [MediatR Integration wiki](https://github.com/chunty/CleverCache/wiki/MediatR-Integration). ## Quick start diff --git a/CleverCache.Redis/CleverCache.Redis.csproj b/CleverCache.Redis/CleverCache.Redis.csproj index 68f5eb5..cbd1b58 100644 --- a/CleverCache.Redis/CleverCache.Redis.csproj +++ b/CleverCache.Redis/CleverCache.Redis.csproj @@ -2,6 +2,7 @@ CleverCache.Redis 2.0.0 + https://github.com/chunty/CleverCache/wiki/Cache-Providers Redis integration for CleverCache — adds UseRedisCache() convenience registration backed by StackExchange.Redis. Redis,StackExchange.Redis,CleverCache,Cache,Automatic Cache NuGetReadMe.md diff --git a/CleverCache.Redis/NuGetReadMe.md b/CleverCache.Redis/NuGetReadMe.md index 43f3c36..cd17e5e 100644 --- a/CleverCache.Redis/NuGetReadMe.md +++ b/CleverCache.Redis/NuGetReadMe.md @@ -2,7 +2,7 @@ Redis integration for [CleverCache](https://www.nuget.org/packages/CleverCache) — adds a `UseRedisCache()` convenience method backed by `StackExchange.Redis`. -For full documentation see the [GitHub repository](https://github.com/chunty/CleverCache). +For full documentation see the [Cache Providers wiki](https://github.com/chunty/CleverCache/wiki/Cache-Providers). ## Quick start diff --git a/CleverCache.Tests/AssemblyScanningTests.cs b/CleverCache.Tests/AssemblyScanningTests.cs new file mode 100644 index 0000000..eb8d289 --- /dev/null +++ b/CleverCache.Tests/AssemblyScanningTests.cs @@ -0,0 +1,84 @@ +using CleverCache.Attributes; +using CleverCache.Implementations; +using Microsoft.Extensions.Caching.Memory; + +namespace CleverCache.Tests; + +// Test entity types with [DependentCaches] in this assembly +[DependentCaches([typeof(ScanDependent)])] +file class ScanPrimary; +file class ScanDependent; + +[DependentCaches([typeof(ScanReverseDependent)], reverse: true)] +file class ScanReverse; +file class ScanReverseDependent; + +file class ScanNoAttribute; + +public class AssemblyScanningTests +{ + private static ICleverCache BuildCache(CleverCacheOptions options) + { + var store = new MemoryCacheStore(new MemoryCache(new MemoryCacheOptions())); + return new CleverCacheService(store, options); + } + + [Fact] + public void ScanAssemblyContaining_RegistersDependentCachesFromAttribute() + { + var options = new CleverCacheOptions(); + options.ScanAssemblyContaining(); + + Assert.Contains(new DependentCache(typeof(ScanPrimary), typeof(ScanDependent)), options.DependentCaches); + } + + [Fact] + public void ScanAssemblyContaining_Reverse_RegistersBothDirections() + { + var options = new CleverCacheOptions(); + options.ScanAssemblyContaining(); + + Assert.Contains(new DependentCache(typeof(ScanReverse), typeof(ScanReverseDependent)), options.DependentCaches); + Assert.Contains(new DependentCache(typeof(ScanReverseDependent), typeof(ScanReverse)), options.DependentCaches); + } + + [Fact] + public void ScanAssemblyContaining_TypeWithNoAttribute_NotRegistered() + { + var options = new CleverCacheOptions(); + options.ScanAssemblyContaining(); + + Assert.DoesNotContain(options.DependentCaches, d => d.Type == typeof(ScanNoAttribute)); + } + + [Fact] + public void ScanAssemblies_MultipleAssemblies_RegistersAll() + { + var options = new CleverCacheOptions(); + options.ScanAssemblies(typeof(AssemblyScanningTests).Assembly); + + Assert.Contains(new DependentCache(typeof(ScanPrimary), typeof(ScanDependent)), options.DependentCaches); + } + + [Fact] + public void CleverCacheService_InitialisesFromOptions_CascadesWork() + { + var options = new CleverCacheOptions(); + options.ScanAssemblyContaining(); + + var store = new MemoryCacheStore(new MemoryCache(new MemoryCacheOptions())); + var cache = new TestCacheManager2(store, options); + + // Adding a key for ScanPrimary should cascade to ScanDependent (wired via [DependentCaches] attribute) + cache.AddKeyToTypes([typeof(ScanPrimary)], "cascade-key"); + + Assert.Contains("cascade-key", cache.KeysFor(typeof(ScanDependent))); + } +} + +// Helper subclass to expose SnapshotKeysFor for the cascade test +file class TestCacheManager2 : CleverCacheService +{ + public TestCacheManager2(ICleverCacheStore store, CleverCacheOptions options) : base(store, options) { } + public object[] KeysFor(Type type) => SnapshotKeysFor(type); +} diff --git a/CleverCache.Tests/CleverCacheServiceTests.cs b/CleverCache.Tests/CleverCacheServiceTests.cs index 63debae..4b2f7d4 100644 --- a/CleverCache.Tests/CleverCacheServiceTests.cs +++ b/CleverCache.Tests/CleverCacheServiceTests.cs @@ -7,7 +7,7 @@ namespace CleverCache.Tests; public class CleverCacheServiceTests { private static CleverCacheService CreateService(ICleverCacheStore? store = null) - => new(store ?? new MemoryCacheStore(new MemoryCache(new MemoryCacheOptions()))); + => new(store ?? new MemoryCacheStore(new MemoryCache(new MemoryCacheOptions())), new CleverCacheOptions()); [Fact] public void GetOrCreate_CacheMiss_InvokesFactory() diff --git a/CleverCache.csproj b/CleverCache.csproj index 0b1b043..a937be4 100644 --- a/CleverCache.csproj +++ b/CleverCache.csproj @@ -2,6 +2,7 @@ CleverCache 2.0.0 + https://github.com/chunty/CleverCache/wiki Like MemoryCache but better! Automatically invalidates cache by entity type, supports memory cache, distributed cache, or a custom provider. MemoryCache,DistributedCache,Automatic Cache,Cache Invalidation NuGetReadMe.md diff --git a/DependencyInjection/ApplicationBuilderExtensions.cs b/DependencyInjection/ApplicationBuilderExtensions.cs index d1e54a7..b04a67b 100644 --- a/DependencyInjection/ApplicationBuilderExtensions.cs +++ b/DependencyInjection/ApplicationBuilderExtensions.cs @@ -8,7 +8,9 @@ public static IApplicationBuilder UseCleverCache(this IApplicationBuil { var cache = app.ApplicationServices.GetRequiredService(); var smartCacheOptions = app.ApplicationServices.GetRequiredService(); - var dbContext = app.ApplicationServices.GetRequiredService(); + + using var scope = app.ApplicationServices.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); var dependentCaches = smartCacheOptions.DependentCaches.ToList(); dbContext.EnsureCleverCacheInterceptor(); diff --git a/Extensions/CacheEntryManagerExtensions.cs b/Extensions/CacheEntryManagerExtensions.cs index a490a6f..c9ada51 100644 --- a/Extensions/CacheEntryManagerExtensions.cs +++ b/Extensions/CacheEntryManagerExtensions.cs @@ -6,26 +6,29 @@ public static class CacheEntryManagerExtensions { /// - /// Adds a dependent cache type. + /// Registers a cascade rule: when entries of are invalidated, + /// entries of are also invalidated. /// - /// The type of the cache. + /// The trigger type — invalidating this type will also invalidate . /// The cache instance. - /// The dependent type of the cache. + /// The type whose entries should also be evicted when is invalidated. public static void AddDependentCache(this ICleverCache cache, Type dependentType) => cache.AddDependentCache(typeof(T), dependentType); /// - /// Adds the specified key to the cache entry type. + /// Registers a cache key under the specified entity type so that the key is automatically + /// evicted when data of type changes. /// - /// The type of the object the cache key belongs to. + /// The entity type to associate with this cache key. /// The cache instance. - /// The key of the cache entry to add. + /// The cache key to register. public static void AddKeyToType(this ICleverCache cache, object key) where T : class => cache.AddKeyToType(typeof(T), key); /// - /// Adds the specified key to the cache entry type. + /// Registers a cache key under the specified entity type so that the key is automatically + /// evicted when data of that type changes. /// /// The cache instance. - /// The type of the object the cache key belongs to. - /// The key of the cache entry to add. + /// The entity type to associate with this cache key. + /// The cache key to register. public static void AddKeyToType(this ICleverCache cache, Type type, object key) => cache.AddKeyToTypes([type], key); } diff --git a/ICleverCache.cs b/ICleverCache.cs index 4ea9461..0032ad4 100644 --- a/ICleverCache.cs +++ b/ICleverCache.cs @@ -3,19 +3,34 @@ public interface ICleverCache { /// - /// Adds a dependent cache type so that invalidating also invalidates . + /// Registers a cascade rule: when all entries of are invalidated, + /// all entries of are also invalidated. + /// Cascades are transitive and cycle-safe. /// + /// + /// This is normally configured automatically via the [DependentCaches] attribute and + /// app.UseCleverCache<TContext>(). Call this directly only when you need to set + /// up cache dependencies programmatically rather than via the attribute. + /// void AddDependentCache(Type type, Type dependentType); /// - /// Associates the specified key with one or more cache types. + /// Registers a cache key under the specified entity types so that the key is automatically + /// evicted when data of any of those types changes (via ). /// + /// + /// This is called automatically by and + /// . Call this directly only when you need to associate + /// a key with a type after the fact — for example, following a bulk operation that bypasses + /// the EF Core change tracker. + /// void AddKeyToTypes(Type[] types, object key); /// /// Gets the value associated with this key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found. + /// The entry is automatically evicted when data of any type in changes. /// - /// An array types the cache key belongs to. + /// The entity types this cache entry is associated with. The entry is evicted when any of these types is invalidated. /// The key of the entry to look for or create. /// The factory that creates the value associated with this key if the key does not exist in the cache. /// The options to be applied to the cache entry if the key does not exist in the cache. @@ -28,9 +43,10 @@ public interface ICleverCache /// /// Asynchronously gets the value associated with this key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found. + /// The entry is automatically evicted when data of any type in changes. /// /// The type of the object to get. - /// An array types the cache key belongs to. + /// The entity types this cache entry is associated with. The entry is evicted when any of these types is invalidated. /// The key of the entry to look for or create. /// The factory task that creates the value associated with this key if the key does not exist in the cache. /// The options to be applied to the cache entry if the key does not exist in the cache. @@ -42,15 +58,16 @@ public interface ICleverCache CleverCacheEntryOptions? createOptions = null); /// - /// Removes the object associated with the given key. + /// Removes the cache entry with the specified key. /// - /// An object identifying the entry. + /// The key identifying the entry to remove. void Remove(object key); /// - /// Removes all cache entries of the specified type. + /// Removes all cache entries associated with the specified entity type, including entries + /// registered under any dependent types (see ). /// - /// The type of the objects to remove cache entries for. + /// The entity type whose cache entries should be evicted. void RemoveByType(Type type); } \ No newline at end of file diff --git a/Implementations/CleverCacheService.cs b/Implementations/CleverCacheService.cs index b0a3cd5..e2aeba1 100644 --- a/Implementations/CleverCacheService.cs +++ b/Implementations/CleverCacheService.cs @@ -3,50 +3,56 @@ namespace CleverCache.Implementations; /// -internal class CleverCacheService(ICleverCacheStore store) : CacheEntryManager, ICleverCache +internal class CleverCacheService : CacheEntryManager, ICleverCache { + private readonly ICleverCacheStore _store; private readonly AsyncKeyedLocker _locker = new(); + public CleverCacheService(ICleverCacheStore store, CleverCacheOptions options) + { + _store = store; + foreach (var dep in options.DependentCaches) + AddDependentCache(dep.Type, dep.DependentType); + } + public TItem? GetOrCreate(Type[] types, object key, Func factory, CleverCacheEntryOptions? options = null) { - if (store.TryGet(key, out var hit)) return hit; + if (_store.TryGet(key, out var hit)) return hit; using var _ = _locker.Lock(key); // Double-check: another thread may have populated the cache while we waited for the lock - if (store.TryGet(key, out hit)) return hit; + if (_store.TryGet(key, out hit)) return hit; AddKeyToTypes(types, key); var value = factory(); - store.Set(key, value, options); + _store.Set(key, value, options); return value; } public async Task GetOrCreateAsync(Type[] types, object key, Func> factory, CleverCacheEntryOptions? options = null) { - var (found, cached) = await store.TryGetAsync(key).ConfigureAwait(false); + var (found, cached) = await _store.TryGetAsync(key).ConfigureAwait(false); if (found) return cached; using var _ = await _locker.LockAsync(key).ConfigureAwait(false); // Double-check: another thread may have populated the cache while we waited for the lock - (found, cached) = await store.TryGetAsync(key).ConfigureAwait(false); + (found, cached) = await _store.TryGetAsync(key).ConfigureAwait(false); if (found) return cached; AddKeyToTypes(types, key); var value = await factory().ConfigureAwait(false); - await store.SetAsync(key, value, options).ConfigureAwait(false); + await _store.SetAsync(key, value, options).ConfigureAwait(false); return value; } public void RemoveByType(Type type) { foreach (var k in SnapshotKeysFor(type)) - { - store.Remove(k); - } + _store.Remove(k); } - public void Remove(object key) => store.Remove(key); + public void Remove(object key) => _store.Remove(key); } diff --git a/Models/CleverCacheOptions.cs b/Models/CleverCacheOptions.cs index c12661e..d2c5c1c 100644 --- a/Models/CleverCacheOptions.cs +++ b/Models/CleverCacheOptions.cs @@ -1,4 +1,6 @@ -using CleverCache.Implementations; +using System.Reflection; +using CleverCache.Attributes; +using CleverCache.Implementations; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; @@ -15,6 +17,42 @@ public class CleverCacheOptions(CleverCacheScanOptions? scanOptions = null, public HashSet DependentCaches { get; set; } = dependentCaches ?? []; public bool DisableAllScanning { get; set; } = disableAllScanning; + /// + /// Scans the assembly containing for + /// and registers the declared cache dependency relationships. + /// Use this instead of relying on UseCleverCache<TContext>() for attribute-based configuration. + /// + /// + /// + /// builder.Services.AddCleverCache(o => o.ScanAssemblyContaining<Order>()); + /// + /// + public CleverCacheOptions ScanAssemblyContaining() => ScanAssemblies(typeof(T).Assembly); + + /// + /// Scans the specified assemblies for and registers + /// the declared cache dependency relationships. + /// + public CleverCacheOptions ScanAssemblies(params Assembly[] assemblies) + { + foreach (var assembly in assemblies) + { + foreach (var type in assembly.GetTypes()) + { + var attr = type.GetCustomAttribute(); + if (attr is null) continue; + + foreach (var depType in attr.DependantTypes) + { + DependentCaches.Add(new DependentCache(type, depType)); + if (attr.Reverse) + DependentCaches.Add(new DependentCache(depType, type)); + } + } + } + return this; + } + internal Action StoreRegistration { get; private set; } = services => { services.AddMemoryCache(); diff --git a/NuGetReadMe.md b/NuGetReadMe.md index 495c6d9..62e0ea0 100644 --- a/NuGetReadMe.md +++ b/NuGetReadMe.md @@ -4,7 +4,7 @@ Automatic cache invalidation for .NET — tracks entity changes via EF Core and Supports **memory cache** (default), **distributed cache** (`IDistributedCache`), or a **custom provider**. -For full documentation, examples, and configuration options see the [GitHub repository](https://github.com/chunty/CleverCache). +For full documentation, examples, and configuration options see the [CleverCache wiki](https://github.com/chunty/CleverCache/wiki). ## Quick start diff --git a/ReadMe.md b/ReadMe.md index 29a5e99..5569421 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -3,396 +3,69 @@ CleverCache [![NuGet](https://img.shields.io/nuget/dt/clevercache.svg)](https://www.nuget.org/packages/clevercache) [![NuGet](https://img.shields.io/nuget/vpre/clevercache.svg)](https://www.nuget.org/packages/clevercache) -**CleverCache** was designed to try and solve the problem having to remember (or know when) to invalidate cache entries -when the data in them is out of date. This often particularly hard when cache entries contain data from multiple entities -a change in any of them effectively means the cached data is now wrong. Trying to do this manually often causes -cross-cutting concerns and invariably we forget something important which proves to be a right pain in the butt. +**CleverCache** solves the problem of remembering when to invalidate cache entries when underlying data changes — especially when a cache entry contains data from multiple entity types. -With a small amount of configuration **CleverCache** will automatically track changes in your database context -and reset the cache for any entity if an entity of that type is create, updated or deleted, and - if required, -any related entity where data is also part of the same cache entry. +With a small amount of configuration, CleverCache automatically tracks entity changes via EF Core and clears related cache entries whenever data is created, updated, or deleted. ## 🚀 MediatR users -Install [`CleverCache.MediatR`](https://www.nuget.org/packages/clevercache.mediatr) -for **automatic query caching with zero changes to your handlers** — just add `[AutoCache]` to your -query class and CleverCache handles the rest, including automatic invalidation when your data changes. +Install [`CleverCache.MediatR`](https://www.nuget.org/packages/clevercache.mediatr) for **automatic query caching with zero changes to your handlers** — just add `[AutoCache]` to your query and CleverCache handles the rest, including automatic invalidation. -[Jump to MediatR docs ↓](#auto-caching-mediatr-queries) +→ [MediatR Integration wiki](https://github.com/chunty/CleverCache/wiki/MediatR-Integration) -> **Automatic invalidation requires Entity Framework Core.** CleverCache hooks into EF Core as a -> `SaveChangesInterceptor` — cache entries are cleared automatically when `SaveChanges` or -> `SaveChangesAsync` completes. If you write data outside EF Core's change tracker (raw SQL via -> `ExecuteUpdate`/`ExecuteDelete`, stored procedures, or external services) those writes won't -> trigger automatic invalidation — call `cache.RemoveByType()` manually instead. +> **Automatic invalidation requires Entity Framework Core.** CleverCache hooks into EF Core as a `SaveChangesInterceptor` — entries are cleared automatically when `SaveChanges` or `SaveChangesAsync` completes. Writes that bypass the change tracker (raw SQL, stored procedures, external services) won't trigger automatic invalidation — see [Bulk Operations](https://github.com/chunty/CleverCache/wiki/Bulk-Operations). > -> **No EF Core at all?** You can still use CleverCache for manual invalidation. `RemoveByType()` -> understands your dependent-cache tree, so one call handles all the cascades — you just trigger it -> yourself rather than it happening automatically on save. +> **No EF Core?** You can still use CleverCache for manual invalidation — `RemoveByType()` understands your full dependency tree and cascades automatically. -## Installing CleverCache -You should install CleverCache with NuGet: -``` -Install-Package CleverCache -``` -Or via the .NET Core command line interface: -``` -dotnet add package CleverCache -``` -Either commands, from Package Manager Console or .NET Core CLI, will download and install -CleverCache and all required dependencies. - -## Cache provider - -By default CleverCache uses `IMemoryCache`. You can switch to a distributed cache, use the dedicated Redis package, or plug in your own provider: - -```csharp -// Memory cache (default) -builder.Services.AddCleverCache(); - -// Redis — install CleverCache.Redis, then: -builder.Services.AddCleverCache(o => o.UseRedisCache("localhost:6379")); - -// Any IDistributedCache backend — register it first, then: -builder.Services.AddDistributedMemoryCache(); // or AddStackExchangeRedisCache, etc. -builder.Services.AddCleverCache(o => o.UseDistributedCache()); - -// Custom provider — implement ICleverCacheStore -builder.Services.AddCleverCache(o => o.UseCustomStore()); -// or via a factory: -builder.Services.AddCleverCache(o => o.UseCustomStore(sp => new MyStore(sp.GetRequiredService()))); -``` - -### Cache entry options - -All `GetOrCreate` / `GetOrCreateAsync` overloads accept an optional `CleverCacheEntryOptions`: - -```csharp -var options = new CleverCacheEntryOptions -{ - // Expire 10 minutes after the entry was created - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10), - - // Or expire at a specific point in time - AbsoluteExpiration = DateTimeOffset.UtcNow.AddHours(1), - - // Extend lifetime on each read (memory cache only) - SlidingExpiration = TimeSpan.FromMinutes(5), -}; - -var result = await cache.GetOrCreateAsync>( - key, - async () => await db.Results.ToListAsync(), - options); -``` - -## Get Started - -1. Register the services: - ```csharp - builder.Services.AddCleverCache(); - ``` - -2. Ensure the interceptor is registered on your database context in any of the following ways: - ```csharp - // The interceptor interface if you have no other interceptors - public class AppDbContext(IInterceptor cleverCacheInterceptor) : DbContext() - { - private readonly IInterceptor _cleverCacheInterceptor = cleverCacheInterceptor; - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.AddInterceptors(new IInterceptor[] { cleverCacheInterceptor }); - } - } - ``` - or - - ```csharp - // The interceptor array if you are already using interceptors - public class AppDbContext(IInterceptor[] interceptors) : DbContext() - { - private readonly IInterceptor[] _interceptors= interceptors; - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.AddInterceptors(interceptors); - } - } - ``` - - or the concrete class - - ```csharp - // The interceptor interface if you have no other interceptors - public class AppDbContext(CleverCacheInterceptor cleverCacheInterceptor) : DbContext() - { - private readonly CleverCacheInterceptor _cleverCacheInterceptor = cleverCacheInterceptor; - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.AddInterceptors(new IInterceptor[] { cleverCacheInterceptor }); - } - } - ``` -3. Add the using to your app specifying the db context you are tracking: - - ```csharp - app.UseCleverCache(); - ``` - -## Usage -You create cache entries in the same way you would with MemoryCache, but specify an additional type parameter to associate a given type with a cache key: -```csharp -// Generic shorthand — associate with a single type -var myItems = await cache.GetOrCreateAsync>( - cacheKey, - async () => await db.MyItems.ToListAsync() -) ?? []; -``` - -The interceptor tracks when any instance of `MyEntityType` is added, changed or deleted and clears all cache keys associated with that type. - -You can also supply cache entry options: -```csharp -var myItems = await cache.GetOrCreateAsync>( - cacheKey, - async () => await db.MyItems.ToListAsync(), - new CleverCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) } -) ?? []; -``` - -## Dependent Caches -Often you have information in a cache entry that contains data from multiple entity types -and the caches needs to be refreshed if ANY of the types changes not -just the primary object. - -> tl;dr: See the `DependantCaches` attribute below - -You can create these associations manually on a type by type basis by calling: -```csharp -cache.AddKeyToType(type, key); -``` -or more succinctly: -```csharp -cache.AddKeyToType(key); -``` - -You can also do multiple types in one call by doing: -```csharp -cache.AddKeyToTypes(arrayOfTypes, key); -``` - -You can also do it by specifying an array of types when calling any of the create methods. - -However, this can be tiresome and result in repetitive code. If you know -you often need to do this for a given entity you can configure it globally via -an attribute on the entity class like this: - -```csharp -[DependantCaches([typeof(ThingTwo),typeof(ThingThree)])] -public class ThingOne -{ - public ThingTwo Two {get; set;}; - public ThingThree Three {get; set;}; -} - -public class ThingTwo; -public class ThingThree; -``` -This will automatically register any keys for `ThingOne` with `ThingTwo` and `ThingThree` -so changes to any object of these types will clear the cache key. You can also reverse these -mappings by using `reverse: true` in the attribute. This will register `ThingTwo` and `ThingThree` with `ThingOne` - -## Auto caching MediatR queries -This is available via the separate **[CleverCache.MediatR](https://www.nuget.org/packages/clevercache.mediatr)** package — install it to keep your main project free of the MediatR dependency. +## Install ``` -Install-Package CleverCache.MediatR +Install-Package CleverCache ``` -Add the following to your MediatR setup: +Or via the .NET CLI: -```csharp -services.AddMediatR(cfg => -{ - // Other config you may have - cfg.AddCleverCache(); // Registers the mediatr pipeline behaviour -}); ``` -Then simply add the following attribute to any query you want to cache, specifing the type(s) -you want the cache for: -```csharp -[AutoCache([typeof(MyEntityType)])] -public record MyQuery : IRequest; +dotnet add package CleverCache ``` -This uses the mediatr request as the cache key so you can use the same query with different parameters -and it will cache each one separately. - -### Auto-invalidating on MediatR commands -Add `[InvalidatesCache]` to any command to automatically clear the specified cache types after the -command handler completes successfully: +## Quick start ```csharp -[InvalidatesCache(typeof(Order), typeof(OrderLine))] -public record DeleteOrderCommand(int OrderId) : IRequest; -``` - -Cache is only cleared if the handler completes without throwing — a failed command leaves the cache untouched. - -## Redis cache - -Install the dedicated **[CleverCache.Redis](https://www.nuget.org/packages/clevercache.redis)** package to add Redis support without bringing the StackExchange.Redis dependency into your main project. - -``` -Install-Package CleverCache.Redis -``` - -```csharp -// Simple connection string -builder.Services.AddCleverCache(o => o.UseRedisCache("localhost:6379")); - -// Full Redis options -builder.Services.AddCleverCache(o => o.UseRedisCache(redis => -{ - redis.Configuration = "localhost:6379"; - redis.InstanceName = "MyApp:"; -})); -``` - -## Custom cache store - -Implement `ICleverCacheStore` to plug in any backing store: +// 1. Register services +builder.Services.AddCleverCache(); -```csharp -public interface ICleverCacheStore +// 2. Add the interceptor to your DbContext +public class AppDbContext(IInterceptor cleverCacheInterceptor) : DbContext { - bool TryGet(object key, out TItem? value); - Task<(bool Hit, TItem? Value)> TryGetAsync(object key, CancellationToken cancellationToken = default); - void Set(object key, TItem value, CleverCacheEntryOptions? options = null); - Task SetAsync(object key, TItem value, CleverCacheEntryOptions? options = null, CancellationToken cancellationToken = default); - void Remove(object key); - Task RemoveAsync(object key, CancellationToken cancellationToken = default); + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.AddInterceptors(cleverCacheInterceptor); } -``` -Example — a simple in-memory dictionary store: +// 3. Register the middleware +app.UseCleverCache(); -```csharp -public class DictionaryCacheStore : ICleverCacheStore +// 4. Use it +public class OrderService(ICleverCache cache, AppDbContext db) { - private readonly Dictionary _store = new(); - - private static string Key(object key) => $"{typeof(TItem).FullName}:{key}"; - - public bool TryGet(object key, out TItem? value) - { - if (_store.TryGetValue(Key(key), out var hit)) - { - value = (TItem?)hit; - return true; - } - value = default; - return false; - } - - public Task<(bool Hit, TItem? Value)> TryGetAsync(object key, CancellationToken cancellationToken = default) - { - var found = TryGet(key, out var value); - return Task.FromResult((found, value)); - } - - public void Set(object key, TItem value, CleverCacheEntryOptions? options = null) - => _store[Key(key)] = value; - - public Task SetAsync(object key, TItem value, CleverCacheEntryOptions? options = null, CancellationToken cancellationToken = default) - { - Set(key, value, options); - return Task.CompletedTask; - } - - public void Remove(object key) => _store.Remove(Key(key)); - - public Task RemoveAsync(object key, CancellationToken cancellationToken = default) - { - Remove(key); - return Task.CompletedTask; - } + public async Task> GetAllAsync() + => await cache.GetOrCreateAsync>( + "orders-all", + async () => await db.Orders.ToListAsync() + ) ?? []; } ``` -Register it with: - -```csharp -builder.Services.AddCleverCache(o => o.UseCustomStore()); -// or via a factory: -builder.Services.AddCleverCache(o => o.UseCustomStore(sp => new DictionaryCacheStore())); -``` - -## Bulk operations and non-EF writes - -EF Core's `ExecuteDelete` and `ExecuteUpdate` (and any other writes that bypass the change tracker — -stored procedures, raw SQL, external services) do **not** trigger the `SaveChangesInterceptor`, so -CleverCache won't automatically invalidate affected entries. Two workarounds are available: - -### Option 1 — Fluent `.InvalidateCaches()` (any project) - -Chain `.InvalidateCaches()` after any operation that returns `int` (rows affected): - -```csharp -// Sync -context.Orders.Where(o => o.IsDeleted) - .ExecuteDelete() - .InvalidateCaches(cache, typeof(Order)); - -// Async -await context.Orders.Where(o => o.IsDeleted) - .ExecuteDeleteAsync() - .InvalidateCaches(cache, typeof(Order)); - -// Generic shorthand — no typeof needed -await context.Orders.Where(o => o.IsDeleted) - .ExecuteDeleteAsync() - .InvalidateCaches(cache); - -// Works after ExecuteUpdate too -await context.Orders.Where(o => o.Status == "pending") - .ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, "complete")) - .InvalidateCaches(cache); -``` - -The call passes through the row count so existing code that uses the return value still compiles. - -### Option 2 — `[InvalidatesCache]` on MediatR commands (CleverCache.MediatR) - -If you're using CQRS with MediatR, decorate the command instead — no manual cache calls needed: - -```csharp -[InvalidatesCache(typeof(Order), typeof(OrderLine))] -public record DeleteOrderCommand(int OrderId) : IRequest; -``` - -See the [MediatR section ↑](#auto-caching-mediatr-queries) for setup. - -## Unit testing -Unit testing methods that use cache is generally fiddly. To help with this, **CleverCache** ships with a -`FakeCache` implementation. It never caches and always calls your underlying factory, so the cache is -completely transparent in your tests. - -```csharp -var mocker = new AutoMocker(); -mocker.Use(new FakeCache()); -var sut = mocker.CreateInstance(); - -// Run unit tests as normal -var result = sut.GetDoorCount(); -``` -Now you can unit test the `GetDoorCount` method without the cache getting in the way. +When any `Order` is saved via EF Core, the `"orders-all"` entry is automatically evicted. -If you need to **verify cache interactions** (e.g. assert the factory was called exactly once, or that -a specific key was used), use `Mock` directly instead — `ICleverCache` is a plain interface -and works with any mocking library. +## 📖 Full documentation -> **Note:** If you are *only* using the MediatR automatic caching (`[AutoCache]`) and never injecting -> `ICleverCache` into your own services, you don't need `FakeCache` at all. +| Topic | | +|---|---| +| [Getting Started](https://github.com/chunty/CleverCache/wiki/Getting-Started) | Setup, interceptor registration, EF Core requirement | +| [Caching Data](https://github.com/chunty/CleverCache/wiki/Caching-Data) | `GetOrCreate`, multi-type, entry options | +| [Cache Providers](https://github.com/chunty/CleverCache/wiki/Cache-Providers) | Memory, distributed, Redis, custom | +| [Dependent Caches](https://github.com/chunty/CleverCache/wiki/Dependent-Caches) | `AddKeyToType`, `AddDependentCache`, `[DependentCaches]` attribute | +| [MediatR Integration](https://github.com/chunty/CleverCache/wiki/MediatR-Integration) | `[AutoCache]`, `[InvalidatesCache]`, pipeline setup | +| [Bulk Operations](https://github.com/chunty/CleverCache/wiki/Bulk-Operations) | `ExecuteDelete`/`ExecuteUpdate` workarounds | +| [Unit Testing](https://github.com/chunty/CleverCache/wiki/Unit-Testing) | `FakeCache`, mocking `ICleverCache` | diff --git a/wiki/Bulk-Operations.md b/wiki/Bulk-Operations.md new file mode 100644 index 0000000..50f1fd0 --- /dev/null +++ b/wiki/Bulk-Operations.md @@ -0,0 +1,106 @@ +# Bulk Operations + +EF Core's `ExecuteDelete` and `ExecuteUpdate` — and any other writes that bypass the change tracker (raw SQL, stored procedures, external services) — do **not** trigger CleverCache's `SaveChangesInterceptor`. Cache entries won't be evicted automatically. + +Two workarounds are available. + +--- + +## Option 1 — Fluent `.InvalidateCaches()` extension + +Chain `.InvalidateCaches()` directly after any operation that returns `int` (rows affected). Works for both sync and async: + +```csharp +// Sync +context.Orders.Where(o => o.IsDeleted) + .ExecuteDelete() + .InvalidateCaches(cache, typeof(Order)); + +// Async +await context.Orders.Where(o => o.IsDeleted) + .ExecuteDeleteAsync() + .InvalidateCaches(cache, typeof(Order)); + +// Generic shorthand — no typeof needed +await context.Orders.Where(o => o.IsDeleted) + .ExecuteDeleteAsync() + .InvalidateCaches(cache); + +// Works after ExecuteUpdate too +await context.Orders.Where(o => o.Status == "pending") + .ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, "complete")) + .InvalidateCaches(cache); +``` + +The call passes the row count through as its return value, so existing code that checks how many rows were affected still compiles. + +### Multiple types + +```csharp +await context.Orders.Where(o => o.IsDeleted) + .ExecuteDeleteAsync() + .InvalidateCaches(cache, typeof(Order), typeof(OrderLine)); +``` + +--- + +## Option 2 — `[InvalidatesCache]` on MediatR commands + +If you're using CQRS with MediatR, decorate the command instead — no manual cache calls anywhere in the codebase: + +```csharp +[InvalidatesCache(typeof(Order), typeof(OrderLine))] +public record BulkDeleteOrdersCommand(int[] OrderIds) : IRequest; +``` + +Cache is cleared after the handler completes successfully. A failed handler leaves the cache untouched. + +See [MediatR Integration](MediatR-Integration) for setup. + +--- + +## Rolling your own invalidation + +If none of the above fit your situation — a message bus consumer, a background job, a third-party SDK that writes directly to the database — you can always call `RemoveByType` directly: + +```csharp +public class OrderSyncService(ICleverCache cache) +{ + public async Task SyncFromExternalSystem(IEnumerable orders) + { + // ... write logic ... + + // Manually evict after writes complete + cache.RemoveByType(); + } +} +``` + +`RemoveByType` understands the full dependency tree, so if `Order` is configured to cascade to `OrderLine` and `OrderNote`, a single call handles all three. You can also pass multiple types: + +```csharp +cache.RemoveByType(); +cache.RemoveByType(); +``` + +Or build a helper that mirrors what `[InvalidatesCache]` does — invalidate a set of types and let the cascades do the rest: + +```csharp +private void InvalidateOrderCaches() +{ + cache.RemoveByType(); // cascades to OrderLine, OrderNote automatically +} +``` + + +The same workarounds apply to any write that bypasses the change tracker — stored procedures, raw SQL via `DbConnection`, or writes from an external service: + +```csharp +// After a stored procedure call +await db.Database.ExecuteSqlRawAsync("EXEC sp_ArchiveOrders"); +cache.RemoveByType(); + +// Or using the extension on the returned task +await db.Database.ExecuteSqlRawAsync("EXEC sp_ArchiveOrders") + .InvalidateCaches(cache); +``` diff --git a/wiki/Cache-Providers.md b/wiki/Cache-Providers.md new file mode 100644 index 0000000..10de4e5 --- /dev/null +++ b/wiki/Cache-Providers.md @@ -0,0 +1,122 @@ +# Cache Providers + +CleverCache supports four cache backend options. + +## Memory cache (default) + +Uses `IMemoryCache`. No extra configuration needed: + +```csharp +builder.Services.AddCleverCache(); +``` + +Supports sliding expiration in addition to absolute expiration. Suitable for single-server deployments. + +## Distributed cache + +Uses any registered `IDistributedCache` backend (Redis, SQL Server, etc.): + +```csharp +// Register your IDistributedCache backend first +builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:6379"); +// or: builder.Services.AddDistributedMemoryCache(); + +// Then tell CleverCache to use it +builder.Services.AddCleverCache(o => o.UseDistributedCache()); +``` + +> **Note:** Distributed cache does not support sliding expiration. + +## Redis (dedicated package) + +Install `CleverCache.Redis` for a direct Redis connection independent of `IDistributedCache`: + +``` +Install-Package CleverCache.Redis +``` + +> **When to use this instead of the distributed cache option:** +> - You are not using `IDistributedCache` anywhere else in your app and don't want to pull it in just for CleverCache +> - You want CleverCache to use a *different* Redis instance than the one registered as your `IDistributedCache` +> +> If you already have `IDistributedCache` pointing at the right Redis instance, `UseDistributedCache()` is simpler. + +```csharp +// Simple connection string +builder.Services.AddCleverCache(o => o.UseRedisCache("localhost:6379")); + +// Full options +builder.Services.AddCleverCache(o => o.UseRedisCache(redis => +{ + redis.Configuration = "localhost:6379"; + redis.InstanceName = "MyApp:"; +})); +``` + +## Custom provider + +Implement `ICleverCacheStore` to plug in any backing store: + +```csharp +public interface ICleverCacheStore +{ + bool TryGet(object key, out TItem? value); + Task<(bool Hit, TItem? Value)> TryGetAsync(object key, CancellationToken cancellationToken = default); + void Set(object key, TItem value, CleverCacheEntryOptions? options = null); + Task SetAsync(object key, TItem value, CleverCacheEntryOptions? options = null, CancellationToken cancellationToken = default); + void Remove(object key); + Task RemoveAsync(object key, CancellationToken cancellationToken = default); +} +``` + +Register it with: + +```csharp +builder.Services.AddCleverCache(o => o.UseCustomStore()); + +// Or via a factory for stores with dependencies: +builder.Services.AddCleverCache(o => o.UseCustomStore(sp => new MyStore(sp.GetRequiredService()))); +``` + +### Example — dictionary-backed store + +```csharp +public class DictionaryCacheStore : ICleverCacheStore +{ + private readonly Dictionary _store = new(); + + public bool TryGet(object key, out TItem? value) + { + if (_store.TryGetValue(key.ToString()!, out var hit)) + { + value = (TItem?)hit; + return true; + } + value = default; + return false; + } + + public Task<(bool Hit, TItem? Value)> TryGetAsync(object key, CancellationToken cancellationToken = default) + { + var found = TryGet(key, out var value); + return Task.FromResult((found, value)); + } + + public void Set(object key, TItem value, CleverCacheEntryOptions? options = null) + => _store[key.ToString()!] = value; + + public Task SetAsync(object key, TItem value, CleverCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + { + Set(key, value, options); + return Task.CompletedTask; + } + + public void Remove(object key) => _store.Remove(key.ToString()!); + + public Task RemoveAsync(object key, CancellationToken cancellationToken = default) + { + Remove(key); + return Task.CompletedTask; + } +} +``` diff --git a/wiki/Caching-Data.md b/wiki/Caching-Data.md new file mode 100644 index 0000000..66e962d --- /dev/null +++ b/wiki/Caching-Data.md @@ -0,0 +1,72 @@ +# Caching Data + +Inject `ICleverCache` into your service and use `GetOrCreate` or `GetOrCreateAsync` — same pattern as `IMemoryCache`, with an extra type parameter that tells CleverCache which entity type owns this cache entry. + +## Basic usage + +```csharp +public class OrderService(ICleverCache cache, AppDbContext db) +{ + public async Task> GetAllAsync() + => await cache.GetOrCreateAsync>( + "orders-all", + async () => await db.Orders.ToListAsync() + ) ?? []; +} +``` + +When any `Order` is saved (created, updated, or deleted) via EF Core, the `"orders-all"` entry is automatically evicted. The same applies if a type that `Order` is registered as a dependent of is saved — for example, if `Customer` is configured to cascade to `Order`, saving a `Customer` will also evict `"orders-all"`. + +## Multiple types + +If a cache entry contains data from more than one entity type, pass all relevant types — the entry will be evicted when *any* of them changes: + +```csharp +var summary = await cache.GetOrCreateAsync>( + new[] { typeof(Order), typeof(Customer) }, + "order-summary", + async () => await BuildSummaryAsync() +); +``` + +## Entry options + +All overloads accept an optional `CleverCacheEntryOptions`: + +```csharp +var options = new CleverCacheEntryOptions +{ + // Expire 10 minutes after creation + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10), + + // Or expire at a fixed point in time + AbsoluteExpiration = DateTimeOffset.UtcNow.AddHours(1), + + // Extend lifetime on each read (memory cache only) + SlidingExpiration = TimeSpan.FromMinutes(5), +}; + +var result = await cache.GetOrCreateAsync>( + "orders-all", + async () => await db.Orders.ToListAsync(), + options +); +``` + +> **Sliding expiration** is only honoured by the memory cache provider. Distributed and Redis providers ignore it. + +## Manual removal + +Remove a specific key: + +```csharp +cache.Remove("orders-all"); +``` + +Remove all entries for a type (also cascades to dependent types — see [Dependent Caches](Dependent-Caches)): + +```csharp +cache.RemoveByType(); +// or +cache.RemoveByType(typeof(Order)); +``` diff --git a/wiki/Dependent-Caches.md b/wiki/Dependent-Caches.md new file mode 100644 index 0000000..c56f895 --- /dev/null +++ b/wiki/Dependent-Caches.md @@ -0,0 +1,126 @@ +# Dependent Caches + +CleverCache tracks which entity types a cache entry is associated with and evicts it automatically when those types change. This page covers four scenarios, from the simplest to the most automatic. + +--- + +## Scenario 1 — A cache entry spans multiple entity types + +If a single cache entry contains data from more than one entity type, pass all relevant types when creating it. The entry is evicted when *any* of them changes: + +```csharp +var summary = await cache.GetOrCreateAsync>( + new[] { typeof(Order), typeof(Customer) }, + "order-summary", + async () => await BuildSummaryAsync() +); +``` + +For keys that weren't created through `GetOrCreate` — for example after a bulk operation — register them after the fact: + +```csharp +cache.AddKeyToType("orders-all"); +cache.AddKeyToTypes(new[] { typeof(Order), typeof(Customer) }, "order-summary"); +``` + +--- + +## Scenario 2 — Invalidating one type should always cascade to another + +If you always want invalidating `Order` to also invalidate `OrderLine` caches — regardless of how or where that invalidation is triggered — register a cascade rule. + +**Via the `[DependentCaches]` attribute** (recommended — configured once at startup): + +```csharp +[DependentCaches([typeof(OrderLine), typeof(OrderNote)])] +public class Order { } +``` + +Register the assembly containing your entities when configuring CleverCache — no EF Core required: + +```csharp +builder.Services.AddCleverCache(o => o.ScanAssemblyContaining()); +``` + +CleverCache scans the assembly at startup and registers the cascade rules. Now `cache.RemoveByType()` also clears all `OrderLine` and `OrderNote` entries automatically. + +If you are already using `UseCleverCache()` for navigation scanning, attribute-based cascades on those same entities are also picked up there — so you don't need `ScanAssemblyContaining` as well. + +**Programmatically** (for dynamic or test scenarios): + +```csharp +cache.AddDependentCache(typeof(OrderLine)); +``` + +### Cascades are transitive + +`A → B → C` means removing `A` also removes `B` and `C`. Cycles are handled safely. + +### Reverse mappings + +Use `reverse: true` to also register the inverse — so invalidating `OrderLine` cascades back to `Order`: + +```csharp +[DependentCaches([typeof(OrderLine)], reverse: true)] +public class Order { } +``` + +--- + +## Scenario 3 — Auto-discover cascades for a specific entity + +Instead of listing dependent types by hand, CleverCache can read the EF Core navigation properties on a specific entity and register cascades for you: + +```csharp +[DependentCaches([], navigationScanMode: DependentCacheNavigationScanMode.Direct)] +public class Order +{ + public Customer Customer { get; set; } // → cascade added + public ICollection Lines { get; set; } // → cascade added +} +``` + +| Mode | Behaviour | +|---|---| +| `None` (default) | No navigation scanning — use explicit `types` list only | +| `Direct` | Scans the immediate navigation properties of this entity | +| `Recursive` | Scans the full navigation graph transitively from this entity | + +This is useful when you want opt-in, per-entity control — only the entities you decorate are scanned. + +--- + +## Scenario 4 — Auto-wire the whole context with no attributes + +If you want every entity in your context wired up automatically without decorating any classes, enable global navigation scanning in `UseCleverCache`: + +```csharp +app.UseCleverCache(o => + o.Scanning.NavigationScanMode = DependentCacheNavigationScanMode.Direct); +``` + +CleverCache scans the navigation properties on every `DbSet` type in `AppDbContext` and registers the cascades at startup. No `[DependentCaches]` attributes needed anywhere. + +```csharp +// Also register reverse cascades (when OrderLine changes, also clear Order) +app.UseCleverCache(o => +{ + o.Scanning.NavigationScanMode = DependentCacheNavigationScanMode.Recursive; + o.Scanning.ReverseNavigationDependencies = true; +}); +``` + +> **⚠️ Consider carefully before using this in large projects.** Global navigation scanning wires up every entity in your context. In a large schema this can create a very wide dependency tree — a change to a central entity like `Customer` or `User` may cascade to dozens of cache keys, leading to excessive invalidation and high memory usage from tracking all those key associations. For most projects, Scenario 2 (`[DependentCaches]` attributes with `ScanAssemblyContaining`) gives you the same convenience with precise, opt-in control over which relationships matter. + +--- + +## How Scenario 3 and 4 interact + +| Global mode | Attribute behaviour | +|---|---| +| `None` (default) | Attributes are processed normally — Scenarios 2 & 3 work as described | +| `Direct` | Global scanning runs first; attributes are **also** processed for any additional explicit types or `reverse` flags | +| `Recursive` | Attribute processing is **skipped entirely** — the full graph is already discovered globally, making per-entity attributes redundant | + +> If you use global `Recursive` scanning, `[DependentCaches]` attributes on your entities are ignored. Choose either global scanning or per-entity attributes — don't mix `Recursive` with attribute-based configuration. + diff --git a/wiki/Getting-Started.md b/wiki/Getting-Started.md new file mode 100644 index 0000000..e00668e --- /dev/null +++ b/wiki/Getting-Started.md @@ -0,0 +1,84 @@ +# Getting Started + +## Install + +``` +Install-Package CleverCache +``` + +Or via the .NET CLI: + +``` +dotnet add package CleverCache +``` + +## Usage paths + +CleverCache can be used with or without EF Core: + +| Scenario | What you need | +|---|---| +| Manual or attribute-driven invalidation | `AddCleverCache` only | +| Automatic invalidation on `SaveChanges` | `AddCleverCache` + EF Core interceptor | +| Automatic cascade discovery from navigation properties | `AddCleverCache` + interceptor + `UseCleverCache` | + +> **Without the interceptor** you only get dependency tree management — you can define cascade rules with `[DependentCaches]` or `AddDependentCache`, and call `RemoveByType()` yourself to invalidate. Cache entries are never evicted automatically; you are responsible for triggering invalidation. The interceptor is what makes CleverCache hands-off. + +## 1. Register services + +```csharp +builder.Services.AddCleverCache(); +``` + +If you are using `[DependentCaches]` attributes, pass the assemblies to scan during registration. CleverCache will discover and wire up the cascade rules at startup — no EF Core required: + +```csharp +builder.Services.AddCleverCache(o => o.ScanAssemblyContaining()); +``` + +See [Cache Providers](Cache-Providers) for memory, distributed, Redis, and custom provider options. + +## 2. Register the EF Core interceptor (optional) + +The interceptor is what makes CleverCache automatic — cache entries for changed entity types are evicted the moment `SaveChanges` or `SaveChangesAsync` completes. Without it, you can still use CleverCache by calling `RemoveByType()` yourself. + +> **Important:** Automatic invalidation only fires for writes that go through EF Core's change tracker. Writes that bypass it — `ExecuteUpdate`, `ExecuteDelete`, raw SQL, stored procedures, or external services — will **not** trigger invalidation. See [Bulk Operations](Bulk-Operations) for workarounds. + +Inject the interceptor into your `DbContext`: + +```csharp +// Option A — single interceptor +public class AppDbContext(IInterceptor cleverCacheInterceptor) : DbContext +{ + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.AddInterceptors(cleverCacheInterceptor); +} + +// Option B — alongside existing interceptors +public class AppDbContext(IInterceptor[] interceptors) : DbContext +{ + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.AddInterceptors(interceptors); +} + +// Option C — concrete type +public class AppDbContext(CleverCacheInterceptor cleverCacheInterceptor) : DbContext +{ + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.AddInterceptors(cleverCacheInterceptor); +} +``` + +## 3. Register the EF Core middleware (optional) + +Only needed if you want CleverCache to scan `DbSet` navigation properties for automatic cascade discovery: + +```csharp +app.UseCleverCache(); +``` + +This scans the `DbSet` types registered on `AppDbContext` using EF Core metadata to build the invalidation dependency tree. See [Dependent Caches](Dependent-Caches). + +> **Prefer `[DependentCaches]` attributes for most projects.** Global DbSet scanning is convenient but wires up every entity in your context — in large schemas this can cause excessive invalidation and high memory usage from tracking too many key associations. `ScanAssemblyContaining` (step 1) gives you the same automatic wiring with opt-in, per-entity control. + +> **Not using EF Core navigation scanning?** If you rely only on `[DependentCaches]` attributes, register those via `ScanAssemblyContaining` in step 1 and skip `UseCleverCache` entirely. diff --git a/wiki/Home.md b/wiki/Home.md new file mode 100644 index 0000000..ae29c12 --- /dev/null +++ b/wiki/Home.md @@ -0,0 +1,27 @@ +# CleverCache + +**CleverCache** solves the problem of remembering when to invalidate cache entries when the underlying data changes — especially when a single cache entry contains data from multiple entity types. + +With a small amount of configuration, CleverCache automatically tracks changes via EF Core and clears related cache entries when any tracked entity is created, updated, or deleted. + +## Documentation + +| Topic | Description | +|---|---| +| [Getting Started](Getting-Started) | Install, configure EF Core interceptor, register services | +| [Caching Data](Caching-Data) | `GetOrCreate`, multi-type associations, entry options | +| [Cache Providers](Cache-Providers) | Memory, distributed, Redis, custom providers | +| [Dependent Caches](Dependent-Caches) | `AddKeyToType`, `AddDependentCache`, `[DependentCaches]` attribute | +| [MediatR Integration](MediatR-Integration) | `[AutoCache]`, `[InvalidatesCache]`, pipeline setup | +| [Bulk Operations](Bulk-Operations) | Handling `ExecuteDelete`/`ExecuteUpdate` and non-EF writes | +| [Unit Testing](Unit-Testing) | `FakeCache`, mocking `ICleverCache` | + +## Packages + +| Package | NuGet | Purpose | +|---|---|---| +| `CleverCache` | [![NuGet](https://img.shields.io/nuget/vpre/clevercache.svg)](https://www.nuget.org/packages/clevercache) | Core package | +| `CleverCache.MediatR` | [![NuGet](https://img.shields.io/nuget/vpre/clevercache.mediatr.svg)](https://www.nuget.org/packages/clevercache.mediatr) | MediatR pipeline behaviours | +| `CleverCache.Redis` | [![NuGet](https://img.shields.io/nuget/vpre/clevercache.redis.svg)](https://www.nuget.org/packages/clevercache.redis) | Redis cache provider | + +> **Requires EF Core.** Automatic invalidation hooks into `SaveChanges`/`SaveChangesAsync` via a `SaveChangesInterceptor`. Without EF Core you can still use CleverCache for manual invalidation — see [Bulk Operations](Bulk-Operations). diff --git a/wiki/MediatR-Integration.md b/wiki/MediatR-Integration.md new file mode 100644 index 0000000..918244c --- /dev/null +++ b/wiki/MediatR-Integration.md @@ -0,0 +1,98 @@ +# MediatR Integration + +Install the separate `CleverCache.MediatR` package to keep your main project free of the MediatR dependency: + +``` +Install-Package CleverCache.MediatR +``` + +## Setup + +Register the CleverCache pipeline behaviours inside your `AddMediatR` call: + +```csharp +services.AddMediatR(cfg => +{ + cfg.AddCleverCache(); +}); +``` + +This registers two pipeline behaviours automatically: +- **`InvalidateCacheBehaviour`** — clears cache after a command succeeds (see below) +- **`AutoCacheBehaviour`** — caches query results (see below) + +--- + +## Auto-caching queries with `[AutoCache]` + +Add `[AutoCache]` to any MediatR query to cache its result. The MediatR request object itself is used as the cache key, so the same query with different parameters gets its own cache entry. + +```csharp +[AutoCache([typeof(Order)])] +public record GetOrdersQuery(int CustomerId) : IRequest>; +``` + +No changes to the handler — caching is handled entirely by the pipeline behaviour. + +When any `Order` is saved via EF Core, all `GetOrdersQuery` cache entries are automatically evicted. + +### Multiple types + +```csharp +[AutoCache([typeof(Order), typeof(Customer)])] +public record GetOrderSummaryQuery(int OrderId) : IRequest; +``` + +The cache entry is evicted when *either* type changes. + +### Entry options + +```csharp +[AutoCache([typeof(Order)], AbsoluteExpirationSeconds = 300)] +public record GetOrdersQuery(int CustomerId) : IRequest>; +``` + +### Respects registered dependencies + +`[AutoCache]` and `[InvalidatesCache]` both work through the same dependency tree as the rest of CleverCache. If you have `[DependentCaches]` configured (or cascades registered via `ScanAssemblyContaining`), you only need to reference the root type — dependents are handled automatically. + +For example, if `Order` is configured to cascade to `OrderLine` and `OrderNote`, this is sufficient: + +```csharp +[AutoCache([typeof(Order)])] +public record GetOrdersQuery(int CustomerId) : IRequest>; + +[InvalidatesCache(typeof(Order))] +public record DeleteOrderCommand(int OrderId) : IRequest; +``` + +You do not need to list `OrderLine` or `OrderNote` — invalidating `Order` cascades to them automatically. + +--- + +Add `[InvalidatesCache]` to any command to automatically clear the specified cache types after the handler completes successfully: + +```csharp +[InvalidatesCache(typeof(Order), typeof(OrderLine))] +public record DeleteOrderCommand(int OrderId) : IRequest; +``` + +- Cache is only cleared if the handler **completes without throwing** — a failed command leaves the cache untouched. +- Works with any return type (`IRequest`, `IRequest`, etc.). +- Types are cleared in the order declared; each call to `RemoveByType` also cascades to dependent types (see [Dependent Caches](Dependent-Caches)). + +### Combined with `[AutoCache]` + +A command decorated with `[InvalidatesCache]` pairs naturally with queries decorated with `[AutoCache]`: + +```csharp +// Query — cache results +[AutoCache([typeof(Order)])] +public record GetOrdersQuery(int CustomerId) : IRequest>; + +// Command — clear cache after success +[InvalidatesCache(typeof(Order))] +public record CreateOrderCommand(int CustomerId, ...) : IRequest; +``` + +When `CreateOrderCommand` succeeds, all `GetOrdersQuery` entries for all customers are evicted — no manual cache calls needed anywhere. diff --git a/wiki/Unit-Testing.md b/wiki/Unit-Testing.md new file mode 100644 index 0000000..3fc0e60 --- /dev/null +++ b/wiki/Unit-Testing.md @@ -0,0 +1,46 @@ +# Unit Testing + +## Making cache transparent with `FakeCache` + +CleverCache ships with a `FakeCache` implementation that never caches — it always calls through to the underlying factory. This makes your service logic fully testable without the cache getting in the way. + +```csharp +using CleverCache; + +var mocker = new AutoMocker(); +mocker.Use(new FakeCache()); +var sut = mocker.CreateInstance(); + +// Tests run as normal — the factory is always called, cache is invisible +var result = await sut.GetAllOrdersAsync(); +``` + +`FakeCache` is in the root `CleverCache` namespace — no additional `using` directives needed beyond what you'd normally import. + +## Verifying cache interactions with `Mock` + +If you need to assert *how* the cache was used — for example, that the factory was called exactly once, or that a specific key was evicted — use a mock instead: + +```csharp +var cacheMock = new Mock(); +cacheMock + .Setup(c => c.GetOrCreateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>>>(), + null)) + .ReturnsAsync(new List()); + +var sut = new OrderService(cacheMock.Object, db); +await sut.GetAllOrdersAsync(); + +cacheMock.Verify(c => c.GetOrCreateAsync(...), Times.Once); +``` + +`ICleverCache` is a plain interface and works with any mocking library (Moq, NSubstitute, FakeItEasy, etc.). + +## MediatR and `[AutoCache]` + +If you are *only* using MediatR automatic caching via `[AutoCache]` and never injecting `ICleverCache` directly into your services, you don't need `FakeCache` at all — the `AutoCacheBehaviour` pipeline behaviour is never part of your unit test boundary. + +For integration tests that exercise the full MediatR pipeline, register `FakeCache` or an in-memory store in your test service collection. From 2f108bf492d77a3d09e1f84dfd98e02aa85b3c04 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 12:01:20 +0100 Subject: [PATCH 11/50] feat: split EF Core concerns into CleverCache.EntityFrameworkCore package - New CleverCache.EntityFrameworkCore project with interceptor, AddCleverCacheEntityFramework(), ScanDbSetsForCacheDependencies(), navigation scanning helpers, and DbContext/DbOptionsBuilder extensions - Core package now has zero EF Core or AspNetCore.Http dependencies - CleverCacheOptions.Scanning / DisableAllScanning removed (breaking) - UseCleverCache replaced by ScanDbSetsForCacheDependencies (breaking) - DependentCachesAttribute XML doc updated for new API - wiki/Getting-Started.md updated throughout for new package split Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Attributes/DependentCachesAttribute.cs | 5 ++- .../CleverCache.EntityFrameworkCore.csproj | 18 ++++++++ .../ApplicationBuilderExtensions.cs | 35 +++++++++++++++ .../ServiceCollectionExtensions.cs | 18 ++++++++ .../Exceptions/MissingInterceptorException.cs | 4 ++ .../Extensions}/DbContextExtensions.cs | 43 ++++++------------- .../Extensions}/DbOptionsBuilderExtensions.cs | 9 ++-- .../GlobalUsings.cs | 10 +++++ .../Helpers/NavigationScanningHelper.cs | 26 +++++++++++ .../Interceptors}/CleverCacheInterceptor.cs | 31 ++----------- .../Models}/CleverCacheScanOptions.cs | 4 +- .../NuGetReadMe.md | 19 ++++++++ CleverCache.csproj | 4 +- CleverCache.sln | 14 ++++++ .../ApplicationBuilderExtensions.cs | 30 ------------- .../ServiceCollectionExtensions.cs | 7 --- Directory.Packages.props | 2 + Exceptions/MissingInterceptorException.cs | 4 -- GlobalUsings.cs | 2 - Helpers/NavigationScanningHelper.cs | 35 --------------- Models/CleverCacheOptions.cs | 7 +-- wiki/Getting-Started.md | 33 +++++++++++--- 22 files changed, 201 insertions(+), 159 deletions(-) create mode 100644 CleverCache.EntityFrameworkCore/CleverCache.EntityFrameworkCore.csproj create mode 100644 CleverCache.EntityFrameworkCore/DependencyInjection/ApplicationBuilderExtensions.cs create mode 100644 CleverCache.EntityFrameworkCore/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 CleverCache.EntityFrameworkCore/Exceptions/MissingInterceptorException.cs rename {Extensions => CleverCache.EntityFrameworkCore/Extensions}/DbContextExtensions.cs (69%) rename {Extensions => CleverCache.EntityFrameworkCore/Extensions}/DbOptionsBuilderExtensions.cs (55%) create mode 100644 CleverCache.EntityFrameworkCore/GlobalUsings.cs create mode 100644 CleverCache.EntityFrameworkCore/Helpers/NavigationScanningHelper.cs rename {Interceptors => CleverCache.EntityFrameworkCore/Interceptors}/CleverCacheInterceptor.cs (77%) rename {Models => CleverCache.EntityFrameworkCore/Models}/CleverCacheScanOptions.cs (86%) create mode 100644 CleverCache.EntityFrameworkCore/NuGetReadMe.md delete mode 100644 DependencyInjection/ApplicationBuilderExtensions.cs delete mode 100644 Exceptions/MissingInterceptorException.cs delete mode 100644 Helpers/NavigationScanningHelper.cs diff --git a/Attributes/DependentCachesAttribute.cs b/Attributes/DependentCachesAttribute.cs index 211691c..fe10ecb 100644 --- a/Attributes/DependentCachesAttribute.cs +++ b/Attributes/DependentCachesAttribute.cs @@ -2,8 +2,9 @@ /// /// Declares cache dependency relationships for an entity type. -/// Applied at startup by app.UseCleverCache<TContext>(), which registers the -/// declared dependencies so that invalidating this type also invalidates the dependent types. +/// Use builder.Services.AddCleverCache(o => o.ScanAssemblyContaining<T>()) to register +/// these at startup, or pick them up automatically via app.ScanDbSetsForCacheDependencies<TContext>() +/// if you are using EF Core navigation scanning. /// /// /// diff --git a/CleverCache.EntityFrameworkCore/CleverCache.EntityFrameworkCore.csproj b/CleverCache.EntityFrameworkCore/CleverCache.EntityFrameworkCore.csproj new file mode 100644 index 0000000..d3ba03f --- /dev/null +++ b/CleverCache.EntityFrameworkCore/CleverCache.EntityFrameworkCore.csproj @@ -0,0 +1,18 @@ + + + CleverCache.EntityFrameworkCore + 2.0.0 + https://github.com/chunty/CleverCache/wiki/Getting-Started + EF Core integration for CleverCache — automatic cache invalidation via SaveChangesInterceptor and DbSet navigation scanning. + MemoryCache,Cache Invalidation,EntityFrameworkCore,EF Core + NuGetReadMe.md + + + + + + + + + + diff --git a/CleverCache.EntityFrameworkCore/DependencyInjection/ApplicationBuilderExtensions.cs b/CleverCache.EntityFrameworkCore/DependencyInjection/ApplicationBuilderExtensions.cs new file mode 100644 index 0000000..ab55994 --- /dev/null +++ b/CleverCache.EntityFrameworkCore/DependencyInjection/ApplicationBuilderExtensions.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Builder; + +namespace CleverCache.EntityFrameworkCore.DependencyInjection; + +public static class ApplicationBuilderExtensions +{ + /// + /// Scans the navigation properties for + /// and registers the discovered cache dependency rules at startup. + /// Can be called multiple times for multiple types, each with their own scan options. + /// + /// + /// + /// app.ScanDbSetsForCacheDependencies<AppDbContext>(o => + /// o.NavigationScanMode = DependentCacheNavigationScanMode.Direct); + /// + /// + public static IApplicationBuilder ScanDbSetsForCacheDependencies( + this IApplicationBuilder app, + Action? configure = null) + where TContext : DbContext + { + var cache = app.ApplicationServices.GetRequiredService(); + var scanOptions = new CleverCacheScanOptions(); + configure?.Invoke(scanOptions); + + using var scope = app.ApplicationServices.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + foreach (var dep in dbContext.DiscoverDependentCaches(scanOptions)) + cache.AddDependentCache(dep.Type, dep.DependentType); + + return app; + } +} diff --git a/CleverCache.EntityFrameworkCore/DependencyInjection/ServiceCollectionExtensions.cs b/CleverCache.EntityFrameworkCore/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..6794b6f --- /dev/null +++ b/CleverCache.EntityFrameworkCore/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace CleverCache.EntityFrameworkCore.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + /// + /// Registers so it can be injected into your . + /// Call this alongside AddCleverCache() when using EF Core. + /// + public static IServiceCollection AddCleverCacheEntityFramework(this IServiceCollection services) + { + services.TryAddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/CleverCache.EntityFrameworkCore/Exceptions/MissingInterceptorException.cs b/CleverCache.EntityFrameworkCore/Exceptions/MissingInterceptorException.cs new file mode 100644 index 0000000..e2047e5 --- /dev/null +++ b/CleverCache.EntityFrameworkCore/Exceptions/MissingInterceptorException.cs @@ -0,0 +1,4 @@ +namespace CleverCache.EntityFrameworkCore.Exceptions; + +internal class MissingInterceptorException() : + Exception("CleverCache requires CleverCacheInterceptor to be registered. Call AddCleverCacheEntityFramework() on your IServiceCollection."); diff --git a/Extensions/DbContextExtensions.cs b/CleverCache.EntityFrameworkCore/Extensions/DbContextExtensions.cs similarity index 69% rename from Extensions/DbContextExtensions.cs rename to CleverCache.EntityFrameworkCore/Extensions/DbContextExtensions.cs index 8d1ef9e..6effbf8 100644 --- a/Extensions/DbContextExtensions.cs +++ b/CleverCache.EntityFrameworkCore/Extensions/DbContextExtensions.cs @@ -1,11 +1,11 @@ using System.Reflection; +using CleverCache.Attributes; +using CleverCache.EntityFrameworkCore.Exceptions; +using CleverCache.EntityFrameworkCore.Helpers; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; -using CleverCache.Attributes; -using CleverCache.Exceptions; -using CleverCache.Helpers; -namespace CleverCache.Extensions; +namespace CleverCache.EntityFrameworkCore.Extensions; internal static class DbContextExtensions { @@ -19,22 +19,18 @@ public static void EnsureCleverCacheInterceptor(this DbContext dbContext) if (!isRegistered) throw new MissingInterceptorException(); } - public static List DiscoverDependentCaches(this DbContext dbContext, CleverCacheOptions smartCacheOptions) + public static List DiscoverDependentCaches(this DbContext dbContext, CleverCacheScanOptions scanOptions) { HashSet dependentCaches = []; foreach (var entityType in dbContext.Model.GetEntityTypes()) { - if (smartCacheOptions.Scanning.NavigationScanMode != DependentCacheNavigationScanMode.None) - { - NavigationScanningHelper.Scan(smartCacheOptions.Scanning, entityType, dependentCaches); - } + if (scanOptions.NavigationScanMode != DependentCacheNavigationScanMode.None) + NavigationScanningHelper.Scan(scanOptions, entityType, dependentCaches); - // Its pointless doing attribute based processing if we already did recursive scanning - if (smartCacheOptions.Scanning.NavigationScanMode != DependentCacheNavigationScanMode.Recursive) - { + // Attribute processing is redundant if recursive scanning already covered the full graph + if (scanOptions.NavigationScanMode != DependentCacheNavigationScanMode.Recursive) ProcessAttribute(dbContext, entityType, dependentCaches); - } } return [.. dependentCaches]; @@ -42,34 +38,21 @@ public static List DiscoverDependentCaches(this DbContext dbCont private static void ProcessAttribute(DbContext dbContext, IEntityType entityType, HashSet dependentCaches) { - // Check if this has attribute var type = entityType.ClrType; var attribute = type.GetCustomAttribute(); - if (attribute is null) - { - return; - } + if (attribute is null) return; - // Do the mappings foreach (var dependentType in attribute.DependantTypes) { dependentCaches.Add(new DependentCache(type, dependentType)); if (attribute.Reverse) - { - dependentCaches.Add(new DependentCache(dependentType, type)); ; - } + dependentCaches.Add(new DependentCache(dependentType, type)); if (attribute.NavigationScanMode == DependentCacheNavigationScanMode.None) - { continue; - } var dependentModelType = dbContext.Model.FindEntityType(dependentType); - - if (dependentModelType is null) - { - continue; - } + if (dependentModelType is null) continue; NavigationScanningHelper.Scan( new CleverCacheScanOptions(attribute.NavigationScanMode, attribute.Reverse), @@ -77,4 +60,4 @@ private static void ProcessAttribute(DbContext dbContext, IEntityType entityType dependentCaches); } } -} \ No newline at end of file +} diff --git a/Extensions/DbOptionsBuilderExtensions.cs b/CleverCache.EntityFrameworkCore/Extensions/DbOptionsBuilderExtensions.cs similarity index 55% rename from Extensions/DbOptionsBuilderExtensions.cs rename to CleverCache.EntityFrameworkCore/Extensions/DbOptionsBuilderExtensions.cs index 44088e2..264ca4f 100644 --- a/Extensions/DbOptionsBuilderExtensions.cs +++ b/CleverCache.EntityFrameworkCore/Extensions/DbOptionsBuilderExtensions.cs @@ -1,13 +1,16 @@ -namespace CleverCache.Extensions; +namespace CleverCache.EntityFrameworkCore.Extensions; public static class DbOptionsBuilderExtensions { + /// + /// Adds to the . + /// Use this when you prefer constructor injection over the DI-based interceptor registration. + /// public static DbContextOptionsBuilder AddCleverCache(this DbContextOptionsBuilder optionsBuilder, IServiceProvider serviceProvider) { var interceptor = serviceProvider.GetRequiredService(); - optionsBuilder.AddInterceptors(interceptor); return optionsBuilder; } -} \ No newline at end of file +} diff --git a/CleverCache.EntityFrameworkCore/GlobalUsings.cs b/CleverCache.EntityFrameworkCore/GlobalUsings.cs new file mode 100644 index 0000000..d4ccd10 --- /dev/null +++ b/CleverCache.EntityFrameworkCore/GlobalUsings.cs @@ -0,0 +1,10 @@ +// Global using directives + +global using Microsoft.AspNetCore.Builder; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.Extensions.DependencyInjection; +global using CleverCache; +global using CleverCache.Models; +global using CleverCache.EntityFrameworkCore.Models; +global using CleverCache.EntityFrameworkCore.Interceptors; +global using CleverCache.EntityFrameworkCore.Extensions; diff --git a/CleverCache.EntityFrameworkCore/Helpers/NavigationScanningHelper.cs b/CleverCache.EntityFrameworkCore/Helpers/NavigationScanningHelper.cs new file mode 100644 index 0000000..08aef39 --- /dev/null +++ b/CleverCache.EntityFrameworkCore/Helpers/NavigationScanningHelper.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CleverCache.EntityFrameworkCore.Helpers; + +internal static class NavigationScanningHelper +{ + public static void Scan(CleverCacheScanOptions scanOptions, IEntityType entityType, HashSet dependentCaches) + { + foreach (var navigation in entityType.GetNavigations()) + { + var sourceType = entityType.ClrType; + var dependentEntityType = navigation.TargetEntityType; + var dependentType = dependentEntityType.ClrType; + + if (dependentCaches.Any(x => x.Type == dependentType)) + continue; + + dependentCaches.Add(new DependentCache(sourceType, dependentType)); + if (scanOptions.ReverseNavigationDependencies) + dependentCaches.Add(new DependentCache(dependentType, sourceType)); + + if (scanOptions.NavigationScanMode == DependentCacheNavigationScanMode.Recursive) + Scan(scanOptions, dependentEntityType, dependentCaches); + } + } +} diff --git a/Interceptors/CleverCacheInterceptor.cs b/CleverCache.EntityFrameworkCore/Interceptors/CleverCacheInterceptor.cs similarity index 77% rename from Interceptors/CleverCacheInterceptor.cs rename to CleverCache.EntityFrameworkCore/Interceptors/CleverCacheInterceptor.cs index 3c15c9a..96b2ea0 100644 --- a/Interceptors/CleverCacheInterceptor.cs +++ b/CleverCache.EntityFrameworkCore/Interceptors/CleverCacheInterceptor.cs @@ -1,7 +1,7 @@ using Microsoft.EntityFrameworkCore.Diagnostics; using System.Collections.Concurrent; -namespace CleverCache.Interceptors; +namespace CleverCache.EntityFrameworkCore.Interceptors; /// /// Interceptor to clear smart memory cache after changes are saved to the database. @@ -26,13 +26,6 @@ public override ValueTask> SavingChangesAsync( return base.SavingChangesAsync(eventData, result, cancellationToken); } - - /// - /// Synchronously handles the event after changes are saved to the database. - /// - /// The event data. - /// The result of the save operation. - /// The result of the save operation. public override int SavedChanges(SaveChangesCompletedEventData eventData, int result) { var savedChanges = base.SavedChanges(eventData, result); @@ -40,13 +33,6 @@ public override int SavedChanges(SaveChangesCompletedEventData eventData, int re return savedChanges; } - /// - /// Asynchronously handles the event after changes are saved to the database. - /// - /// The event data. - /// The result of the save operation. - /// The cancellation token. - /// The result of the save operation. public override ValueTask SavedChangesAsync( SaveChangesCompletedEventData eventData, int result, @@ -59,11 +45,8 @@ public override ValueTask SavedChangesAsync( public override void SaveChangesFailed(DbContextErrorEventData eventData) { - // Guard for null Context (defensive; eventData.Context is nullable) if (eventData.Context != null) - { _pendingTypes.TryRemove(eventData.Context.ContextId, out _); - } base.SaveChangesFailed(eventData); } @@ -72,9 +55,7 @@ public override Task SaveChangesFailedAsync( CancellationToken cancellationToken = default) { if (eventData.Context != null) - { _pendingTypes.TryRemove(eventData.Context.ContextId, out _); - } return base.SaveChangesFailedAsync(eventData, cancellationToken); } @@ -83,25 +64,19 @@ private void CaptureTypes(DbContextEventData eventData) if (eventData.Context is null) return; var contextId = eventData.Context.ContextId; - var set = _pendingTypes.GetOrAdd(contextId, _ => []); foreach (var entry in eventData.Context.ChangeTracker.Entries()) { if (entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) - { set.Add(entry.Metadata.ClrType); - } } } - + private void InvalidateAndClear(SaveChangesCompletedEventData eventData) { if (eventData.Context is null) return; - if (!_pendingTypes.TryRemove(eventData.Context.ContextId, out var types) || types is not { Count: > 0 }) return; foreach (var t in types.Distinct()) - { cache.RemoveByType(t); - } } -} \ No newline at end of file +} diff --git a/Models/CleverCacheScanOptions.cs b/CleverCache.EntityFrameworkCore/Models/CleverCacheScanOptions.cs similarity index 86% rename from Models/CleverCacheScanOptions.cs rename to CleverCache.EntityFrameworkCore/Models/CleverCacheScanOptions.cs index 90b4fcf..e3cea0f 100644 --- a/Models/CleverCacheScanOptions.cs +++ b/CleverCache.EntityFrameworkCore/Models/CleverCacheScanOptions.cs @@ -1,4 +1,4 @@ -namespace CleverCache.Models; +namespace CleverCache.EntityFrameworkCore.Models; public class CleverCacheScanOptions( DependentCacheNavigationScanMode navigationScanMode = DependentCacheNavigationScanMode.None, @@ -7,4 +7,4 @@ public class CleverCacheScanOptions( { public DependentCacheNavigationScanMode NavigationScanMode { get; set; } = navigationScanMode; public bool ReverseNavigationDependencies { get; set; } = reverseNavigationDependencies; -} \ No newline at end of file +} diff --git a/CleverCache.EntityFrameworkCore/NuGetReadMe.md b/CleverCache.EntityFrameworkCore/NuGetReadMe.md new file mode 100644 index 0000000..d302efc --- /dev/null +++ b/CleverCache.EntityFrameworkCore/NuGetReadMe.md @@ -0,0 +1,19 @@ +# CleverCache.EntityFrameworkCore + +EF Core integration for [CleverCache](https://www.nuget.org/packages/CleverCache) — automatic cache invalidation via `SaveChangesInterceptor` and optional DbSet navigation scanning. + +## Setup + +```csharp +// Register CleverCache core +builder.Services.AddCleverCache(); + +// Register EF Core integration (interceptor only) +builder.Services.AddCleverCacheEntityFramework(); + +// Or with automatic DbSet navigation scanning +builder.Services.AddCleverCacheEntityFramework(o => + o.NavigationScanMode = DependentCacheNavigationScanMode.Direct); +``` + +See the [Getting Started](https://github.com/chunty/CleverCache/wiki/Getting-Started) wiki for full setup instructions. diff --git a/CleverCache.csproj b/CleverCache.csproj index a937be4..19ce5ab 100644 --- a/CleverCache.csproj +++ b/CleverCache.csproj @@ -7,14 +7,12 @@ MemoryCache,DistributedCache,Automatic Cache,Cache Invalidation NuGetReadMe.md - $(DefaultItemExcludes);CleverCache.MediatR\**;CleverCache.Redis\**;CleverCache.Tests\** + $(DefaultItemExcludes);CleverCache.MediatR\**;CleverCache.Redis\**;CleverCache.Tests\**;CleverCache.EntityFrameworkCore\** - - diff --git a/CleverCache.sln b/CleverCache.sln index 7f6ad1f..dd71b24 100644 --- a/CleverCache.sln +++ b/CleverCache.sln @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleverCache.Redis", "Clever EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleverCache.Tests", "CleverCache.Tests\CleverCache.Tests.csproj", "{261F2368-C237-4CF0-A4C0-50E650D64412}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleverCache.EntityFrameworkCore", "CleverCache.EntityFrameworkCore\CleverCache.EntityFrameworkCore.csproj", "{55D369A1-A5D2-4F0F-896F-95EBD9BE1241}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -69,6 +71,18 @@ Global {261F2368-C237-4CF0-A4C0-50E650D64412}.Release|x64.Build.0 = Release|Any CPU {261F2368-C237-4CF0-A4C0-50E650D64412}.Release|x86.ActiveCfg = Release|Any CPU {261F2368-C237-4CF0-A4C0-50E650D64412}.Release|x86.Build.0 = Release|Any CPU + {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Debug|Any CPU.Build.0 = Debug|Any CPU + {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Debug|x64.ActiveCfg = Debug|Any CPU + {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Debug|x64.Build.0 = Debug|Any CPU + {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Debug|x86.ActiveCfg = Debug|Any CPU + {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Debug|x86.Build.0 = Debug|Any CPU + {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Release|Any CPU.ActiveCfg = Release|Any CPU + {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Release|Any CPU.Build.0 = Release|Any CPU + {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Release|x64.ActiveCfg = Release|Any CPU + {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Release|x64.Build.0 = Release|Any CPU + {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Release|x86.ActiveCfg = Release|Any CPU + {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/DependencyInjection/ApplicationBuilderExtensions.cs b/DependencyInjection/ApplicationBuilderExtensions.cs deleted file mode 100644 index b04a67b..0000000 --- a/DependencyInjection/ApplicationBuilderExtensions.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.AspNetCore.Builder; - -namespace CleverCache.DependencyInjection; - -public static class ApplicationBuilderExtensions -{ - public static IApplicationBuilder UseCleverCache(this IApplicationBuilder app) where TContext : DbContext - { - var cache = app.ApplicationServices.GetRequiredService(); - var smartCacheOptions = app.ApplicationServices.GetRequiredService(); - - using var scope = app.ApplicationServices.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var dependentCaches = smartCacheOptions.DependentCaches.ToList(); - - dbContext.EnsureCleverCacheInterceptor(); - - if (!smartCacheOptions.DisableAllScanning) - { - dependentCaches.AddRange(dbContext.DiscoverDependentCaches(smartCacheOptions)); - } - - foreach (var dependentCache in dependentCaches.Distinct()) - { - cache.AddDependentCache(dependentCache.Type, dependentCache.DependentType); - } - - return app; - } -} \ No newline at end of file diff --git a/DependencyInjection/ServiceCollectionExtensions.cs b/DependencyInjection/ServiceCollectionExtensions.cs index 8008713..7021c06 100644 --- a/DependencyInjection/ServiceCollectionExtensions.cs +++ b/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ using CleverCache.Implementations; -using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection.Extensions; // ReSharper disable IdentifierTypo @@ -21,12 +20,6 @@ public static IServiceCollection AddCleverCache(this IServiceCollection services // Register ICleverCache backed by the store services.TryAddSingleton(); - // Register the Smart Cache Interceptor as Service - services.TryAddScoped(); - - // Register the Smart Cache Interceptor as Interceptor - services.AddScoped(); - return services; } } \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 4b4866f..c22a6ec 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,6 +20,7 @@ + @@ -28,5 +29,6 @@ + diff --git a/Exceptions/MissingInterceptorException.cs b/Exceptions/MissingInterceptorException.cs deleted file mode 100644 index 4ff779d..0000000 --- a/Exceptions/MissingInterceptorException.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace CleverCache.Exceptions; - -internal class MissingInterceptorException() : - Exception("CleverCache requires the ClearSmartMemoryCacheInterceptor to be added to the database context."); \ No newline at end of file diff --git a/GlobalUsings.cs b/GlobalUsings.cs index 9497c1b..59e706f 100644 --- a/GlobalUsings.cs +++ b/GlobalUsings.cs @@ -1,7 +1,5 @@ // Global using directives -global using Microsoft.EntityFrameworkCore; global using Microsoft.Extensions.DependencyInjection; global using CleverCache.Extensions; -global using CleverCache.Interceptors; global using CleverCache.Models; \ No newline at end of file diff --git a/Helpers/NavigationScanningHelper.cs b/Helpers/NavigationScanningHelper.cs deleted file mode 100644 index a2336ae..0000000 --- a/Helpers/NavigationScanningHelper.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.EntityFrameworkCore.Metadata; - -namespace CleverCache.Helpers; - -internal static class NavigationScanningHelper -{ - public static void Scan(CleverCacheScanOptions scanOptions, IEntityType entityType, HashSet dependentCaches) - { - foreach (var navigation in entityType.GetNavigations()) - { - var sourceType = entityType.ClrType; - var dependentEntityType = navigation.TargetEntityType; - var dependentType = dependentEntityType.ClrType; - - // If we already have this in the list, skip - if (dependentCaches.Any(x => x.Type == dependentType)) - { - continue; - } - - dependentCaches.Add(new DependentCache(sourceType, dependentType)); - if (scanOptions.ReverseNavigationDependencies) - { - dependentCaches.Add(new DependentCache(dependentType, sourceType)); - } - - if (!scanOptions.NavigationScanMode.Equals(DependentCacheNavigationScanMode.Recursive)) - { - continue; - } - - Scan(scanOptions, dependentEntityType, dependentCaches); - } - } -} \ No newline at end of file diff --git a/Models/CleverCacheOptions.cs b/Models/CleverCacheOptions.cs index d2c5c1c..3e07442 100644 --- a/Models/CleverCacheOptions.cs +++ b/Models/CleverCacheOptions.cs @@ -8,14 +8,9 @@ namespace CleverCache.Models; -public class CleverCacheOptions(CleverCacheScanOptions? scanOptions = null, - HashSet? dependentCaches = null, - bool disableAllScanning = false) +public class CleverCacheOptions(HashSet? dependentCaches = null) { - // ReSharper disable once IdentifierTypo - public CleverCacheScanOptions Scanning { get; set; } = scanOptions ?? new CleverCacheScanOptions(); public HashSet DependentCaches { get; set; } = dependentCaches ?? []; - public bool DisableAllScanning { get; set; } = disableAllScanning; /// /// Scans the assembly containing for diff --git a/wiki/Getting-Started.md b/wiki/Getting-Started.md index e00668e..71eb27b 100644 --- a/wiki/Getting-Started.md +++ b/wiki/Getting-Started.md @@ -20,7 +20,7 @@ CleverCache can be used with or without EF Core: |---|---| | Manual or attribute-driven invalidation | `AddCleverCache` only | | Automatic invalidation on `SaveChanges` | `AddCleverCache` + EF Core interceptor | -| Automatic cascade discovery from navigation properties | `AddCleverCache` + interceptor + `UseCleverCache` | +| Automatic cascade discovery from navigation properties | `AddCleverCache` + interceptor + `ScanDbSetsForCacheDependencies` | > **Without the interceptor** you only get dependency tree management — you can define cascade rules with `[DependentCaches]` or `AddDependentCache`, and call `RemoveByType()` yourself to invalidate. Cache entries are never evicted automatically; you are responsible for triggering invalidation. The interceptor is what makes CleverCache hands-off. @@ -42,6 +42,16 @@ See [Cache Providers](Cache-Providers) for memory, distributed, Redis, and custo The interceptor is what makes CleverCache automatic — cache entries for changed entity types are evicted the moment `SaveChanges` or `SaveChangesAsync` completes. Without it, you can still use CleverCache by calling `RemoveByType()` yourself. +Install the EF Core package and register the interceptor: + +``` +dotnet add package CleverCache.EntityFrameworkCore +``` + +```csharp +builder.Services.AddCleverCacheEntityFramework(); +``` + > **Important:** Automatic invalidation only fires for writes that go through EF Core's change tracker. Writes that bypass it — `ExecuteUpdate`, `ExecuteDelete`, raw SQL, stored procedures, or external services — will **not** trigger invalidation. See [Bulk Operations](Bulk-Operations) for workarounds. Inject the interceptor into your `DbContext`: @@ -69,16 +79,25 @@ public class AppDbContext(CleverCacheInterceptor cleverCacheInterceptor) : DbCon } ``` -## 3. Register the EF Core middleware (optional) +## 3. Scan DbSet navigation properties (optional) -Only needed if you want CleverCache to scan `DbSet` navigation properties for automatic cascade discovery: +Only needed if you want CleverCache to auto-discover cascade rules from EF Core navigation properties. This can be called multiple times for multiple DbContext types, each with their own scan options: ```csharp -app.UseCleverCache(); +app.ScanDbSetsForCacheDependencies(); + +// With navigation scanning options: +app.ScanDbSetsForCacheDependencies(o => + o.NavigationScanMode = DependentCacheNavigationScanMode.Direct); + +// Multiple contexts with different options: +app.ScanDbSetsForCacheDependencies(o => + o.NavigationScanMode = DependentCacheNavigationScanMode.Direct); +app.ScanDbSetsForCacheDependencies(); ``` -This scans the `DbSet` types registered on `AppDbContext` using EF Core metadata to build the invalidation dependency tree. See [Dependent Caches](Dependent-Caches). +See [Dependent Caches](Dependent-Caches) for full details on navigation scanning modes. -> **Prefer `[DependentCaches]` attributes for most projects.** Global DbSet scanning is convenient but wires up every entity in your context — in large schemas this can cause excessive invalidation and high memory usage from tracking too many key associations. `ScanAssemblyContaining` (step 1) gives you the same automatic wiring with opt-in, per-entity control. +> **Prefer `[DependentCaches]` attributes for most projects.** Global DbSet scanning wires up every entity in your context — in large schemas this can cause excessive invalidation and high memory usage from tracking too many key associations. `ScanAssemblyContaining` (step 1) gives you the same automatic wiring with opt-in, per-entity control. -> **Not using EF Core navigation scanning?** If you rely only on `[DependentCaches]` attributes, register those via `ScanAssemblyContaining` in step 1 and skip `UseCleverCache` entirely. +> **Not using EF Core navigation scanning?** Skip this step entirely — `[DependentCaches]` attributes via `ScanAssemblyContaining` don't need it. From 08a5b2075b40a7a363481e47abea6280e5213494 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 12:04:02 +0100 Subject: [PATCH 12/50] chore: update solution file for Visual Studio 2022 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CleverCache.sln | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/CleverCache.sln b/CleverCache.sln index dd71b24..bacfce9 100644 --- a/CleverCache.sln +++ b/CleverCache.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.12.35527.113 +# Visual Studio Version 18 +VisualStudioVersion = 18.5.11716.220 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleverCache", "CleverCache.csproj", "{2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}" EndProject @@ -11,7 +11,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleverCache.Redis", "Clever EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleverCache.Tests", "CleverCache.Tests\CleverCache.Tests.csproj", "{261F2368-C237-4CF0-A4C0-50E650D64412}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleverCache.EntityFrameworkCore", "CleverCache.EntityFrameworkCore\CleverCache.EntityFrameworkCore.csproj", "{55D369A1-A5D2-4F0F-896F-95EBD9BE1241}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleverCache.EntityFrameworkCore", "CleverCache.EntityFrameworkCore\CleverCache.EntityFrameworkCore.csproj", "{96A9C78A-506E-4691-2C0B-35E0C678B967}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -71,18 +71,18 @@ Global {261F2368-C237-4CF0-A4C0-50E650D64412}.Release|x64.Build.0 = Release|Any CPU {261F2368-C237-4CF0-A4C0-50E650D64412}.Release|x86.ActiveCfg = Release|Any CPU {261F2368-C237-4CF0-A4C0-50E650D64412}.Release|x86.Build.0 = Release|Any CPU - {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Debug|Any CPU.Build.0 = Debug|Any CPU - {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Debug|x64.ActiveCfg = Debug|Any CPU - {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Debug|x64.Build.0 = Debug|Any CPU - {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Debug|x86.ActiveCfg = Debug|Any CPU - {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Debug|x86.Build.0 = Debug|Any CPU - {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Release|Any CPU.ActiveCfg = Release|Any CPU - {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Release|Any CPU.Build.0 = Release|Any CPU - {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Release|x64.ActiveCfg = Release|Any CPU - {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Release|x64.Build.0 = Release|Any CPU - {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Release|x86.ActiveCfg = Release|Any CPU - {55D369A1-A5D2-4F0F-896F-95EBD9BE1241}.Release|x86.Build.0 = Release|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Debug|Any CPU.Build.0 = Debug|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Debug|x64.ActiveCfg = Debug|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Debug|x64.Build.0 = Debug|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Debug|x86.ActiveCfg = Debug|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Debug|x86.Build.0 = Debug|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Release|Any CPU.ActiveCfg = Release|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Release|Any CPU.Build.0 = Release|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Release|x64.ActiveCfg = Release|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Release|x64.Build.0 = Release|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Release|x86.ActiveCfg = Release|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 853f753440de3449aae768e145ab457e6fbdf62f Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 12:10:23 +0100 Subject: [PATCH 13/50] test: add CleverCacheInterceptor and DbContextExtensions tests - CleverCacheInterceptorTests: 7 tests covering Add/Modify/Delete, deduplication of same-type entities, multiple types, no-changes, async - DbContextExtensionsTests: 6 tests covering EnsureCleverCacheInterceptor (with/without), direct and recursive navigation scanning, None mode, and DependentCaches attribute discovery - Added CleverCache.EntityFrameworkCore project reference to test project - Added Microsoft.EntityFrameworkCore.InMemory package for both targets - Added InternalsVisibleTo(CleverCache.Tests) to EF package 62/62 tests passing on net9.0 and net10.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CleverCache.EntityFrameworkCore.csproj | 5 + CleverCache.Tests/CleverCache.Tests.csproj | 2 + .../CleverCacheInterceptorTests.cs | 110 ++++++++++++++++++ CleverCache.Tests/DbContextExtensionsTests.cs | 96 +++++++++++++++ Directory.Packages.props | 2 + 5 files changed, 215 insertions(+) create mode 100644 CleverCache.Tests/CleverCacheInterceptorTests.cs create mode 100644 CleverCache.Tests/DbContextExtensionsTests.cs diff --git a/CleverCache.EntityFrameworkCore/CleverCache.EntityFrameworkCore.csproj b/CleverCache.EntityFrameworkCore/CleverCache.EntityFrameworkCore.csproj index d3ba03f..d7f96b1 100644 --- a/CleverCache.EntityFrameworkCore/CleverCache.EntityFrameworkCore.csproj +++ b/CleverCache.EntityFrameworkCore/CleverCache.EntityFrameworkCore.csproj @@ -15,4 +15,9 @@ + + + <_Parameter1>CleverCache.Tests + + diff --git a/CleverCache.Tests/CleverCache.Tests.csproj b/CleverCache.Tests/CleverCache.Tests.csproj index 5656eea..4f9c463 100644 --- a/CleverCache.Tests/CleverCache.Tests.csproj +++ b/CleverCache.Tests/CleverCache.Tests.csproj @@ -5,6 +5,7 @@ true + @@ -16,5 +17,6 @@ + diff --git a/CleverCache.Tests/CleverCacheInterceptorTests.cs b/CleverCache.Tests/CleverCacheInterceptorTests.cs new file mode 100644 index 0000000..70091ea --- /dev/null +++ b/CleverCache.Tests/CleverCacheInterceptorTests.cs @@ -0,0 +1,110 @@ +using CleverCache.EntityFrameworkCore.Interceptors; +using Microsoft.EntityFrameworkCore; +using Moq; + +namespace CleverCache.Tests; + +internal class IcOrder { public int Id { get; set; } public string Name { get; set; } = ""; } +internal class IcCustomer { public int Id { get; set; } } + +internal class IcDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Orders => Set(); + public DbSet Customers => Set(); +} + +public class CleverCacheInterceptorTests +{ + private static (IcDbContext context, Mock mockCache) CreateContext() + { + var mockCache = new Mock(); + var interceptor = new CleverCacheInterceptor(mockCache.Object); + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(interceptor) + .Options; + return (new IcDbContext(options), mockCache); + } + + [Fact] + public void SavedChanges_EntityAdded_CallsRemoveByType() + { + var (context, mockCache) = CreateContext(); + context.Orders.Add(new IcOrder { Id = 1, Name = "Test" }); + context.SaveChanges(); + + mockCache.Verify(c => c.RemoveByType(typeof(IcOrder)), Times.Once); + } + + [Fact] + public void SavedChanges_EntityModified_CallsRemoveByType() + { + var (context, mockCache) = CreateContext(); + context.Orders.Add(new IcOrder { Id = 1, Name = "Test" }); + context.SaveChanges(); + mockCache.Invocations.Clear(); + + var order = context.Orders.Find(1)!; + order.Name = "Updated"; + context.SaveChanges(); + + mockCache.Verify(c => c.RemoveByType(typeof(IcOrder)), Times.Once); + } + + [Fact] + public void SavedChanges_EntityDeleted_CallsRemoveByType() + { + var (context, mockCache) = CreateContext(); + context.Orders.Add(new IcOrder { Id = 1, Name = "Test" }); + context.SaveChanges(); + mockCache.Invocations.Clear(); + + context.Orders.Remove(context.Orders.Find(1)!); + context.SaveChanges(); + + mockCache.Verify(c => c.RemoveByType(typeof(IcOrder)), Times.Once); + } + + [Fact] + public void SavedChanges_MultipleEntitiesOfSameType_CallsRemoveByTypeOnce() + { + var (context, mockCache) = CreateContext(); + context.Orders.AddRange( + new IcOrder { Id = 1, Name = "A" }, + new IcOrder { Id = 2, Name = "B" }); + context.SaveChanges(); + + mockCache.Verify(c => c.RemoveByType(typeof(IcOrder)), Times.Once); + } + + [Fact] + public void SavedChanges_MultipleEntityTypes_CallsRemoveByTypeForEach() + { + var (context, mockCache) = CreateContext(); + context.Orders.Add(new IcOrder { Id = 1, Name = "Test" }); + context.Customers.Add(new IcCustomer { Id = 1 }); + context.SaveChanges(); + + mockCache.Verify(c => c.RemoveByType(typeof(IcOrder)), Times.Once); + mockCache.Verify(c => c.RemoveByType(typeof(IcCustomer)), Times.Once); + } + + [Fact] + public void SavedChanges_NoChanges_DoesNotCallRemoveByType() + { + var (context, mockCache) = CreateContext(); + context.SaveChanges(); + + mockCache.Verify(c => c.RemoveByType(It.IsAny()), Times.Never); + } + + [Fact] + public async Task SavedChangesAsync_EntityAdded_CallsRemoveByType() + { + var (context, mockCache) = CreateContext(); + context.Orders.Add(new IcOrder { Id = 1, Name = "Test" }); + await context.SaveChangesAsync(); + + mockCache.Verify(c => c.RemoveByType(typeof(IcOrder)), Times.Once); + } +} diff --git a/CleverCache.Tests/DbContextExtensionsTests.cs b/CleverCache.Tests/DbContextExtensionsTests.cs new file mode 100644 index 0000000..3dfe8f7 --- /dev/null +++ b/CleverCache.Tests/DbContextExtensionsTests.cs @@ -0,0 +1,96 @@ +using CleverCache.Attributes; +using CleverCache.EntityFrameworkCore.Exceptions; +using CleverCache.EntityFrameworkCore.Extensions; +using CleverCache.EntityFrameworkCore.Interceptors; +using CleverCache.EntityFrameworkCore.Models; +using Microsoft.EntityFrameworkCore; +using Moq; + +namespace CleverCache.Tests; + +// Navigation model: DbExt prefix avoids any collision with other test types +internal class DbExtOrder { public int Id { get; set; } public List Lines { get; set; } = []; } +internal class DbExtOrderLine { public int Id { get; set; } public int DbExtOrderId { get; set; } public DbExtOrder? Order { get; set; } } + +[DependentCaches([typeof(DbExtProduct)])] +internal class DbExtCategory { public int Id { get; set; } } +internal class DbExtProduct { public int Id { get; set; } } + +// Recursive model: A → B → C +internal class DbExtA { public int Id { get; set; } public DbExtB? B { get; set; } } +internal class DbExtB { public int Id { get; set; } public DbExtC? C { get; set; } } +internal class DbExtC { public int Id { get; set; } } + +internal class ScanTestDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Orders => Set(); + public DbSet OrderLines => Set(); + public DbSet Categories => Set(); + public DbSet Products => Set(); + public DbSet As => Set(); + public DbSet Bs => Set(); + public DbSet Cs => Set(); +} + +public class DbContextExtensionsTests +{ + private static ScanTestDbContext CreateContext(bool withInterceptor = false) + { + var builder = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()); + if (withInterceptor) + builder.AddInterceptors(new CleverCacheInterceptor(new Mock().Object)); + return new ScanTestDbContext(builder.Options); + } + + [Fact] + public void EnsureCleverCacheInterceptor_WithInterceptor_DoesNotThrow() + { + using var context = CreateContext(withInterceptor: true); + context.EnsureCleverCacheInterceptor(); // should not throw + } + + [Fact] + public void EnsureCleverCacheInterceptor_WithoutInterceptor_ThrowsMissingInterceptorException() + { + using var context = CreateContext(withInterceptor: false); + Assert.Throws(() => context.EnsureCleverCacheInterceptor()); + } + + [Fact] + public void DiscoverDependentCaches_DirectNavigation_DiscoversRelationship() + { + using var context = CreateContext(); + var result = context.DiscoverDependentCaches(new CleverCacheScanOptions(DependentCacheNavigationScanMode.Direct)); + + Assert.Contains(result, d => d.Type == typeof(DbExtOrder) && d.DependentType == typeof(DbExtOrderLine)); + } + + [Fact] + public void DiscoverDependentCaches_NoneMode_DoesNotScanNavigations() + { + using var context = CreateContext(); + var result = context.DiscoverDependentCaches(new CleverCacheScanOptions(DependentCacheNavigationScanMode.None)); + + Assert.DoesNotContain(result, d => d.Type == typeof(DbExtOrder) && d.DependentType == typeof(DbExtOrderLine)); + } + + [Fact] + public void DiscoverDependentCaches_RecursiveNavigation_DiscoversTransitiveRelationships() + { + using var context = CreateContext(); + var result = context.DiscoverDependentCaches(new CleverCacheScanOptions(DependentCacheNavigationScanMode.Recursive)); + + Assert.Contains(result, d => d.Type == typeof(DbExtA) && d.DependentType == typeof(DbExtB)); + Assert.Contains(result, d => d.Type == typeof(DbExtB) && d.DependentType == typeof(DbExtC)); + } + + [Fact] + public void DiscoverDependentCaches_DependentCachesAttribute_RegistersDependency() + { + using var context = CreateContext(); + var result = context.DiscoverDependentCaches(new CleverCacheScanOptions(DependentCacheNavigationScanMode.None)); + + Assert.Contains(result, d => d.Type == typeof(DbExtCategory) && d.DependentType == typeof(DbExtProduct)); + } +} diff --git a/Directory.Packages.props b/Directory.Packages.props index c22a6ec..1c83d8e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,6 +17,7 @@ + @@ -26,6 +27,7 @@ + From ca7093d328ce14347c99907e75f926eaf925a557 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 12:14:20 +0100 Subject: [PATCH 14/50] docs: add V1 to V2 migration guide Covers all breaking changes with before/after examples: - New CleverCache.EntityFrameworkCore package requirement - AddCleverCacheEntityFramework() registration - UseCleverCache -> ScanDbSetsForCacheDependencies - Scan options moved off CleverCacheOptions - GetOrCreate factory signature (ICacheEntry removed, CleverCacheEntryOptions) - [DependantCaches] -> [DependentCaches] spelling fix - FakeCache namespace change - New V2 features section Also updated Home.md to link migration page and add EF Core package row Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- wiki/Home.md | 4 +- wiki/Migrating-to-V2.md | 259 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 wiki/Migrating-to-V2.md diff --git a/wiki/Home.md b/wiki/Home.md index ae29c12..adbb703 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -15,13 +15,15 @@ With a small amount of configuration, CleverCache automatically tracks changes v | [MediatR Integration](MediatR-Integration) | `[AutoCache]`, `[InvalidatesCache]`, pipeline setup | | [Bulk Operations](Bulk-Operations) | Handling `ExecuteDelete`/`ExecuteUpdate` and non-EF writes | | [Unit Testing](Unit-Testing) | `FakeCache`, mocking `ICleverCache` | +| [Migrating to V2](Migrating-to-V2) | Breaking changes and before/after examples for V1 → V2 | ## Packages | Package | NuGet | Purpose | |---|---|---| | `CleverCache` | [![NuGet](https://img.shields.io/nuget/vpre/clevercache.svg)](https://www.nuget.org/packages/clevercache) | Core package | +| `CleverCache.EntityFrameworkCore` | [![NuGet](https://img.shields.io/nuget/vpre/clevercache.entityframeworkcore.svg)](https://www.nuget.org/packages/clevercache.entityframeworkcore) | EF Core interceptor and DbSet scanning | | `CleverCache.MediatR` | [![NuGet](https://img.shields.io/nuget/vpre/clevercache.mediatr.svg)](https://www.nuget.org/packages/clevercache.mediatr) | MediatR pipeline behaviours | | `CleverCache.Redis` | [![NuGet](https://img.shields.io/nuget/vpre/clevercache.redis.svg)](https://www.nuget.org/packages/clevercache.redis) | Redis cache provider | -> **Requires EF Core.** Automatic invalidation hooks into `SaveChanges`/`SaveChangesAsync` via a `SaveChangesInterceptor`. Without EF Core you can still use CleverCache for manual invalidation — see [Bulk Operations](Bulk-Operations). +> **EF Core.** Automatic invalidation via `SaveChanges` requires the `CleverCache.EntityFrameworkCore` package. Without EF Core you can still use CleverCache for manual invalidation — see [Bulk Operations](Bulk-Operations). diff --git a/wiki/Migrating-to-V2.md b/wiki/Migrating-to-V2.md new file mode 100644 index 0000000..fd1cf36 --- /dev/null +++ b/wiki/Migrating-to-V2.md @@ -0,0 +1,259 @@ +# Migrating from V1 to V2 + +V2 is a significant refactor. The core caching API is mostly the same but several breaking changes tighten the design and reduce coupling. This page documents every change with before/after examples. + +--- + +## Summary of breaking changes + +| Area | V1 | V2 | +|---|---|---| +| EF Core dependency | Bundled in `CleverCache` | Separate `CleverCache.EntityFrameworkCore` package | +| Startup (interceptor) | `AddCleverCache()` registered interceptor | `AddCleverCacheEntityFramework()` required | +| Startup (scanning) | `app.UseCleverCache()` | `app.ScanDbSetsForCacheDependencies()` | +| Scan options | `CleverCacheOptions.Scanning` | Passed directly to `ScanDbSetsForCacheDependencies` | +| `GetOrCreate` factory | `Func` | `Func` | +| `GetOrCreateAsync` factory | `Func>` | `Func>` | +| Cache entry options type | `MemoryCacheEntryOptions` | `CleverCacheEntryOptions` | +| Dependent cache attribute | `[DependantCaches]` | `[DependentCaches]` | +| `FakeCache` namespace | `CleverCache.Implementations` | `CleverCache` | + +--- + +## Step-by-step migration + +### 1. Install the new EF Core package + +V2 moves all EF Core concerns into a dedicated package. Your main project no longer depends on `Microsoft.EntityFrameworkCore`. + +**Before** — one package does everything: +``` +dotnet add package CleverCache +``` + +**After** — install both: +``` +dotnet add package CleverCache +dotnet add package CleverCache.EntityFrameworkCore +``` + +--- + +### 2. Update DI registration + +`AddCleverCache()` no longer registers the EF Core interceptor. You must call `AddCleverCacheEntityFramework()` alongside it. + +**Before:** +```csharp +builder.Services.AddCleverCache(); +``` + +**After:** +```csharp +builder.Services.AddCleverCache(); +builder.Services.AddCleverCacheEntityFramework(); // new — registers the SaveChanges interceptor +``` + +> If you are not using EF Core (manual invalidation only), just keep `AddCleverCache()` and skip `AddCleverCacheEntityFramework()`. + +--- + +### 3. Update `UseCleverCache` → `ScanDbSetsForCacheDependencies` + +`app.UseCleverCache()` has been replaced with a more descriptive name that lives on `IApplicationBuilder`. + +**Before:** +```csharp +app.UseCleverCache(); +``` + +**After:** +```csharp +app.ScanDbSetsForCacheDependencies(); +``` + +Scan options are now passed directly instead of being stored on `CleverCacheOptions`. + +**Before:** +```csharp +builder.Services.AddCleverCache(o => +{ + o.Scanning.NavigationScanMode = DependentCacheNavigationScanMode.Direct; + o.Scanning.ReverseNavigationDependencies = true; +}); +app.UseCleverCache(); +``` + +**After:** +```csharp +builder.Services.AddCleverCache(); +builder.Services.AddCleverCacheEntityFramework(); +// ... +app.ScanDbSetsForCacheDependencies(o => +{ + o.NavigationScanMode = DependentCacheNavigationScanMode.Direct; + o.ReverseNavigationDependencies = true; +}); +``` + +Multiple DbContexts each get their own call with independent options: + +```csharp +app.ScanDbSetsForCacheDependencies(); +app.ScanDbSetsForCacheDependencies(o => + o.NavigationScanMode = DependentCacheNavigationScanMode.Recursive); +``` + +--- + +### 4. Update `GetOrCreate` / `GetOrCreateAsync` factory signatures + +The factory delegate no longer receives an `ICacheEntry`. Cache entry options are now a separate parameter of type `CleverCacheEntryOptions` instead of `MemoryCacheEntryOptions`. + +**Before:** +```csharp +var result = await cache.GetOrCreateAsync>( + [typeof(Order)], + "orders-all", + async entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10); + return await db.Orders.ToListAsync(); + }); +``` + +**After:** +```csharp +var result = await cache.GetOrCreateAsync>( + [typeof(Order)], + "orders-all", + async () => await db.Orders.ToListAsync(), + new CleverCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) }); +``` + +The synchronous overload changes identically: + +**Before:** +```csharp +var result = cache.GetOrCreate( + [typeof(Order)], + "order-count", + entry => db.Orders.Count()); +``` + +**After:** +```csharp +var result = cache.GetOrCreate( + [typeof(Order)], + "order-count", + () => db.Orders.Count()); +``` + +--- + +### 5. Update the `[DependantCaches]` attribute + +The attribute class was renamed to fix a spelling mistake. The property `DependantTypes` retains the original spelling for now. + +**Before:** +```csharp +[DependantCaches([typeof(OrderLine), typeof(OrderNote)])] +public class Order { } +``` + +**After:** +```csharp +[DependentCaches([typeof(OrderLine), typeof(OrderNote)])] +public class Order { } +``` + +`[DependentCaches]` now also accepts a `NavigationScanMode` parameter to control whether navigation properties on the decorated type are additionally scanned: + +```csharp +[DependentCaches([typeof(OrderLine)], navigationScanMode: DependentCacheNavigationScanMode.Direct)] +public class Order { } +``` + +--- + +### 6. Register `[DependentCaches]` attributes at startup + +In V1, `UseCleverCache` processed `[DependantCaches]` attributes automatically via EF model scanning. In V2 you have two options: + +**Option A — attribute-only (no EF model scan):** use `ScanAssemblyContaining` in `AddCleverCache`. Works without the EF package and is the recommended approach when you don't need navigation scanning. + +```csharp +builder.Services.AddCleverCache(o => o.ScanAssemblyContaining()); +``` + +**Option B — EF navigation scan (also picks up attributes):** call `ScanDbSetsForCacheDependencies`. This scans navigations AND processes `[DependentCaches]` attributes on each entity type in the model. + +```csharp +app.ScanDbSetsForCacheDependencies(o => + o.NavigationScanMode = DependentCacheNavigationScanMode.Direct); +``` + +--- + +### 7. Update `FakeCache` namespace + +`FakeCache` has moved from the `CleverCache.Implementations` namespace to the root `CleverCache` namespace. + +**Before:** +```csharp +using CleverCache.Implementations; + +mocker.Use(new FakeCache()); +``` + +**After:** +```csharp +// No extra using needed — FakeCache is in the CleverCache namespace +mocker.Use(new FakeCache()); +``` + +--- + +### 8. DbContext constructor injection (optional cleanup) + +V1 required you to inject `IInterceptor` or `CleverCacheInterceptor` into your DbContext constructor. This still works in V2 (the interceptor is still registered as `IInterceptor`), but the preferred V2 pattern is to wire it up in `AddDbContext` using the extension method: + +**V1 (still valid in V2 — no change required):** +```csharp +public class AppDbContext(IInterceptor cleverCacheInterceptor) : DbContext +{ + protected override void OnConfiguring(DbContextOptionsBuilder options) + => options.AddInterceptors(cleverCacheInterceptor); +} +``` + +**V2 preferred (no DbContext constructor changes):** +```csharp +// Program.cs +builder.Services.AddDbContext((sp, options) => +{ + options.UseSqlServer(connectionString); + options.AddCleverCache(sp); // extension from CleverCache.EntityFrameworkCore +}); + +// AppDbContext.cs — no interceptor injection needed +public class AppDbContext(DbContextOptions options) : DbContext(options) { } +``` + +--- + +## New features in V2 + +These aren't breaking changes but are worth knowing about: + +- **`ScanAssemblyContaining()`** — scan assemblies for `[DependentCaches]` attributes without EF Core. Replaces manually calling `AddDependentCache` for each type. + +- **`[InvalidatesCache]` for MediatR** — decorate any command with `[InvalidatesCache(typeof(Order))]` to evict cache entries automatically after the command succeeds. + +- **`InvalidateCaches` bulk extension** — chain onto `ExecuteDelete` / `ExecuteDeleteAsync` results for bulk operations that bypass the change tracker. + +- **Distributed cache and Redis** — `CleverCache.Redis` is now the only way to bring in StackExchange.Redis. The `AddCleverCache()` options no longer accept `UseRedisCache`. + +- **Custom cache provider** — implement `ICleverCacheStore` and register with `AddCleverCache(o => o.UseCustomStore())`. + +- **Stampede protection** — concurrent requests for the same key now only invoke the factory once. From 157c9c7920bc39af5b9322d97a0b990e1471b5df Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 12:19:05 +0100 Subject: [PATCH 15/50] feat: AddCleverCacheEntityFramework calls AddCleverCache internally Single registration call for EF Core users. AddCleverCacheEntityFramework now accepts Action? and calls AddCleverCache internally, so users do not need to call both. Updated Getting-Started and Migrating-to-V2 wiki docs accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ServiceCollectionExtensions.cs | 8 +++++--- .../GlobalUsings.cs | 1 + wiki/Getting-Started.md | 20 +++++++++++-------- wiki/Migrating-to-V2.md | 10 +++++----- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/CleverCache.EntityFrameworkCore/DependencyInjection/ServiceCollectionExtensions.cs b/CleverCache.EntityFrameworkCore/DependencyInjection/ServiceCollectionExtensions.cs index 6794b6f..1e817a5 100644 --- a/CleverCache.EntityFrameworkCore/DependencyInjection/ServiceCollectionExtensions.cs +++ b/CleverCache.EntityFrameworkCore/DependencyInjection/ServiceCollectionExtensions.cs @@ -6,11 +6,13 @@ namespace CleverCache.EntityFrameworkCore.DependencyInjection; public static class ServiceCollectionExtensions { /// - /// Registers so it can be injected into your . - /// Call this alongside AddCleverCache() when using EF Core. + /// Registers CleverCache services and the EF Core . + /// This is the single call needed when using EF Core — there is no need to also call AddCleverCache(). /// - public static IServiceCollection AddCleverCacheEntityFramework(this IServiceCollection services) + public static IServiceCollection AddCleverCacheEntityFramework(this IServiceCollection services, + Action? options = null) { + services.AddCleverCache(options); services.TryAddScoped(); services.AddScoped(); return services; diff --git a/CleverCache.EntityFrameworkCore/GlobalUsings.cs b/CleverCache.EntityFrameworkCore/GlobalUsings.cs index d4ccd10..b102bbc 100644 --- a/CleverCache.EntityFrameworkCore/GlobalUsings.cs +++ b/CleverCache.EntityFrameworkCore/GlobalUsings.cs @@ -4,6 +4,7 @@ global using Microsoft.EntityFrameworkCore; global using Microsoft.Extensions.DependencyInjection; global using CleverCache; +global using CleverCache.DependencyInjection; global using CleverCache.Models; global using CleverCache.EntityFrameworkCore.Models; global using CleverCache.EntityFrameworkCore.Interceptors; diff --git a/wiki/Getting-Started.md b/wiki/Getting-Started.md index 71eb27b..871ec59 100644 --- a/wiki/Getting-Started.md +++ b/wiki/Getting-Started.md @@ -19,21 +19,27 @@ CleverCache can be used with or without EF Core: | Scenario | What you need | |---|---| | Manual or attribute-driven invalidation | `AddCleverCache` only | -| Automatic invalidation on `SaveChanges` | `AddCleverCache` + EF Core interceptor | -| Automatic cascade discovery from navigation properties | `AddCleverCache` + interceptor + `ScanDbSetsForCacheDependencies` | +| Automatic invalidation on `SaveChanges` | `AddCleverCacheEntityFramework` | +| Automatic cascade discovery from navigation properties | `AddCleverCacheEntityFramework` + `ScanDbSetsForCacheDependencies` | > **Without the interceptor** you only get dependency tree management — you can define cascade rules with `[DependentCaches]` or `AddDependentCache`, and call `RemoveByType()` yourself to invalidate. Cache entries are never evicted automatically; you are responsible for triggering invalidation. The interceptor is what makes CleverCache hands-off. ## 1. Register services +**Without EF Core** (manual or attribute-driven invalidation only): + ```csharp builder.Services.AddCleverCache(); +// optionally scan assemblies for [DependentCaches] attributes: +builder.Services.AddCleverCache(o => o.ScanAssemblyContaining()); ``` -If you are using `[DependentCaches]` attributes, pass the assemblies to scan during registration. CleverCache will discover and wire up the cascade rules at startup — no EF Core required: +**With EF Core** — `AddCleverCacheEntityFramework` registers the interceptor and calls `AddCleverCache` internally, so a single call is all you need: ```csharp -builder.Services.AddCleverCache(o => o.ScanAssemblyContaining()); +builder.Services.AddCleverCacheEntityFramework(); +// with CleverCache options: +builder.Services.AddCleverCacheEntityFramework(o => o.ScanAssemblyContaining()); ``` See [Cache Providers](Cache-Providers) for memory, distributed, Redis, and custom provider options. @@ -42,15 +48,13 @@ See [Cache Providers](Cache-Providers) for memory, distributed, Redis, and custo The interceptor is what makes CleverCache automatic — cache entries for changed entity types are evicted the moment `SaveChanges` or `SaveChangesAsync` completes. Without it, you can still use CleverCache by calling `RemoveByType()` yourself. -Install the EF Core package and register the interceptor: +Install the EF Core package: ``` dotnet add package CleverCache.EntityFrameworkCore ``` -```csharp -builder.Services.AddCleverCacheEntityFramework(); -``` +`AddCleverCacheEntityFramework()` registers both the core services and the interceptor, so you only need one call (see step 1 above). > **Important:** Automatic invalidation only fires for writes that go through EF Core's change tracker. Writes that bypass it — `ExecuteUpdate`, `ExecuteDelete`, raw SQL, stored procedures, or external services — will **not** trigger invalidation. See [Bulk Operations](Bulk-Operations) for workarounds. diff --git a/wiki/Migrating-to-V2.md b/wiki/Migrating-to-V2.md index fd1cf36..4b8d8d8 100644 --- a/wiki/Migrating-to-V2.md +++ b/wiki/Migrating-to-V2.md @@ -41,7 +41,7 @@ dotnet add package CleverCache.EntityFrameworkCore ### 2. Update DI registration -`AddCleverCache()` no longer registers the EF Core interceptor. You must call `AddCleverCacheEntityFramework()` alongside it. +`AddCleverCache()` no longer registers the EF Core interceptor. Replace both calls with a single `AddCleverCacheEntityFramework()`, which registers the interceptor and calls `AddCleverCache()` internally. **Before:** ```csharp @@ -50,11 +50,12 @@ builder.Services.AddCleverCache(); **After:** ```csharp -builder.Services.AddCleverCache(); -builder.Services.AddCleverCacheEntityFramework(); // new — registers the SaveChanges interceptor +builder.Services.AddCleverCacheEntityFramework(); +// with CleverCache options (e.g. assembly scanning): +builder.Services.AddCleverCacheEntityFramework(o => o.ScanAssemblyContaining()); ``` -> If you are not using EF Core (manual invalidation only), just keep `AddCleverCache()` and skip `AddCleverCacheEntityFramework()`. +> If you are not using EF Core (manual invalidation only), keep using `AddCleverCache()` — `AddCleverCacheEntityFramework` is only needed if you have the EF package installed. --- @@ -86,7 +87,6 @@ app.UseCleverCache(); **After:** ```csharp -builder.Services.AddCleverCache(); builder.Services.AddCleverCacheEntityFramework(); // ... app.ScanDbSetsForCacheDependencies(o => From ca66a9111bbb526384108bd02eaf119ee51d5f70 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 12:21:58 +0100 Subject: [PATCH 16/50] docs: clarify attribute scanning requirement when not using ScanDbSetsForCacheDependencies Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- wiki/Migrating-to-V2.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/wiki/Migrating-to-V2.md b/wiki/Migrating-to-V2.md index 4b8d8d8..b48cf14 100644 --- a/wiki/Migrating-to-V2.md +++ b/wiki/Migrating-to-V2.md @@ -73,6 +73,15 @@ app.UseCleverCache(); app.ScanDbSetsForCacheDependencies(); ``` +> **Important — `[DependantCaches]` attribute processing:** In V1, `UseCleverCache` silently processed `[DependantCaches]` attributes on EF model types as a side effect. `ScanDbSetsForCacheDependencies` does the same, so a direct swap preserves this behaviour. +> +> However, if you are migrating away from `ScanDbSetsForCacheDependencies` entirely (e.g. you only need the interceptor, not navigation scanning), those attributes will no longer be processed. You must add `ScanAssemblyContaining` to preserve them: +> +> ```csharp +> // Interceptor only — must tell CleverCache which assemblies contain [DependentCaches] attributes +> builder.Services.AddCleverCacheEntityFramework(o => o.ScanAssemblyContaining()); +> ``` + Scan options are now passed directly instead of being stored on `CleverCacheOptions`. **Before:** From 87804f310fd08dbb03cddaf308c50370b4b75315 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 12:28:10 +0100 Subject: [PATCH 17/50] Clean up DependentCachesAttribute and wiki docs - Remove NavigationScanMode from DependentCachesAttribute - it was only used by DbContextExtensions.ProcessAttribute, which no longer exists. Attributes and navigation scanning are now fully independent concepts. - Remove ProcessAttribute from DbContextExtensions; DiscoverDependentCaches is now navigation-scanning-only (None guard returns early) - Change CleverCacheScanOptions default NavigationScanMode to Direct so a no-arg ScanDbSetsForCacheDependencies() call does something useful - Rewrite wiki/Dependent-Caches.md: remove stale Scenario 3 (per-entity attribute navigation), remove Scenario 3/4 interaction table, rename Scenario 4 to Scenario 3, update all API references to V2 names - Update wiki/Migrating-to-V2.md step 5: note navigationScanMode removal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Attributes/DependentCachesAttribute.cs | 8 +-- .../Extensions/DbContextExtensions.cs | 41 ++--------- .../Models/CleverCacheScanOptions.cs | 2 +- CleverCache.Tests/DbContextExtensionsTests.cs | 21 +----- wiki/Dependent-Caches.md | 68 ++++++------------- wiki/Migrating-to-V2.md | 30 +++----- 6 files changed, 40 insertions(+), 130 deletions(-) diff --git a/Attributes/DependentCachesAttribute.cs b/Attributes/DependentCachesAttribute.cs index fe10ecb..1839ca0 100644 --- a/Attributes/DependentCachesAttribute.cs +++ b/Attributes/DependentCachesAttribute.cs @@ -3,8 +3,8 @@ /// /// Declares cache dependency relationships for an entity type. /// Use builder.Services.AddCleverCache(o => o.ScanAssemblyContaining<T>()) to register -/// these at startup, or pick them up automatically via app.ScanDbSetsForCacheDependencies<TContext>() -/// if you are using EF Core navigation scanning. +/// these at startup. When any entry for this type is invalidated, entries for all declared +/// dependent types are also invalidated. /// /// /// @@ -20,16 +20,12 @@ [AttributeUsage(AttributeTargets.Class)] public class DependentCachesAttribute( Type[] types, - DependentCacheNavigationScanMode navigationScanMode = DependentCacheNavigationScanMode.None, bool reverse = false ) : Attribute { /// The entity types that should also be invalidated when this type's entries are evicted. public Type[] DependantTypes { get; } = types ?? []; - /// Controls whether navigation properties are scanned to discover additional dependent types. - public DependentCacheNavigationScanMode NavigationScanMode { get; set; } = navigationScanMode; - /// When true, also registers the inverse dependency so that invalidating any dependent type also invalidates this type. public bool Reverse { get; set; } = reverse; } \ No newline at end of file diff --git a/CleverCache.EntityFrameworkCore/Extensions/DbContextExtensions.cs b/CleverCache.EntityFrameworkCore/Extensions/DbContextExtensions.cs index 6effbf8..a678850 100644 --- a/CleverCache.EntityFrameworkCore/Extensions/DbContextExtensions.cs +++ b/CleverCache.EntityFrameworkCore/Extensions/DbContextExtensions.cs @@ -1,6 +1,4 @@ -using System.Reflection; -using CleverCache.Attributes; -using CleverCache.EntityFrameworkCore.Exceptions; +using CleverCache.EntityFrameworkCore.Exceptions; using CleverCache.EntityFrameworkCore.Helpers; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -21,43 +19,14 @@ public static void EnsureCleverCacheInterceptor(this DbContext dbContext) public static List DiscoverDependentCaches(this DbContext dbContext, CleverCacheScanOptions scanOptions) { + if (scanOptions.NavigationScanMode == DependentCacheNavigationScanMode.None) + return []; + HashSet dependentCaches = []; foreach (var entityType in dbContext.Model.GetEntityTypes()) - { - if (scanOptions.NavigationScanMode != DependentCacheNavigationScanMode.None) - NavigationScanningHelper.Scan(scanOptions, entityType, dependentCaches); - - // Attribute processing is redundant if recursive scanning already covered the full graph - if (scanOptions.NavigationScanMode != DependentCacheNavigationScanMode.Recursive) - ProcessAttribute(dbContext, entityType, dependentCaches); - } + NavigationScanningHelper.Scan(scanOptions, entityType, dependentCaches); return [.. dependentCaches]; } - - private static void ProcessAttribute(DbContext dbContext, IEntityType entityType, HashSet dependentCaches) - { - var type = entityType.ClrType; - var attribute = type.GetCustomAttribute(); - if (attribute is null) return; - - foreach (var dependentType in attribute.DependantTypes) - { - dependentCaches.Add(new DependentCache(type, dependentType)); - if (attribute.Reverse) - dependentCaches.Add(new DependentCache(dependentType, type)); - - if (attribute.NavigationScanMode == DependentCacheNavigationScanMode.None) - continue; - - var dependentModelType = dbContext.Model.FindEntityType(dependentType); - if (dependentModelType is null) continue; - - NavigationScanningHelper.Scan( - new CleverCacheScanOptions(attribute.NavigationScanMode, attribute.Reverse), - entityType, - dependentCaches); - } - } } diff --git a/CleverCache.EntityFrameworkCore/Models/CleverCacheScanOptions.cs b/CleverCache.EntityFrameworkCore/Models/CleverCacheScanOptions.cs index e3cea0f..6df5642 100644 --- a/CleverCache.EntityFrameworkCore/Models/CleverCacheScanOptions.cs +++ b/CleverCache.EntityFrameworkCore/Models/CleverCacheScanOptions.cs @@ -1,7 +1,7 @@ namespace CleverCache.EntityFrameworkCore.Models; public class CleverCacheScanOptions( - DependentCacheNavigationScanMode navigationScanMode = DependentCacheNavigationScanMode.None, + DependentCacheNavigationScanMode navigationScanMode = DependentCacheNavigationScanMode.Direct, bool reverseNavigationDependencies = false ) { diff --git a/CleverCache.Tests/DbContextExtensionsTests.cs b/CleverCache.Tests/DbContextExtensionsTests.cs index 3dfe8f7..d62f77b 100644 --- a/CleverCache.Tests/DbContextExtensionsTests.cs +++ b/CleverCache.Tests/DbContextExtensionsTests.cs @@ -1,4 +1,3 @@ -using CleverCache.Attributes; using CleverCache.EntityFrameworkCore.Exceptions; using CleverCache.EntityFrameworkCore.Extensions; using CleverCache.EntityFrameworkCore.Interceptors; @@ -12,10 +11,6 @@ namespace CleverCache.Tests; internal class DbExtOrder { public int Id { get; set; } public List Lines { get; set; } = []; } internal class DbExtOrderLine { public int Id { get; set; } public int DbExtOrderId { get; set; } public DbExtOrder? Order { get; set; } } -[DependentCaches([typeof(DbExtProduct)])] -internal class DbExtCategory { public int Id { get; set; } } -internal class DbExtProduct { public int Id { get; set; } } - // Recursive model: A → B → C internal class DbExtA { public int Id { get; set; } public DbExtB? B { get; set; } } internal class DbExtB { public int Id { get; set; } public DbExtC? C { get; set; } } @@ -25,8 +20,6 @@ internal class ScanTestDbContext(DbContextOptions options) : { public DbSet Orders => Set(); public DbSet OrderLines => Set(); - public DbSet Categories => Set(); - public DbSet Products => Set(); public DbSet As => Set(); public DbSet Bs => Set(); public DbSet Cs => Set(); @@ -67,12 +60,12 @@ public void DiscoverDependentCaches_DirectNavigation_DiscoversRelationship() } [Fact] - public void DiscoverDependentCaches_NoneMode_DoesNotScanNavigations() + public void DiscoverDependentCaches_NoneMode_ReturnsEmpty() { using var context = CreateContext(); var result = context.DiscoverDependentCaches(new CleverCacheScanOptions(DependentCacheNavigationScanMode.None)); - Assert.DoesNotContain(result, d => d.Type == typeof(DbExtOrder) && d.DependentType == typeof(DbExtOrderLine)); + Assert.Empty(result); } [Fact] @@ -84,13 +77,5 @@ public void DiscoverDependentCaches_RecursiveNavigation_DiscoversTransitiveRelat Assert.Contains(result, d => d.Type == typeof(DbExtA) && d.DependentType == typeof(DbExtB)); Assert.Contains(result, d => d.Type == typeof(DbExtB) && d.DependentType == typeof(DbExtC)); } - - [Fact] - public void DiscoverDependentCaches_DependentCachesAttribute_RegistersDependency() - { - using var context = CreateContext(); - var result = context.DiscoverDependentCaches(new CleverCacheScanOptions(DependentCacheNavigationScanMode.None)); - - Assert.Contains(result, d => d.Type == typeof(DbExtCategory) && d.DependentType == typeof(DbExtProduct)); - } } + diff --git a/wiki/Dependent-Caches.md b/wiki/Dependent-Caches.md index c56f895..33ad071 100644 --- a/wiki/Dependent-Caches.md +++ b/wiki/Dependent-Caches.md @@ -1,6 +1,6 @@ # Dependent Caches -CleverCache tracks which entity types a cache entry is associated with and evicts it automatically when those types change. This page covers four scenarios, from the simplest to the most automatic. +CleverCache tracks which entity types a cache entry is associated with and evicts it automatically when those types change. This page covers the scenarios for configuring cache dependency relationships. --- @@ -40,12 +40,12 @@ Register the assembly containing your entities when configuring CleverCache — ```csharp builder.Services.AddCleverCache(o => o.ScanAssemblyContaining()); +// or, when using EF Core: +builder.Services.AddCleverCacheEntityFramework(o => o.ScanAssemblyContaining()); ``` CleverCache scans the assembly at startup and registers the cascade rules. Now `cache.RemoveByType()` also clears all `OrderLine` and `OrderNote` entries automatically. -If you are already using `UseCleverCache()` for navigation scanning, attribute-based cascades on those same entities are also picked up there — so you don't need `ScanAssemblyContaining` as well. - **Programmatically** (for dynamic or test scenarios): ```csharp @@ -67,60 +67,32 @@ public class Order { } --- -## Scenario 3 — Auto-discover cascades for a specific entity +## Scenario 3 — Auto-wire the whole context via navigation scanning -Instead of listing dependent types by hand, CleverCache can read the EF Core navigation properties on a specific entity and register cascades for you: +If you want cascade rules discovered automatically from your EF Core model without decorating any classes, call `ScanDbSetsForCacheDependencies` after building your app. This inspects every entity type in the context's model and registers cascades based on navigation properties: ```csharp -[DependentCaches([], navigationScanMode: DependentCacheNavigationScanMode.Direct)] -public class Order -{ - public Customer Customer { get; set; } // → cascade added - public ICollection Lines { get; set; } // → cascade added -} -``` - -| Mode | Behaviour | -|---|---| -| `None` (default) | No navigation scanning — use explicit `types` list only | -| `Direct` | Scans the immediate navigation properties of this entity | -| `Recursive` | Scans the full navigation graph transitively from this entity | +app.ScanDbSetsForCacheDependencies(); -This is useful when you want opt-in, per-entity control — only the entities you decorate are scanned. +// Control the depth of scanning: +app.ScanDbSetsForCacheDependencies(o => + o.NavigationScanMode = DependentCacheNavigationScanMode.Direct); ---- - -## Scenario 4 — Auto-wire the whole context with no attributes - -If you want every entity in your context wired up automatically without decorating any classes, enable global navigation scanning in `UseCleverCache`: - -```csharp -app.UseCleverCache(o => - o.Scanning.NavigationScanMode = DependentCacheNavigationScanMode.Direct); -``` - -CleverCache scans the navigation properties on every `DbSet` type in `AppDbContext` and registers the cascades at startup. No `[DependentCaches]` attributes needed anywhere. - -```csharp -// Also register reverse cascades (when OrderLine changes, also clear Order) -app.UseCleverCache(o => +// Also register reverse cascades (when OrderLine changes, also clear Order): +app.ScanDbSetsForCacheDependencies(o => { - o.Scanning.NavigationScanMode = DependentCacheNavigationScanMode.Recursive; - o.Scanning.ReverseNavigationDependencies = true; + o.NavigationScanMode = DependentCacheNavigationScanMode.Recursive; + o.ReverseNavigationDependencies = true; }); ``` -> **⚠️ Consider carefully before using this in large projects.** Global navigation scanning wires up every entity in your context. In a large schema this can create a very wide dependency tree — a change to a central entity like `Customer` or `User` may cascade to dozens of cache keys, leading to excessive invalidation and high memory usage from tracking all those key associations. For most projects, Scenario 2 (`[DependentCaches]` attributes with `ScanAssemblyContaining`) gives you the same convenience with precise, opt-in control over which relationships matter. - ---- - -## How Scenario 3 and 4 interact - -| Global mode | Attribute behaviour | +| Mode | Behaviour | |---|---| -| `None` (default) | Attributes are processed normally — Scenarios 2 & 3 work as described | -| `Direct` | Global scanning runs first; attributes are **also** processed for any additional explicit types or `reverse` flags | -| `Recursive` | Attribute processing is **skipped entirely** — the full graph is already discovered globally, making per-entity attributes redundant | +| `Direct` (default) | Scans the immediate navigation properties of each entity | +| `Recursive` | Scans the full navigation graph transitively from each entity | +| `None` | No scanning — returns nothing | + +> **⚠️ Consider carefully before using this in large projects.** Global navigation scanning wires up every entity in your context. In a large schema this can create a very wide dependency tree — a change to a central entity like `Customer` or `User` may cascade to dozens of cache keys, leading to excessive invalidation and high memory usage from tracking all those key associations. For most projects, Scenario 2 (`[DependentCaches]` attributes with `ScanAssemblyContaining`) gives you the same convenience with precise, opt-in control over which relationships matter. -> If you use global `Recursive` scanning, `[DependentCaches]` attributes on your entities are ignored. Choose either global scanning or per-entity attributes — don't mix `Recursive` with attribute-based configuration. +> **Navigation scanning and attributes are independent.** `ScanDbSetsForCacheDependencies` discovers relationships purely from EF navigation properties. `[DependentCaches]` attributes are registered separately via `ScanAssemblyContaining`. Use both together for full coverage. diff --git a/wiki/Migrating-to-V2.md b/wiki/Migrating-to-V2.md index b48cf14..3085e86 100644 --- a/wiki/Migrating-to-V2.md +++ b/wiki/Migrating-to-V2.md @@ -73,12 +73,14 @@ app.UseCleverCache(); app.ScanDbSetsForCacheDependencies(); ``` -> **Important — `[DependantCaches]` attribute processing:** In V1, `UseCleverCache` silently processed `[DependantCaches]` attributes on EF model types as a side effect. `ScanDbSetsForCacheDependencies` does the same, so a direct swap preserves this behaviour. +> **Important — `[DependantCaches]` attribute processing:** In V1, `UseCleverCache` silently processed `[DependantCaches]` attributes on EF model types as a side effect. In V2 these are two separate concerns: > -> However, if you are migrating away from `ScanDbSetsForCacheDependencies` entirely (e.g. you only need the interceptor, not navigation scanning), those attributes will no longer be processed. You must add `ScanAssemblyContaining` to preserve them: +> - `ScanDbSetsForCacheDependencies` is **navigation scanning only** — it does not process attributes +> - `[DependentCaches]` attributes are picked up by `ScanAssemblyContaining` in `AddCleverCacheEntityFramework` +> +> If you relied on `UseCleverCache` for attribute-based dependency registration, you must add `ScanAssemblyContaining`: > > ```csharp -> // Interceptor only — must tell CleverCache which assemblies contain [DependentCaches] attributes > builder.Services.AddCleverCacheEntityFramework(o => o.ScanAssemblyContaining()); > ``` @@ -162,7 +164,7 @@ var result = cache.GetOrCreate( ### 5. Update the `[DependantCaches]` attribute -The attribute class was renamed to fix a spelling mistake. The property `DependantTypes` retains the original spelling for now. +The attribute class was renamed to fix a spelling mistake. The property `DependantTypes` retains the original spelling for now. The `navigationScanMode` parameter has been removed — navigation scanning is now exclusively handled by `ScanDbSetsForCacheDependencies`. **Before:** ```csharp @@ -176,31 +178,17 @@ public class Order { } public class Order { } ``` -`[DependentCaches]` now also accepts a `NavigationScanMode` parameter to control whether navigation properties on the decorated type are additionally scanned: - -```csharp -[DependentCaches([typeof(OrderLine)], navigationScanMode: DependentCacheNavigationScanMode.Direct)] -public class Order { } -``` - --- ### 6. Register `[DependentCaches]` attributes at startup -In V1, `UseCleverCache` processed `[DependantCaches]` attributes automatically via EF model scanning. In V2 you have two options: - -**Option A — attribute-only (no EF model scan):** use `ScanAssemblyContaining` in `AddCleverCache`. Works without the EF package and is the recommended approach when you don't need navigation scanning. +In V1, `UseCleverCache` processed `[DependantCaches]` attributes as a side effect of EF model scanning. In V2 these are separate concerns — `ScanDbSetsForCacheDependencies` handles navigation scanning only. Attributes must be registered via `ScanAssemblyContaining`: ```csharp -builder.Services.AddCleverCache(o => o.ScanAssemblyContaining()); +builder.Services.AddCleverCacheEntityFramework(o => o.ScanAssemblyContaining()); ``` -**Option B — EF navigation scan (also picks up attributes):** call `ScanDbSetsForCacheDependencies`. This scans navigations AND processes `[DependentCaches]` attributes on each entity type in the model. - -```csharp -app.ScanDbSetsForCacheDependencies(o => - o.NavigationScanMode = DependentCacheNavigationScanMode.Direct); -``` +You can combine both: use `ScanAssemblyContaining` for explicit attribute-based dependencies and `ScanDbSetsForCacheDependencies` for navigation-driven discovery. --- From eb4eca69b18b5f7060d2d4d9b37d4c0f0385de4c Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 14:21:43 +0100 Subject: [PATCH 18/50] docs: add attribute discovery breaking change to migration guide summary Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- wiki/Migrating-to-V2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/wiki/Migrating-to-V2.md b/wiki/Migrating-to-V2.md index 3085e86..6c77bbd 100644 --- a/wiki/Migrating-to-V2.md +++ b/wiki/Migrating-to-V2.md @@ -11,6 +11,7 @@ V2 is a significant refactor. The core caching API is mostly the same but severa | EF Core dependency | Bundled in `CleverCache` | Separate `CleverCache.EntityFrameworkCore` package | | Startup (interceptor) | `AddCleverCache()` registered interceptor | `AddCleverCacheEntityFramework()` required | | Startup (scanning) | `app.UseCleverCache()` | `app.ScanDbSetsForCacheDependencies()` | +| Attribute discovery | `UseCleverCache` picked up `[DependantCaches]` automatically | Must call `ScanAssemblyContaining()` explicitly in `AddCleverCacheEntityFramework` | | Scan options | `CleverCacheOptions.Scanning` | Passed directly to `ScanDbSetsForCacheDependencies` | | `GetOrCreate` factory | `Func` | `Func` | | `GetOrCreateAsync` factory | `Func>` | `Func>` | From ff8a0472c5a581c20fdc25cad2abde58b849f863 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 14:30:04 +0100 Subject: [PATCH 19/50] docs: move attribute steps adjacent to UseCleverCache step in migration guide Reorder steps so attribute rename (4) and attribute registration (5) immediately follow the UseCleverCache rename (3) they are related to. Factory signature change moves to step 6. Trims the now-redundant callout in step 3 to a brief forward reference. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- wiki/Migrating-to-V2.md | 89 ++++++++++++++++------------------------- 1 file changed, 34 insertions(+), 55 deletions(-) diff --git a/wiki/Migrating-to-V2.md b/wiki/Migrating-to-V2.md index 6c77bbd..c0bff25 100644 --- a/wiki/Migrating-to-V2.md +++ b/wiki/Migrating-to-V2.md @@ -62,30 +62,7 @@ builder.Services.AddCleverCacheEntityFramework(o => o.ScanAssemblyContaining()` has been replaced with a more descriptive name that lives on `IApplicationBuilder`. - -**Before:** -```csharp -app.UseCleverCache(); -``` - -**After:** -```csharp -app.ScanDbSetsForCacheDependencies(); -``` - -> **Important — `[DependantCaches]` attribute processing:** In V1, `UseCleverCache` silently processed `[DependantCaches]` attributes on EF model types as a side effect. In V2 these are two separate concerns: -> -> - `ScanDbSetsForCacheDependencies` is **navigation scanning only** — it does not process attributes -> - `[DependentCaches]` attributes are picked up by `ScanAssemblyContaining` in `AddCleverCacheEntityFramework` -> -> If you relied on `UseCleverCache` for attribute-based dependency registration, you must add `ScanAssemblyContaining`: -> -> ```csharp -> builder.Services.AddCleverCacheEntityFramework(o => o.ScanAssemblyContaining()); -> ``` - -Scan options are now passed directly instead of being stored on `CleverCacheOptions`. +`app.UseCleverCache()` has been replaced with a more descriptive name that lives on `IApplicationBuilder`. Scan options are now passed directly instead of being stored on `CleverCacheOptions`. **Before:** ```csharp @@ -116,9 +93,41 @@ app.ScanDbSetsForCacheDependencies(o => o.NavigationScanMode = DependentCacheNavigationScanMode.Recursive); ``` +> **Note:** `ScanDbSetsForCacheDependencies` is navigation scanning only — it no longer processes `[DependentCaches]` attributes. See step 5 below. + +--- + +### 4. Update the `[DependantCaches]` attribute + +The attribute class was renamed to fix a spelling mistake. The property `DependantTypes` retains the original spelling for now. The `navigationScanMode` parameter has been removed — navigation scanning is now exclusively handled by `ScanDbSetsForCacheDependencies`. + +**Before:** +```csharp +[DependantCaches([typeof(OrderLine), typeof(OrderNote)])] +public class Order { } +``` + +**After:** +```csharp +[DependentCaches([typeof(OrderLine), typeof(OrderNote)])] +public class Order { } +``` + --- -### 4. Update `GetOrCreate` / `GetOrCreateAsync` factory signatures +### 5. Register `[DependentCaches]` attributes at startup + +In V1, `UseCleverCache` processed `[DependantCaches]` attributes as a side effect of EF model scanning. In V2 these are separate concerns — `ScanDbSetsForCacheDependencies` handles navigation scanning only. Attributes must now be registered explicitly via `ScanAssemblyContaining`: + +```csharp +builder.Services.AddCleverCacheEntityFramework(o => o.ScanAssemblyContaining()); +``` + +You can combine both: use `ScanAssemblyContaining` for explicit attribute-based dependencies and `ScanDbSetsForCacheDependencies` for navigation-driven discovery. + +--- + +### 6. Update `GetOrCreate` / `GetOrCreateAsync` factory signatures The factory delegate no longer receives an `ICacheEntry`. Cache entry options are now a separate parameter of type `CleverCacheEntryOptions` instead of `MemoryCacheEntryOptions`. @@ -163,36 +172,6 @@ var result = cache.GetOrCreate( --- -### 5. Update the `[DependantCaches]` attribute - -The attribute class was renamed to fix a spelling mistake. The property `DependantTypes` retains the original spelling for now. The `navigationScanMode` parameter has been removed — navigation scanning is now exclusively handled by `ScanDbSetsForCacheDependencies`. - -**Before:** -```csharp -[DependantCaches([typeof(OrderLine), typeof(OrderNote)])] -public class Order { } -``` - -**After:** -```csharp -[DependentCaches([typeof(OrderLine), typeof(OrderNote)])] -public class Order { } -``` - ---- - -### 6. Register `[DependentCaches]` attributes at startup - -In V1, `UseCleverCache` processed `[DependantCaches]` attributes as a side effect of EF model scanning. In V2 these are separate concerns — `ScanDbSetsForCacheDependencies` handles navigation scanning only. Attributes must be registered via `ScanAssemblyContaining`: - -```csharp -builder.Services.AddCleverCacheEntityFramework(o => o.ScanAssemblyContaining()); -``` - -You can combine both: use `ScanAssemblyContaining` for explicit attribute-based dependencies and `ScanDbSetsForCacheDependencies` for navigation-driven discovery. - ---- - ### 7. Update `FakeCache` namespace `FakeCache` has moved from the `CleverCache.Implementations` namespace to the root `CleverCache` namespace. From 73b5ec8eb527900070edae0ab9da4dce07ffaabe Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 14:32:01 +0100 Subject: [PATCH 20/50] fix: rename DependantTypes to DependentTypes on DependentCachesAttribute Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Attributes/DependentCachesAttribute.cs | 2 +- Models/CleverCacheOptions.cs | 2 +- wiki/Migrating-to-V2.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Attributes/DependentCachesAttribute.cs b/Attributes/DependentCachesAttribute.cs index 1839ca0..67fba2f 100644 --- a/Attributes/DependentCachesAttribute.cs +++ b/Attributes/DependentCachesAttribute.cs @@ -24,7 +24,7 @@ public class DependentCachesAttribute( ) : Attribute { /// The entity types that should also be invalidated when this type's entries are evicted. - public Type[] DependantTypes { get; } = types ?? []; + public Type[] DependentTypes { get; } = types ?? []; /// When true, also registers the inverse dependency so that invalidating any dependent type also invalidates this type. public bool Reverse { get; set; } = reverse; diff --git a/Models/CleverCacheOptions.cs b/Models/CleverCacheOptions.cs index 3e07442..6d18c8c 100644 --- a/Models/CleverCacheOptions.cs +++ b/Models/CleverCacheOptions.cs @@ -37,7 +37,7 @@ public CleverCacheOptions ScanAssemblies(params Assembly[] assemblies) var attr = type.GetCustomAttribute(); if (attr is null) continue; - foreach (var depType in attr.DependantTypes) + foreach (var depType in attr.DependentTypes) { DependentCaches.Add(new DependentCache(type, depType)); if (attr.Reverse) diff --git a/wiki/Migrating-to-V2.md b/wiki/Migrating-to-V2.md index c0bff25..c8aa4cb 100644 --- a/wiki/Migrating-to-V2.md +++ b/wiki/Migrating-to-V2.md @@ -99,7 +99,7 @@ app.ScanDbSetsForCacheDependencies(o => ### 4. Update the `[DependantCaches]` attribute -The attribute class was renamed to fix a spelling mistake. The property `DependantTypes` retains the original spelling for now. The `navigationScanMode` parameter has been removed — navigation scanning is now exclusively handled by `ScanDbSetsForCacheDependencies`. +The attribute class was renamed to fix a spelling mistake. The `navigationScanMode` parameter has been removed — navigation scanning is now exclusively handled by `ScanDbSetsForCacheDependencies`. **Before:** ```csharp From a3a65cd3ccf03f6ca1d0c683d04c4d513319180b Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 14:34:07 +0100 Subject: [PATCH 21/50] docs: add V2 breaking changes warning to README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ReadMe.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ReadMe.md b/ReadMe.md index 5569421..f3d3207 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -3,6 +3,9 @@ CleverCache [![NuGet](https://img.shields.io/nuget/dt/clevercache.svg)](https://www.nuget.org/packages/clevercache) [![NuGet](https://img.shields.io/nuget/vpre/clevercache.svg)](https://www.nuget.org/packages/clevercache) +> [!WARNING] +> **V2 contains breaking changes.** EF Core support has moved to a separate package, the startup API has changed, and `[DependentCaches]` attributes must now be registered explicitly. See the **[V1 → V2 Migration Guide](https://github.com/chunty/CleverCache/wiki/Migrating-to-V2)** before upgrading. + **CleverCache** solves the problem of remembering when to invalidate cache entries when underlying data changes — especially when a cache entry contains data from multiple entity types. With a small amount of configuration, CleverCache automatically tracks entity changes via EF Core and clears related cache entries whenever data is created, updated, or deleted. From e2388904d5fa15e9f783d391ded3919e335f0a6f Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 14:35:19 +0100 Subject: [PATCH 22/50] docs: simplify V2 warning in README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ReadMe.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ReadMe.md b/ReadMe.md index f3d3207..504cc42 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -4,7 +4,7 @@ CleverCache [![NuGet](https://img.shields.io/nuget/vpre/clevercache.svg)](https://www.nuget.org/packages/clevercache) > [!WARNING] -> **V2 contains breaking changes.** EF Core support has moved to a separate package, the startup API has changed, and `[DependentCaches]` attributes must now be registered explicitly. See the **[V1 → V2 Migration Guide](https://github.com/chunty/CleverCache/wiki/Migrating-to-V2)** before upgrading. +> **V2 contains breaking changes.** Read the **[V1 → V2 Migration Guide](https://github.com/chunty/CleverCache/wiki/Migrating-to-V2)** before upgrading. **CleverCache** solves the problem of remembering when to invalidate cache entries when underlying data changes — especially when a cache entry contains data from multiple entity types. From 946201121488efb893665d6cb1b820613729afa8 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 14:40:30 +0100 Subject: [PATCH 23/50] docs: use emoji warning icon for NuGet compatibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ReadMe.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ReadMe.md b/ReadMe.md index 504cc42..55c3f34 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -3,8 +3,7 @@ CleverCache [![NuGet](https://img.shields.io/nuget/dt/clevercache.svg)](https://www.nuget.org/packages/clevercache) [![NuGet](https://img.shields.io/nuget/vpre/clevercache.svg)](https://www.nuget.org/packages/clevercache) -> [!WARNING] -> **V2 contains breaking changes.** Read the **[V1 → V2 Migration Guide](https://github.com/chunty/CleverCache/wiki/Migrating-to-V2)** before upgrading. +> ⚠️ **V2 contains breaking changes.** Read the **[V1 → V2 Migration Guide](https://github.com/chunty/CleverCache/wiki/Migrating-to-V2)** before upgrading. **CleverCache** solves the problem of remembering when to invalidate cache entries when underlying data changes — especially when a cache entry contains data from multiple entity types. From b6e047ab541048e8668ab890ae0adb79dda5222e Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 14:45:29 +0100 Subject: [PATCH 24/50] feat: add GetDiagnostics and RenderDependencyTree - Add CleverCacheDiagnostics record with Dependants and KeysByType - Add GetDiagnostics() to ICleverCache, CleverCacheService, FakeCache - Add SnapshotDiagnostics() protected method to CacheEntryManager - Add RenderDependencyTree() extension method on ICleverCache producing a readable tree of cascade rules and tracked keys per type - 4 new tests (65 total, all passing) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CacheKeyManager.cs | 16 ++++++++ CleverCache.Tests/CacheEntryManagerTests.cs | 18 +++++++++ CleverCache.Tests/CleverCacheServiceTests.cs | 38 ++++++++++++++++++ CleverCache.Tests/GlobalUsings.cs | 1 + Extensions/CleverCacheExtensions.cs | 42 ++++++++++++++++++++ ICleverCache.cs | 6 +++ Implementations/CleverCacheService.cs | 2 + Implementations/FakeCache.cs | 4 ++ Models/CleverCacheDiagnostics.cs | 11 +++++ 9 files changed, 138 insertions(+) create mode 100644 Models/CleverCacheDiagnostics.cs diff --git a/CacheKeyManager.cs b/CacheKeyManager.cs index 2cd6b19..945ad5f 100644 --- a/CacheKeyManager.cs +++ b/CacheKeyManager.cs @@ -37,6 +37,22 @@ public void AddKeyToTypes(Type[] types, object key) protected object[] SnapshotKeysFor(Type type) => _keysByType.TryGetValue(type, out var set) ? set.Keys.ToArray() : []; + /// + /// Snapshot of the full dependency graph and tracked keys. + /// + protected CleverCacheDiagnostics SnapshotDiagnostics() + { + var dependants = _dependants.ToDictionary( + kvp => kvp.Key, + kvp => (IReadOnlyList)kvp.Value.Keys.OrderBy(t => t.Name).ToList()); + + var keysByType = _keysByType.ToDictionary( + kvp => kvp.Key, + kvp => (IReadOnlyList)kvp.Value.Keys.ToList()); + + return new CleverCacheDiagnostics(dependants, keysByType); + } + /// /// Transitive closure over dependents, cycle-safe /// diff --git a/CleverCache.Tests/CacheEntryManagerTests.cs b/CleverCache.Tests/CacheEntryManagerTests.cs index 70d4330..45cc2b6 100644 --- a/CleverCache.Tests/CacheEntryManagerTests.cs +++ b/CleverCache.Tests/CacheEntryManagerTests.cs @@ -4,6 +4,7 @@ namespace CleverCache.Tests; file class TestCacheManager : CacheEntryManager { public object[] KeysFor(Type type) => SnapshotKeysFor(type); + public CleverCacheDiagnostics Diagnostics() => SnapshotDiagnostics(); } public class CacheEntryManagerTests @@ -78,4 +79,21 @@ public void SnapshotKeysFor_UnknownType_ReturnsEmpty() Assert.Empty(mgr.KeysFor(typeof(double))); } + + [Fact] + public void SnapshotDiagnostics_ReflectsDependantsAndKeys() + { + var mgr = new TestCacheManager(); + mgr.AddDependentCache(typeof(string), typeof(int)); + mgr.AddKeyToTypes([typeof(string)], "k1"); + + var d = mgr.Diagnostics(); + + Assert.True(d.Dependants.ContainsKey(typeof(string))); + Assert.Contains(typeof(int), d.Dependants[typeof(string)]); + Assert.True(d.KeysByType.ContainsKey(typeof(string))); + Assert.Contains("k1", d.KeysByType[typeof(string)]); + // int inherits the key via cascade expansion + Assert.Contains("k1", d.KeysByType[typeof(int)]); + } } diff --git a/CleverCache.Tests/CleverCacheServiceTests.cs b/CleverCache.Tests/CleverCacheServiceTests.cs index 4b2f7d4..f75352c 100644 --- a/CleverCache.Tests/CleverCacheServiceTests.cs +++ b/CleverCache.Tests/CleverCacheServiceTests.cs @@ -143,4 +143,42 @@ public async Task GetOrCreateAsync_ConcurrentRequestsSameKey_FactoryCalledOnce() Assert.Equal(1, callCount); Assert.All(results, r => Assert.Equal(42, r)); } + + [Fact] + public void GetDiagnostics_ReturnsCascadesAndKeys() + { + var sut = CreateService(); + sut.AddDependentCache(typeof(string), typeof(int)); + sut.GetOrCreate([typeof(string)], "k1", () => 1); + + var d = sut.GetDiagnostics(); + + Assert.Contains(typeof(int), d.Dependants[typeof(string)]); + Assert.Contains("k1", d.KeysByType[typeof(string)]); + } + + [Fact] + public void RenderDependencyTree_ContainsTypeNamesAndKeys() + { + var sut = CreateService(); + sut.AddDependentCache(typeof(string), typeof(int)); + sut.GetOrCreate([typeof(string)], "my-key", () => 1); + + var output = sut.RenderDependencyTree(); + + Assert.Contains("String", output); + Assert.Contains("Int32", output); + Assert.Contains("my-key", output); + Assert.Contains("cascades to", output); + } + + [Fact] + public void RenderDependencyTree_NoTypes_ReturnsEmptyMessage() + { + var sut = CreateService(); + + var output = sut.RenderDependencyTree(); + + Assert.Contains("no types registered", output); + } } diff --git a/CleverCache.Tests/GlobalUsings.cs b/CleverCache.Tests/GlobalUsings.cs index 8fbbaef..08af72d 100644 --- a/CleverCache.Tests/GlobalUsings.cs +++ b/CleverCache.Tests/GlobalUsings.cs @@ -1,2 +1,3 @@ global using Xunit; global using CleverCache.Models; +global using CleverCache.Extensions; diff --git a/Extensions/CleverCacheExtensions.cs b/Extensions/CleverCacheExtensions.cs index 8d6b857..d372d96 100644 --- a/Extensions/CleverCacheExtensions.cs +++ b/Extensions/CleverCacheExtensions.cs @@ -63,5 +63,47 @@ public static class CleverCacheExtensions Func> factory, CleverCacheEntryOptions? createOptions = null) => await cache.GetOrCreateAsync([type], key, factory, createOptions); + + /// + /// Returns a human-readable representation of the dependency graph and tracked cache keys. + /// Useful for debugging — log it at startup or on-demand via a diagnostic endpoint. + /// + /// + /// + /// logger.LogDebug(cache.RenderDependencyTree()); + /// + /// + public static string RenderDependencyTree(this ICleverCache cache) + { + var d = cache.GetDiagnostics(); + + // Collect all types that appear in either cascades or key tracking + var allTypes = new HashSet(d.Dependants.Keys); + foreach (var cascades in d.Dependants.Values) + foreach (var t in cascades) allTypes.Add(t); + foreach (var t in d.KeysByType.Keys) allTypes.Add(t); + + if (allTypes.Count == 0) + return "CleverCache: no types registered."; + + var sb = new System.Text.StringBuilder(); + sb.AppendLine("CleverCache Dependency Tree"); + sb.AppendLine(new string('─', 40)); + + foreach (var type in allTypes.OrderBy(t => t.Name)) + { + sb.AppendLine(type.Name); + + if (d.Dependants.TryGetValue(type, out var cascades) && cascades.Count > 0) + sb.AppendLine($" ↳ cascades to : {string.Join(", ", cascades.Select(t => t.Name))}"); + + if (d.KeysByType.TryGetValue(type, out var keys) && keys.Count > 0) + sb.AppendLine($" ↳ tracked keys : {string.Join(", ", keys)}"); + } + + var totalKeys = d.KeysByType.Values.SelectMany(k => k).Distinct().Count(); + sb.Append($"─ {allTypes.Count} type(s) | {d.Dependants.Count} cascade rule(s) | {totalKeys} tracked key(s) ─"); + return sb.ToString(); + } } diff --git a/ICleverCache.cs b/ICleverCache.cs index 0032ad4..fd4aff2 100644 --- a/ICleverCache.cs +++ b/ICleverCache.cs @@ -63,6 +63,12 @@ public interface ICleverCache /// The key identifying the entry to remove. void Remove(object key); + /// + /// Returns a point-in-time snapshot of the dependency graph (cascade rules) and all + /// currently tracked cache keys, grouped by entity type. Useful for diagnostics and debugging. + /// + CleverCacheDiagnostics GetDiagnostics(); + /// /// Removes all cache entries associated with the specified entity type, including entries /// registered under any dependent types (see ). diff --git a/Implementations/CleverCacheService.cs b/Implementations/CleverCacheService.cs index e2aeba1..9a81dcb 100644 --- a/Implementations/CleverCacheService.cs +++ b/Implementations/CleverCacheService.cs @@ -54,5 +54,7 @@ public void RemoveByType(Type type) } public void Remove(object key) => _store.Remove(key); + + public CleverCacheDiagnostics GetDiagnostics() => SnapshotDiagnostics(); } diff --git a/Implementations/FakeCache.cs b/Implementations/FakeCache.cs index 7e948ce..47468c4 100644 --- a/Implementations/FakeCache.cs +++ b/Implementations/FakeCache.cs @@ -30,5 +30,9 @@ public void RemoveByType(Type type) { } /// public void Remove(object key) { } + + /// + public CleverCacheDiagnostics GetDiagnostics() => + new(new Dictionary>(), new Dictionary>()); } diff --git a/Models/CleverCacheDiagnostics.cs b/Models/CleverCacheDiagnostics.cs new file mode 100644 index 0000000..9b02844 --- /dev/null +++ b/Models/CleverCacheDiagnostics.cs @@ -0,0 +1,11 @@ +namespace CleverCache.Models; + +/// +/// A point-in-time snapshot of the cache's dependency graph and tracked keys. +/// +/// Direct cascade edges: when a type is invalidated, all listed types are also invalidated. +/// Cache keys currently tracked per type (including keys inherited via cascade expansion). +public record CleverCacheDiagnostics( + IReadOnlyDictionary> Dependants, + IReadOnlyDictionary> KeysByType +); From dd4507699cc9cca2271dcf7cfc611dbfab99b13a Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 14:46:55 +0100 Subject: [PATCH 25/50] docs: add Diagnostics wiki page for GetDiagnostics and RenderDependencyTree Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- wiki/Diagnostics.md | 56 +++++++++++++++++++++++++++++++++++++++++++++ wiki/Home.md | 1 + 2 files changed, 57 insertions(+) create mode 100644 wiki/Diagnostics.md diff --git a/wiki/Diagnostics.md b/wiki/Diagnostics.md new file mode 100644 index 0000000..fef2ddc --- /dev/null +++ b/wiki/Diagnostics.md @@ -0,0 +1,56 @@ +# Diagnostics + +CleverCache can produce a snapshot of its internal state at any point — useful for debugging misconfigured dependency trees or unexpectedly broad cache invalidation. + +--- + +## `GetDiagnostics()` + +Returns a `CleverCacheDiagnostics` record with two properties: + +| Property | Type | Description | +|---|---|---| +| `Dependants` | `IReadOnlyDictionary>` | Direct cascade edges — the types that will also be invalidated when a given type changes | +| `KeysByType` | `IReadOnlyDictionary>` | All cache keys currently tracked under each type, including keys inherited via cascade expansion | + +```csharp +var diagnostics = cache.GetDiagnostics(); + +// Which types does invalidating Order cascade to? +var cascades = diagnostics.Dependants[typeof(Order)]; + +// Which keys are currently tracked under Order? +var keys = diagnostics.KeysByType[typeof(Order)]; +``` + +--- + +## `RenderDependencyTree()` + +Formats the full dependency graph as a human-readable string. Useful for logging at startup or exposing via a diagnostic endpoint. + +```csharp +// Log at startup +logger.LogDebug(cache.RenderDependencyTree()); + +// Expose via a diagnostic endpoint +app.MapGet("/_cache/tree", (ICleverCache cache) => cache.RenderDependencyTree()); +``` + +Example output: + +``` +CleverCache Dependency Tree +──────────────────────────────────────── +Customer + ↳ cascades to : Order +Order + ↳ cascades to : OrderLine, OrderNote + ↳ tracked keys : orders-all, order:123, order:456 +OrderLine + ↳ tracked keys : orderlines-for-123 +OrderNote +─ 4 type(s) | 2 cascade rule(s) | 4 tracked key(s) ─ +``` + +> **Note:** `KeysByType` reflects the keys registered since the application started. Keys are removed from the underlying cache store when invalidated, but the type→key association in CleverCache persists until the process restarts. An entry appearing in `tracked keys` does not necessarily mean it is still present in the cache — only that it was registered at some point. diff --git a/wiki/Home.md b/wiki/Home.md index adbb703..37b3029 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -14,6 +14,7 @@ With a small amount of configuration, CleverCache automatically tracks changes v | [Dependent Caches](Dependent-Caches) | `AddKeyToType`, `AddDependentCache`, `[DependentCaches]` attribute | | [MediatR Integration](MediatR-Integration) | `[AutoCache]`, `[InvalidatesCache]`, pipeline setup | | [Bulk Operations](Bulk-Operations) | Handling `ExecuteDelete`/`ExecuteUpdate` and non-EF writes | +| [Diagnostics](Diagnostics) | Inspecting the dependency graph and tracked keys at runtime | | [Unit Testing](Unit-Testing) | `FakeCache`, mocking `ICleverCache` | | [Migrating to V2](Migrating-to-V2) | Breaking changes and before/after examples for V1 → V2 | From ef991d54faa7cc7a56498b9f4fe464884ce25e6b Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 14:51:28 +0100 Subject: [PATCH 26/50] ci: add workflow to auto-publish wiki/ to GitHub Wiki on push to main Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/publish-wiki.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/publish-wiki.yml diff --git a/.github/workflows/publish-wiki.yml b/.github/workflows/publish-wiki.yml new file mode 100644 index 0000000..249226a --- /dev/null +++ b/.github/workflows/publish-wiki.yml @@ -0,0 +1,21 @@ +name: Publish Wiki + +on: + push: + branches: [main] + paths: ['wiki/**'] + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Push wiki to GitHub Wiki + uses: Andrew-Chen-Wang/github-wiki-action@v4 + with: + path: wiki/ + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 3293f2c3946fc19da663c9dca7cb170726686b30 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 20:39:45 +0100 Subject: [PATCH 27/50] docs: clarify step 3 is only needed when using navigation scanning Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- wiki/Migrating-to-V2.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/wiki/Migrating-to-V2.md b/wiki/Migrating-to-V2.md index c8aa4cb..b0e8a91 100644 --- a/wiki/Migrating-to-V2.md +++ b/wiki/Migrating-to-V2.md @@ -62,7 +62,9 @@ builder.Services.AddCleverCacheEntityFramework(o => o.ScanAssemblyContaining()` has been replaced with a more descriptive name that lives on `IApplicationBuilder`. Scan options are now passed directly instead of being stored on `CleverCacheOptions`. +> **Only needed if you were using navigation scanning.** If you used `UseCleverCache` purely to pick up `[DependantCaches]` attributes, you can simply **remove** the call — attribute registration is now handled separately in step 5. + +If you were using navigation scanning, replace `app.UseCleverCache()` with `app.ScanDbSetsForCacheDependencies()`. Scan options are now passed directly instead of being stored on `CleverCacheOptions`. **Before:** ```csharp From d7210251d4286d8ddeeb431144180f6b644ee984 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 22:37:59 +0100 Subject: [PATCH 28/50] feat: propagate CancellationToken and add RemoveByTypeAsync - GetOrCreateAsync now accepts CancellationToken and passes it to store - Add RemoveByTypeAsync(Type, CancellationToken) to ICleverCache, CleverCacheService, FakeCache - Add RemoveByTypeAsync generic extension in CacheEntryManagerExtensions - CleverCacheInterceptor.SavedChangesAsync now awaits RemoveByTypeAsync (sync SavedChanges path unchanged) - InvalidateCacheBehaviour switches to await RemoveByTypeAsync - AutoCacheBehaviour passes cancellationToken to GetOrCreateAsync - All tests updated; 2 new tests (67 total) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Interceptors/CleverCacheInterceptor.cs | 12 ++++++++++-- CleverCache.MediatR/AutoCacheBehaviour.cs | 3 ++- .../InvalidateCacheBehaviour.cs | 2 +- CleverCache.Tests/AutoCacheBehaviourTests.cs | 10 +++++----- .../CleverCacheInterceptorTests.cs | 6 ++++-- CleverCache.Tests/CleverCacheServiceTests.cs | 19 +++++++++++++++++++ CleverCache.Tests/FakeCacheTests.cs | 8 ++++++++ .../InvalidateCacheBehaviourTests.cs | 17 +++++++++++------ Extensions/CacheEntryManagerExtensions.cs | 7 +++++++ Extensions/CleverCacheExtensions.cs | 11 +++++++---- ICleverCache.cs | 12 +++++++++++- Implementations/CleverCacheService.cs | 14 ++++++++++---- Implementations/FakeCache.cs | 6 +++++- 13 files changed, 100 insertions(+), 27 deletions(-) diff --git a/CleverCache.EntityFrameworkCore/Interceptors/CleverCacheInterceptor.cs b/CleverCache.EntityFrameworkCore/Interceptors/CleverCacheInterceptor.cs index 96b2ea0..8d6466d 100644 --- a/CleverCache.EntityFrameworkCore/Interceptors/CleverCacheInterceptor.cs +++ b/CleverCache.EntityFrameworkCore/Interceptors/CleverCacheInterceptor.cs @@ -38,8 +38,16 @@ public override ValueTask SavedChangesAsync( int result, CancellationToken cancellationToken = default) { - var savedChanges = base.SavedChangesAsync(eventData, result, cancellationToken); - InvalidateAndClear(eventData); + return InvalidateAndClearAsync(eventData, result, cancellationToken); + } + + private async ValueTask InvalidateAndClearAsync(SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken) + { + var savedChanges = await base.SavedChangesAsync(eventData, result, cancellationToken); + if (eventData.Context is null) return savedChanges; + if (!_pendingTypes.TryRemove(eventData.Context.ContextId, out var types) || types is not { Count: > 0 }) return savedChanges; + foreach (var t in types) + await cache.RemoveByTypeAsync(t, cancellationToken).ConfigureAwait(false); return savedChanges; } diff --git a/CleverCache.MediatR/AutoCacheBehaviour.cs b/CleverCache.MediatR/AutoCacheBehaviour.cs index ba2b56d..61acbf9 100644 --- a/CleverCache.MediatR/AutoCacheBehaviour.cs +++ b/CleverCache.MediatR/AutoCacheBehaviour.cs @@ -21,7 +21,8 @@ public async Task Handle(TRequest request, RequestHandlerDelegate next(cancellationToken) + () => next(cancellationToken), + cancellationToken: cancellationToken ); if (result is not null) diff --git a/CleverCache.MediatR/InvalidateCacheBehaviour.cs b/CleverCache.MediatR/InvalidateCacheBehaviour.cs index 399016b..0aab28c 100644 --- a/CleverCache.MediatR/InvalidateCacheBehaviour.cs +++ b/CleverCache.MediatR/InvalidateCacheBehaviour.cs @@ -19,7 +19,7 @@ public async Task Handle(TRequest request, RequestHandlerDelegate c.GetOrCreateAsync( - It.IsAny(), It.IsAny(), It.IsAny>>(), It.IsAny()), Times.Never); + It.IsAny(), It.IsAny(), It.IsAny>>(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -35,8 +35,8 @@ public async Task Handle_WithAttribute_CacheMiss_CallsNextAndCaches() var cacheMock = new Mock(); cacheMock .Setup(c => c.GetOrCreateAsync( - It.IsAny(), It.IsAny(), It.IsAny>>(), It.IsAny())) - .Returns>, CleverCacheEntryOptions?>((_, _, factory, _) => factory()); + It.IsAny(), It.IsAny(), It.IsAny>>(), It.IsAny(), It.IsAny())) + .Returns>, CleverCacheEntryOptions?, CancellationToken>((_, _, factory, _, _) => factory()); var sut = new AutoCacheBehaviour(cacheMock.Object); var callCount = 0; @@ -47,7 +47,7 @@ public async Task Handle_WithAttribute_CacheMiss_CallsNextAndCaches() Assert.Equal("fresh", result); Assert.Equal(1, callCount); cacheMock.Verify(c => c.GetOrCreateAsync( - It.IsAny(), It.IsAny(), It.IsAny>>(), It.IsAny()), Times.Once); + It.IsAny(), It.IsAny(), It.IsAny>>(), It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -56,7 +56,7 @@ public async Task Handle_WithAttribute_CacheHit_DoesNotCallNext() var cacheMock = new Mock(); cacheMock .Setup(c => c.GetOrCreateAsync( - It.IsAny(), It.IsAny(), It.IsAny>>(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny>>(), It.IsAny(), It.IsAny())) .ReturnsAsync("cached-value"); // returns cached directly, never invokes factory var sut = new AutoCacheBehaviour(cacheMock.Object); diff --git a/CleverCache.Tests/CleverCacheInterceptorTests.cs b/CleverCache.Tests/CleverCacheInterceptorTests.cs index 70091ea..7f4b515 100644 --- a/CleverCache.Tests/CleverCacheInterceptorTests.cs +++ b/CleverCache.Tests/CleverCacheInterceptorTests.cs @@ -99,12 +99,14 @@ public void SavedChanges_NoChanges_DoesNotCallRemoveByType() } [Fact] - public async Task SavedChangesAsync_EntityAdded_CallsRemoveByType() + public async Task SavedChangesAsync_EntityAdded_CallsRemoveByTypeAsync() { var (context, mockCache) = CreateContext(); + mockCache.Setup(c => c.RemoveByTypeAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); context.Orders.Add(new IcOrder { Id = 1, Name = "Test" }); await context.SaveChangesAsync(); - mockCache.Verify(c => c.RemoveByType(typeof(IcOrder)), Times.Once); + mockCache.Verify(c => c.RemoveByTypeAsync(typeof(IcOrder), It.IsAny()), Times.Once); } } diff --git a/CleverCache.Tests/CleverCacheServiceTests.cs b/CleverCache.Tests/CleverCacheServiceTests.cs index f75352c..fa41203 100644 --- a/CleverCache.Tests/CleverCacheServiceTests.cs +++ b/CleverCache.Tests/CleverCacheServiceTests.cs @@ -181,4 +181,23 @@ public void RenderDependencyTree_NoTypes_ReturnsEmptyMessage() Assert.Contains("no types registered", output); } + + [Fact] + public async Task RemoveByTypeAsync_RemovesAllKeysForType() + { + var storeMock = new Mock(); + storeMock.Setup(s => s.TryGet(It.IsAny(), out It.Ref.IsAny)).Returns(false); + storeMock.Setup(s => s.TryGetAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((false, default(int))); + storeMock.Setup(s => s.RemoveAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + var sut = new CleverCacheService(storeMock.Object, new CleverCacheOptions()); + + sut.GetOrCreate([typeof(string)], "k1", () => 1); + sut.GetOrCreate([typeof(string)], "k2", () => 2); + await sut.RemoveByTypeAsync(typeof(string)); + + storeMock.Verify(s => s.RemoveAsync("k1", It.IsAny()), Times.Once); + storeMock.Verify(s => s.RemoveAsync("k2", It.IsAny()), Times.Once); + } } diff --git a/CleverCache.Tests/FakeCacheTests.cs b/CleverCache.Tests/FakeCacheTests.cs index 9b8b0d7..fc72af9 100644 --- a/CleverCache.Tests/FakeCacheTests.cs +++ b/CleverCache.Tests/FakeCacheTests.cs @@ -42,6 +42,14 @@ public void RemoveByType_DoesNotThrow() Assert.Null(ex); } + [Fact] + public async Task RemoveByTypeAsync_DoesNotThrow() + { + var fake = new FakeCache(); + var ex = await Record.ExceptionAsync(() => fake.RemoveByTypeAsync(typeof(string))); + Assert.Null(ex); + } + [Fact] public void AddKeyToTypes_DoesNotThrow() { diff --git a/CleverCache.Tests/InvalidateCacheBehaviourTests.cs b/CleverCache.Tests/InvalidateCacheBehaviourTests.cs index 85fe69a..034fe63 100644 --- a/CleverCache.Tests/InvalidateCacheBehaviourTests.cs +++ b/CleverCache.Tests/InvalidateCacheBehaviourTests.cs @@ -23,20 +23,22 @@ public async Task Handle_NoAttribute_DoesNotInvalidate() await sut.Handle(new NoInvalidationCommand(1), next, CancellationToken.None); - cacheMock.Verify(c => c.RemoveByType(It.IsAny()), Times.Never); + cacheMock.Verify(c => c.RemoveByTypeAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Handle_WithAttribute_InvalidatesAllDeclaredTypes() { var cacheMock = new Mock(); + cacheMock.Setup(c => c.RemoveByTypeAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); var sut = new InvalidateCacheBehaviour(cacheMock.Object); RequestHandlerDelegate next = _ => Task.FromResult(true); await sut.Handle(new DeleteCommand(1), next, CancellationToken.None); - cacheMock.Verify(c => c.RemoveByType(typeof(InvalidatedEntity)), Times.Once); - cacheMock.Verify(c => c.RemoveByType(typeof(DependentEntity)), Times.Once); + cacheMock.Verify(c => c.RemoveByTypeAsync(typeof(InvalidatedEntity), It.IsAny()), Times.Once); + cacheMock.Verify(c => c.RemoveByTypeAsync(typeof(DependentEntity), It.IsAny()), Times.Once); } [Fact] @@ -46,8 +48,9 @@ public async Task Handle_WithAttribute_InvalidatesAfterNextCompletes() var order = new List(); var sut = new InvalidateCacheBehaviour(cacheMock.Object); - cacheMock.Setup(c => c.RemoveByType(It.IsAny())) - .Callback(_ => order.Add("invalidate")); + cacheMock.Setup(c => c.RemoveByTypeAsync(It.IsAny(), It.IsAny())) + .Callback((_, _) => order.Add("invalidate")) + .Returns(Task.CompletedTask); RequestHandlerDelegate next = _ => { @@ -64,6 +67,8 @@ public async Task Handle_WithAttribute_InvalidatesAfterNextCompletes() public async Task Handle_WithAttribute_ReturnsHandlerResult() { var cacheMock = new Mock(); + cacheMock.Setup(c => c.RemoveByTypeAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); var sut = new InvalidateCacheBehaviour(cacheMock.Object); RequestHandlerDelegate next = _ => Task.FromResult(true); @@ -82,6 +87,6 @@ public async Task Handle_HandlerThrows_DoesNotInvalidate() await Assert.ThrowsAsync(() => sut.Handle(new DeleteCommand(1), next, CancellationToken.None)); - cacheMock.Verify(c => c.RemoveByType(It.IsAny()), Times.Never); + cacheMock.Verify(c => c.RemoveByTypeAsync(It.IsAny(), It.IsAny()), Times.Never); } } diff --git a/Extensions/CacheEntryManagerExtensions.cs b/Extensions/CacheEntryManagerExtensions.cs index c9ada51..03fba3f 100644 --- a/Extensions/CacheEntryManagerExtensions.cs +++ b/Extensions/CacheEntryManagerExtensions.cs @@ -31,4 +31,11 @@ public static class CacheEntryManagerExtensions /// The entity type to associate with this cache key. /// The cache key to register. public static void AddKeyToType(this ICleverCache cache, Type type, object key) => cache.AddKeyToTypes([type], key); + + /// + /// Asynchronously removes all cache entries associated with , + /// including any dependent types. Prefer over RemoveByType when using a distributed cache backend. + /// + public static Task RemoveByTypeAsync(this ICleverCache cache, CancellationToken cancellationToken = default) => + cache.RemoveByTypeAsync(typeof(T), cancellationToken); } diff --git a/Extensions/CleverCacheExtensions.cs b/Extensions/CleverCacheExtensions.cs index d372d96..9f37977 100644 --- a/Extensions/CleverCacheExtensions.cs +++ b/Extensions/CleverCacheExtensions.cs @@ -45,8 +45,9 @@ public static class CleverCacheExtensions /// The task object representing the asynchronous operation. public static async Task GetOrCreateAsync(this ICleverCache cache, object key, Func> factory, - CleverCacheEntryOptions? createOptions = null) where T : class => - await cache.GetOrCreateAsync(typeof(T), key, factory, createOptions); + CleverCacheEntryOptions? createOptions = null, + CancellationToken cancellationToken = default) where T : class => + await cache.GetOrCreateAsync(typeof(T), key, factory, createOptions, cancellationToken); /// /// Asynchronously gets the value associated with this key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found. @@ -57,12 +58,14 @@ public static class CleverCacheExtensions /// The key of the entry to look for or create. /// The factory task that creates the value associated with this key if the key does not exist in the cache. /// The options to be applied to the cache entry if the key does not exist in the cache. + /// A token to cancel the async operation. /// The task object representing the asynchronous operation. public static async Task GetOrCreateAsync(this ICleverCache cache, Type type, object key, Func> factory, - CleverCacheEntryOptions? createOptions = null) => - await cache.GetOrCreateAsync([type], key, factory, createOptions); + CleverCacheEntryOptions? createOptions = null, + CancellationToken cancellationToken = default) => + await cache.GetOrCreateAsync([type], key, factory, createOptions, cancellationToken); /// /// Returns a human-readable representation of the dependency graph and tracked cache keys. diff --git a/ICleverCache.cs b/ICleverCache.cs index fd4aff2..6a1b8fb 100644 --- a/ICleverCache.cs +++ b/ICleverCache.cs @@ -55,7 +55,8 @@ public interface ICleverCache Type[] types, object key, Func> factory, - CleverCacheEntryOptions? createOptions = null); + CleverCacheEntryOptions? createOptions = null, + CancellationToken cancellationToken = default); /// /// Removes the cache entry with the specified key. @@ -76,4 +77,13 @@ public interface ICleverCache /// The entity type whose cache entries should be evicted. void RemoveByType(Type type); + /// + /// Asynchronously removes all cache entries associated with the specified entity type, + /// including entries registered under any dependent types (see ). + /// Prefer this over when using a distributed cache backend. + /// + /// The entity type whose cache entries should be evicted. + /// A token to cancel the async operation. + Task RemoveByTypeAsync(Type type, CancellationToken cancellationToken = default); + } \ No newline at end of file diff --git a/Implementations/CleverCacheService.cs b/Implementations/CleverCacheService.cs index 9a81dcb..70290c3 100644 --- a/Implementations/CleverCacheService.cs +++ b/Implementations/CleverCacheService.cs @@ -30,20 +30,20 @@ public CleverCacheService(ICleverCacheStore store, CleverCacheOptions options) return value; } - public async Task GetOrCreateAsync(Type[] types, object key, Func> factory, CleverCacheEntryOptions? options = null) + public async Task GetOrCreateAsync(Type[] types, object key, Func> factory, CleverCacheEntryOptions? options = null, CancellationToken cancellationToken = default) { - var (found, cached) = await _store.TryGetAsync(key).ConfigureAwait(false); + var (found, cached) = await _store.TryGetAsync(key, cancellationToken).ConfigureAwait(false); if (found) return cached; using var _ = await _locker.LockAsync(key).ConfigureAwait(false); // Double-check: another thread may have populated the cache while we waited for the lock - (found, cached) = await _store.TryGetAsync(key).ConfigureAwait(false); + (found, cached) = await _store.TryGetAsync(key, cancellationToken).ConfigureAwait(false); if (found) return cached; AddKeyToTypes(types, key); var value = await factory().ConfigureAwait(false); - await _store.SetAsync(key, value, options).ConfigureAwait(false); + await _store.SetAsync(key, value, options, cancellationToken).ConfigureAwait(false); return value; } @@ -53,6 +53,12 @@ public void RemoveByType(Type type) _store.Remove(k); } + public async Task RemoveByTypeAsync(Type type, CancellationToken cancellationToken = default) + { + foreach (var k in SnapshotKeysFor(type)) + await _store.RemoveAsync(k, cancellationToken).ConfigureAwait(false); + } + public void Remove(object key) => _store.Remove(key); public CleverCacheDiagnostics GetDiagnostics() => SnapshotDiagnostics(); diff --git a/Implementations/FakeCache.cs b/Implementations/FakeCache.cs index 47468c4..e8ea881 100644 --- a/Implementations/FakeCache.cs +++ b/Implementations/FakeCache.cs @@ -14,6 +14,9 @@ public void AddKeyToTypes(Type[] types, object key) { } /// public void RemoveByType(Type type) { } + /// + public Task RemoveByTypeAsync(Type type, CancellationToken cancellationToken = default) => Task.CompletedTask; + /// public TItem? GetOrCreate( Type[] types, @@ -26,7 +29,8 @@ public void RemoveByType(Type type) { } Type[] types, object key, Func> factory, - CleverCacheEntryOptions? createOptions = null) => await factory(); + CleverCacheEntryOptions? createOptions = null, + CancellationToken cancellationToken = default) => await factory(); /// public void Remove(object key) { } From a2ceda17abd2baa9e5e770cce4efd07cc60c2ef5 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 22:38:50 +0100 Subject: [PATCH 29/50] ci: update repo About and add wiki badge on push to main - publish-wiki.yml now also calls 'gh repo edit' to keep the repo description, homepage URL, and topics in sync after wiki publishes - Add Publish Wiki status badge to README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/publish-wiki.yml | 15 +++++++++++++++ ReadMe.md | 1 + 2 files changed, 16 insertions(+) diff --git a/.github/workflows/publish-wiki.yml b/.github/workflows/publish-wiki.yml index 249226a..ff6acaa 100644 --- a/.github/workflows/publish-wiki.yml +++ b/.github/workflows/publish-wiki.yml @@ -19,3 +19,18 @@ jobs: path: wiki/ env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Update repo About + run: | + gh repo edit chunty/CleverCache \ + --description "Automatic cache invalidation for .NET — tracks EF Core changes and evicts cache entries automatically. Supports memory, distributed, Redis, and MediatR." \ + --homepage "https://github.com/chunty/CleverCache/wiki" \ + --add-topic dotnet \ + --add-topic caching \ + --add-topic efcore \ + --add-topic csharp \ + --add-topic nuget \ + --add-topic mediatr \ + --add-topic redis + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/ReadMe.md b/ReadMe.md index 55c3f34..780c038 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -2,6 +2,7 @@ CleverCache ==================================================== [![NuGet](https://img.shields.io/nuget/dt/clevercache.svg)](https://www.nuget.org/packages/clevercache) [![NuGet](https://img.shields.io/nuget/vpre/clevercache.svg)](https://www.nuget.org/packages/clevercache) +[![Publish Wiki](https://github.com/chunty/CleverCache/actions/workflows/publish-wiki.yml/badge.svg)](https://github.com/chunty/CleverCache/actions/workflows/publish-wiki.yml) > ⚠️ **V2 contains breaking changes.** Read the **[V1 → V2 Migration Guide](https://github.com/chunty/CleverCache/wiki/Migrating-to-V2)** before upgrading. From 1dbe451bedd453baf2ff9737cbcd5e48e4816526 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Sun, 3 May 2026 22:46:31 +0100 Subject: [PATCH 30/50] ci: auto-update repo About from csproj metadata on push to main - New update-about.yml workflow extracts and from CleverCache.csproj and syncs them to the GitHub repo About section - Remove the hardcoded gh repo edit step from publish-wiki.yml Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/publish-wiki.yml | 14 ------------ .github/workflows/update-about.yml | 34 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/update-about.yml diff --git a/.github/workflows/publish-wiki.yml b/.github/workflows/publish-wiki.yml index ff6acaa..8635792 100644 --- a/.github/workflows/publish-wiki.yml +++ b/.github/workflows/publish-wiki.yml @@ -20,17 +20,3 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Update repo About - run: | - gh repo edit chunty/CleverCache \ - --description "Automatic cache invalidation for .NET — tracks EF Core changes and evicts cache entries automatically. Supports memory, distributed, Redis, and MediatR." \ - --homepage "https://github.com/chunty/CleverCache/wiki" \ - --add-topic dotnet \ - --add-topic caching \ - --add-topic efcore \ - --add-topic csharp \ - --add-topic nuget \ - --add-topic mediatr \ - --add-topic redis - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/update-about.yml b/.github/workflows/update-about.yml new file mode 100644 index 0000000..7a580ef --- /dev/null +++ b/.github/workflows/update-about.yml @@ -0,0 +1,34 @@ +name: Update Repo About + +on: + push: + branches: [main] + +jobs: + update: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Extract description and tags from csproj + id: meta + run: | + DESCRIPTION=$(grep -oPm1 '(?<=)[^<]+' CleverCache.csproj) + TAGS=$(grep -oPm1 '(?<=)[^<]+' CleverCache.csproj) + + # Convert comma-separated tags to --add-topic arguments + TOPIC_ARGS=$(echo "$TAGS" | tr ',' '\n' | sed 's/ //g' | tr '[:upper:]' '[:lower:]' | sed 's/^/--add-topic /' | tr '\n' ' ') + + echo "description=$DESCRIPTION" >> $GITHUB_OUTPUT + echo "topic_args=$TOPIC_ARGS" >> $GITHUB_OUTPUT + + - name: Update GitHub repo About + run: | + gh repo edit ${{ github.repository }} \ + --description "${{ steps.meta.outputs.description }}" \ + --homepage "https://github.com/${{ github.repository }}/wiki" \ + ${{ steps.meta.outputs.topic_args }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From a1a8f10f6d2904ac9c9996a27f2e39b15e4009a6 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Tue, 5 May 2026 11:05:09 +0100 Subject: [PATCH 31/50] fix: NavigationScanningHelper dedup logic drops valid bidirectional rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Any(x => x.Type == dependentType) guard checked whether the dependent type already appeared as a *source* in the set — this wrongly skipped rules when a bidirectional navigation caused the same type to appear on both sides (e.g. OrderLine -> Order was dropped because Order was already a source in Order -> OrderLine). Fix: remove the incorrect guard (HashSet record equality already deduplicates exact rules for free) and replace with a proper visited-set cycle guard that only applies in Recursive mode. Add regression tests: - BidirectionalNavigation_RegistersBothDirections (the exact bug) - CircularNavigation_DoesNotStackOverflow (cycle guard) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/NavigationScanningHelper.cs | 14 ++++++---- CleverCache.Tests/DbContextExtensionsTests.cs | 26 +++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/CleverCache.EntityFrameworkCore/Helpers/NavigationScanningHelper.cs b/CleverCache.EntityFrameworkCore/Helpers/NavigationScanningHelper.cs index 08aef39..6f3a6ad 100644 --- a/CleverCache.EntityFrameworkCore/Helpers/NavigationScanningHelper.cs +++ b/CleverCache.EntityFrameworkCore/Helpers/NavigationScanningHelper.cs @@ -4,23 +4,27 @@ namespace CleverCache.EntityFrameworkCore.Helpers; internal static class NavigationScanningHelper { - public static void Scan(CleverCacheScanOptions scanOptions, IEntityType entityType, HashSet dependentCaches) + public static void Scan(CleverCacheScanOptions scanOptions, IEntityType entityType, HashSet dependentCaches, HashSet? visited = null) { + if (scanOptions.NavigationScanMode == DependentCacheNavigationScanMode.Recursive) + { + visited ??= []; + if (!visited.Add(entityType.ClrType)) + return; + } + foreach (var navigation in entityType.GetNavigations()) { var sourceType = entityType.ClrType; var dependentEntityType = navigation.TargetEntityType; var dependentType = dependentEntityType.ClrType; - if (dependentCaches.Any(x => x.Type == dependentType)) - continue; - dependentCaches.Add(new DependentCache(sourceType, dependentType)); if (scanOptions.ReverseNavigationDependencies) dependentCaches.Add(new DependentCache(dependentType, sourceType)); if (scanOptions.NavigationScanMode == DependentCacheNavigationScanMode.Recursive) - Scan(scanOptions, dependentEntityType, dependentCaches); + Scan(scanOptions, dependentEntityType, dependentCaches, visited); } } } diff --git a/CleverCache.Tests/DbContextExtensionsTests.cs b/CleverCache.Tests/DbContextExtensionsTests.cs index d62f77b..7040b46 100644 --- a/CleverCache.Tests/DbContextExtensionsTests.cs +++ b/CleverCache.Tests/DbContextExtensionsTests.cs @@ -16,6 +16,10 @@ internal class DbExtA { public int Id { get; set; } public DbExtB? B { get; set; internal class DbExtB { public int Id { get; set; } public DbExtC? C { get; set; } } internal class DbExtC { public int Id { get; set; } } +// Circular model: P ↔ Q (bidirectional) — DbExtQ is the dependent (has FK) +internal class DbExtP { public int Id { get; set; } public DbExtQ? Q { get; set; } } +internal class DbExtQ { public int Id { get; set; } public int DbExtPId { get; set; } public DbExtP? P { get; set; } } + internal class ScanTestDbContext(DbContextOptions options) : DbContext(options) { public DbSet Orders => Set(); @@ -23,6 +27,8 @@ internal class ScanTestDbContext(DbContextOptions options) : public DbSet As => Set(); public DbSet Bs => Set(); public DbSet Cs => Set(); + public DbSet Ps => Set(); + public DbSet Qs => Set(); } public class DbContextExtensionsTests @@ -77,5 +83,25 @@ public void DiscoverDependentCaches_RecursiveNavigation_DiscoversTransitiveRelat Assert.Contains(result, d => d.Type == typeof(DbExtA) && d.DependentType == typeof(DbExtB)); Assert.Contains(result, d => d.Type == typeof(DbExtB) && d.DependentType == typeof(DbExtC)); } + + [Fact] + public void DiscoverDependentCaches_BidirectionalNavigation_RegistersBothDirections() + { + using var context = CreateContext(); + var result = context.DiscoverDependentCaches(new CleverCacheScanOptions(DependentCacheNavigationScanMode.Direct)); + + Assert.Contains(result, d => d.Type == typeof(DbExtOrder) && d.DependentType == typeof(DbExtOrderLine)); + Assert.Contains(result, d => d.Type == typeof(DbExtOrderLine) && d.DependentType == typeof(DbExtOrder)); + } + + [Fact] + public void DiscoverDependentCaches_CircularNavigation_DoesNotStackOverflow() + { + using var context = CreateContext(); + var result = context.DiscoverDependentCaches(new CleverCacheScanOptions(DependentCacheNavigationScanMode.Recursive)); + + Assert.Contains(result, d => d.Type == typeof(DbExtP) && d.DependentType == typeof(DbExtQ)); + Assert.Contains(result, d => d.Type == typeof(DbExtQ) && d.DependentType == typeof(DbExtP)); + } } From 924a02b241886b5378e17e6a4efb16713fdcb619 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Tue, 5 May 2026 11:55:40 +0100 Subject: [PATCH 32/50] refactor: move DependentCacheNavigationScanMode to EF Core package The enum is an EF-specific concept and was only ever used by CleverCache.EntityFrameworkCore. Moving it removes an EF concern from the core package. Breaking change: namespace changes from CleverCache.Models to CleverCache.EntityFrameworkCore.Models. Noted in migration guide. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models}/DependentCacheNavigationScanMode.cs | 4 ++-- CleverCache.Tests/GlobalUsings.cs | 1 + wiki/Migrating-to-V2.md | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) rename {Models => CleverCache.EntityFrameworkCore/Models}/DependentCacheNavigationScanMode.cs (59%) diff --git a/Models/DependentCacheNavigationScanMode.cs b/CleverCache.EntityFrameworkCore/Models/DependentCacheNavigationScanMode.cs similarity index 59% rename from Models/DependentCacheNavigationScanMode.cs rename to CleverCache.EntityFrameworkCore/Models/DependentCacheNavigationScanMode.cs index 8b17690..7b6e0c0 100644 --- a/Models/DependentCacheNavigationScanMode.cs +++ b/CleverCache.EntityFrameworkCore/Models/DependentCacheNavigationScanMode.cs @@ -1,8 +1,8 @@ -namespace CleverCache.Models; +namespace CleverCache.EntityFrameworkCore.Models; public enum DependentCacheNavigationScanMode { None, Direct, Recursive -} \ No newline at end of file +} diff --git a/CleverCache.Tests/GlobalUsings.cs b/CleverCache.Tests/GlobalUsings.cs index 08af72d..b4e18b1 100644 --- a/CleverCache.Tests/GlobalUsings.cs +++ b/CleverCache.Tests/GlobalUsings.cs @@ -1,3 +1,4 @@ global using Xunit; global using CleverCache.Models; global using CleverCache.Extensions; +global using CleverCache.EntityFrameworkCore.Models; diff --git a/wiki/Migrating-to-V2.md b/wiki/Migrating-to-V2.md index b0e8a91..aed93c3 100644 --- a/wiki/Migrating-to-V2.md +++ b/wiki/Migrating-to-V2.md @@ -18,6 +18,7 @@ V2 is a significant refactor. The core caching API is mostly the same but severa | Cache entry options type | `MemoryCacheEntryOptions` | `CleverCacheEntryOptions` | | Dependent cache attribute | `[DependantCaches]` | `[DependentCaches]` | | `FakeCache` namespace | `CleverCache.Implementations` | `CleverCache` | +| `DependentCacheNavigationScanMode` namespace | `CleverCache.Models` | `CleverCache.EntityFrameworkCore.Models` | --- From e0f5f58e0556c949d6b9405dd0bcc3cd414ef014 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Tue, 5 May 2026 12:23:09 +0100 Subject: [PATCH 33/50] fix: AutoCacheBehaviour null result causes handler to execute twice When the handler legitimately returned null, the old code removed the cache entry and called next() a second time. GetOrCreateAsync already invoked the factory (and thus the handler) on a cache miss, so the second call was always a duplicate. Fix: propagate null via 'result ?? default!' rather than re-executing the handler. Comment explains why default! is used to satisfy the non-nullable TResponse return type. Add regression test: NullResult_DoesNotCallNextTwice (70 total) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CleverCache.MediatR/AutoCacheBehaviour.cs | 11 ++++------- CleverCache.Tests/AutoCacheBehaviourTests.cs | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/CleverCache.MediatR/AutoCacheBehaviour.cs b/CleverCache.MediatR/AutoCacheBehaviour.cs index 61acbf9..38069d1 100644 --- a/CleverCache.MediatR/AutoCacheBehaviour.cs +++ b/CleverCache.MediatR/AutoCacheBehaviour.cs @@ -25,12 +25,9 @@ public async Task Handle(TRequest request, RequestHandlerDelegate(); + cacheMock + .Setup(c => c.GetOrCreateAsync( + It.IsAny(), It.IsAny(), It.IsAny>>(), It.IsAny(), It.IsAny())) + .Returns>, CleverCacheEntryOptions?, CancellationToken>((_, _, factory, _, _) => factory()); + + var sut = new AutoCacheBehaviour(cacheMock.Object); + var callCount = 0; + RequestHandlerDelegate next = _ => { callCount++; return Task.FromResult(null!); }; + + var result = await sut.Handle(new CachedQuery(1), next, CancellationToken.None); + + Assert.Null(result); + Assert.Equal(1, callCount); // handler must only execute once even when result is null + } } From dc9d4cff4cfd07ff64855d008ac9be03a57ccf7c Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Tue, 5 May 2026 12:26:05 +0100 Subject: [PATCH 34/50] feat: add IServiceProvider overload for ScanDbSetsForCacheDependencies Enables navigation scanning in worker services and console apps where IApplicationBuilder is not available. The existing IApplicationBuilder overload now delegates to the new IServiceProvider implementation. Update Getting-Started wiki to document both overloads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ApplicationBuilderExtensions.cs | 34 +++++++++++++++++-- wiki/Getting-Started.md | 16 ++++++++- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/CleverCache.EntityFrameworkCore/DependencyInjection/ApplicationBuilderExtensions.cs b/CleverCache.EntityFrameworkCore/DependencyInjection/ApplicationBuilderExtensions.cs index ab55994..b9145ea 100644 --- a/CleverCache.EntityFrameworkCore/DependencyInjection/ApplicationBuilderExtensions.cs +++ b/CleverCache.EntityFrameworkCore/DependencyInjection/ApplicationBuilderExtensions.cs @@ -9,6 +9,10 @@ public static class ApplicationBuilderExtensions /// and registers the discovered cache dependency rules at startup. /// Can be called multiple times for multiple types, each with their own scan options. /// + /// + /// Use this overload in ASP.NET Core apps where is available. + /// For worker services or console apps, use the overload instead. + /// /// /// /// app.ScanDbSetsForCacheDependencies<AppDbContext>(o => @@ -20,16 +24,40 @@ public static IApplicationBuilder ScanDbSetsForCacheDependencies( Action? configure = null) where TContext : DbContext { - var cache = app.ApplicationServices.GetRequiredService(); + app.ApplicationServices.ScanDbSetsForCacheDependencies(configure); + return app; + } + + /// + /// Scans the navigation properties for + /// and registers the discovered cache dependency rules at startup. + /// Can be called multiple times for multiple types, each with their own scan options. + /// + /// + /// Use this overload in worker services or console apps where is not available. + /// + /// + /// + /// host.Services.ScanDbSetsForCacheDependencies<AppDbContext>(o => + /// o.NavigationScanMode = DependentCacheNavigationScanMode.Direct); + /// await host.RunAsync(); + /// + /// + public static IServiceProvider ScanDbSetsForCacheDependencies( + this IServiceProvider services, + Action? configure = null) + where TContext : DbContext + { + var cache = services.GetRequiredService(); var scanOptions = new CleverCacheScanOptions(); configure?.Invoke(scanOptions); - using var scope = app.ApplicationServices.CreateScope(); + using var scope = services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); foreach (var dep in dbContext.DiscoverDependentCaches(scanOptions)) cache.AddDependentCache(dep.Type, dep.DependentType); - return app; + return services; } } diff --git a/wiki/Getting-Started.md b/wiki/Getting-Started.md index 871ec59..d39119d 100644 --- a/wiki/Getting-Started.md +++ b/wiki/Getting-Started.md @@ -85,7 +85,9 @@ public class AppDbContext(CleverCacheInterceptor cleverCacheInterceptor) : DbCon ## 3. Scan DbSet navigation properties (optional) -Only needed if you want CleverCache to auto-discover cascade rules from EF Core navigation properties. This can be called multiple times for multiple DbContext types, each with their own scan options: +Only needed if you want CleverCache to auto-discover cascade rules from EF Core navigation properties. This can be called multiple times for multiple DbContext types, each with their own scan options. + +**ASP.NET Core apps** — call on `IApplicationBuilder`: ```csharp app.ScanDbSetsForCacheDependencies(); @@ -100,6 +102,18 @@ app.ScanDbSetsForCacheDependencies(o => app.ScanDbSetsForCacheDependencies(); ``` +**Worker services and console apps** — `IApplicationBuilder` isn't available, call on `IServiceProvider` instead: + +```csharp +var host = Host.CreateDefaultBuilder(args) + .ConfigureServices(services => services.AddCleverCacheEntityFramework()) + .Build(); + +host.Services.ScanDbSetsForCacheDependencies(); + +await host.RunAsync(); +``` + See [Dependent Caches](Dependent-Caches) for full details on navigation scanning modes. > **Prefer `[DependentCaches]` attributes for most projects.** Global DbSet scanning wires up every entity in your context — in large schemas this can cause excessive invalidation and high memory usage from tracking too many key associations. `ScanAssemblyContaining` (step 1) gives you the same automatic wiring with opt-in, per-entity control. From cb6f6f1282305c6983b97a42d659f0be3f80721a Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Tue, 5 May 2026 13:46:44 +0100 Subject: [PATCH 35/50] fix: clean up _keysByType on explicit Remove and RemoveByType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keys tracked in _keysByType were never removed when cache entries were explicitly evicted, causing unbounded growth in long-running apps. Fix: after removing a key from the store, remove it from all type sets via RemoveKeyFromAllTypes. This covers: - Remove(key): cleans up the specific key across all type sets - RemoveByType(type): cleans up each invalidated key across all type sets (including dependent types that shared the key via transitive expansion) Natural expiry (TTL) still leaks — that requires eviction callbacks which will be addressed separately. Add regression tests: Remove_CleansUpKeyFromAllTypeSets and RemoveByType_CleansUpKeysFromAllTypeSets (72 total) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CacheKeyManager.cs | 10 +++++++ CleverCache.Tests/CleverCacheServiceTests.cs | 28 ++++++++++++++++++++ Implementations/CleverCacheService.cs | 12 ++++++++- 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/CacheKeyManager.cs b/CacheKeyManager.cs index 945ad5f..c4a6deb 100644 --- a/CacheKeyManager.cs +++ b/CacheKeyManager.cs @@ -31,6 +31,16 @@ public void AddKeyToTypes(Type[] types, object key) } + /// + /// Removes a key from every type's tracked key set. + /// Called after a key is removed from the store so the tracking set stays in sync. + /// + protected void RemoveKeyFromAllTypes(object key) + { + foreach (var typeSet in _keysByType.Values) + typeSet.TryRemove(key, out _); + } + /// /// Snapshot keys for a given type (safe to enumerate). /// diff --git a/CleverCache.Tests/CleverCacheServiceTests.cs b/CleverCache.Tests/CleverCacheServiceTests.cs index fa41203..0727411 100644 --- a/CleverCache.Tests/CleverCacheServiceTests.cs +++ b/CleverCache.Tests/CleverCacheServiceTests.cs @@ -200,4 +200,32 @@ public async Task RemoveByTypeAsync_RemovesAllKeysForType() storeMock.Verify(s => s.RemoveAsync("k1", It.IsAny()), Times.Once); storeMock.Verify(s => s.RemoveAsync("k2", It.IsAny()), Times.Once); } + + [Fact] + public void Remove_CleansUpKeyFromAllTypeSets() + { + var sut = CreateService(); + sut.AddDependentCache(typeof(string), typeof(int)); + sut.GetOrCreate([typeof(string)], "k1", () => 1); // k1 added to String and Int32 + + sut.Remove("k1"); + + var d = sut.GetDiagnostics(); + Assert.DoesNotContain("k1", d.KeysByType.GetValueOrDefault(typeof(string)) ?? []); + Assert.DoesNotContain("k1", d.KeysByType.GetValueOrDefault(typeof(int)) ?? []); + } + + [Fact] + public void RemoveByType_CleansUpKeysFromAllTypeSets() + { + var sut = CreateService(); + sut.AddDependentCache(typeof(string), typeof(int)); + sut.GetOrCreate([typeof(string)], "k1", () => 1); // k1 added to String and Int32 transitively + + sut.RemoveByType(typeof(string)); + + var d = sut.GetDiagnostics(); + Assert.DoesNotContain("k1", d.KeysByType.GetValueOrDefault(typeof(string)) ?? []); + Assert.DoesNotContain("k1", d.KeysByType.GetValueOrDefault(typeof(int)) ?? []); // must also clean up dependent type + } } diff --git a/Implementations/CleverCacheService.cs b/Implementations/CleverCacheService.cs index 70290c3..8e00ee5 100644 --- a/Implementations/CleverCacheService.cs +++ b/Implementations/CleverCacheService.cs @@ -50,16 +50,26 @@ public CleverCacheService(ICleverCacheStore store, CleverCacheOptions options) public void RemoveByType(Type type) { foreach (var k in SnapshotKeysFor(type)) + { _store.Remove(k); + RemoveKeyFromAllTypes(k); + } } public async Task RemoveByTypeAsync(Type type, CancellationToken cancellationToken = default) { foreach (var k in SnapshotKeysFor(type)) + { await _store.RemoveAsync(k, cancellationToken).ConfigureAwait(false); + RemoveKeyFromAllTypes(k); + } } - public void Remove(object key) => _store.Remove(key); + public void Remove(object key) + { + _store.Remove(key); + RemoveKeyFromAllTypes(key); + } public CleverCacheDiagnostics GetDiagnostics() => SnapshotDiagnostics(); } From 3c457e8e92049c107a087bcb6b50f0af650d9ad0 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Tue, 5 May 2026 13:49:59 +0100 Subject: [PATCH 36/50] feat: add IEvictionNotifyingStore for TTL-based key cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a cache entry expires naturally (TTL) it was never removed from _keysByType, causing unbounded memory growth. Add IEvictionNotifyingStore — an optional interface that cache stores implement to signal evictions back to the service layer: - MemoryCacheStore implements it via IMemoryCache PostEvictionCallback - DistributedCacheStore does not (distributed caches have no eviction API) - CleverCacheService wires RemoveKeyFromAllTypes as the callback if the store implements the interface Custom store authors can opt in by implementing IEvictionNotifyingStore. Add tests: RegisterEvictionCallback_CalledWhenEntryEvicted and Eviction_WhenStoreSupportsNotification_CleansUpKeyFromTypeSets (74 total) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CleverCache.Tests/CleverCacheServiceTests.cs | 14 ++++++++++++++ CleverCache.Tests/MemoryCacheStoreTests.cs | 14 ++++++++++++++ IEvictionNotifyingStore.cs | 20 ++++++++++++++++++++ Implementations/CleverCacheService.cs | 3 +++ Implementations/MemoryCacheStore.cs | 9 ++++++++- 5 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 IEvictionNotifyingStore.cs diff --git a/CleverCache.Tests/CleverCacheServiceTests.cs b/CleverCache.Tests/CleverCacheServiceTests.cs index 0727411..3acfcca 100644 --- a/CleverCache.Tests/CleverCacheServiceTests.cs +++ b/CleverCache.Tests/CleverCacheServiceTests.cs @@ -228,4 +228,18 @@ public void RemoveByType_CleansUpKeysFromAllTypeSets() Assert.DoesNotContain("k1", d.KeysByType.GetValueOrDefault(typeof(string)) ?? []); Assert.DoesNotContain("k1", d.KeysByType.GetValueOrDefault(typeof(int)) ?? []); // must also clean up dependent type } + + [Fact] + public void Eviction_WhenStoreSupportsNotification_CleansUpKeyFromTypeSets() + { + // MemoryCacheStore implements IEvictionNotifyingStore — eviction should clean up _keysByType + var sut = CreateService(); + sut.GetOrCreate([typeof(string)], "k1", () => 1); + + Assert.Contains("k1", sut.GetDiagnostics().KeysByType[typeof(string)]); + + sut.Remove("k1"); // triggers store eviction → PostEvictionCallback → RemoveKeyFromAllTypes + + Assert.DoesNotContain("k1", sut.GetDiagnostics().KeysByType.GetValueOrDefault(typeof(string)) ?? []); + } } diff --git a/CleverCache.Tests/MemoryCacheStoreTests.cs b/CleverCache.Tests/MemoryCacheStoreTests.cs index d91c114..235889c 100644 --- a/CleverCache.Tests/MemoryCacheStoreTests.cs +++ b/CleverCache.Tests/MemoryCacheStoreTests.cs @@ -79,4 +79,18 @@ public void Set_WithOptions_StoresValue() Assert.True(store.TryGet("key1", out var val)); Assert.Equal(99, val); } + + [Fact] + public void RegisterEvictionCallback_CalledWhenEntryEvicted() + { + var store = CreateStore(); + var tcs = new TaskCompletionSource(); + store.RegisterEvictionCallback(k => tcs.TrySetResult(k)); + + store.Set("key1", 42); + store.Remove("key1"); // triggers PostEvictionCallback (async) + + Assert.True(tcs.Task.Wait(TimeSpan.FromSeconds(2)), "eviction callback was not fired"); + Assert.Equal("key1", tcs.Task.Result); + } } diff --git a/IEvictionNotifyingStore.cs b/IEvictionNotifyingStore.cs new file mode 100644 index 0000000..a3b1987 --- /dev/null +++ b/IEvictionNotifyingStore.cs @@ -0,0 +1,20 @@ +namespace CleverCache; + +/// +/// Optional interface for cache stores that can notify when entries are evicted. +/// Implement this alongside to enable automatic +/// cleanup of tracked keys when entries expire or are evicted by the store. +/// +/// implements this interface. +/// does not, as distributed caches +/// have no eviction notification mechanism. +/// +/// +public interface IEvictionNotifyingStore +{ + /// + /// Registers a callback to be invoked when a cache entry is evicted. + /// + /// Called with the evicted key. + void RegisterEvictionCallback(Action onEvicted); +} diff --git a/Implementations/CleverCacheService.cs b/Implementations/CleverCacheService.cs index 8e00ee5..9c0ef4f 100644 --- a/Implementations/CleverCacheService.cs +++ b/Implementations/CleverCacheService.cs @@ -13,6 +13,9 @@ public CleverCacheService(ICleverCacheStore store, CleverCacheOptions options) _store = store; foreach (var dep in options.DependentCaches) AddDependentCache(dep.Type, dep.DependentType); + + if (store is IEvictionNotifyingStore evicting) + evicting.RegisterEvictionCallback(RemoveKeyFromAllTypes); } public TItem? GetOrCreate(Type[] types, object key, Func factory, CleverCacheEntryOptions? options = null) diff --git a/Implementations/MemoryCacheStore.cs b/Implementations/MemoryCacheStore.cs index 8a63cdb..ab38b98 100644 --- a/Implementations/MemoryCacheStore.cs +++ b/Implementations/MemoryCacheStore.cs @@ -2,8 +2,12 @@ namespace CleverCache.Implementations; -public class MemoryCacheStore(IMemoryCache memoryCache) : ICleverCacheStore +public class MemoryCacheStore(IMemoryCache memoryCache) : ICleverCacheStore, IEvictionNotifyingStore { + private Action? _onEvicted; + + public void RegisterEvictionCallback(Action onEvicted) => _onEvicted = onEvicted; + public bool TryGet(object key, out TItem? value) { if (memoryCache.TryGetValue(key, out var hit)) @@ -27,6 +31,9 @@ public void Set(object key, TItem value, CleverCacheEntryOptions? options using var entry = memoryCache.CreateEntry(key); entry.Value = value; + if (_onEvicted is not null) + entry.RegisterPostEvictionCallback((k, _, _, _) => _onEvicted(k)); + if (options is null) return; entry.AbsoluteExpiration = options.AbsoluteExpiration; entry.AbsoluteExpirationRelativeToNow = options.AbsoluteExpirationRelativeToNow; From b35633a4020310fec31fa9b5b98ad553a64d4cf6 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Tue, 5 May 2026 13:53:34 +0100 Subject: [PATCH 37/50] docs: document IEvictionNotifyingStore and update key tracking notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cache-Providers.md: add 'Eviction notifications' section under Custom provider explaining how to implement IEvictionNotifyingStore and when to use it - Diagnostics.md: update stale note — keys are now removed on explicit Remove/RemoveByType and on natural expiry (for stores that implement IEvictionNotifyingStore); clarify distributed cache behaviour Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- wiki/Cache-Providers.md | 25 +++++++++++++++++++++++++ wiki/Diagnostics.md | 7 ++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/wiki/Cache-Providers.md b/wiki/Cache-Providers.md index 10de4e5..1fc24ba 100644 --- a/wiki/Cache-Providers.md +++ b/wiki/Cache-Providers.md @@ -78,6 +78,31 @@ builder.Services.AddCleverCache(o => o.UseCustomStore()); builder.Services.AddCleverCache(o => o.UseCustomStore(sp => new MyStore(sp.GetRequiredService()))); ``` +### Eviction notifications (optional) + +CleverCache tracks which keys belong to which types in memory. To keep this tracking accurate when entries expire via TTL, your store can implement `IEvictionNotifyingStore` alongside `ICleverCacheStore`: + +```csharp +public class MyStore : ICleverCacheStore, IEvictionNotifyingStore +{ + private Action? _onEvicted; + + public void RegisterEvictionCallback(Action onEvicted) => _onEvicted = onEvicted; + + public void Set(object key, TItem value, CleverCacheEntryOptions? options = null) + { + // ... store the value ... + // Call _onEvicted(key) whenever this entry is evicted or expires + } + + // ... rest of ICleverCacheStore implementation +} +``` + +When `CleverCacheService` detects that your store implements `IEvictionNotifyingStore` at startup, it registers itself as the eviction listener automatically — no additional configuration needed. + +If your backing store has no eviction notification API (as is the case with `IDistributedCache`), simply omit `IEvictionNotifyingStore`. Tracked keys are still cleaned up on explicit `Remove`/`RemoveByType` calls; only naturally-expired entries may linger briefly in the in-memory tracking set. + ### Example — dictionary-backed store ```csharp diff --git a/wiki/Diagnostics.md b/wiki/Diagnostics.md index fef2ddc..e9fe391 100644 --- a/wiki/Diagnostics.md +++ b/wiki/Diagnostics.md @@ -53,4 +53,9 @@ OrderNote ─ 4 type(s) | 2 cascade rule(s) | 4 tracked key(s) ─ ``` -> **Note:** `KeysByType` reflects the keys registered since the application started. Keys are removed from the underlying cache store when invalidated, but the type→key association in CleverCache persists until the process restarts. An entry appearing in `tracked keys` does not necessarily mean it is still present in the cache — only that it was registered at some point. +> **Note:** `KeysByType` reflects the keys currently tracked by CleverCache. Keys are removed from the tracking set when: +> - `Remove(key)` is called explicitly +> - `RemoveByType(type)` is called (e.g. via the EF Core interceptor on `SaveChanges`) +> - The entry is evicted from the underlying store and the store implements `IEvictionNotifyingStore` (e.g. `MemoryCacheStore`) +> +> For distributed cache stores that don't support eviction notifications, keys may remain visible in `KeysByType` after their TTL expires in the remote store. This does not affect correctness — stale keys are simply no-ops when invalidation fires. From 1d6d8ed0f6f14863fb7812c88cf0b0fc3e16624b Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Wed, 6 May 2026 14:08:06 +0100 Subject: [PATCH 38/50] chore: bump .NET 9 packages to 9.0.15 and xunit runner to 3.1.5 - Microsoft.EntityFrameworkCore 9.0.9 -> 9.0.15 - Microsoft.EntityFrameworkCore.InMemory 9.0.9 -> 9.0.15 - Microsoft.Extensions.Caching.Abstractions 9.0.9 -> 9.0.15 - Microsoft.Extensions.Caching.Memory 9.0.9 -> 9.0.15 - Microsoft.Extensions.Caching.StackExchangeRedis 9.0.9 -> 9.0.15 - Microsoft.Extensions.Hosting.Abstractions 9.0.9 -> 9.0.15 - xunit.runner.visualstudio 2.8.2 -> 3.1.5 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Build.props | 4 ++-- Directory.Packages.props | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 98f8b85..9e3beb4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,8 +6,8 @@ Chris Hunt TappetyClick MIT - https://github.com/chunty/CleverCache + https://github.com/chunty/Simpository git - https://github.com/chunty/CleverCache + https://github.com/chunty/Simpository diff --git a/Directory.Packages.props b/Directory.Packages.props index 1c83d8e..71ba0da 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,17 +11,17 @@ - + - - - - - - + + + + + + From 453752d2a7872ce12efcb67625a6b4726eacc524 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Thu, 7 May 2026 10:02:06 +0100 Subject: [PATCH 39/50] ci: fix update-about workflow to use PAT and atomic topic replacement The previous workflow had two bugs: - Used GITHUB_TOKEN which lacks administration permission to update repo description and topics (was silently failing) - Used --add-topic which only adds, never removes stale topics Fix: - Switch to GH_PAT secret (Fine-grained PAT, Administration R/W) - Use 'gh api PUT /repos/{repo}/topics' to atomically replace all topics from in CleverCache.csproj (lowercase, spaces to hyphens) - Split into two steps for clarity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/update-about.yml | 34 +++++++++++++++++++----------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/.github/workflows/update-about.yml b/.github/workflows/update-about.yml index 7a580ef..6691b2c 100644 --- a/.github/workflows/update-about.yml +++ b/.github/workflows/update-about.yml @@ -7,28 +7,38 @@ on: jobs: update: runs-on: ubuntu-latest - permissions: - contents: read steps: - uses: actions/checkout@v4 - - name: Extract description and tags from csproj + - name: Extract metadata from csproj id: meta run: | DESCRIPTION=$(grep -oPm1 '(?<=)[^<]+' CleverCache.csproj) TAGS=$(grep -oPm1 '(?<=)[^<]+' CleverCache.csproj) - # Convert comma-separated tags to --add-topic arguments - TOPIC_ARGS=$(echo "$TAGS" | tr ',' '\n' | sed 's/ //g' | tr '[:upper:]' '[:lower:]' | sed 's/^/--add-topic /' | tr '\n' ' ') - echo "description=$DESCRIPTION" >> $GITHUB_OUTPUT - echo "topic_args=$TOPIC_ARGS" >> $GITHUB_OUTPUT + echo "tags=$TAGS" >> $GITHUB_OUTPUT - - name: Update GitHub repo About + - name: Update description and homepage run: | - gh repo edit ${{ github.repository }} \ + gh repo edit "$GITHUB_REPOSITORY" \ --description "${{ steps.meta.outputs.description }}" \ - --homepage "https://github.com/${{ github.repository }}/wiki" \ - ${{ steps.meta.outputs.topic_args }} + --homepage "https://github.com/$GITHUB_REPOSITORY/wiki" + env: + GH_TOKEN: ${{ secrets.GH_PAT }} + + - name: Replace topics from PackageTags + run: | + # Convert comma-separated tags to a JSON array of lowercase, hyphenated topics + TOPICS_JSON=$(echo "${{ steps.meta.outputs.tags }}" \ + | tr ',' '\n' \ + | sed 's/^ *//;s/ *$//' \ + | tr '[:upper:]' '[:lower:]' \ + | tr ' ' '-' \ + | jq -R . \ + | jq -sc '{names: .}') + + echo "Topics: $TOPICS_JSON" + echo "$TOPICS_JSON" | gh api --method PUT "/repos/$GITHUB_REPOSITORY/topics" --input - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GH_PAT }} From a42b909af8b73197e38c65ac475bba4ddd86755c Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Thu, 7 May 2026 10:06:14 +0100 Subject: [PATCH 40/50] ci: rename PAT secret to SYNC_META_TOKEN Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/update-about.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/update-about.yml b/.github/workflows/update-about.yml index 6691b2c..eab9e30 100644 --- a/.github/workflows/update-about.yml +++ b/.github/workflows/update-about.yml @@ -25,7 +25,7 @@ jobs: --description "${{ steps.meta.outputs.description }}" \ --homepage "https://github.com/$GITHUB_REPOSITORY/wiki" env: - GH_TOKEN: ${{ secrets.GH_PAT }} + GH_TOKEN: ${{ secrets.SYNC_META_TOKEN }} - name: Replace topics from PackageTags run: | @@ -41,4 +41,4 @@ jobs: echo "Topics: $TOPICS_JSON" echo "$TOPICS_JSON" | gh api --method PUT "/repos/$GITHUB_REPOSITORY/topics" --input - env: - GH_TOKEN: ${{ secrets.GH_PAT }} + GH_TOKEN: ${{ secrets.SYNC_META_TOKEN }} From 9d1088d9d6c5b9681ac3e92298c79f099b9d752a Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Thu, 7 May 2026 10:09:03 +0100 Subject: [PATCH 41/50] ci: document required token scope (public_repo) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/update-about.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/update-about.yml b/.github/workflows/update-about.yml index eab9e30..6287bf6 100644 --- a/.github/workflows/update-about.yml +++ b/.github/workflows/update-about.yml @@ -1,4 +1,5 @@ name: Update Repo About +# Requires a classic PAT with 'public_repo' scope stored as SYNC_META_TOKEN. on: push: From 310f0b708c98c479b666aaeb7edd6a5aa66c43cc Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Thu, 7 May 2026 10:20:47 +0100 Subject: [PATCH 42/50] ci: add CI workflow to run tests on PRs and pushes to main - Runs on pull_request (targeting main) and push to main - Installs .NET 9 and 10, restores, builds, and tests - Add CI badge to README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++++++++++ ReadMe.md | 1 + 2 files changed, 34 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5a30157 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + test: + name: Build & Test + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 9.x + 10.x + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Test + run: dotnet test CleverCache.Tests/CleverCache.Tests.csproj --no-build --configuration Release --logger "github" --verbosity normal diff --git a/ReadMe.md b/ReadMe.md index 780c038..d9dd38d 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -1,5 +1,6 @@ CleverCache ==================================================== +[![CI](https://github.com/chunty/CleverCache/actions/workflows/ci.yml/badge.svg)](https://github.com/chunty/CleverCache/actions/workflows/ci.yml) [![NuGet](https://img.shields.io/nuget/dt/clevercache.svg)](https://www.nuget.org/packages/clevercache) [![NuGet](https://img.shields.io/nuget/vpre/clevercache.svg)](https://www.nuget.org/packages/clevercache) [![Publish Wiki](https://github.com/chunty/CleverCache/actions/workflows/publish-wiki.yml/badge.svg)](https://github.com/chunty/CleverCache/actions/workflows/publish-wiki.yml) From 51ab80ada40deccb39fe19af93d7d6dc8db03cbb Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Thu, 7 May 2026 10:56:46 +0100 Subject: [PATCH 43/50] feat: add .NET 8 (LTS) support - Target net8.0;net9.0;net10.0 in Directory.Build.props - Add net8.0 condition block in Directory.Packages.props - EF Core 8.0.15 (has 8.x patch releases) - Microsoft.Extensions.* pinned to 9.0.15 (8.0.0 has known vulnerability GHSA-qj66-m88j-hmgj; 9.x supports net8.0 TFM) - StackExchangeRedis 8.0.15 (has 8.x patch releases) - Install .NET 8 in ci.yml workflow - 74/74 tests passing on net8.0, net9.0, and net10.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 1 + CleverCache.sln | 15 +++++++++++++++ Directory.Build.props | 2 +- Directory.Packages.props | 11 +++++++++++ 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a30157..77ad75b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: | + 8.x 9.x 10.x diff --git a/CleverCache.sln b/CleverCache.sln index bacfce9..c29a658 100644 --- a/CleverCache.sln +++ b/CleverCache.sln @@ -13,6 +13,15 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleverCache.Tests", "Clever EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleverCache.EntityFrameworkCore", "CleverCache.EntityFrameworkCore\CleverCache.EntityFrameworkCore.csproj", "{96A9C78A-506E-4691-2C0B-35E0C678B967}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{C6C9B89B-A02A-48AA-94E4-D181F5BDB289}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{8D5E3E15-A947-4EC4-8E73-A99B45C8BA82}" + ProjectSection(SolutionItems) = preProject + .github\workflows\ci.yml = .github\workflows\ci.yml + .github\workflows\publish-wiki.yml = .github\workflows\publish-wiki.yml + .github\workflows\update-about.yml = .github\workflows\update-about.yml + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -87,4 +96,10 @@ Global GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {8D5E3E15-A947-4EC4-8E73-A99B45C8BA82} = {C6C9B89B-A02A-48AA-94E4-D181F5BDB289} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {5D6FA936-EAF9-4BA3-8509-8BA10E6D096A} + EndGlobalSection EndGlobal diff --git a/Directory.Build.props b/Directory.Build.props index 9e3beb4..0b583f0 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - net9.0;net10.0 + net8.0;net9.0;net10.0 enable enable Chris Hunt diff --git a/Directory.Packages.props b/Directory.Packages.props index 71ba0da..fa3c4b3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,6 +14,17 @@ + + + + + + + + + + + From 77ad6e06ef36511fb65a2baaa3df12324b48fce4 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Thu, 7 May 2026 11:08:00 +0100 Subject: [PATCH 44/50] test: migrate to xunit v3 - Replace xunit 2.9.3 with xunit.v3 3.2.2 - Add OutputType=Exe to test project (v3 produces stand-alone executables) - Fix xUnit1031: convert eviction callback test to async Task - Fix xUnit1051: pass TestContext.Current.CancellationToken to all async method calls that accept a CancellationToken across all test files 74/74 tests passing on net8.0, net9.0, and net10.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CleverCache.Tests/CleverCache.Tests.csproj | 3 +- .../CleverCacheInterceptorTests.cs | 2 +- CleverCache.Tests/CleverCacheServiceTests.cs | 30 +++++++++++-------- .../DistributedCacheStoreTests.cs | 10 +++---- CleverCache.Tests/FakeCacheTests.cs | 6 ++-- CleverCache.Tests/MemoryCacheStoreTests.cs | 13 ++++---- Directory.Packages.props | 2 +- 7 files changed, 36 insertions(+), 30 deletions(-) diff --git a/CleverCache.Tests/CleverCache.Tests.csproj b/CleverCache.Tests/CleverCache.Tests.csproj index 4f9c463..2c7b8ae 100644 --- a/CleverCache.Tests/CleverCache.Tests.csproj +++ b/CleverCache.Tests/CleverCache.Tests.csproj @@ -3,12 +3,13 @@ CleverCache.Tests false true + Exe - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CleverCache.Tests/CleverCacheInterceptorTests.cs b/CleverCache.Tests/CleverCacheInterceptorTests.cs index 7f4b515..9799358 100644 --- a/CleverCache.Tests/CleverCacheInterceptorTests.cs +++ b/CleverCache.Tests/CleverCacheInterceptorTests.cs @@ -105,7 +105,7 @@ public async Task SavedChangesAsync_EntityAdded_CallsRemoveByTypeAsync() mockCache.Setup(c => c.RemoveByTypeAsync(It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); context.Orders.Add(new IcOrder { Id = 1, Name = "Test" }); - await context.SaveChangesAsync(); + await context.SaveChangesAsync(TestContext.Current.CancellationToken); mockCache.Verify(c => c.RemoveByTypeAsync(typeof(IcOrder), It.IsAny()), Times.Once); } diff --git a/CleverCache.Tests/CleverCacheServiceTests.cs b/CleverCache.Tests/CleverCacheServiceTests.cs index 3acfcca..4987716 100644 --- a/CleverCache.Tests/CleverCacheServiceTests.cs +++ b/CleverCache.Tests/CleverCacheServiceTests.cs @@ -41,7 +41,8 @@ public async Task GetOrCreateAsync_CacheMiss_InvokesFactory() var callCount = 0; var result = await sut.GetOrCreateAsync([typeof(string)], "key1", - async () => { callCount++; await Task.Yield(); return 42; }); + async () => { callCount++; await Task.Yield(); return 42; }, + cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(42, result); Assert.Equal(1, callCount); @@ -54,9 +55,11 @@ public async Task GetOrCreateAsync_CacheHit_DoesNotInvokeFactoryAgain() var callCount = 0; await sut.GetOrCreateAsync([typeof(string)], "key1", - async () => { callCount++; await Task.Yield(); return 42; }); + async () => { callCount++; await Task.Yield(); return 42; }, + cancellationToken: TestContext.Current.CancellationToken); var result = await sut.GetOrCreateAsync([typeof(string)], "key1", - async () => { callCount++; await Task.Yield(); return 99; }); + async () => { callCount++; await Task.Yield(); return 99; }, + cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(42, result); Assert.Equal(1, callCount); @@ -68,9 +71,9 @@ public async Task Remove_AllowsFactoryToBeCalledAgain() var sut = CreateService(); var callCount = 0; - await sut.GetOrCreateAsync([typeof(string)], "key1", async () => { callCount++; await Task.Yield(); return 1; }); + await sut.GetOrCreateAsync([typeof(string)], "key1", async () => { callCount++; await Task.Yield(); return 1; }, cancellationToken: TestContext.Current.CancellationToken); sut.Remove("key1"); - await sut.GetOrCreateAsync([typeof(string)], "key1", async () => { callCount++; await Task.Yield(); return 2; }); + await sut.GetOrCreateAsync([typeof(string)], "key1", async () => { callCount++; await Task.Yield(); return 2; }, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(2, callCount); } @@ -81,13 +84,13 @@ public async Task RemoveByType_RemovesAllKeysForType() var sut = CreateService(); var callCount = 0; - await sut.GetOrCreateAsync([typeof(string)], "key1", async () => { await Task.Yield(); return 1; }); - await sut.GetOrCreateAsync([typeof(string)], "key2", async () => { await Task.Yield(); return 2; }); + await sut.GetOrCreateAsync([typeof(string)], "key1", async () => { await Task.Yield(); return 1; }, cancellationToken: TestContext.Current.CancellationToken); + await sut.GetOrCreateAsync([typeof(string)], "key2", async () => { await Task.Yield(); return 2; }, cancellationToken: TestContext.Current.CancellationToken); sut.RemoveByType(typeof(string)); - await sut.GetOrCreateAsync([typeof(string)], "key1", async () => { callCount++; await Task.Yield(); return 99; }); - await sut.GetOrCreateAsync([typeof(string)], "key2", async () => { callCount++; await Task.Yield(); return 99; }); + await sut.GetOrCreateAsync([typeof(string)], "key1", async () => { callCount++; await Task.Yield(); return 99; }, cancellationToken: TestContext.Current.CancellationToken); + await sut.GetOrCreateAsync([typeof(string)], "key2", async () => { callCount++; await Task.Yield(); return 99; }, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(2, callCount); } @@ -107,14 +110,15 @@ public async Task GetOrCreateAsync_NestedCachedCallWithDifferentKey_DoesNotDeadl { // Simulate a nested cached call with a DIFFERENT key — the original deadlock scenario var inner = await sut.GetOrCreateAsync([typeof(int)], "inner-key", - async () => { await Task.Yield(); return 99; }); + async () => { await Task.Yield(); return 99; }, + cancellationToken: TestContext.Current.CancellationToken); outerCompleted = true; return $"result:{inner}"; - }); + }, cancellationToken: TestContext.Current.CancellationToken); // If deadlocked, this will throw after the timeout rather than hang the test suite - var completed = await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(5))); + var completed = await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); Assert.Same(task, completed); // task finished, not the timeout Assert.True(outerCompleted); @@ -195,7 +199,7 @@ public async Task RemoveByTypeAsync_RemovesAllKeysForType() sut.GetOrCreate([typeof(string)], "k1", () => 1); sut.GetOrCreate([typeof(string)], "k2", () => 2); - await sut.RemoveByTypeAsync(typeof(string)); + await sut.RemoveByTypeAsync(typeof(string), TestContext.Current.CancellationToken); storeMock.Verify(s => s.RemoveAsync("k1", It.IsAny()), Times.Once); storeMock.Verify(s => s.RemoveAsync("k2", It.IsAny()), Times.Once); diff --git a/CleverCache.Tests/DistributedCacheStoreTests.cs b/CleverCache.Tests/DistributedCacheStoreTests.cs index d677698..6c7f037 100644 --- a/CleverCache.Tests/DistributedCacheStoreTests.cs +++ b/CleverCache.Tests/DistributedCacheStoreTests.cs @@ -52,8 +52,8 @@ public async Task SetAsync_ThenTryGetAsync_ReturnsValue() { var store = CreateStore(); - await store.SetAsync("key1", "async-value"); - var (found, value) = await store.TryGetAsync("key1"); + await store.SetAsync("key1", "async-value", cancellationToken: TestContext.Current.CancellationToken); + var (found, value) = await store.TryGetAsync("key1", TestContext.Current.CancellationToken); Assert.True(found); Assert.Equal("async-value", value); @@ -63,11 +63,11 @@ public async Task SetAsync_ThenTryGetAsync_ReturnsValue() public async Task RemoveAsync_AfterSetAsync_TryGetReturnsFalse() { var store = CreateStore(); - await store.SetAsync("key1", 99); + await store.SetAsync("key1", 99, cancellationToken: TestContext.Current.CancellationToken); - await store.RemoveAsync("key1"); + await store.RemoveAsync("key1", TestContext.Current.CancellationToken); - var (found, _) = await store.TryGetAsync("key1"); + var (found, _) = await store.TryGetAsync("key1", TestContext.Current.CancellationToken); Assert.False(found); } } diff --git a/CleverCache.Tests/FakeCacheTests.cs b/CleverCache.Tests/FakeCacheTests.cs index fc72af9..a56cd20 100644 --- a/CleverCache.Tests/FakeCacheTests.cs +++ b/CleverCache.Tests/FakeCacheTests.cs @@ -20,8 +20,8 @@ public async Task GetOrCreateAsync_AlwaysCallsFactory() var fake = new FakeCache(); var callCount = 0; - await fake.GetOrCreateAsync([typeof(string)], "key", async () => { callCount++; await Task.Yield(); return 1; }); - await fake.GetOrCreateAsync([typeof(string)], "key", async () => { callCount++; await Task.Yield(); return 2; }); + await fake.GetOrCreateAsync([typeof(string)], "key", async () => { callCount++; await Task.Yield(); return 1; }, cancellationToken: TestContext.Current.CancellationToken); + await fake.GetOrCreateAsync([typeof(string)], "key", async () => { callCount++; await Task.Yield(); return 2; }, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(2, callCount); } @@ -46,7 +46,7 @@ public void RemoveByType_DoesNotThrow() public async Task RemoveByTypeAsync_DoesNotThrow() { var fake = new FakeCache(); - var ex = await Record.ExceptionAsync(() => fake.RemoveByTypeAsync(typeof(string))); + var ex = await Record.ExceptionAsync(() => fake.RemoveByTypeAsync(typeof(string), TestContext.Current.CancellationToken)); Assert.Null(ex); } diff --git a/CleverCache.Tests/MemoryCacheStoreTests.cs b/CleverCache.Tests/MemoryCacheStoreTests.cs index 235889c..20ff02b 100644 --- a/CleverCache.Tests/MemoryCacheStoreTests.cs +++ b/CleverCache.Tests/MemoryCacheStoreTests.cs @@ -47,7 +47,7 @@ public async Task TryGetAsync_MissingKey_ReturnsFalse() { var store = CreateStore(); - var (found, value) = await store.TryGetAsync("missing"); + var (found, value) = await store.TryGetAsync("missing", TestContext.Current.CancellationToken); Assert.False(found); Assert.Null(value); @@ -58,8 +58,8 @@ public async Task SetAsync_ThenTryGetAsync_ReturnsValue() { var store = CreateStore(); - await store.SetAsync("key1", "async-hello"); - var (found, value) = await store.TryGetAsync("key1"); + await store.SetAsync("key1", "async-hello", cancellationToken: TestContext.Current.CancellationToken); + var (found, value) = await store.TryGetAsync("key1", TestContext.Current.CancellationToken); Assert.True(found); Assert.Equal("async-hello", value); @@ -81,7 +81,7 @@ public void Set_WithOptions_StoresValue() } [Fact] - public void RegisterEvictionCallback_CalledWhenEntryEvicted() + public async Task RegisterEvictionCallback_CalledWhenEntryEvicted() { var store = CreateStore(); var tcs = new TaskCompletionSource(); @@ -90,7 +90,8 @@ public void RegisterEvictionCallback_CalledWhenEntryEvicted() store.Set("key1", 42); store.Remove("key1"); // triggers PostEvictionCallback (async) - Assert.True(tcs.Task.Wait(TimeSpan.FromSeconds(2)), "eviction callback was not fired"); - Assert.Equal("key1", tcs.Task.Result); + var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken)) == tcs.Task; + Assert.True(completed, "eviction callback was not fired"); + Assert.Equal("key1", await tcs.Task); } } diff --git a/Directory.Packages.props b/Directory.Packages.props index fa3c4b3..a22080a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,7 +10,7 @@ - + From e3298ff3cd5d0de31f4d42cac36724a641ba7ba6 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Thu, 7 May 2026 11:33:37 +0100 Subject: [PATCH 45/50] package update --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index a22080a..5f9bcc5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,7 +7,7 @@ - + From 44368c6d2f068620717398908dc574bf17d16fea Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Thu, 7 May 2026 14:12:05 +0100 Subject: [PATCH 46/50] Treat warning as errors --- Directory.Build.props | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 0b583f0..09d4167 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,13 +1,14 @@ - - net8.0;net9.0;net10.0 - enable - enable - Chris Hunt - TappetyClick - MIT - https://github.com/chunty/Simpository - git - https://github.com/chunty/Simpository - + + net8.0;net9.0;net10.0 + enable + enable + Chris Hunt + TappetyClick + MIT + https://github.com/chunty/Simpository + git + https://github.com/chunty/Simpository + True + From 3bb5c608c1f10fd0aee649092e544d288f463185 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Tue, 26 May 2026 16:46:45 +0100 Subject: [PATCH 47/50] clean up --- CleverCache.MediatR/MediatRServiceConfigurationExt.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/CleverCache.MediatR/MediatRServiceConfigurationExt.cs b/CleverCache.MediatR/MediatRServiceConfigurationExt.cs index 92af708..631263c 100644 --- a/CleverCache.MediatR/MediatRServiceConfigurationExt.cs +++ b/CleverCache.MediatR/MediatRServiceConfigurationExt.cs @@ -1,5 +1,3 @@ -using MediatR; -using MediatR.Registration; using Microsoft.Extensions.DependencyInjection; namespace CleverCache.Mediatr; From 758eb176fc266a69eaf29a562d0b24d13deebfe7 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Tue, 26 May 2026 16:46:59 +0100 Subject: [PATCH 48/50] update packages --- Directory.Packages.props | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 5f9bcc5..2166052 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,29 +2,26 @@ true - - + - - - + + - + - @@ -34,14 +31,13 @@ - - - - - - - + + + + + + - + \ No newline at end of file From a513823664aba1e9c86d596e78cb27756999a7c8 Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Tue, 26 May 2026 17:16:18 +0100 Subject: [PATCH 49/50] ci: run Build & Test on master and main Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77ad75b..c19d8eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: pull_request: - branches: [main] + branches: [master, main] push: - branches: [main] + branches: [master, main] jobs: test: From 7959826e42fc2fd8107b96da6cb4273cc9f9596a Mon Sep 17 00:00:00 2001 From: Chris Hunt Date: Tue, 26 May 2026 18:33:12 +0100 Subject: [PATCH 50/50] ci: use trx logger in test step Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c19d8eb..f16b2b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,4 +31,4 @@ jobs: run: dotnet build --no-restore --configuration Release - name: Test - run: dotnet test CleverCache.Tests/CleverCache.Tests.csproj --no-build --configuration Release --logger "github" --verbosity normal + run: dotnet test CleverCache.Tests/CleverCache.Tests.csproj --no-build --configuration Release --logger "trx" --verbosity normal