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)