From b4c7ab8773df6f5a6bdec13d35423846cb65e4e4 Mon Sep 17 00:00:00 2001
From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com>
Date: Fri, 14 Nov 2025 12:45:45 -0500
Subject: [PATCH] Add FusionCache to service collection (#6575)
* Add FusionCache to service collection
* Refactored to it's own service collection extension, added full unit tests, added TryAdd style support
* Move to ExtendedCache instead of FusionCache, re-use exsting DistributedCache if present, expose backplane to DI
* Reworked builders to reuse multiplexer if present
---
src/Core/Core.csproj | 3 +
src/Core/Settings/GlobalSettings.cs | 13 ++
...xtendedCacheServiceCollectionExtensions.cs | 90 +++++++++
...edCacheServiceCollectionExtensionsTests.cs | 171 ++++++++++++++++++
4 files changed, 277 insertions(+)
create mode 100644 src/Core/Utilities/ExtendedCacheServiceCollectionExtensions.cs
create mode 100644 test/Core.Test/Utilities/ExtendedCacheServiceCollectionExtensionsTests.cs
diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj
index 4901c5b43c..81370fe173 100644
--- a/src/Core/Core.csproj
+++ b/src/Core/Core.csproj
@@ -68,6 +68,9 @@
+
+
+
diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs
index c467d1e652..e2c2168656 100644
--- a/src/Core/Settings/GlobalSettings.cs
+++ b/src/Core/Settings/GlobalSettings.cs
@@ -783,6 +783,19 @@ public class GlobalSettings : IGlobalSettings
{
public virtual IConnectionStringSettings Redis { get; set; } = new ConnectionStringSettings();
public virtual IConnectionStringSettings Cosmos { 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);
+ public TimeSpan FailSafeThrottleDuration { get; set; } = TimeSpan.FromSeconds(30);
+ public float? EagerRefreshThreshold { get; set; } = 0.9f;
+ public TimeSpan FactorySoftTimeout { get; set; } = TimeSpan.FromMilliseconds(100);
+ public TimeSpan FactoryHardTimeout { get; set; } = TimeSpan.FromMilliseconds(1500);
+ public TimeSpan DistributedCacheSoftTimeout { get; set; } = TimeSpan.FromSeconds(1);
+ public TimeSpan DistributedCacheHardTimeout { get; set; } = TimeSpan.FromSeconds(2);
+ public bool AllowBackgroundDistributedCacheOperations { get; set; } = true;
+ public TimeSpan JitterMaxDuration { get; set; } = TimeSpan.FromSeconds(2);
+ public TimeSpan DistributedCacheCircuitBreakerDuration { get; set; } = TimeSpan.FromSeconds(30);
}
public class WebPushSettings : IWebPushSettings
diff --git a/src/Core/Utilities/ExtendedCacheServiceCollectionExtensions.cs b/src/Core/Utilities/ExtendedCacheServiceCollectionExtensions.cs
new file mode 100644
index 0000000000..3f926fd468
--- /dev/null
+++ b/src/Core/Utilities/ExtendedCacheServiceCollectionExtensions.cs
@@ -0,0 +1,90 @@
+using Bit.Core.Settings;
+using Bit.Core.Utilities;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Caching.StackExchangeRedis;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using StackExchange.Redis;
+using ZiggyCreatures.Caching.Fusion;
+using ZiggyCreatures.Caching.Fusion.Backplane;
+using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis;
+using ZiggyCreatures.Caching.Fusion.Serialization.SystemTextJson;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+public static class ExtendedCacheServiceCollectionExtensions
+{
+ ///
+ /// Add Fusion Cache to the service
+ /// collection.
+ ///
+ /// If Redis is configured, it uses Redis for an L2 cache and backplane. If not, it simply uses in-memory caching.
+ ///
+ public static IServiceCollection TryAddExtendedCacheServices(this IServiceCollection services, GlobalSettings globalSettings)
+ {
+ if (services.Any(s => s.ServiceType == typeof(IFusionCache)))
+ {
+ return services;
+ }
+
+ var fusionCacheBuilder = services.AddFusionCache()
+ .WithOptions(options =>
+ {
+ options.DistributedCacheCircuitBreakerDuration = globalSettings.DistributedCache.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
+ })
+ .WithSerializer(
+ new FusionCacheSystemTextJsonSerializer()
+ );
+
+ if (!CoreHelpers.SettingHasValue(globalSettings.DistributedCache.Redis.ConnectionString))
+ {
+ return services;
+ }
+
+ services.TryAddSingleton(sp =>
+ ConnectionMultiplexer.Connect(globalSettings.DistributedCache.Redis.ConnectionString));
+
+ fusionCacheBuilder
+ .WithDistributedCache(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)
+ {
+ return backplane;
+ }
+ var mux = sp.GetRequiredService();
+ return new RedisBackplane(new RedisBackplaneOptions
+ {
+ ConnectionMultiplexerFactory = () => Task.FromResult(mux)
+ });
+ });
+
+ return services;
+ }
+}
diff --git a/test/Core.Test/Utilities/ExtendedCacheServiceCollectionExtensionsTests.cs b/test/Core.Test/Utilities/ExtendedCacheServiceCollectionExtensionsTests.cs
new file mode 100644
index 0000000000..f2156a6d26
--- /dev/null
+++ b/test/Core.Test/Utilities/ExtendedCacheServiceCollectionExtensionsTests.cs
@@ -0,0 +1,171 @@
+using Bit.Core.Settings;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using NSubstitute;
+using StackExchange.Redis;
+using Xunit;
+using ZiggyCreatures.Caching.Fusion;
+
+namespace Bit.Core.Test.Utilities;
+
+public class ExtendedCacheServiceCollectionExtensionsTests
+{
+ private readonly IServiceCollection _services;
+ private readonly GlobalSettings _globalSettings;
+
+ public ExtendedCacheServiceCollectionExtensionsTests()
+ {
+ _services = new ServiceCollection();
+
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary())
+ .Build();
+
+ _globalSettings = new GlobalSettings();
+ config.GetSection("GlobalSettings").Bind(_globalSettings);
+
+ _services.TryAddSingleton(config);
+ _services.TryAddSingleton(_globalSettings);
+ _services.TryAddSingleton(_globalSettings);
+ _services.AddLogging();
+ }
+
+ [Fact]
+ public void TryAddFusionCoreServices_CustomSettings_OverridesDefaults()
+ {
+ var settings = CreateGlobalSettings(new Dictionary
+ {
+ { "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" },
+ });
+
+ _services.TryAddExtendedCacheServices(settings);
+ using var provider = _services.BuildServiceProvider();
+ var fusionCache = provider.GetRequiredService();
+ var options = fusionCache.DefaultEntryOptions;
+
+ 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);
+ }
+
+ [Fact]
+ public void TryAddFusionCoreServices_DefaultSettings_ConfiguresExpectedValues()
+ {
+ _services.TryAddExtendedCacheServices(_globalSettings);
+ using var provider = _services.BuildServiceProvider();
+
+ var fusionCache = provider.GetRequiredService();
+ var options = fusionCache.DefaultEntryOptions;
+
+ 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);
+ }
+
+ [Fact]
+ public void TryAddFusionCoreServices_MultipleCalls_OnlyConfiguresOnce()
+ {
+ var settings = CreateGlobalSettings(new Dictionary
+ {
+ { "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" },
+ });
+ _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);
+
+ using var provider = _services.BuildServiceProvider();
+ var fusionCache = provider.GetRequiredService();
+ Assert.NotNull(fusionCache);
+ }
+
+ [Fact]
+ public void TryAddFusionCoreServices_WithRedis_EnablesDistributedCacheAndBackplane()
+ {
+ var settings = CreateGlobalSettings(new Dictionary
+ {
+ { "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" },
+ });
+
+ _services.AddSingleton(Substitute.For());
+ _services.TryAddExtendedCacheServices(settings);
+ using var provider = _services.BuildServiceProvider();
+
+ var fusionCache = provider.GetRequiredService();
+ Assert.True(fusionCache.HasDistributedCache);
+ Assert.True(fusionCache.HasBackplane);
+ }
+
+ [Fact]
+ public void TryAddFusionCoreServices_WithExistingRedis_EnablesDistributedCacheAndBackplane()
+ {
+ var settings = CreateGlobalSettings(new Dictionary
+ {
+ { "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);
+ }
+
+ [Fact]
+ public void TryAddFusionCoreServices_WithoutRedis_DisablesDistributedCacheAndBackplane()
+ {
+ _services.TryAddExtendedCacheServices(_globalSettings);
+ using var provider = _services.BuildServiceProvider();
+
+ var fusionCache = provider.GetRequiredService();
+ Assert.False(fusionCache.HasDistributedCache);
+ Assert.False(fusionCache.HasBackplane);
+ }
+
+ private static GlobalSettings CreateGlobalSettings(Dictionary data)
+ {
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(data)
+ .Build();
+
+ var settings = new GlobalSettings();
+ config.GetSection("GlobalSettings").Bind(settings);
+ return settings;
+ }
+}