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