1
0
mirror of https://github.com/bitwarden/server synced 2026-01-03 17:14:00 +00:00

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
This commit is contained in:
Brant DeBow
2025-11-19 14:42:03 -05:00
committed by GitHub
parent 4c543fa824
commit 3c874646e8
4 changed files with 510 additions and 121 deletions

View File

@@ -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();
}
/// <summary>
/// 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.
/// </summary>
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);

View File

@@ -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
{
/// <summary>
/// Add Fusion Cache <see href="https://github.com/ZiggyCreatures/FusionCache"/> to the service
/// collection.<br/>
/// Adds a new, named Fusion Cache <see href="https://github.com/ZiggyCreatures/FusionCache"/> to the service
/// collection. If an existing cache of the same name is found, it will do nothing.<br/>
/// <br/>
/// If Redis is configured, it uses Redis for an L2 cache and backplane. If not, it simply uses in-memory caching.
/// <b>Note</b>: When re-using the existing Redis cache, it is expected to call this method <b>after</b> calling
/// <code>services.AddDistributedCache(globalSettings)</code><br />This ensures that DI correctly finds,
/// configures, and re-uses all the shared Redis architecture.
/// </summary>
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<IConnectionMultiplexer>(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<IConnectionMultiplexer>(sp =>
CreateConnectionMultiplexer(sp, cacheName, globalSettings.DistributedCache.Redis.ConnectionString));
services.TryAddSingleton<IDistributedCache>(sp =>
{
var cache = sp.GetService<IDistributedCache>();
if (cache is not null)
{
return cache;
}
var mux = sp.GetRequiredService<IConnectionMultiplexer>();
return new RedisCache(new RedisCacheOptions
{
ConnectionMultiplexerFactory = () => Task.FromResult(mux)
});
})
.WithBackplane(sp =>
{
var backplane = sp.GetService<IFusionCacheBackplane>();
if (backplane is not null)
});
services.TryAddSingleton<IFusionCacheBackplane>(sp =>
{
return backplane;
}
var mux = sp.GetRequiredService<IConnectionMultiplexer>();
var mux = sp.GetRequiredService<IConnectionMultiplexer>();
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<IConnectionMultiplexer>(
cacheName,
(sp, _) => CreateConnectionMultiplexer(sp, cacheName, settings.Redis.ConnectionString)
);
services.TryAddKeyedSingleton<IDistributedCache>(
cacheName,
(sp, _) =>
{
var mux = sp.GetRequiredKeyedService<IConnectionMultiplexer>(cacheName);
return new RedisCache(new RedisCacheOptions
{
ConnectionMultiplexerFactory = () => Task.FromResult(mux)
});
}
);
services.TryAddKeyedSingleton<IFusionCacheBackplane>(
cacheName,
(sp, _) =>
{
var mux = sp.GetRequiredKeyedService<IConnectionMultiplexer>(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<ILogger>();
logger?.LogError(ex, "Failed to connect to Redis for cache {CacheName}", cacheName);
throw;
}
}
}

View File

@@ -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<IFusionCache>(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>(
$"item:{id}",
_ => _itemRepository.GetById(id)
);
```
`ExtendedCache` doesnt 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.