mirror of
https://github.com/bitwarden/server
synced 2025-12-17 16:53:23 +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:
@@ -783,7 +783,18 @@ public class GlobalSettings : IGlobalSettings
|
|||||||
{
|
{
|
||||||
public virtual IConnectionStringSettings Redis { get; set; } = new ConnectionStringSettings();
|
public virtual IConnectionStringSettings Redis { get; set; } = new ConnectionStringSettings();
|
||||||
public virtual IConnectionStringSettings Cosmos { 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 TimeSpan Duration { get; set; } = TimeSpan.FromMinutes(30);
|
||||||
public bool IsFailSafeEnabled { get; set; } = true;
|
public bool IsFailSafeEnabled { get; set; } = true;
|
||||||
public TimeSpan FailSafeMaxDuration { get; set; } = TimeSpan.FromHours(2);
|
public TimeSpan FailSafeMaxDuration { get; set; } = TimeSpan.FromHours(2);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Bit.Core.Utilities;
|
|||||||
using Microsoft.Extensions.Caching.Distributed;
|
using Microsoft.Extensions.Caching.Distributed;
|
||||||
using Microsoft.Extensions.Caching.StackExchangeRedis;
|
using Microsoft.Extensions.Caching.StackExchangeRedis;
|
||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using StackExchange.Redis;
|
using StackExchange.Redis;
|
||||||
using ZiggyCreatures.Caching.Fusion;
|
using ZiggyCreatures.Caching.Fusion;
|
||||||
using ZiggyCreatures.Caching.Fusion.Backplane;
|
using ZiggyCreatures.Caching.Fusion.Backplane;
|
||||||
@@ -14,70 +15,84 @@ namespace Microsoft.Extensions.DependencyInjection;
|
|||||||
public static class ExtendedCacheServiceCollectionExtensions
|
public static class ExtendedCacheServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Add Fusion Cache <see href="https://github.com/ZiggyCreatures/FusionCache"/> to the service
|
/// Adds a new, named Fusion Cache <see href="https://github.com/ZiggyCreatures/FusionCache"/> to the service
|
||||||
/// collection.<br/>
|
/// collection. If an existing cache of the same name is found, it will do nothing.<br/>
|
||||||
/// <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>
|
/// </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;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
var fusionCacheBuilder = services.AddFusionCache()
|
// If a cache already exists with this key, do nothing
|
||||||
.WithOptions(options =>
|
if (services.Any(s => s.ServiceType == typeof(IFusionCache) &&
|
||||||
|
s.ServiceKey?.Equals(cacheName) == true))
|
||||||
{
|
{
|
||||||
options.DistributedCacheCircuitBreakerDuration = globalSettings.DistributedCache.DistributedCacheCircuitBreakerDuration;
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (services.All(s => s.ServiceType != typeof(FusionCacheSystemTextJsonSerializer)))
|
||||||
|
{
|
||||||
|
services.AddFusionCacheSystemTextJsonSerializer();
|
||||||
|
}
|
||||||
|
var fusionCacheBuilder = services
|
||||||
|
.AddFusionCache(cacheName)
|
||||||
|
.WithCacheKeyPrefix($"{cacheName}:")
|
||||||
|
.AsKeyedServiceByCacheName()
|
||||||
|
.WithOptions(opt =>
|
||||||
|
{
|
||||||
|
opt.DistributedCacheCircuitBreakerDuration = settings.DistributedCacheCircuitBreakerDuration;
|
||||||
})
|
})
|
||||||
.WithDefaultEntryOptions(new FusionCacheEntryOptions
|
.WithDefaultEntryOptions(new FusionCacheEntryOptions
|
||||||
{
|
{
|
||||||
Duration = globalSettings.DistributedCache.Duration,
|
Duration = settings.Duration,
|
||||||
IsFailSafeEnabled = globalSettings.DistributedCache.IsFailSafeEnabled,
|
IsFailSafeEnabled = settings.IsFailSafeEnabled,
|
||||||
FailSafeMaxDuration = globalSettings.DistributedCache.FailSafeMaxDuration,
|
FailSafeMaxDuration = settings.FailSafeMaxDuration,
|
||||||
FailSafeThrottleDuration = globalSettings.DistributedCache.FailSafeThrottleDuration,
|
FailSafeThrottleDuration = settings.FailSafeThrottleDuration,
|
||||||
EagerRefreshThreshold = globalSettings.DistributedCache.EagerRefreshThreshold,
|
EagerRefreshThreshold = settings.EagerRefreshThreshold,
|
||||||
FactorySoftTimeout = globalSettings.DistributedCache.FactorySoftTimeout,
|
FactorySoftTimeout = settings.FactorySoftTimeout,
|
||||||
FactoryHardTimeout = globalSettings.DistributedCache.FactoryHardTimeout,
|
FactoryHardTimeout = settings.FactoryHardTimeout,
|
||||||
DistributedCacheSoftTimeout = globalSettings.DistributedCache.DistributedCacheSoftTimeout,
|
DistributedCacheSoftTimeout = settings.DistributedCacheSoftTimeout,
|
||||||
DistributedCacheHardTimeout = globalSettings.DistributedCache.DistributedCacheHardTimeout,
|
DistributedCacheHardTimeout = settings.DistributedCacheHardTimeout,
|
||||||
AllowBackgroundDistributedCacheOperations = globalSettings.DistributedCache.AllowBackgroundDistributedCacheOperations,
|
AllowBackgroundDistributedCacheOperations = settings.AllowBackgroundDistributedCacheOperations,
|
||||||
JitterMaxDuration = globalSettings.DistributedCache.JitterMaxDuration
|
JitterMaxDuration = settings.JitterMaxDuration
|
||||||
})
|
})
|
||||||
.WithSerializer(
|
.WithRegisteredSerializer();
|
||||||
new FusionCacheSystemTextJsonSerializer()
|
|
||||||
);
|
if (!settings.EnableDistributedCache)
|
||||||
|
return services;
|
||||||
|
|
||||||
|
if (settings.UseSharedRedisCache)
|
||||||
|
{
|
||||||
|
// Using Shared Redis, TryAdd and reuse all pieces (multiplexer, distributed cache and backplane)
|
||||||
|
|
||||||
if (!CoreHelpers.SettingHasValue(globalSettings.DistributedCache.Redis.ConnectionString))
|
if (!CoreHelpers.SettingHasValue(globalSettings.DistributedCache.Redis.ConnectionString))
|
||||||
{
|
|
||||||
return services;
|
return services;
|
||||||
}
|
|
||||||
|
|
||||||
services.TryAddSingleton<IConnectionMultiplexer>(sp =>
|
services.TryAddSingleton<IConnectionMultiplexer>(sp =>
|
||||||
ConnectionMultiplexer.Connect(globalSettings.DistributedCache.Redis.ConnectionString));
|
CreateConnectionMultiplexer(sp, cacheName, globalSettings.DistributedCache.Redis.ConnectionString));
|
||||||
|
|
||||||
fusionCacheBuilder
|
services.TryAddSingleton<IDistributedCache>(sp =>
|
||||||
.WithDistributedCache(sp =>
|
|
||||||
{
|
{
|
||||||
var cache = sp.GetService<IDistributedCache>();
|
|
||||||
if (cache is not null)
|
|
||||||
{
|
|
||||||
return cache;
|
|
||||||
}
|
|
||||||
var mux = sp.GetRequiredService<IConnectionMultiplexer>();
|
var mux = sp.GetRequiredService<IConnectionMultiplexer>();
|
||||||
return new RedisCache(new RedisCacheOptions
|
return new RedisCache(new RedisCacheOptions
|
||||||
{
|
{
|
||||||
ConnectionMultiplexerFactory = () => Task.FromResult(mux)
|
ConnectionMultiplexerFactory = () => Task.FromResult(mux)
|
||||||
});
|
});
|
||||||
})
|
});
|
||||||
.WithBackplane(sp =>
|
|
||||||
|
services.TryAddSingleton<IFusionCacheBackplane>(sp =>
|
||||||
{
|
{
|
||||||
var backplane = sp.GetService<IFusionCacheBackplane>();
|
|
||||||
if (backplane is not null)
|
|
||||||
{
|
|
||||||
return backplane;
|
|
||||||
}
|
|
||||||
var mux = sp.GetRequiredService<IConnectionMultiplexer>();
|
var mux = sp.GetRequiredService<IConnectionMultiplexer>();
|
||||||
return new RedisBackplane(new RedisBackplaneOptions
|
return new RedisBackplane(new RedisBackplaneOptions
|
||||||
{
|
{
|
||||||
@@ -85,6 +100,64 @@ public static class ExtendedCacheServiceCollectionExtensions
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fusionCacheBuilder
|
||||||
|
.WithRegisteredDistributedCache()
|
||||||
|
.WithRegisteredBackplane();
|
||||||
|
|
||||||
return services;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
157
src/Core/Utilities/README.md
Normal file
157
src/Core/Utilities/README.md
Normal 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` 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.
|
||||||
@@ -14,6 +14,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests
|
|||||||
{
|
{
|
||||||
private readonly IServiceCollection _services;
|
private readonly IServiceCollection _services;
|
||||||
private readonly GlobalSettings _globalSettings;
|
private readonly GlobalSettings _globalSettings;
|
||||||
|
private const string _cacheName = "TestCache";
|
||||||
|
|
||||||
public ExtendedCacheServiceCollectionExtensionsTests()
|
public ExtendedCacheServiceCollectionExtensionsTests()
|
||||||
{
|
{
|
||||||
@@ -33,129 +34,276 @@ public class ExtendedCacheServiceCollectionExtensionsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryAddFusionCoreServices_CustomSettings_OverridesDefaults()
|
public void AddExtendedCache_CustomSettings_OverridesDefaults()
|
||||||
{
|
{
|
||||||
var settings = CreateGlobalSettings(new Dictionary<string, string?>
|
var settings = new GlobalSettings.ExtendedCacheSettings
|
||||||
{
|
{
|
||||||
{ "GlobalSettings:DistributedCache:Duration", "00:12:00" },
|
Duration = TimeSpan.FromMinutes(12),
|
||||||
{ "GlobalSettings:DistributedCache:FailSafeMaxDuration", "01:30:00" },
|
FailSafeMaxDuration = TimeSpan.FromHours(1.5),
|
||||||
{ "GlobalSettings:DistributedCache:FailSafeThrottleDuration", "00:01:00" },
|
FailSafeThrottleDuration = TimeSpan.FromMinutes(1),
|
||||||
{ "GlobalSettings:DistributedCache:EagerRefreshThreshold", "0.75" },
|
EagerRefreshThreshold = 0.75f,
|
||||||
{ "GlobalSettings:DistributedCache:FactorySoftTimeout", "00:00:00.020" },
|
FactorySoftTimeout = TimeSpan.FromMilliseconds(20),
|
||||||
{ "GlobalSettings:DistributedCache:FactoryHardTimeout", "00:00:03" },
|
FactoryHardTimeout = TimeSpan.FromSeconds(3),
|
||||||
{ "GlobalSettings:DistributedCache:DistributedCacheSoftTimeout", "00:00:00.500" },
|
DistributedCacheSoftTimeout = TimeSpan.FromSeconds(0.5),
|
||||||
{ "GlobalSettings:DistributedCache:DistributedCacheHardTimeout", "00:00:01.500" },
|
DistributedCacheHardTimeout = TimeSpan.FromSeconds(1.5),
|
||||||
{ "GlobalSettings:DistributedCache:JitterMaxDuration", "00:00:05" },
|
JitterMaxDuration = TimeSpan.FromSeconds(5),
|
||||||
{ "GlobalSettings:DistributedCache:IsFailSafeEnabled", "false" },
|
IsFailSafeEnabled = false,
|
||||||
{ "GlobalSettings:DistributedCache:AllowBackgroundDistributedCacheOperations", "false" },
|
AllowBackgroundDistributedCacheOperations = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
_services.AddExtendedCache(_cacheName, _globalSettings, settings);
|
||||||
|
|
||||||
|
using var provider = _services.BuildServiceProvider();
|
||||||
|
var cache = provider.GetRequiredKeyedService<IFusionCache>(_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<IFusionCache>(_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<IFusionCache>(_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<IFusionCache>(_cacheName);
|
||||||
|
Assert.Null(cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddExtendedCache_MultipleCalls_OnlyAddsOneCacheService()
|
||||||
|
{
|
||||||
|
var settings = CreateGlobalSettings(new()
|
||||||
|
{
|
||||||
|
{ "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" }
|
||||||
});
|
});
|
||||||
|
|
||||||
_services.TryAddExtendedCacheServices(settings);
|
// Provide a multiplexer (shared)
|
||||||
using var provider = _services.BuildServiceProvider();
|
_services.AddSingleton(Substitute.For<IConnectionMultiplexer>());
|
||||||
var fusionCache = provider.GetRequiredService<IFusionCache>();
|
|
||||||
var options = fusionCache.DefaultEntryOptions;
|
|
||||||
|
|
||||||
Assert.Equal(TimeSpan.FromMinutes(12), options.Duration);
|
_services.AddExtendedCache(_cacheName, settings);
|
||||||
Assert.False(options.IsFailSafeEnabled);
|
_services.AddExtendedCache(_cacheName, settings);
|
||||||
Assert.Equal(TimeSpan.FromHours(1.5), options.FailSafeMaxDuration);
|
_services.AddExtendedCache(_cacheName, settings);
|
||||||
Assert.Equal(TimeSpan.FromMinutes(1), options.FailSafeThrottleDuration);
|
|
||||||
Assert.Equal(0.75f, options.EagerRefreshThreshold);
|
var regs = _services.Where(s => s.ServiceType == typeof(IFusionCache)).ToList();
|
||||||
Assert.Equal(TimeSpan.FromMilliseconds(20), options.FactorySoftTimeout);
|
Assert.Single(regs);
|
||||||
Assert.Equal(TimeSpan.FromMilliseconds(3000), options.FactoryHardTimeout);
|
|
||||||
Assert.Equal(TimeSpan.FromSeconds(0.5), options.DistributedCacheSoftTimeout);
|
using var provider = _services.BuildServiceProvider();
|
||||||
Assert.Equal(TimeSpan.FromSeconds(1.5), options.DistributedCacheHardTimeout);
|
var cache = provider.GetRequiredKeyedService<IFusionCache>(_cacheName);
|
||||||
Assert.False(options.AllowBackgroundDistributedCacheOperations);
|
Assert.NotNull(cache);
|
||||||
Assert.Equal(TimeSpan.FromSeconds(5), options.JitterMaxDuration);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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();
|
using var provider = _services.BuildServiceProvider();
|
||||||
|
|
||||||
var fusionCache = provider.GetRequiredService<IFusionCache>();
|
var cache1 = provider.GetRequiredKeyedService<IFusionCache>("Cache1");
|
||||||
var options = fusionCache.DefaultEntryOptions;
|
var cache2 = provider.GetRequiredKeyedService<IFusionCache>("Cache2");
|
||||||
|
|
||||||
Assert.Equal(TimeSpan.FromMinutes(30), options.Duration);
|
Assert.NotNull(cache1);
|
||||||
Assert.True(options.IsFailSafeEnabled);
|
Assert.NotNull(cache2);
|
||||||
Assert.Equal(TimeSpan.FromHours(2), options.FailSafeMaxDuration);
|
Assert.NotSame(cache1, cache2);
|
||||||
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]
|
[Fact]
|
||||||
public void TryAddFusionCoreServices_MultipleCalls_OnlyConfiguresOnce()
|
public void AddExtendedCache_WithRedis_EnablesDistributedCacheAndBackplane()
|
||||||
{
|
{
|
||||||
var settings = CreateGlobalSettings(new Dictionary<string, string?>
|
var settings = CreateGlobalSettings(new()
|
||||||
{
|
{
|
||||||
{ "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" },
|
{ "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" },
|
||||||
|
{ "GlobalSettings:DistributedCache:DefaultExtendedCache:UseSharedRedisCache", "true" }
|
||||||
});
|
});
|
||||||
_services.AddSingleton(Substitute.For<IConnectionMultiplexer>());
|
|
||||||
_services.TryAddExtendedCacheServices(settings);
|
|
||||||
_services.TryAddExtendedCacheServices(settings);
|
|
||||||
_services.TryAddExtendedCacheServices(settings);
|
|
||||||
|
|
||||||
var registrations = _services.Where(s => s.ServiceType == typeof(IFusionCache)).ToList();
|
// Provide a multiplexer (shared)
|
||||||
Assert.Single(registrations);
|
_services.AddSingleton(Substitute.For<IConnectionMultiplexer>());
|
||||||
|
|
||||||
|
_services.AddExtendedCache(_cacheName, settings);
|
||||||
|
|
||||||
using var provider = _services.BuildServiceProvider();
|
using var provider = _services.BuildServiceProvider();
|
||||||
var fusionCache = provider.GetRequiredService<IFusionCache>();
|
var cache = provider.GetRequiredKeyedService<IFusionCache>(_cacheName);
|
||||||
Assert.NotNull(fusionCache);
|
|
||||||
|
Assert.True(cache.HasDistributedCache);
|
||||||
|
Assert.True(cache.HasBackplane);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryAddFusionCoreServices_WithRedis_EnablesDistributedCacheAndBackplane()
|
public void AddExtendedCache_InvalidRedisConnection_LogsAndThrows()
|
||||||
{
|
{
|
||||||
var settings = CreateGlobalSettings(new Dictionary<string, string?>
|
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<IConnectionMultiplexer>());
|
|
||||||
_services.TryAddExtendedCacheServices(settings);
|
|
||||||
using var provider = _services.BuildServiceProvider();
|
using var provider = _services.BuildServiceProvider();
|
||||||
|
Assert.Throws<RedisConnectionException>(() =>
|
||||||
var fusionCache = provider.GetRequiredService<IFusionCache>();
|
{
|
||||||
Assert.True(fusionCache.HasDistributedCache);
|
var cache = provider.GetRequiredKeyedService<IFusionCache>(_cacheName);
|
||||||
Assert.True(fusionCache.HasBackplane);
|
// Trigger lazy initialization
|
||||||
|
cache.GetOrDefault<string>("test");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryAddFusionCoreServices_WithExistingRedis_EnablesDistributedCacheAndBackplane()
|
public void AddExtendedCache_WithExistingRedis_UsesExistingDistributedCacheAndBackplane()
|
||||||
{
|
{
|
||||||
var settings = CreateGlobalSettings(new Dictionary<string, string?>
|
var settings = CreateGlobalSettings(new()
|
||||||
{
|
{
|
||||||
{ "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" },
|
{ "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" },
|
||||||
});
|
});
|
||||||
|
|
||||||
_services.AddSingleton(Substitute.For<IConnectionMultiplexer>());
|
_services.AddSingleton(Substitute.For<IConnectionMultiplexer>());
|
||||||
_services.AddSingleton(Substitute.For<IDistributedCache>());
|
_services.AddSingleton(Substitute.For<IDistributedCache>());
|
||||||
_services.TryAddExtendedCacheServices(settings);
|
|
||||||
using var provider = _services.BuildServiceProvider();
|
|
||||||
|
|
||||||
var fusionCache = provider.GetRequiredService<IFusionCache>();
|
_services.AddExtendedCache(_cacheName, settings);
|
||||||
Assert.True(fusionCache.HasDistributedCache);
|
|
||||||
Assert.True(fusionCache.HasBackplane);
|
using var provider = _services.BuildServiceProvider();
|
||||||
var distributedCache = provider.GetRequiredService<IDistributedCache>();
|
var cache = provider.GetRequiredKeyedService<IFusionCache>(_cacheName);
|
||||||
Assert.NotNull(distributedCache);
|
|
||||||
|
Assert.True(cache.HasDistributedCache);
|
||||||
|
Assert.True(cache.HasBackplane);
|
||||||
|
|
||||||
|
var existingCache = provider.GetRequiredService<IDistributedCache>();
|
||||||
|
Assert.NotNull(existingCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryAddFusionCoreServices_WithoutRedis_DisablesDistributedCacheAndBackplane()
|
public void AddExtendedCache_NoRedis_DisablesDistributedCacheAndBackplane()
|
||||||
{
|
{
|
||||||
_services.TryAddExtendedCacheServices(_globalSettings);
|
_services.AddExtendedCache(_cacheName, _globalSettings);
|
||||||
using var provider = _services.BuildServiceProvider();
|
|
||||||
|
|
||||||
var fusionCache = provider.GetRequiredService<IFusionCache>();
|
using var provider = _services.BuildServiceProvider();
|
||||||
Assert.False(fusionCache.HasDistributedCache);
|
var cache = provider.GetRequiredKeyedService<IFusionCache>(_cacheName);
|
||||||
Assert.False(fusionCache.HasBackplane);
|
|
||||||
|
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<IFusionCache>(_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<IConnectionMultiplexer>());
|
||||||
|
_services.AddKeyedSingleton("CacheB", Substitute.For<IConnectionMultiplexer>());
|
||||||
|
|
||||||
|
_services.AddExtendedCache("CacheA", _globalSettings, settingsA);
|
||||||
|
_services.AddExtendedCache("CacheB", _globalSettings, settingsB);
|
||||||
|
|
||||||
|
using var provider = _services.BuildServiceProvider();
|
||||||
|
var muxA = provider.GetRequiredKeyedService<IConnectionMultiplexer>("CacheA");
|
||||||
|
var muxB = provider.GetRequiredKeyedService<IConnectionMultiplexer>("CacheB");
|
||||||
|
|
||||||
|
Assert.NotNull(muxA);
|
||||||
|
Assert.NotNull(muxB);
|
||||||
|
Assert.NotSame(muxA, muxB);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddExtendedCache_WithExistingKeyedDistributedCache_ReusesIt()
|
||||||
|
{
|
||||||
|
var existingCache = Substitute.For<IDistributedCache>();
|
||||||
|
_services.AddKeyedSingleton<IDistributedCache>(_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<IDistributedCache>(_cacheName);
|
||||||
|
|
||||||
|
Assert.Same(existingCache, resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GlobalSettings CreateGlobalSettings(Dictionary<string, string?> data)
|
private static GlobalSettings CreateGlobalSettings(Dictionary<string, string?> data)
|
||||||
|
|||||||
Reference in New Issue
Block a user