Singleton HybridCache is created twice when RedisCache uses ConnectionMultiplexerFactory — re-entrant GetService<HybridCache>() during DefaultHybridCache construction breaks RemoveByTagAsync
- Dominant language
- C#
- Stars
- 38.4k
- Forks
- 10.9k
- Avg merge
- 2d 5h
- Merged PRs (30d)
- 276
Description
### Is there an existing issue for this?
- [x] I have searched the existing issues
### Describe the bug
When `AddStackExchangeRedisCache` is configured with `RedisCacheOptions.ConnectionMultiplexerFactory` (a shared `IConnectionMultiplexer` — the exact pattern that `Aspire.StackExchange.Redis.DistributedCaching` wires up), the unkeyed `HybridCache` singleton is **materialized twice** in the same `ServiceProvider`: constructor-injected consumers receive one instance, `GetService()` callers (including minimal-API parameter binding) receive another.
Both instances share the same `IDistributedCache` backend, so all data operations behave identically and the duplication is invisible — **except for tag invalidation**: each `DefaultHybridCache` keeps its tag-invalidation timestamps in a per-instance in-process dictionary and reads the L2 tag entry only once. `RemoveByTagAsync` called on one instance is therefore never observed by the other, so tag invalidation silently stops working across injection paths.
### Root cause chain
1. `DefaultHybridCache`'s constructor immediately kicks off an L2 read for the wildcard tag (`_globalInvalidateTimestamp = ... SafeReadTagInvalidationAsync(TagSet.WildcardTag)` in [DefaultHybridCache.cs](https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs)).
2. That read triggers the first Redis connect. `PrepareConnection` → `TryAddSuffix` calls `IsHybridCacheActive()`, which `RedisCacheImpl` implements as `_services.GetService()` ([RedisCacheImpl.cs](https://github.com/dotnet/aspnetcore/blob/v10.0.10/src/Caching/StackExchangeRedis/src/RedisCacheImpl.cs) — note the existing comment *"important: do not check for HybridCache here due to dependency - creates a cycle"*; the cycle was moved out of the ctor but still fires during connect).
3. With `ConnectionMultiplexerFactory` returning an already-completed task, `ConnectSlowAsync` completes **synchronously** — so `GetService()` executes re-entrantly **while the `HybridCache` singleton is still inside its constructor**. `Microsoft.Extensions.DependencyInjection` does not detect this re-entrancy and manufactures a second instance instead.
Without the factory (plain `options.Configuration`), `ConnectionMultiplexer.ConnectAsync` yields at a real `await`, the constructor completes and publishes the singleton before `TryAddSuffix` runs, and everything stays consistent — which is why this only bites shared-multiplexer setups.
### Expected Behavior
A singleton is created exactly once regardless of resolution path. At minimum, re-entrant resolution of a singleton under construction should throw (like circular constructor dependencies do) rather than silently duplicating state.
### Steps To Reproduce
`docker run -d -p 6379:6379 redis` — then:
```csharp
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Caching.StackExchangeRedis;
using StackExchange.Redis;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddStackExchangeRedisCache(o => o.InstanceName = "app:");
builder.Services.AddHybridCache();
// shared multiplexer — same wiring as Aspire.StackExchange.Redis.DistributedCaching
builder.Services.AddSingleton(_ => ConnectionMultiplexer.Connect("localhost:6379"));
builder.Services.AddOptions().Configure((RedisCacheOptions o, IServiceProvider sp) =>
o.ConnectionMultiplexerFactory = () => Task.FromResult(sp.GetRequiredService()));
builder.Services.AddSingleton();
var app = builder.Build();
app.MapGet("/identity", (HybridCache viaParam, CtorProbe probe) =>
$"same={ReferenceEquals(probe.Cache, viaParam)}");
app.Run();
sealed class CtorProbe(HybridCache cache) { public HybridCache Cache => cache; }
```
`GET /identity` → `same=False` (deterministic, every run). Remove the `ConnectionMultiplexerFactory` block → `same=True`.
Functional impact repro: `SetAsync(key, value, tags: ["t"])` via the ctor-injected instance, then `RemoveByTagAsync("t")` via the parameter-injected one → the entry keeps being served as valid.
### Exceptions (if any)
None — the failure is completely silent.
### .NET Version
10.0.302
### Anything else?
Package versions: `Microsoft.Extensions.Caching.StackExchangeRedis` 10.0.10, `Microsoft.Extensions.Caching.Hybrid` 10.8.0. Also reproduced through `Aspire.StackExchange.Redis.DistributedCaching` 13.4.6, which sets up exactly this `ConnectionMultiplexerFactory` pattern — so any Aspire + HybridCache application is affected out of the box.
Heap-dump evidence from a real application: `dumpheap -type DefaultHybridCache` shows two instances sharing the same `RedisCacheImpl` backend object and the same `HybridCacheOptions` object — one rooted in `ConstructorCallSite`s, one in the `ServiceProvider` accessor cache.
Possible fixes, any one of which breaks the chain:
- (a) `RedisCacheImpl` resolves `HybridCache` lazily/deferred instead of during connect;
- (b) `DefaultHybridCache` defers the wildcard-tag read out of the constructor;
- (c) `Microsoft.Extensions.DependencyInjection` detects re-entrant singleton resolution.
Workaround for affected users: force the first Redis connect before `HybridCache` is ever resolved (e.g. a dummy `IDistributedCache.GetAsync` in an `IHostedService` registered before anything touches the cache).
Contributor guide
Research direction
Start by tracing the constructor-triggered read in src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs and the Redis connection path in src/Caching/StackExchangeRedis/src/RedisCacheImpl.cs. Reproduce the shared ConnectionMultiplexerFactory case and inspect how re-entrant GetService() occurs. Done means one HybridCache instance is created and tag invalidation works across both resolution paths, with regression coverage for the reported behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp, redis
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100