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);
- }
-}