From 3c874646e891d7265640937ca4d58bb16a31de63 Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Wed, 19 Nov 2025 14:42:03 -0500 Subject: [PATCH] Upgrade ExtendedCache with support for named caches (#6591) * Upgrade ExtendedCache with support for named caches * Addressed Claude PR suggestions - defensive mux creation, defend empty cache name, added tests * Addressed PR suggestions; Fixed issue where IDistributedCache was missing when using the shared route; Added more unit tests * Revert to TryAdd, document expectation that AddDistributedCache is called first --- src/Core/Settings/GlobalSettings.cs | 11 + ...xtendedCacheServiceCollectionExtensions.cs | 159 ++++++--- src/Core/Utilities/README.md | 157 +++++++++ ...edCacheServiceCollectionExtensionsTests.cs | 304 +++++++++++++----- 4 files changed, 510 insertions(+), 121 deletions(-) create mode 100644 src/Core/Utilities/README.md diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 4bbf80e6ba..147b88623a 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -783,7 +783,18 @@ public class GlobalSettings : IGlobalSettings { public virtual IConnectionStringSettings Redis { get; set; } = new ConnectionStringSettings(); public virtual IConnectionStringSettings Cosmos { get; set; } = new ConnectionStringSettings(); + public ExtendedCacheSettings DefaultExtendedCache { get; set; } = new ExtendedCacheSettings(); + } + /// + /// A collection of Settings for customizing the FusionCache used in extended caching. Defaults are + /// provided for every attribute so that only specific values need to be overridden if needed. + /// + public class ExtendedCacheSettings + { + public bool EnableDistributedCache { get; set; } = true; + public bool UseSharedRedisCache { get; set; } = true; + public IConnectionStringSettings Redis { get; set; } = new ConnectionStringSettings(); public TimeSpan Duration { get; set; } = TimeSpan.FromMinutes(30); public bool IsFailSafeEnabled { get; set; } = true; public TimeSpan FailSafeMaxDuration { get; set; } = TimeSpan.FromHours(2); diff --git a/src/Core/Utilities/ExtendedCacheServiceCollectionExtensions.cs b/src/Core/Utilities/ExtendedCacheServiceCollectionExtensions.cs index 3f926fd468..a928240fd7 100644 --- a/src/Core/Utilities/ExtendedCacheServiceCollectionExtensions.cs +++ b/src/Core/Utilities/ExtendedCacheServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Bit.Core.Utilities; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.StackExchangeRedis; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; using StackExchange.Redis; using ZiggyCreatures.Caching.Fusion; using ZiggyCreatures.Caching.Fusion.Backplane; @@ -14,77 +15,149 @@ namespace Microsoft.Extensions.DependencyInjection; public static class ExtendedCacheServiceCollectionExtensions { /// - /// Add Fusion Cache to the service - /// collection.
+ /// Adds a new, named Fusion Cache to the service + /// collection. If an existing cache of the same name is found, it will do nothing.
///
- /// If Redis is configured, it uses Redis for an L2 cache and backplane. If not, it simply uses in-memory caching. + /// Note: When re-using the existing Redis cache, it is expected to call this method after calling + /// services.AddDistributedCache(globalSettings)
This ensures that DI correctly finds, + /// configures, and re-uses all the shared Redis architecture. ///
- public static IServiceCollection TryAddExtendedCacheServices(this IServiceCollection services, GlobalSettings globalSettings) + public static IServiceCollection AddExtendedCache( + this IServiceCollection services, + string cacheName, + GlobalSettings globalSettings, + GlobalSettings.ExtendedCacheSettings? settings = null) { - if (services.Any(s => s.ServiceType == typeof(IFusionCache))) + settings ??= globalSettings.DistributedCache.DefaultExtendedCache; + if (settings is null || string.IsNullOrEmpty(cacheName)) { return services; } - var fusionCacheBuilder = services.AddFusionCache() - .WithOptions(options => + // If a cache already exists with this key, do nothing + if (services.Any(s => s.ServiceType == typeof(IFusionCache) && + s.ServiceKey?.Equals(cacheName) == true)) + { + return services; + } + + if (services.All(s => s.ServiceType != typeof(FusionCacheSystemTextJsonSerializer))) + { + services.AddFusionCacheSystemTextJsonSerializer(); + } + var fusionCacheBuilder = services + .AddFusionCache(cacheName) + .WithCacheKeyPrefix($"{cacheName}:") + .AsKeyedServiceByCacheName() + .WithOptions(opt => { - options.DistributedCacheCircuitBreakerDuration = globalSettings.DistributedCache.DistributedCacheCircuitBreakerDuration; + opt.DistributedCacheCircuitBreakerDuration = settings.DistributedCacheCircuitBreakerDuration; }) .WithDefaultEntryOptions(new FusionCacheEntryOptions { - Duration = globalSettings.DistributedCache.Duration, - IsFailSafeEnabled = globalSettings.DistributedCache.IsFailSafeEnabled, - FailSafeMaxDuration = globalSettings.DistributedCache.FailSafeMaxDuration, - FailSafeThrottleDuration = globalSettings.DistributedCache.FailSafeThrottleDuration, - EagerRefreshThreshold = globalSettings.DistributedCache.EagerRefreshThreshold, - FactorySoftTimeout = globalSettings.DistributedCache.FactorySoftTimeout, - FactoryHardTimeout = globalSettings.DistributedCache.FactoryHardTimeout, - DistributedCacheSoftTimeout = globalSettings.DistributedCache.DistributedCacheSoftTimeout, - DistributedCacheHardTimeout = globalSettings.DistributedCache.DistributedCacheHardTimeout, - AllowBackgroundDistributedCacheOperations = globalSettings.DistributedCache.AllowBackgroundDistributedCacheOperations, - JitterMaxDuration = globalSettings.DistributedCache.JitterMaxDuration + Duration = settings.Duration, + IsFailSafeEnabled = settings.IsFailSafeEnabled, + FailSafeMaxDuration = settings.FailSafeMaxDuration, + FailSafeThrottleDuration = settings.FailSafeThrottleDuration, + EagerRefreshThreshold = settings.EagerRefreshThreshold, + FactorySoftTimeout = settings.FactorySoftTimeout, + FactoryHardTimeout = settings.FactoryHardTimeout, + DistributedCacheSoftTimeout = settings.DistributedCacheSoftTimeout, + DistributedCacheHardTimeout = settings.DistributedCacheHardTimeout, + AllowBackgroundDistributedCacheOperations = settings.AllowBackgroundDistributedCacheOperations, + JitterMaxDuration = settings.JitterMaxDuration }) - .WithSerializer( - new FusionCacheSystemTextJsonSerializer() - ); + .WithRegisteredSerializer(); - if (!CoreHelpers.SettingHasValue(globalSettings.DistributedCache.Redis.ConnectionString)) - { + if (!settings.EnableDistributedCache) return services; - } - services.TryAddSingleton(sp => - ConnectionMultiplexer.Connect(globalSettings.DistributedCache.Redis.ConnectionString)); + if (settings.UseSharedRedisCache) + { + // Using Shared Redis, TryAdd and reuse all pieces (multiplexer, distributed cache and backplane) - fusionCacheBuilder - .WithDistributedCache(sp => + if (!CoreHelpers.SettingHasValue(globalSettings.DistributedCache.Redis.ConnectionString)) + return services; + + services.TryAddSingleton(sp => + CreateConnectionMultiplexer(sp, cacheName, globalSettings.DistributedCache.Redis.ConnectionString)); + + services.TryAddSingleton(sp => { - var cache = sp.GetService(); - if (cache is not null) - { - return cache; - } var mux = sp.GetRequiredService(); return new RedisCache(new RedisCacheOptions { ConnectionMultiplexerFactory = () => Task.FromResult(mux) }); - }) - .WithBackplane(sp => - { - var backplane = sp.GetService(); - if (backplane is not null) + }); + + services.TryAddSingleton(sp => { - return backplane; - } - var mux = sp.GetRequiredService(); + var mux = sp.GetRequiredService(); + return new RedisBackplane(new RedisBackplaneOptions + { + ConnectionMultiplexerFactory = () => Task.FromResult(mux) + }); + }); + + fusionCacheBuilder + .WithRegisteredDistributedCache() + .WithRegisteredBackplane(); + + return services; + } + + // Using keyed Redis / Distributed Cache. Create all pieces as keyed services. + + if (!CoreHelpers.SettingHasValue(settings.Redis.ConnectionString)) + return services; + + services.TryAddKeyedSingleton( + cacheName, + (sp, _) => CreateConnectionMultiplexer(sp, cacheName, settings.Redis.ConnectionString) + ); + services.TryAddKeyedSingleton( + cacheName, + (sp, _) => + { + var mux = sp.GetRequiredKeyedService(cacheName); + return new RedisCache(new RedisCacheOptions + { + ConnectionMultiplexerFactory = () => Task.FromResult(mux) + }); + } + ); + services.TryAddKeyedSingleton( + cacheName, + (sp, _) => + { + var mux = sp.GetRequiredKeyedService(cacheName); return new RedisBackplane(new RedisBackplaneOptions { ConnectionMultiplexerFactory = () => Task.FromResult(mux) }); - }); + } + ); + + fusionCacheBuilder + .WithRegisteredKeyedDistributedCacheByCacheName() + .WithRegisteredKeyedBackplaneByCacheName(); return services; } + + private static ConnectionMultiplexer CreateConnectionMultiplexer(IServiceProvider sp, string cacheName, + string connectionString) + { + try + { + return ConnectionMultiplexer.Connect(connectionString); + } + catch (Exception ex) + { + var logger = sp.GetService(); + logger?.LogError(ex, "Failed to connect to Redis for cache {CacheName}", cacheName); + throw; + } + } } diff --git a/src/Core/Utilities/README.md b/src/Core/Utilities/README.md new file mode 100644 index 0000000000..d2de7bf84f --- /dev/null +++ b/src/Core/Utilities/README.md @@ -0,0 +1,157 @@ +## Extended Cache + +`ExtendedCache` is a wrapper around [FusionCache](https://github.com/ZiggyCreatures/FusionCache) +that provides a simple way to register **named, isolated caches** with sensible defaults. +The goal is to make it trivial for each subsystem or feature to have its own cache - +with optional distributed caching and backplane support - without repeatedly wiring up +FusionCache, Redis, and related infrastructure. + +Each named cache automatically receives: + +- Its own `FusionCache` instance +- Its own configuration (default or overridden) +- Its own key prefix +- Optional distributed store +- Optional backplane + +`ExtendedCache` supports several deployment modes: + +- **Memory-only caching** (with stampede protection) +- **Memory + distributed cache + backplane** using the **shared** application Redis +- **Memory + distributed cache + backplane** using a **fully isolated** Redis instance + +**Note**: When using the shared Redis cache option (which is on by default, if the +Redis connection string is configured), it is expected to call +`services.AddDistributedCache(globalSettings)` **before** calling +`AddExtendedCache`. The idea is to set up the distributed cache in our normal pattern +and then "extend" it to include more functionality. + +### Configuration + +`ExtendedCache` exposes a set of default properties that define how each named cache behaves. +These map directly to FusionCache configuration options such as timeouts, duration, +jitter, fail-safe mode, etc. Any cache can override these defaults independently. + +#### Default configuration + +The simplest approach registers a new named cache with default settings and reusing +the existing distributed cache: + +``` csharp + services.AddDistributedCache(globalSettings); + services.AddExtendedCache(cacheName, globalSettings); +``` + +By default: + - If `GlobalSettings.DistributedCache.Redis.ConnectionString` is configured: + - The cache is memory + distributed (Redis) + - The Redis cache created by `AddDistributedCache` is re-used + - A Redis backplane is configured, re-using the same multiplexer + - If Redis is **not** configured the cache automatically falls back to memory-only + +#### Overriding default properties + +A number of default properties are provided (see +`GlobalSettings.DistributedCache.DefaultExtendedCache` for specific values). A named +cache can override any (or all) of these properties simply by providing its own +instance of `ExtendedCacheSettings`: + +``` csharp + services.AddExtendedCache(cacheName, globalSettings, new GlobalSettings.ExtendedCacheSettings + { + Duration = TimeSpan.FromHours(1), + }); +``` + +This example keeps all other defaults—including shared Redis—but changes the +default cached item duration from 30 minutes to 1 hour. + +#### Isolated Redis configuration + +ExtendedCache can also run in a fully isolated mode where the cache uses its own: + - Redis multiplexer + - Distributed cache + - Backplane + +To enable this, specify a Redis connection string and set `UseSharedRedisCache` +to `false`: + +``` csharp + services.AddExtendedCache(cacheName, globalSettings, new GlobalSettings.ExtendedCacheSettings + { + UseSharedRedisCache = false, + Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379" } + }); +``` + +When configured this way: + - A dedicated `IConnectionMultiplexer` is created + - A dedicated `IDistributedCache` is created + - A dedicated FusionCache backplane is created + - All three are exposed to DI as keyed services (using the cache name as service key) + +### Accessing a named cache + +A named cache can be retrieved either: + - Directly via DI using keyed services + - Through `IFusionCacheProvider` (similar to + [IHttpClientFactory](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/http-requests?view=aspnetcore-7.0#named-clients)) + +#### Keyed service + +In the consuming class, declare an IFusionCache field: + +```csharp + private IFusionCache _cache; +``` + +Then ask DI to inject the keyed cache: + +```csharp + public MyService([FromKeyedServices("MyCache")] IFusionCache cache) + { + _cache = cache; + } +``` + +Or request it manually: + +```csharp + cache: provider.GetRequiredKeyedService(serviceKey: cacheName) +``` + +#### Injecting a provider + +Alternatively, an `IFusionCacheProvider` can be injected and used to request a named +cache - similar to how `IHttpClientFactory` can be used to create named `HttpClient` +instances + +In the class using the cache, use an injected provider to request the named cache: + +```csharp + private readonly IFusionCache _cache; + + public MyController(IFusionCacheProvider cacheProvider) + { + _cache = cacheProvider.GetCache("CacheName"); + } +``` + +### Using a cache + +Using the cache in code is as simple as replacing the direct repository calls with +`FusionCache`'s `GetOrSet` call. If the class previously fetched an `Item` from +an `ItemRepository`, all that we need to do is provide a key and the original +repository call as the fallback: + +```csharp + var item = _cache.GetOrSet( + $"item:{id}", + _ => _itemRepository.GetById(id) + ); +``` + +`ExtendedCache` doesn’t change how `FusionCache` is used in code, which means all +the functionality and full `FusionCache` API is available. See the +[FusionCache docs](https://github.com/ZiggyCreatures/FusionCache/blob/main/docs/CoreMethods.md) +for more details. diff --git a/test/Core.Test/Utilities/ExtendedCacheServiceCollectionExtensionsTests.cs b/test/Core.Test/Utilities/ExtendedCacheServiceCollectionExtensionsTests.cs index f2156a6d26..6f7fa4df06 100644 --- a/test/Core.Test/Utilities/ExtendedCacheServiceCollectionExtensionsTests.cs +++ b/test/Core.Test/Utilities/ExtendedCacheServiceCollectionExtensionsTests.cs @@ -14,6 +14,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests { private readonly IServiceCollection _services; private readonly GlobalSettings _globalSettings; + private const string _cacheName = "TestCache"; public ExtendedCacheServiceCollectionExtensionsTests() { @@ -33,129 +34,276 @@ public class ExtendedCacheServiceCollectionExtensionsTests } [Fact] - public void TryAddFusionCoreServices_CustomSettings_OverridesDefaults() + public void AddExtendedCache_CustomSettings_OverridesDefaults() { - var settings = CreateGlobalSettings(new Dictionary + var settings = new GlobalSettings.ExtendedCacheSettings { - { "GlobalSettings:DistributedCache:Duration", "00:12:00" }, - { "GlobalSettings:DistributedCache:FailSafeMaxDuration", "01:30:00" }, - { "GlobalSettings:DistributedCache:FailSafeThrottleDuration", "00:01:00" }, - { "GlobalSettings:DistributedCache:EagerRefreshThreshold", "0.75" }, - { "GlobalSettings:DistributedCache:FactorySoftTimeout", "00:00:00.020" }, - { "GlobalSettings:DistributedCache:FactoryHardTimeout", "00:00:03" }, - { "GlobalSettings:DistributedCache:DistributedCacheSoftTimeout", "00:00:00.500" }, - { "GlobalSettings:DistributedCache:DistributedCacheHardTimeout", "00:00:01.500" }, - { "GlobalSettings:DistributedCache:JitterMaxDuration", "00:00:05" }, - { "GlobalSettings:DistributedCache:IsFailSafeEnabled", "false" }, - { "GlobalSettings:DistributedCache:AllowBackgroundDistributedCacheOperations", "false" }, + Duration = TimeSpan.FromMinutes(12), + FailSafeMaxDuration = TimeSpan.FromHours(1.5), + FailSafeThrottleDuration = TimeSpan.FromMinutes(1), + EagerRefreshThreshold = 0.75f, + FactorySoftTimeout = TimeSpan.FromMilliseconds(20), + FactoryHardTimeout = TimeSpan.FromSeconds(3), + DistributedCacheSoftTimeout = TimeSpan.FromSeconds(0.5), + DistributedCacheHardTimeout = TimeSpan.FromSeconds(1.5), + JitterMaxDuration = TimeSpan.FromSeconds(5), + IsFailSafeEnabled = false, + AllowBackgroundDistributedCacheOperations = false, + }; + + _services.AddExtendedCache(_cacheName, _globalSettings, settings); + + using var provider = _services.BuildServiceProvider(); + var cache = provider.GetRequiredKeyedService(_cacheName); + var opt = cache.DefaultEntryOptions; + + Assert.Equal(TimeSpan.FromMinutes(12), opt.Duration); + Assert.False(opt.IsFailSafeEnabled); + Assert.Equal(TimeSpan.FromHours(1.5), opt.FailSafeMaxDuration); + Assert.Equal(TimeSpan.FromMinutes(1), opt.FailSafeThrottleDuration); + Assert.Equal(0.75f, opt.EagerRefreshThreshold); + Assert.Equal(TimeSpan.FromMilliseconds(20), opt.FactorySoftTimeout); + Assert.Equal(TimeSpan.FromMilliseconds(3000), opt.FactoryHardTimeout); + Assert.Equal(TimeSpan.FromSeconds(0.5), opt.DistributedCacheSoftTimeout); + Assert.Equal(TimeSpan.FromSeconds(1.5), opt.DistributedCacheHardTimeout); + Assert.False(opt.AllowBackgroundDistributedCacheOperations); + Assert.Equal(TimeSpan.FromSeconds(5), opt.JitterMaxDuration); + } + + [Fact] + public void AddExtendedCache_DefaultSettings_ConfiguresExpectedValues() + { + _services.AddExtendedCache(_cacheName, _globalSettings); + + using var provider = _services.BuildServiceProvider(); + var cache = provider.GetRequiredKeyedService(_cacheName); + var opt = cache.DefaultEntryOptions; + + Assert.Equal(TimeSpan.FromMinutes(30), opt.Duration); + Assert.True(opt.IsFailSafeEnabled); + Assert.Equal(TimeSpan.FromHours(2), opt.FailSafeMaxDuration); + Assert.Equal(TimeSpan.FromSeconds(30), opt.FailSafeThrottleDuration); + Assert.Equal(0.9f, opt.EagerRefreshThreshold); + Assert.Equal(TimeSpan.FromMilliseconds(100), opt.FactorySoftTimeout); + Assert.Equal(TimeSpan.FromMilliseconds(1500), opt.FactoryHardTimeout); + Assert.Equal(TimeSpan.FromSeconds(1), opt.DistributedCacheSoftTimeout); + Assert.Equal(TimeSpan.FromSeconds(2), opt.DistributedCacheHardTimeout); + Assert.True(opt.AllowBackgroundDistributedCacheOperations); + Assert.Equal(TimeSpan.FromSeconds(2), opt.JitterMaxDuration); + } + + [Fact] + public void AddExtendedCache_DisabledDistributedCache_DoesNotRegisterBackplaneOrRedis() + { + var settings = new GlobalSettings.ExtendedCacheSettings + { + EnableDistributedCache = false, + }; + + _services.AddExtendedCache(_cacheName, _globalSettings, settings); + + using var provider = _services.BuildServiceProvider(); + var cache = provider.GetRequiredKeyedService(_cacheName); + + Assert.False(cache.HasDistributedCache); + Assert.False(cache.HasBackplane); + } + + [Fact] + public void AddExtendedCache_EmptyCacheName_DoesNothing() + { + _services.AddExtendedCache(string.Empty, _globalSettings); + + var regs = _services.Where(s => s.ServiceType == typeof(IFusionCache)).ToList(); + Assert.Empty(regs); + + using var provider = _services.BuildServiceProvider(); + var cache = provider.GetKeyedService(_cacheName); + Assert.Null(cache); + } + + [Fact] + public void AddExtendedCache_MultipleCalls_OnlyAddsOneCacheService() + { + var settings = CreateGlobalSettings(new() + { + { "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" } }); - _services.TryAddExtendedCacheServices(settings); - using var provider = _services.BuildServiceProvider(); - var fusionCache = provider.GetRequiredService(); - var options = fusionCache.DefaultEntryOptions; + // Provide a multiplexer (shared) + _services.AddSingleton(Substitute.For()); - Assert.Equal(TimeSpan.FromMinutes(12), options.Duration); - Assert.False(options.IsFailSafeEnabled); - Assert.Equal(TimeSpan.FromHours(1.5), options.FailSafeMaxDuration); - Assert.Equal(TimeSpan.FromMinutes(1), options.FailSafeThrottleDuration); - Assert.Equal(0.75f, options.EagerRefreshThreshold); - Assert.Equal(TimeSpan.FromMilliseconds(20), options.FactorySoftTimeout); - Assert.Equal(TimeSpan.FromMilliseconds(3000), options.FactoryHardTimeout); - Assert.Equal(TimeSpan.FromSeconds(0.5), options.DistributedCacheSoftTimeout); - Assert.Equal(TimeSpan.FromSeconds(1.5), options.DistributedCacheHardTimeout); - Assert.False(options.AllowBackgroundDistributedCacheOperations); - Assert.Equal(TimeSpan.FromSeconds(5), options.JitterMaxDuration); + _services.AddExtendedCache(_cacheName, settings); + _services.AddExtendedCache(_cacheName, settings); + _services.AddExtendedCache(_cacheName, settings); + + var regs = _services.Where(s => s.ServiceType == typeof(IFusionCache)).ToList(); + Assert.Single(regs); + + using var provider = _services.BuildServiceProvider(); + var cache = provider.GetRequiredKeyedService(_cacheName); + Assert.NotNull(cache); } [Fact] - public void TryAddFusionCoreServices_DefaultSettings_ConfiguresExpectedValues() + public void AddExtendedCache_MultipleDifferentCaches_AddsAll() { - _services.TryAddExtendedCacheServices(_globalSettings); + _services.AddExtendedCache("Cache1", _globalSettings); + _services.AddExtendedCache("Cache2", _globalSettings); + using var provider = _services.BuildServiceProvider(); - var fusionCache = provider.GetRequiredService(); - var options = fusionCache.DefaultEntryOptions; + var cache1 = provider.GetRequiredKeyedService("Cache1"); + var cache2 = provider.GetRequiredKeyedService("Cache2"); - Assert.Equal(TimeSpan.FromMinutes(30), options.Duration); - Assert.True(options.IsFailSafeEnabled); - Assert.Equal(TimeSpan.FromHours(2), options.FailSafeMaxDuration); - Assert.Equal(TimeSpan.FromSeconds(30), options.FailSafeThrottleDuration); - Assert.Equal(0.9f, options.EagerRefreshThreshold); - Assert.Equal(TimeSpan.FromMilliseconds(100), options.FactorySoftTimeout); - Assert.Equal(TimeSpan.FromMilliseconds(1500), options.FactoryHardTimeout); - Assert.Equal(TimeSpan.FromSeconds(1), options.DistributedCacheSoftTimeout); - Assert.Equal(TimeSpan.FromSeconds(2), options.DistributedCacheHardTimeout); - Assert.True(options.AllowBackgroundDistributedCacheOperations); - Assert.Equal(TimeSpan.FromSeconds(2), options.JitterMaxDuration); + Assert.NotNull(cache1); + Assert.NotNull(cache2); + Assert.NotSame(cache1, cache2); } [Fact] - public void TryAddFusionCoreServices_MultipleCalls_OnlyConfiguresOnce() + public void AddExtendedCache_WithRedis_EnablesDistributedCacheAndBackplane() { - var settings = CreateGlobalSettings(new Dictionary + var settings = CreateGlobalSettings(new() { { "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" }, + { "GlobalSettings:DistributedCache:DefaultExtendedCache:UseSharedRedisCache", "true" } }); - _services.AddSingleton(Substitute.For()); - _services.TryAddExtendedCacheServices(settings); - _services.TryAddExtendedCacheServices(settings); - _services.TryAddExtendedCacheServices(settings); - var registrations = _services.Where(s => s.ServiceType == typeof(IFusionCache)).ToList(); - Assert.Single(registrations); + // Provide a multiplexer (shared) + _services.AddSingleton(Substitute.For()); + + _services.AddExtendedCache(_cacheName, settings); using var provider = _services.BuildServiceProvider(); - var fusionCache = provider.GetRequiredService(); - Assert.NotNull(fusionCache); + var cache = provider.GetRequiredKeyedService(_cacheName); + + Assert.True(cache.HasDistributedCache); + Assert.True(cache.HasBackplane); } [Fact] - public void TryAddFusionCoreServices_WithRedis_EnablesDistributedCacheAndBackplane() + public void AddExtendedCache_InvalidRedisConnection_LogsAndThrows() { - var settings = CreateGlobalSettings(new Dictionary + var settings = new GlobalSettings.ExtendedCacheSettings { - { "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" }, - }); + UseSharedRedisCache = false, + Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "invalid:9999" } + }; + + _services.AddExtendedCache(_cacheName, _globalSettings, settings); - _services.AddSingleton(Substitute.For()); - _services.TryAddExtendedCacheServices(settings); using var provider = _services.BuildServiceProvider(); - - var fusionCache = provider.GetRequiredService(); - Assert.True(fusionCache.HasDistributedCache); - Assert.True(fusionCache.HasBackplane); + Assert.Throws(() => + { + var cache = provider.GetRequiredKeyedService(_cacheName); + // Trigger lazy initialization + cache.GetOrDefault("test"); + }); } [Fact] - public void TryAddFusionCoreServices_WithExistingRedis_EnablesDistributedCacheAndBackplane() + public void AddExtendedCache_WithExistingRedis_UsesExistingDistributedCacheAndBackplane() { - var settings = CreateGlobalSettings(new Dictionary + var settings = CreateGlobalSettings(new() { { "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" }, }); _services.AddSingleton(Substitute.For()); _services.AddSingleton(Substitute.For()); - _services.TryAddExtendedCacheServices(settings); - using var provider = _services.BuildServiceProvider(); - var fusionCache = provider.GetRequiredService(); - Assert.True(fusionCache.HasDistributedCache); - Assert.True(fusionCache.HasBackplane); - var distributedCache = provider.GetRequiredService(); - Assert.NotNull(distributedCache); + _services.AddExtendedCache(_cacheName, settings); + + using var provider = _services.BuildServiceProvider(); + var cache = provider.GetRequiredKeyedService(_cacheName); + + Assert.True(cache.HasDistributedCache); + Assert.True(cache.HasBackplane); + + var existingCache = provider.GetRequiredService(); + Assert.NotNull(existingCache); } [Fact] - public void TryAddFusionCoreServices_WithoutRedis_DisablesDistributedCacheAndBackplane() + public void AddExtendedCache_NoRedis_DisablesDistributedCacheAndBackplane() { - _services.TryAddExtendedCacheServices(_globalSettings); - using var provider = _services.BuildServiceProvider(); + _services.AddExtendedCache(_cacheName, _globalSettings); - var fusionCache = provider.GetRequiredService(); - Assert.False(fusionCache.HasDistributedCache); - Assert.False(fusionCache.HasBackplane); + using var provider = _services.BuildServiceProvider(); + var cache = provider.GetRequiredKeyedService(_cacheName); + + Assert.False(cache.HasDistributedCache); + Assert.False(cache.HasBackplane); + } + + [Fact] + public void AddExtendedCache_NoSharedRedisButNoConnectionString_DisablesDistributedCacheAndBackplane() + { + var settings = new GlobalSettings.ExtendedCacheSettings + { + UseSharedRedisCache = false, + // No Redis connection string + }; + + _services.AddExtendedCache(_cacheName, _globalSettings, settings); + + using var provider = _services.BuildServiceProvider(); + var cache = provider.GetRequiredKeyedService(_cacheName); + + Assert.False(cache.HasDistributedCache); + Assert.False(cache.HasBackplane); + } + + [Fact] + public void AddExtendedCache_KeyedRedis_UsesSeparateMultiplexers() + { + var settingsA = new GlobalSettings.ExtendedCacheSettings + { + EnableDistributedCache = true, + UseSharedRedisCache = false, + Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379" } + }; + var settingsB = new GlobalSettings.ExtendedCacheSettings + { + EnableDistributedCache = true, + UseSharedRedisCache = false, + Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6380" } + }; + + _services.AddKeyedSingleton("CacheA", Substitute.For()); + _services.AddKeyedSingleton("CacheB", Substitute.For()); + + _services.AddExtendedCache("CacheA", _globalSettings, settingsA); + _services.AddExtendedCache("CacheB", _globalSettings, settingsB); + + using var provider = _services.BuildServiceProvider(); + var muxA = provider.GetRequiredKeyedService("CacheA"); + var muxB = provider.GetRequiredKeyedService("CacheB"); + + Assert.NotNull(muxA); + Assert.NotNull(muxB); + Assert.NotSame(muxA, muxB); + } + + [Fact] + public void AddExtendedCache_WithExistingKeyedDistributedCache_ReusesIt() + { + var existingCache = Substitute.For(); + _services.AddKeyedSingleton(_cacheName, existingCache); + + var settings = new GlobalSettings.ExtendedCacheSettings + { + UseSharedRedisCache = false, + Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379" } + }; + + _services.AddExtendedCache(_cacheName, _globalSettings, settings); + + using var provider = _services.BuildServiceProvider(); + var resolved = provider.GetRequiredKeyedService(_cacheName); + + Assert.Same(existingCache, resolved); } private static GlobalSettings CreateGlobalSettings(Dictionary data)