diff --git a/Directory.Packages.props b/Directory.Packages.props index 62c095ee..53ce23e0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -58,6 +58,8 @@ + + diff --git a/framework/SimpleModule.Core/Caching/CacheEntryOptions.cs b/framework/SimpleModule.Core/Caching/CacheEntryOptions.cs deleted file mode 100644 index 82850d52..00000000 --- a/framework/SimpleModule.Core/Caching/CacheEntryOptions.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace SimpleModule.Core.Caching; - -/// -/// Implementation-agnostic options describing how an entry should be retained in the cache. -/// -public sealed class CacheEntryOptions -{ - /// - /// Lifetime relative to the time the entry is written. Mutually exclusive with - /// . - /// - public TimeSpan? AbsoluteExpirationRelativeToNow { get; init; } - - /// - /// An absolute point in time at which the entry expires. Mutually exclusive with - /// . - /// - public DateTimeOffset? AbsoluteExpiration { get; init; } - - /// - /// Sliding expiration window. The entry is evicted if it is not accessed within this window. - /// - public TimeSpan? SlidingExpiration { get; init; } - - /// - /// Optional size hint, used by stores that enforce a size limit. - /// - public long? Size { get; init; } - - /// - /// Creates options that expire after the supplied duration. - /// - public static CacheEntryOptions Expires(TimeSpan duration) => - new() { AbsoluteExpirationRelativeToNow = duration }; - - /// - /// Creates options with a sliding expiration window. - /// - public static CacheEntryOptions Sliding(TimeSpan window) => - new() { SlidingExpiration = window }; -} diff --git a/framework/SimpleModule.Core/Caching/CacheKey.cs b/framework/SimpleModule.Core/Caching/CacheKey.cs deleted file mode 100644 index 32d5f022..00000000 --- a/framework/SimpleModule.Core/Caching/CacheKey.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace SimpleModule.Core.Caching; - -/// -/// Helpers for composing consistent cache keys. -/// -public static class CacheKey -{ - /// - /// Joins the supplied parts with :, skipping null or empty segments. - /// - /// - /// CacheKey.Compose("settings", scope.ToString(), userId, key); - /// - public static string Compose(params string?[] parts) - { - ArgumentNullException.ThrowIfNull(parts); - return string.Join(':', parts.Where(p => !string.IsNullOrEmpty(p))); - } -} diff --git a/framework/SimpleModule.Core/Caching/CacheResult.cs b/framework/SimpleModule.Core/Caching/CacheResult.cs deleted file mode 100644 index 4fc81380..00000000 --- a/framework/SimpleModule.Core/Caching/CacheResult.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace SimpleModule.Core.Caching; - -/// -/// Result of a cache lookup. Distinguishes a miss from a hit that contains a -/// value (negative caching). -/// -/// The cached value type. -public readonly record struct CacheResult(bool Hit, T? Value); - -/// -/// Non-generic helpers for constructing values. -/// -public static class CacheResult -{ - /// - /// Creates a miss result for type . - /// - public static CacheResult Miss() => default; - - /// - /// Creates a hit result with the supplied value (which may be ). - /// - public static CacheResult Hit(T? value) => new(true, value); -} diff --git a/framework/SimpleModule.Core/Caching/CacheStoreExtensions.cs b/framework/SimpleModule.Core/Caching/CacheStoreExtensions.cs deleted file mode 100644 index c9302126..00000000 --- a/framework/SimpleModule.Core/Caching/CacheStoreExtensions.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace SimpleModule.Core.Caching; - -/// -/// Convenience extensions over . -/// -public static class CacheStoreExtensions -{ - /// - /// Returns a view over the store where every key is automatically prefixed with - /// (joined with :). Useful for module- or tenant-scoped - /// cache namespacing without forcing every call site to remember the prefix. - /// - public static ICacheStore WithPrefix(this ICacheStore store, string prefix) - { - ArgumentNullException.ThrowIfNull(store); - return new PrefixedCacheStore(store, prefix); - } - - /// - /// Synchronous-style helper for the common pattern var v = await cache.GetOrCreateAsync(...) - /// where the factory is itself synchronous. - /// - public static ValueTask GetOrCreateAsync( - this ICacheStore store, - string key, - Func factory, - CacheEntryOptions? options = null, - CancellationToken cancellationToken = default - ) - { - ArgumentNullException.ThrowIfNull(store); - ArgumentNullException.ThrowIfNull(factory); - return store.GetOrCreateAsync( - key, - _ => new ValueTask(factory()), - options, - cancellationToken - ); - } -} diff --git a/framework/SimpleModule.Core/Caching/CachingServiceCollectionExtensions.cs b/framework/SimpleModule.Core/Caching/CachingServiceCollectionExtensions.cs deleted file mode 100644 index b692085c..00000000 --- a/framework/SimpleModule.Core/Caching/CachingServiceCollectionExtensions.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace SimpleModule.Core.Caching; - -/// -/// DI registration for the unified SimpleModule caching abstraction. -/// -public static class CachingServiceCollectionExtensions -{ - /// - /// Registers with the default in-process - /// implementation, along with the underlying - /// . Safe to call - /// multiple times — registrations are added with TryAdd. - /// - public static IServiceCollection AddSimpleModuleCaching(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - services.AddMemoryCache(); - services.TryAddSingleton(); - return services; - } -} diff --git a/framework/SimpleModule.Core/Caching/ICacheStore.cs b/framework/SimpleModule.Core/Caching/ICacheStore.cs deleted file mode 100644 index 8b7141a7..00000000 --- a/framework/SimpleModule.Core/Caching/ICacheStore.cs +++ /dev/null @@ -1,55 +0,0 @@ -namespace SimpleModule.Core.Caching; - -/// -/// Unified caching abstraction used across SimpleModule modules. -/// -/// -/// The default registration is an in-process MemoryCacheStore backed by -/// . The interface is intentionally -/// async-first so that distributed implementations (Redis, etc.) can be plugged in without -/// changing call sites. -/// -public interface ICacheStore -{ - /// - /// Looks up an entry. Returns a that distinguishes a miss from a - /// hit containing a value (negative caching). - /// - ValueTask> TryGetAsync( - string key, - CancellationToken cancellationToken = default - ); - - /// - /// Writes an entry, replacing any existing value for . - /// - ValueTask SetAsync( - string key, - T? value, - CacheEntryOptions? options = null, - CancellationToken cancellationToken = default - ); - - /// - /// Returns the cached value for , invoking - /// to populate the cache on a miss. Implementations must guard against cache stampedes — - /// concurrent callers for the same key see invoked at most once. - /// - ValueTask GetOrCreateAsync( - string key, - Func> factory, - CacheEntryOptions? options = null, - CancellationToken cancellationToken = default - ); - - /// - /// Removes a single entry. No-op if the key is absent. - /// - ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default); - - /// - /// Removes every entry whose key starts with . Useful for - /// invalidating a logical group (e.g., all entries for a user, tenant, or module). - /// - ValueTask RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default); -} diff --git a/framework/SimpleModule.Core/Caching/MemoryCacheStore.cs b/framework/SimpleModule.Core/Caching/MemoryCacheStore.cs deleted file mode 100644 index e32b6956..00000000 --- a/framework/SimpleModule.Core/Caching/MemoryCacheStore.cs +++ /dev/null @@ -1,210 +0,0 @@ -using System.Collections.Concurrent; -using Microsoft.Extensions.Caching.Memory; - -namespace SimpleModule.Core.Caching; - -/// -/// In-process implementation backed by -/// . Adds two capabilities on top of the raw memory cache: -/// stampede-safe via per-key locking, and -/// via a tracked key set. -/// -public sealed class MemoryCacheStore : ICacheStore, IDisposable -{ - private readonly IMemoryCache _cache; - private readonly ConcurrentDictionary _trackedKeys = new(StringComparer.Ordinal); - private readonly ConcurrentDictionary _keyLocks = new( - StringComparer.Ordinal - ); - - public MemoryCacheStore(IMemoryCache cache) - { - ArgumentNullException.ThrowIfNull(cache); - _cache = cache; - } - - public ValueTask> TryGetAsync( - string key, - CancellationToken cancellationToken = default - ) - { - ArgumentException.ThrowIfNullOrEmpty(key); - cancellationToken.ThrowIfCancellationRequested(); - - if (_cache.TryGetValue(key, out var raw)) - { - return ValueTask.FromResult(CacheResult.Hit((T?)raw)); - } - - return ValueTask.FromResult(CacheResult.Miss()); - } - - public ValueTask SetAsync( - string key, - T? value, - CacheEntryOptions? options = null, - CancellationToken cancellationToken = default - ) - { - ArgumentException.ThrowIfNullOrEmpty(key); - cancellationToken.ThrowIfCancellationRequested(); - - SetCore(key, value, options); - return ValueTask.CompletedTask; - } - - public async ValueTask GetOrCreateAsync( - string key, - Func> factory, - CacheEntryOptions? options = null, - CancellationToken cancellationToken = default - ) - { - ArgumentException.ThrowIfNullOrEmpty(key); - ArgumentNullException.ThrowIfNull(factory); - - if (_cache.TryGetValue(key, out var existing)) - { - return (T?)existing; - } - - var gate = _keyLocks.GetOrAdd(key, static _ => new SemaphoreSlim(1, 1)); - await gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - if (_cache.TryGetValue(key, out existing)) - { - return (T?)existing; - } - - var value = await factory(cancellationToken).ConfigureAwait(false); - SetCore(key, value, options); - return value; - } - finally - { - gate.Release(); - // Reclaim the lock entry once no other caller is waiting on it. - // CurrentCount == 1 means the semaphore is fully released and idle; any - // concurrent waiter would hold it below 1. This bounds the dictionary to - // keys currently being populated rather than every key ever populated. - if (gate.CurrentCount == 1 && _keyLocks.TryRemove(key, out var removed)) - { - removed.Dispose(); - } - } - } - - public ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrEmpty(key); - cancellationToken.ThrowIfCancellationRequested(); - - _cache.Remove(key); - _trackedKeys.TryRemove(key, out _); - TryReclaimIdleLock(key); - return ValueTask.CompletedTask; - } - - public ValueTask RemoveByPrefixAsync( - string prefix, - CancellationToken cancellationToken = default - ) - { - ArgumentException.ThrowIfNullOrEmpty(prefix); - cancellationToken.ThrowIfCancellationRequested(); - - foreach (var key in _trackedKeys.Keys) - { - if (key.StartsWith(prefix, StringComparison.Ordinal)) - { - _cache.Remove(key); - _trackedKeys.TryRemove(key, out _); - TryReclaimIdleLock(key); - } - } - - return ValueTask.CompletedTask; - } - - /// - /// Removes and disposes a per-key semaphore only when it is observably idle - /// (no waiters, fully released). If a caller - /// is still holding the gate, the lock is left in place and that caller's - /// own finally block will reclaim it after release. - /// - private void TryReclaimIdleLock(string key) - { - if ( - _keyLocks.TryGetValue(key, out var gate) - && gate.CurrentCount == 1 - && _keyLocks.TryRemove(key, out var removed) - ) - { - removed.Dispose(); - } - } - - private void SetCore(string key, T? value, CacheEntryOptions? options) - { - using var entry = _cache.CreateEntry(key); - entry.Value = value; - - if (options is not null) - { - if (options.AbsoluteExpirationRelativeToNow is { } relative) - { - entry.AbsoluteExpirationRelativeToNow = relative; - } - - if (options.AbsoluteExpiration is { } absolute) - { - entry.AbsoluteExpiration = absolute; - } - - if (options.SlidingExpiration is { } sliding) - { - entry.SlidingExpiration = sliding; - } - - if (options.Size is { } size) - { - entry.Size = size; - } - } - - // Track the key so RemoveByPrefixAsync can find it. The eviction callback - // releases both tracking entries and any idle per-key lock when the entry - // naturally expires or is evicted by memory pressure, so both sets stay bounded. - _trackedKeys[key] = 0; - entry.RegisterPostEvictionCallback( - static (evictedKey, _, _, state) => - { - if (state is not MemoryCacheStore self || evictedKey is not string s) - { - return; - } - - self._trackedKeys.TryRemove(s, out _); - if ( - self._keyLocks.TryGetValue(s, out var gate) - && gate.CurrentCount == 1 - && self._keyLocks.TryRemove(s, out var removed) - ) - { - removed.Dispose(); - } - }, - this - ); - } - - public void Dispose() - { - foreach (var gate in _keyLocks.Values) - { - gate.Dispose(); - } - _keyLocks.Clear(); - } -} diff --git a/framework/SimpleModule.Core/Caching/PrefixedCacheStore.cs b/framework/SimpleModule.Core/Caching/PrefixedCacheStore.cs deleted file mode 100644 index 5a10dd51..00000000 --- a/framework/SimpleModule.Core/Caching/PrefixedCacheStore.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace SimpleModule.Core.Caching; - -/// -/// Decorator that scopes every key with a fixed prefix before forwarding to the inner store. -/// Created via . -/// -internal sealed class PrefixedCacheStore : ICacheStore -{ - private readonly ICacheStore _inner; - private readonly string _prefix; - - public PrefixedCacheStore(ICacheStore inner, string prefix) - { - ArgumentNullException.ThrowIfNull(inner); - ArgumentException.ThrowIfNullOrEmpty(prefix); - _inner = inner; - _prefix = prefix.EndsWith(':') ? prefix : prefix + ':'; - } - - private string Scope(string key) => _prefix + key; - - public ValueTask> TryGetAsync( - string key, - CancellationToken cancellationToken = default - ) => _inner.TryGetAsync(Scope(key), cancellationToken); - - public ValueTask SetAsync( - string key, - T? value, - CacheEntryOptions? options = null, - CancellationToken cancellationToken = default - ) => _inner.SetAsync(Scope(key), value, options, cancellationToken); - - public ValueTask GetOrCreateAsync( - string key, - Func> factory, - CacheEntryOptions? options = null, - CancellationToken cancellationToken = default - ) => _inner.GetOrCreateAsync(Scope(key), factory, options, cancellationToken); - - public ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default) => - _inner.RemoveAsync(Scope(key), cancellationToken); - - public ValueTask RemoveByPrefixAsync( - string prefix, - CancellationToken cancellationToken = default - ) => _inner.RemoveByPrefixAsync(_prefix + prefix, cancellationToken); -} diff --git a/framework/SimpleModule.Core/SimpleModule.Core.csproj b/framework/SimpleModule.Core/SimpleModule.Core.csproj index eafe3ebc..c8cd3274 100644 --- a/framework/SimpleModule.Core/SimpleModule.Core.csproj +++ b/framework/SimpleModule.Core/SimpleModule.Core.csproj @@ -7,5 +7,6 @@ + diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index 6454c40a..4ddb2968 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -8,7 +8,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; -using SimpleModule.Core.Caching; using SimpleModule.Core.Constants; using SimpleModule.Core.Events; using SimpleModule.Core.Exceptions; @@ -24,6 +23,7 @@ using SimpleModule.Hosting.Inertia; using SimpleModule.Hosting.Middleware; using SimpleModule.Hosting.RateLimiting; +using ZiggyCreatures.Caching.Fusion; namespace SimpleModule.Hosting; @@ -67,8 +67,11 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure( builder.Services.AddSingleton(); - // Unified caching abstraction (ICacheStore) shared across all modules. - builder.Services.AddSimpleModuleCaching(); + // Unified caching abstraction (IFusionCache) shared across all modules. + // Stampede-safe GetOrSetAsync built in; five-minute default entry duration. + builder + .Services.AddFusionCache() + .WithDefaultEntryOptions(o => o.Duration = TimeSpan.FromMinutes(5)); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs index 8e8c37d7..b8e47e2e 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs @@ -3,9 +3,9 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using SimpleModule.Core.Caching; using SimpleModule.Core.Events; using SimpleModule.Database.Interceptors; +using ZiggyCreatures.Caching.Fusion; namespace SimpleModule.Hosting; @@ -31,7 +31,9 @@ public static HostApplicationBuilder AddSimpleModuleWorker(this HostApplicationB builder.Configuration["BackgroundJobs:WorkerMode"] = "Consumer"; // Core infrastructure that the worker needs: - builder.Services.AddSimpleModuleCaching(); + builder + .Services.AddFusionCache() + .WithDefaultEntryOptions(o => o.Duration = TimeSpan.FromMinutes(5)); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddScoped(); diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.Resolution.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.Resolution.cs index 1ea6fa8d..beadc41d 100644 --- a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.Resolution.cs +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.Resolution.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; -using SimpleModule.Core.Caching; using SimpleModule.Core.Entities; using SimpleModule.Core.FeatureFlags; using SimpleModule.FeatureFlags.Contracts; +using ZiggyCreatures.Caching.Fusion; namespace SimpleModule.FeatureFlags; @@ -10,9 +10,9 @@ public sealed partial class FeatureFlagService { private async Task> GetAllFlagDataAsync() { - var result = await cache.GetOrCreateAsync>( + var result = await cache.GetOrSetAsync>( AllFlagDataCacheKey, - async ct => + async (_, ct) => { var definitions = registry.GetAllDefinitions(); var flagNames = definitions.Select(d => d.Name).ToList(); @@ -45,26 +45,21 @@ private async Task> GetAllFlagDataAsync() var data = BuildFlagData(isEnabled, flagOverrides); allData[def.Name] = data; - await cache.SetAsync( - FlagDataCacheKey(def.Name), - data, - CacheEntryOptions.Expires(CacheDuration), - ct - ); + await cache.SetAsync(FlagDataCacheKey(def.Name), data, CacheOptions, token: ct); } return allData; }, - CacheEntryOptions.Expires(CacheDuration) + CacheOptions ); return result ?? []; } private async Task GetFlagDataAsync(string flagName) { - var result = await cache.GetOrCreateAsync( + var result = await cache.GetOrSetAsync( FlagDataCacheKey(flagName), - async ct => + async (_, ct) => { var flag = await db .FeatureFlags.AsNoTracking() @@ -80,7 +75,7 @@ private async Task GetFlagDataAsync(string flagName) return BuildFlagData(isEnabled, overrides); }, - CacheEntryOptions.Expires(CacheDuration) + CacheOptions ); return result ?? BuildFlagData(false, []); } diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.cs index c8e50779..ea431ccd 100644 --- a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.cs +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.cs @@ -1,17 +1,17 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using SimpleModule.Core.Caching; using SimpleModule.Core.Entities; using SimpleModule.Core.FeatureFlags; using SimpleModule.FeatureFlags.Contracts; +using ZiggyCreatures.Caching.Fusion; namespace SimpleModule.FeatureFlags; public sealed partial class FeatureFlagService( FeatureFlagsDbContext db, IFeatureFlagRegistry registry, - ICacheStore cache, + IFusionCache cache, ILogger logger, IServiceProvider serviceProvider ) : IFeatureFlagContracts, IFeatureFlagService @@ -19,7 +19,10 @@ IServiceProvider serviceProvider private readonly Lazy _tenantContext = new(() => serviceProvider.GetService() ); - private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(30); + private static readonly FusionCacheEntryOptions CacheOptions = new() + { + Duration = TimeSpan.FromSeconds(30), + }; private const string AllFlagDataCacheKey = "ff:all-data"; private const string FlagDataKeyPrefix = "ff:data:"; diff --git a/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/Unit/FeatureFlagServiceTests.cs b/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/Unit/FeatureFlagServiceTests.cs index 87974f75..c838f96e 100644 --- a/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/Unit/FeatureFlagServiceTests.cs +++ b/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/Unit/FeatureFlagServiceTests.cs @@ -1,14 +1,13 @@ using FluentAssertions; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using SimpleModule.Core.Caching; using SimpleModule.Core.FeatureFlags; using SimpleModule.Database; using SimpleModule.FeatureFlags; using SimpleModule.FeatureFlags.Contracts; +using ZiggyCreatures.Caching.Fusion; namespace FeatureFlags.Tests.Unit; @@ -17,10 +16,8 @@ public sealed class FeatureFlagServiceTests : IDisposable private readonly FeatureFlagsDbContext _db; private readonly FeatureFlagService _sut; private readonly IFeatureFlagRegistry _registry; - private readonly MemoryCache _cache; - private readonly MemoryCacheStore _cacheStore; - private readonly List _freshCaches = []; - private readonly List _freshCacheStores = []; + private readonly FusionCache _cache; + private readonly List _freshCaches = []; public FeatureFlagServiceTests() { @@ -52,12 +49,11 @@ public FeatureFlagServiceTests() ); _registry = builder.Build(); - _cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); - _cacheStore = new MemoryCacheStore(_cache); + _cache = new FusionCache(new FusionCacheOptions()); _sut = new FeatureFlagService( _db, _registry, - _cacheStore, + _cache, NullLogger.Instance, new ServiceCollection().BuildServiceProvider() ); @@ -65,17 +61,11 @@ public FeatureFlagServiceTests() public void Dispose() { - foreach (var s in _freshCacheStores) - { - s.Dispose(); - } - foreach (var c in _freshCaches) { c.Dispose(); } - _cacheStore.Dispose(); _cache.Dispose(); _db.Dispose(); } @@ -83,14 +73,12 @@ public void Dispose() private FeatureFlagService CreateFreshService() { // Create a new cache to bypass cached results - var freshCache = new MemoryCache(Options.Create(new MemoryCacheOptions())); + var freshCache = new FusionCache(new FusionCacheOptions()); _freshCaches.Add(freshCache); - var freshStore = new MemoryCacheStore(freshCache); - _freshCacheStores.Add(freshStore); return new FeatureFlagService( _db, _registry, - freshStore, + freshCache, NullLogger.Instance, new ServiceCollection().BuildServiceProvider() ); diff --git a/modules/Localization/src/SimpleModule.Localization/Middleware/LocaleResolutionMiddleware.cs b/modules/Localization/src/SimpleModule.Localization/Middleware/LocaleResolutionMiddleware.cs index fd1d0d54..60dc81d1 100644 --- a/modules/Localization/src/SimpleModule.Localization/Middleware/LocaleResolutionMiddleware.cs +++ b/modules/Localization/src/SimpleModule.Localization/Middleware/LocaleResolutionMiddleware.cs @@ -3,12 +3,12 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using SimpleModule.Core.Caching; using SimpleModule.Core.Inertia; using SimpleModule.Core.Settings; using SimpleModule.Localization.Contracts; using SimpleModule.Localization.Services; using SimpleModule.Settings.Contracts; +using ZiggyCreatures.Caching.Fusion; namespace SimpleModule.Localization.Middleware; @@ -16,14 +16,17 @@ public sealed class LocaleResolutionMiddleware( RequestDelegate next, IConfiguration configuration, TranslationLoader loader, - ICacheStore cache + IFusionCache cache ) { - private static readonly CacheEntryOptions UserLocaleCacheOptions = CacheEntryOptions.Expires( - TimeSpan.FromMinutes(5) - ); - private static readonly CacheEntryOptions AcceptLanguageCacheOptions = - CacheEntryOptions.Expires(TimeSpan.FromMinutes(30)); + private static readonly FusionCacheEntryOptions UserLocaleCacheOptions = new() + { + Duration = TimeSpan.FromMinutes(5), + }; + private static readonly FusionCacheEntryOptions AcceptLanguageCacheOptions = new() + { + Duration = TimeSpan.FromMinutes(30), + }; public async Task InvokeAsync(HttpContext context) { @@ -66,7 +69,7 @@ private async Task ResolveLocaleAsync(HttpContext context) // leaks to another browser for the same user. var cacheKey = UserLocaleKey(userId); var cachedHit = await cache.TryGetAsync(cacheKey); - if (cachedHit.Hit && !string.IsNullOrEmpty(cachedHit.Value)) + if (cachedHit.HasValue && !string.IsNullOrEmpty(cachedHit.Value)) { return cachedHit.Value; } @@ -101,7 +104,7 @@ private async Task ResolveFromAcceptLanguageAsync(HttpContext context) var cacheKey = AcceptLanguageKey(rawHeader); var cachedHit = await cache.TryGetAsync(cacheKey); - if (cachedHit.Hit && !string.IsNullOrEmpty(cachedHit.Value)) + if (cachedHit.HasValue && !string.IsNullOrEmpty(cachedHit.Value)) { return cachedHit.Value; } diff --git a/modules/Localization/tests/SimpleModule.Localization.Tests/Unit/LocaleResolutionMiddlewareTests.cs b/modules/Localization/tests/SimpleModule.Localization.Tests/Unit/LocaleResolutionMiddlewareTests.cs index 5f3eeb5b..19dcdd79 100644 --- a/modules/Localization/tests/SimpleModule.Localization.Tests/Unit/LocaleResolutionMiddlewareTests.cs +++ b/modules/Localization/tests/SimpleModule.Localization.Tests/Unit/LocaleResolutionMiddlewareTests.cs @@ -2,23 +2,21 @@ using System.Security.Claims; using FluentAssertions; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using SimpleModule.Core.Caching; using SimpleModule.Core.Inertia; using SimpleModule.Core.Settings; using SimpleModule.Localization.Middleware; using SimpleModule.Localization.Services; using SimpleModule.Settings.Contracts; +using ZiggyCreatures.Caching.Fusion; namespace SimpleModule.Localization.Tests.Unit; public sealed class LocaleResolutionMiddlewareTests : IDisposable { private readonly TranslationLoader _loader; - private readonly MemoryCache _cache = new(new MemoryCacheOptions()); - private readonly MemoryCacheStore _cacheStore; + private readonly FusionCache _cache = new(new FusionCacheOptions()); public LocaleResolutionMiddlewareTests() { @@ -31,7 +29,6 @@ public LocaleResolutionMiddlewareTests() "es", new Dictionary { ["common.save"] = "Guardar" } ); - _cacheStore = new MemoryCacheStore(_cache); } [Fact] @@ -44,7 +41,7 @@ public async Task Invoke_AuthenticatedUserWithLanguageSetting_UsesUserLocale() var middleware = CreateMiddleware( CaptureLocale(v => capturedLocale = v), CreateConfiguration(null), - _cacheStore + _cache ); await middleware.InvokeAsync(context); @@ -67,7 +64,7 @@ public async Task Invoke_AnonymousWithAcceptLanguageHeader_UsesHeaderLocale() var middleware = CreateMiddleware( CaptureLocale(v => capturedLocale = v), CreateConfiguration(null), - _cacheStore + _cache ); await middleware.InvokeAsync(context); @@ -85,7 +82,7 @@ public async Task Invoke_NoHeaderNoSetting_UsesConfigDefault() var middleware = CreateMiddleware( CaptureLocale(v => capturedLocale = v), CreateConfiguration("es"), - _cacheStore + _cache ); await middleware.InvokeAsync(context); @@ -103,7 +100,7 @@ public async Task Invoke_NoHeaderNoSettingNoConfig_FallsBackToEn() var middleware = CreateMiddleware( CaptureLocale(v => capturedLocale = v), CreateConfiguration(null), - _cacheStore + _cache ); await middleware.InvokeAsync(context); @@ -116,13 +113,12 @@ public async Task Invoke_CachesExplicitUserSetting() { var callCount = 0; var settings = new FakeSettingsContracts("es", onGet: () => callCount++); - using var localCache = new MemoryCache(new MemoryCacheOptions()); - using var localCacheStore = new MemoryCacheStore(localCache); + using var localCache = new FusionCache(new FusionCacheOptions()); var middleware = CreateMiddleware( _ => Task.CompletedTask, CreateConfiguration(null), - localCacheStore + localCache ); var context1 = CreateHttpContext(settings, userId: "user-1"); @@ -143,13 +139,12 @@ public async Task Invoke_DoesNotCacheFallbackPerUser() // avoid cross-browser cache pollution. var callCount = 0; var settings = new FakeSettingsContracts(null, onGet: () => callCount++); - using var localCache = new MemoryCache(new MemoryCacheOptions()); - using var localCacheStore = new MemoryCacheStore(localCache); + using var localCache = new FusionCache(new FusionCacheOptions()); var middleware = CreateMiddleware( _ => Task.CompletedTask, CreateConfiguration(null), - localCacheStore + localCache ); var context1 = CreateHttpContext(settings, userId: "user-2"); @@ -165,7 +160,7 @@ public async Task Invoke_DoesNotCacheFallbackPerUser() private LocaleResolutionMiddleware CreateMiddleware( RequestDelegate next, IConfiguration config, - ICacheStore cache + IFusionCache cache ) { return new LocaleResolutionMiddleware(next, config, _loader, cache); @@ -260,7 +255,6 @@ public Task> GetSettingsAsync(SettingsFilter? filter = null public void Dispose() { - _cacheStore.Dispose(); _cache.Dispose(); } } diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/InstalledPackageDetector.cs b/modules/Marketplace/src/SimpleModule.Marketplace/InstalledPackageDetector.cs index 2d191883..4963663f 100644 --- a/modules/Marketplace/src/SimpleModule.Marketplace/InstalledPackageDetector.cs +++ b/modules/Marketplace/src/SimpleModule.Marketplace/InstalledPackageDetector.cs @@ -2,26 +2,27 @@ using System.Xml.Linq; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Logging; -using SimpleModule.Core.Caching; +using ZiggyCreatures.Caching.Fusion; namespace SimpleModule.Marketplace; public partial class InstalledPackageDetector( IWebHostEnvironment environment, - ICacheStore cache, + IFusionCache cache, ILogger logger ) { private const string CacheKey = "Marketplace:InstalledPackages"; - private static readonly CacheEntryOptions CacheOptions = CacheEntryOptions.Expires( - TimeSpan.FromMinutes(1) - ); + private static readonly FusionCacheEntryOptions CacheOptions = new() + { + Duration = TimeSpan.FromMinutes(1), + }; public async Task> GetInstalledPackageIdsAsync() { - var result = await cache.GetOrCreateAsync>( + var result = await cache.GetOrSetAsync>( CacheKey, - _ => new ValueTask?>(ReadInstalledPackages()), + (_, _) => Task.FromResult(ReadInstalledPackages()), CacheOptions ); return result ?? []; diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs b/modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs index 8d5163b3..66cb6dc0 100644 --- a/modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs +++ b/modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs @@ -2,8 +2,8 @@ using System.Net.Http.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Options; -using SimpleModule.Core.Caching; using SimpleModule.Marketplace.Contracts; +using ZiggyCreatures.Caching.Fusion; namespace SimpleModule.Marketplace; @@ -11,19 +11,27 @@ public class NuGetMarketplaceService( IHttpClientFactory httpClientFactory, IOptions options, InstalledPackageDetector installedPackageDetector, - ICacheStore cache + IFusionCache cache ) : IMarketplaceContracts { + private readonly FusionCacheEntryOptions _searchCacheOptions = new() + { + Duration = TimeSpan.FromMinutes(options.Value.SearchCacheDurationMinutes), + }; + + private readonly FusionCacheEntryOptions _detailCacheOptions = new() + { + Duration = TimeSpan.FromMinutes(options.Value.DetailCacheDurationMinutes), + }; + public async Task SearchPackagesAsync(MarketplaceSearchRequest request) { var cacheKey = $"Marketplace:Search:{request.Query}"; - var cached = await cache.GetOrCreateAsync( + var cached = await cache.GetOrSetAsync( cacheKey, - async _ => await FetchAllPackagesAsync(request.Query), - CacheEntryOptions.Expires( - TimeSpan.FromMinutes(options.Value.SearchCacheDurationMinutes) - ) + async (_, _) => await FetchAllPackagesAsync(request.Query), + _searchCacheOptions ); var result = cached ?? new MarketplaceSearchResult(); @@ -55,12 +63,10 @@ .. packages.OrderByDescending(p => p.TotalDownloads), { var cacheKey = $"Marketplace:Detail:{packageId}"; - return await cache.GetOrCreateAsync( + return await cache.GetOrSetAsync( cacheKey, - async _ => await FetchPackageDetailsAsync(packageId), - CacheEntryOptions.Expires( - TimeSpan.FromMinutes(options.Value.DetailCacheDurationMinutes) - ) + async (_, _) => await FetchPackageDetailsAsync(packageId), + _detailCacheOptions ); } diff --git a/modules/Permissions/src/SimpleModule.Permissions/PermissionClaimsTransformation.cs b/modules/Permissions/src/SimpleModule.Permissions/PermissionClaimsTransformation.cs index 511bac71..aa357cfb 100644 --- a/modules/Permissions/src/SimpleModule.Permissions/PermissionClaimsTransformation.cs +++ b/modules/Permissions/src/SimpleModule.Permissions/PermissionClaimsTransformation.cs @@ -1,21 +1,22 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authentication; -using SimpleModule.Core.Caching; using SimpleModule.Core.Extensions; using SimpleModule.Permissions.Contracts; using SimpleModule.Users.Contracts; +using ZiggyCreatures.Caching.Fusion; namespace SimpleModule.Permissions; public sealed class PermissionClaimsTransformation( IPermissionContracts permissionContracts, IUserContracts userContracts, - ICacheStore cache + IFusionCache cache ) : IClaimsTransformation { - private static readonly CacheEntryOptions CacheOptions = CacheEntryOptions.Expires( - TimeSpan.FromMinutes(5) - ); + private static readonly FusionCacheEntryOptions CacheOptions = new() + { + Duration = TimeSpan.FromMinutes(5), + }; public async Task TransformAsync(ClaimsPrincipal principal) { @@ -36,9 +37,9 @@ public async Task TransformAsync(ClaimsPrincipal principal) var cacheKey = $"permissions:{userId}:{rolesKey}"; var permissions = - await cache.GetOrCreateAsync>( + await cache.GetOrSetAsync>( cacheKey, - async _ => + async (_, _) => { var roleIdMap = roles.Count > 0 diff --git a/modules/Settings/src/SimpleModule.Settings/Services/PublicMenuService.cs b/modules/Settings/src/SimpleModule.Settings/Services/PublicMenuService.cs index 249ae714..8f9fd1ee 100644 --- a/modules/Settings/src/SimpleModule.Settings/Services/PublicMenuService.cs +++ b/modules/Settings/src/SimpleModule.Settings/Services/PublicMenuService.cs @@ -1,25 +1,30 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; -using SimpleModule.Core.Caching; using SimpleModule.Core.Menu; using SimpleModule.Settings.Contracts; +using ZiggyCreatures.Caching.Fusion; namespace SimpleModule.Settings.Services; public sealed class PublicMenuService( SettingsDbContext db, - ICacheStore cache, + IFusionCache cache, IOptions moduleOptions ) : IPublicMenuProvider { private const string MenuTreeCacheKey = "PublicMenu_Tree"; private const string HomePageCacheKey = "PublicMenu_Home"; + private readonly FusionCacheEntryOptions _cacheOptions = new() + { + Duration = moduleOptions.Value.CacheDuration, + }; + public async Task> GetMenuTreeAsync() { - var result = await cache.GetOrCreateAsync>( + var result = await cache.GetOrSetAsync>( MenuTreeCacheKey, - async ct => + async (_, ct) => { var entities = await db .PublicMenuItems.Where(e => e.IsVisible) @@ -27,7 +32,7 @@ public async Task> GetMenuTreeAsync() .ToListAsync(ct); return BuildPublicTree(entities, parentId: null); }, - CacheEntryOptions.Expires(moduleOptions.Value.CacheDuration) + _cacheOptions ); return result ?? []; } @@ -39,16 +44,16 @@ public async Task> GetMenuTreeAsync() )] public async Task GetHomePageUrlAsync() { - return await cache.GetOrCreateAsync( + return await cache.GetOrSetAsync( HomePageCacheKey, - async ct => + async (_, ct) => { var entity = await db .PublicMenuItems.Where(e => e.IsVisible && e.IsHomePage) .FirstOrDefaultAsync(ct); return entity is not null ? (entity.Url ?? entity.PageRoute) : null; }, - CacheEntryOptions.Expires(moduleOptions.Value.CacheDuration) + _cacheOptions ); } diff --git a/modules/Settings/src/SimpleModule.Settings/SettingsService.cs b/modules/Settings/src/SimpleModule.Settings/SettingsService.cs index 8a58f38e..d8d68224 100644 --- a/modules/Settings/src/SimpleModule.Settings/SettingsService.cs +++ b/modules/Settings/src/SimpleModule.Settings/SettingsService.cs @@ -2,23 +2,28 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using SimpleModule.Core.Caching; using SimpleModule.Core.Events; using SimpleModule.Core.Settings; using SimpleModule.Settings.Contracts; using SimpleModule.Settings.Contracts.Events; +using ZiggyCreatures.Caching.Fusion; namespace SimpleModule.Settings; public sealed partial class SettingsService( SettingsDbContext db, ISettingsDefinitionRegistry definitions, - ICacheStore cache, + IFusionCache cache, Lazy eventBus, IOptions moduleOptions, ILogger logger ) : ISettingsContracts { + private readonly FusionCacheEntryOptions _cacheOptions = new() + { + Duration = moduleOptions.Value.CacheDuration, + }; + public async Task GetSettingAsync( string key, SettingScope scope, @@ -27,24 +32,23 @@ ILogger logger { var cacheKey = BuildCacheKey(key, scope, userId); - var hit = await cache.TryGetAsync(cacheKey); - if (hit.Hit) - return hit.Value; - - var entity = await db - .Settings.AsNoTracking() - .FirstOrDefaultAsync(s => - s.Key == key - && s.Scope == scope - && (scope == SettingScope.User ? s.UserId == userId : s.UserId == null) - ); - - await cache.SetAsync( + return await cache.GetOrSetAsync( cacheKey, - entity?.Value, - CacheEntryOptions.Expires(moduleOptions.Value.CacheDuration) + async (_, ct) => + { + var entity = await db + .Settings.AsNoTracking() + .FirstOrDefaultAsync( + s => + s.Key == key + && s.Scope == scope + && (scope == SettingScope.User ? s.UserId == userId : s.UserId == null), + ct + ); + return entity?.Value; + }, + _cacheOptions ); - return entity?.Value; } public async Task GetSettingAsync(string key, SettingScope scope, string? userId = null) @@ -187,5 +191,5 @@ public async Task> GetSettingsAsync(SettingsFilter? filter private partial void LogDeserializationError(string key, string type, string error); private static string BuildCacheKey(string key, SettingScope scope, string? userId) => - CacheKey.Compose("setting", scope.ToString(), userId, key); + string.IsNullOrEmpty(userId) ? $"setting:{scope}:{key}" : $"setting:{scope}:{userId}:{key}"; } diff --git a/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/PublicMenuServiceTests.cs b/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/PublicMenuServiceTests.cs index 8c5951f2..1b596b37 100644 --- a/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/PublicMenuServiceTests.cs +++ b/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/PublicMenuServiceTests.cs @@ -1,12 +1,11 @@ using FluentAssertions; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; -using SimpleModule.Core.Caching; using SimpleModule.Database; using SimpleModule.Settings; using SimpleModule.Settings.Contracts; using SimpleModule.Settings.Services; +using ZiggyCreatures.Caching.Fusion; namespace Settings.Tests.Unit; @@ -14,8 +13,7 @@ public sealed class PublicMenuServiceTests : IDisposable { private readonly SettingsDbContext _db; private readonly PublicMenuService _service; - private readonly MemoryCache _cache; - private readonly MemoryCacheStore _cacheStore; + private readonly FusionCache _cache; public PublicMenuServiceTests() { @@ -28,13 +26,8 @@ public PublicMenuServiceTests() _db = new SettingsDbContext(options, dbOptions); _db.Database.EnsureCreated(); - _cache = new MemoryCache(new MemoryCacheOptions()); - _cacheStore = new MemoryCacheStore(_cache); - _service = new PublicMenuService( - _db, - _cacheStore, - Options.Create(new SettingsModuleOptions()) - ); + _cache = new FusionCache(new FusionCacheOptions()); + _service = new PublicMenuService(_db, _cache, Options.Create(new SettingsModuleOptions())); } [Fact] @@ -228,7 +221,6 @@ public async Task SetHomePageAsync_ClearsPreviousHomePage() public void Dispose() { - _cacheStore.Dispose(); _cache.Dispose(); _db.Dispose(); GC.SuppressFinalize(this); diff --git a/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/SettingsServiceTests.cs b/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/SettingsServiceTests.cs index 241d8a8d..154eac86 100644 --- a/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/SettingsServiceTests.cs +++ b/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/SettingsServiceTests.cs @@ -1,22 +1,20 @@ using FluentAssertions; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using SimpleModule.Core.Caching; using SimpleModule.Core.Events; using SimpleModule.Core.Settings; using SimpleModule.Database; using SimpleModule.Settings; using SimpleModule.Tests.Shared.Fakes; +using ZiggyCreatures.Caching.Fusion; namespace Settings.Tests.Unit; public sealed class SettingsServiceTests : IDisposable { private readonly SettingsDbContext _db; - private readonly MemoryCache _cache; - private readonly MemoryCacheStore _cacheStore; + private readonly FusionCache _cache; private readonly SettingsService _service; public SettingsServiceTests() @@ -41,13 +39,12 @@ public SettingsServiceTests() }, ]); - _cache = new MemoryCache(new MemoryCacheOptions()); - _cacheStore = new MemoryCacheStore(_cache); + _cache = new FusionCache(new FusionCacheOptions()); _service = new SettingsService( _db, registry, - _cacheStore, + _cache, new Lazy(() => new TestEventBus()), Options.Create(new SettingsModuleOptions()), NullLogger.Instance @@ -146,7 +143,6 @@ public async Task GetSettingAsync_Bool_NoDbValue_ReturnsFalse() public void Dispose() { - _cacheStore.Dispose(); _cache.Dispose(); _db.Dispose(); GC.SuppressFinalize(this); diff --git a/modules/Tenants/src/SimpleModule.Tenants/Resolvers/HostNameTenantResolver.cs b/modules/Tenants/src/SimpleModule.Tenants/Resolvers/HostNameTenantResolver.cs index ab82e928..16cf3676 100644 --- a/modules/Tenants/src/SimpleModule.Tenants/Resolvers/HostNameTenantResolver.cs +++ b/modules/Tenants/src/SimpleModule.Tenants/Resolvers/HostNameTenantResolver.cs @@ -1,14 +1,15 @@ using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; -using SimpleModule.Core.Caching; +using ZiggyCreatures.Caching.Fusion; namespace SimpleModule.Tenants.Resolvers; -public sealed class HostNameTenantResolver(TenantsDbContext db, ICacheStore cache) +public sealed class HostNameTenantResolver(TenantsDbContext db, IFusionCache cache) { - private static readonly CacheEntryOptions CacheOptions = CacheEntryOptions.Expires( - TimeSpan.FromMinutes(5) - ); + private static readonly FusionCacheEntryOptions CacheOptions = new() + { + Duration = TimeSpan.FromMinutes(5), + }; public async Task ResolveAsync(HttpContext context) { @@ -19,9 +20,9 @@ public sealed class HostNameTenantResolver(TenantsDbContext db, ICacheStore cach } var cacheKey = $"tenant:host:{host}"; - return await cache.GetOrCreateAsync( + return await cache.GetOrSetAsync( cacheKey, - async ct => + async (_, ct) => { var tenantHost = await db .TenantHosts.AsNoTracking() diff --git a/tests/SimpleModule.Core.Tests/Caching/MemoryCacheStoreTests.cs b/tests/SimpleModule.Core.Tests/Caching/MemoryCacheStoreTests.cs deleted file mode 100644 index f88b7440..00000000 --- a/tests/SimpleModule.Core.Tests/Caching/MemoryCacheStoreTests.cs +++ /dev/null @@ -1,300 +0,0 @@ -using System.Collections.Concurrent; -using System.Reflection; -using FluentAssertions; -using Microsoft.Extensions.Caching.Memory; -using SimpleModule.Core.Caching; - -namespace SimpleModule.Core.Tests.Caching; - -public sealed class MemoryCacheStoreTests : IDisposable -{ - private readonly MemoryCache _memoryCache = new(new MemoryCacheOptions()); - private readonly MemoryCacheStore _store; - - public MemoryCacheStoreTests() - { - _store = new MemoryCacheStore(_memoryCache); - } - - [Fact] - public async Task TryGetAsync_ReturnsMiss_WhenKeyAbsent() - { - var result = await _store.TryGetAsync("missing"); - - result.Hit.Should().BeFalse(); - result.Value.Should().BeNull(); - } - - [Fact] - public async Task SetAsync_ThenTryGetAsync_RoundTripsValue() - { - await _store.SetAsync("k1", "value"); - - var result = await _store.TryGetAsync("k1"); - - result.Hit.Should().BeTrue(); - result.Value.Should().Be("value"); - } - - [Fact] - public async Task SetAsync_AllowsCachingNullForNegativeCaching() - { - await _store.SetAsync("k1", null); - - var result = await _store.TryGetAsync("k1"); - - result.Hit.Should().BeTrue("a cached null should be a hit, not a miss"); - result.Value.Should().BeNull(); - } - - [Fact] - public async Task SetAsync_OverwritesExistingValue() - { - await _store.SetAsync("k1", "first"); - await _store.SetAsync("k1", "second"); - - var result = await _store.TryGetAsync("k1"); - - result.Value.Should().Be("second"); - } - - [Fact] - public async Task RemoveAsync_DeletesEntry() - { - await _store.SetAsync("k1", "value"); - - await _store.RemoveAsync("k1"); - - var result = await _store.TryGetAsync("k1"); - result.Hit.Should().BeFalse(); - } - - [Fact] - public async Task RemoveAsync_IsNoOp_WhenKeyAbsent() - { - var act = async () => await _store.RemoveAsync("ghost"); - - await act.Should().NotThrowAsync(); - } - - [Fact] - public async Task GetOrCreateAsync_InvokesFactory_OnMiss() - { - var calls = 0; - - var value = await _store.GetOrCreateAsync( - "k1", - _ => - { - calls++; - return new ValueTask("created"); - } - ); - - value.Should().Be("created"); - calls.Should().Be(1); - } - - [Fact] - public async Task GetOrCreateAsync_SkipsFactory_OnHit() - { - await _store.SetAsync("k1", "existing"); - var calls = 0; - - var value = await _store.GetOrCreateAsync( - "k1", - _ => - { - calls++; - return new ValueTask("created"); - } - ); - - value.Should().Be("existing"); - calls.Should().Be(0); - } - - [Fact] - public async Task GetOrCreateAsync_PreventsStampede_UnderConcurrentCallers() - { - var factoryCalls = 0; - var gate = new TaskCompletionSource(); - - async ValueTask Factory(CancellationToken _) - { - Interlocked.Increment(ref factoryCalls); - await gate.Task; - return "value"; - } - - var t1 = _store.GetOrCreateAsync("stampede", Factory).AsTask(); - var t2 = _store.GetOrCreateAsync("stampede", Factory).AsTask(); - var t3 = _store.GetOrCreateAsync("stampede", Factory).AsTask(); - - // Let the first factory release. - gate.SetResult(); - var results = await Task.WhenAll(t1, t2, t3); - - factoryCalls - .Should() - .Be(1, "concurrent GetOrCreateAsync calls for the same key must coalesce"); - results.Should().AllBeEquivalentTo("value"); - } - - [Fact] - public async Task GetOrCreateAsync_RespectsExpirationOptions() - { - await _store.GetOrCreateAsync( - "k1", - _ => new ValueTask("v"), - CacheEntryOptions.Expires(TimeSpan.FromMilliseconds(50)) - ); - - await Task.Delay(150); - - var result = await _store.TryGetAsync("k1"); - result.Hit.Should().BeFalse(); - } - - [Fact] - public async Task RemoveByPrefixAsync_RemovesAllMatchingKeys() - { - await _store.SetAsync("user:1:profile", "p1"); - await _store.SetAsync("user:1:settings", "s1"); - await _store.SetAsync("user:2:profile", "p2"); - await _store.SetAsync("system:bootstrap", "x"); - - await _store.RemoveByPrefixAsync("user:1:"); - - (await _store.TryGetAsync("user:1:profile")).Hit.Should().BeFalse(); - (await _store.TryGetAsync("user:1:settings")).Hit.Should().BeFalse(); - (await _store.TryGetAsync("user:2:profile")).Hit.Should().BeTrue(); - (await _store.TryGetAsync("system:bootstrap")).Hit.Should().BeTrue(); - } - - [Fact] - public async Task RemoveByPrefixAsync_RemovesEverything_WithEmptyKey() - { - await _store.SetAsync("a", "1"); - await _store.SetAsync("b", "2"); - - await _store.RemoveByPrefixAsync("a"); - - (await _store.TryGetAsync("a")).Hit.Should().BeFalse(); - (await _store.TryGetAsync("b")).Hit.Should().BeTrue(); - } - - [Fact] - public async Task TryGetAsync_ThrowsOnNullOrEmptyKey() - { - var act1 = async () => await _store.TryGetAsync(null!); - var act2 = async () => await _store.TryGetAsync(string.Empty); - - await act1.Should().ThrowAsync(); - await act2.Should().ThrowAsync(); - } - - [Fact] - public async Task WithPrefix_ScopesAllOperations() - { - var scoped = _store.WithPrefix("tenant-a"); - - await scoped.SetAsync("user", "alice"); - - // Scoped store sees the value. - var hit = await scoped.TryGetAsync("user"); - hit.Hit.Should().BeTrue(); - hit.Value.Should().Be("alice"); - - // Underlying store sees the prefixed key. - var underlying = await _store.TryGetAsync("tenant-a:user"); - underlying.Hit.Should().BeTrue(); - underlying.Value.Should().Be("alice"); - - // The unscoped key does not exist. - (await _store.TryGetAsync("user")) - .Hit.Should() - .BeFalse(); - } - - [Fact] - public async Task WithPrefix_RemoveByPrefix_ScopesPrefix() - { - var scoped = _store.WithPrefix("tenant-a"); - await scoped.SetAsync("user:1", "alice"); - await scoped.SetAsync("user:2", "bob"); - await _store.SetAsync("user:1", "global"); // unscoped, must survive - - await scoped.RemoveByPrefixAsync("user"); - - (await scoped.TryGetAsync("user:1")).Hit.Should().BeFalse(); - (await scoped.TryGetAsync("user:2")).Hit.Should().BeFalse(); - (await _store.TryGetAsync("user:1")).Hit.Should().BeTrue(); - } - - [Fact] - public async Task GetOrCreateAsync_DoesNotLeakPerKeyLocks() - { - // Populate many distinct keys. After each uncontended call the per-key - // semaphore should be released; the lock dictionary must not grow unbounded. - for (var i = 0; i < 50; i++) - { - await _store.GetOrCreateAsync($"leak:{i}", _ => new ValueTask(i)); - } - - GetKeyLocks(_store).Should().BeEmpty(); - } - - [Fact] - public async Task RemoveAsync_DuringInFlightFactory_DoesNotCrash() - { - // RemoveAsync must not dispose a semaphore that a concurrent GetOrCreateAsync - // caller is still holding. The in-flight caller's own finally block reclaims - // the lock after it releases the gate. - var factoryGate = new TaskCompletionSource(); - var held = _store - .GetOrCreateAsync( - "contended", - async _ => - { - await factoryGate.Task; - return "value"; - } - ) - .AsTask(); - - await Task.Yield(); - await _store.RemoveAsync("contended"); - - factoryGate.SetResult(); - var result = await held; - - result.Should().Be("value"); - GetKeyLocks(_store).Should().NotContainKey("contended"); - } - - private static ConcurrentDictionary GetKeyLocks(MemoryCacheStore store) - { - var field = typeof(MemoryCacheStore).GetField( - "_keyLocks", - BindingFlags.NonPublic | BindingFlags.Instance - )!; - return (ConcurrentDictionary)field.GetValue(store)!; - } - - [Fact] - public void CacheKey_Compose_JoinsPartsAndSkipsEmpty() - { - CacheKey.Compose("a", "b", "c").Should().Be("a:b:c"); - CacheKey.Compose("a", null, "c").Should().Be("a:c"); - CacheKey.Compose("a", string.Empty, "c").Should().Be("a:c"); - CacheKey.Compose("only").Should().Be("only"); - } - - public void Dispose() - { - _store.Dispose(); - _memoryCache.Dispose(); - GC.SuppressFinalize(this); - } -}