diff --git a/.agents/rules/caching.md b/.agents/rules/caching.md index cc26b8898d..325bbf2561 100644 --- a/.agents/rules/caching.md +++ b/.agents/rules/caching.md @@ -8,6 +8,7 @@ - `CachingOptions.Redis` empty → in-memory only (dev fallback). Set → a **single shared `ConnectionMultiplexer`** (singleton `IConnectionMultiplexer`) backs both the L2 cache and the DataProtection key ring. - Defaults: total expiration 1h, L1 (local) expiration 2min (`CachingOptions`). +- **`CachingOptions:KeyPrefix`** namespaces everything the framework writes to Redis: L2 entries + tag invalidations (`RedisCacheOptions.InstanceName`), the Data Protection key ring, idempotency locks, quota counters (unless `QuotaOptions:KeyPrefix` overrides) and the SignalR channel. Set it per app **and** environment (`MyApp`, `MyApp:prod`) — a `defaultDatabase` index is not enough (pub/sub ignores it). Shared helper: `FSH.Framework.Core.Common.RedisKeyPrefix`. Code that talks to `IConnectionMultiplexer` directly must prepend `RedisKeyPrefix.Normalize(...)` itself; `CacheKeys` stay unprefixed (the prefix is applied underneath). Changing the prefix rotates the Data Protection key ring and starts a cold cache. - `ObservableHybridCache` transparently decorates `HybridCache` to emit OpenTelemetry (hits/misses/factory-duration/invalidations). You don't reference it — just inject `HybridCache`. ## Pattern diff --git a/.agents/rules/realtime.md b/.agents/rules/realtime.md index a541fb284a..1851560686 100644 --- a/.agents/rules/realtime.md +++ b/.agents/rules/realtime.md @@ -9,7 +9,7 @@ - **Channel-group join is connect-time + on-demand.** `OnConnectedAsync` auto-joins `user:{id}`, `tenant:{id}`, and every `channel:{id}` the user is *already* a member of. A channel that becomes relevant **after** the socket is live (a new DM, or being added to a channel) is **not** auto-joined — the client must call the membership-gated **`JoinChannel(channelId)`** hub method (the dashboard does this on channel open + reconnect). Without it, group broadcasts silently miss that connection until a page reload re-runs `OnConnectedAsync`. New-DM creation pushes `ChatChannelAdded` to each other participant's `user:{id}` group so their channel list refreshes. - **⚠️ Read the user from `Context.User`, NOT `ICurrentUser`.** `ICurrentUser` flows through `IHttpContextAccessor`, but the negotiate `HttpContext` isn't pinned to subsequent hub invocations → `ICurrentUser` returns nulls inside the hub. Use `Context.User` (the hub's `GetUserId()`/`GetTenantId()` helpers). - Broadcasts are **scoped to groups** (`tenant:{id}`, `user:{id}`, `channel:{id}`), never `Clients.All`. `PresenceChanged` goes to the tenant group. -- Redis backplane is added automatically when `CachingOptions:Redis` is set (channel prefix `fsh-signalr`) — required for multi-replica. +- Redis backplane is added automatically when `CachingOptions:Redis` is set — required for multi-replica. Channel prefix is `{CachingOptions:KeyPrefix}:signalr` (`fsh-signalr` when the prefix is empty). Pub/sub ignores the Redis database index, so the prefix is what stops two apps on one Redis server from receiving each other's `tenant:root` / `user:{id}` broadcasts. - Push to a user from a module via `IHubContext` to group `user:{userId}` (e.g. Notifications' `"NotificationCreated"`). - `IPresenceTracker` (in-memory, **per-host** — single-replica only for presence). Modules supply `IChannelMembershipChecker`/`IUserChannelLookup` adapters so the shared hub can authorize channel groups without depending on Chat. diff --git a/src/BuildingBlocks/Caching/CachingOptions.cs b/src/BuildingBlocks/Caching/CachingOptions.cs index 8b05db5bef..f539b37cac 100644 --- a/src/BuildingBlocks/Caching/CachingOptions.cs +++ b/src/BuildingBlocks/Caching/CachingOptions.cs @@ -9,6 +9,17 @@ public sealed class CachingOptions /// Redis connection string. If empty, the L2 distributed cache falls back to in-memory. public string Redis { get; set; } = string.Empty; + /// + /// Namespace for every key and channel the framework writes to Redis (cache entries, Data + /// Protection key ring, idempotency locks, quota counters, SignalR backplane). Set it per + /// application and environment, e.g. MyApp:prod, whenever applications share a Redis + /// server — a defaultDatabase index alone does not isolate the SignalR backplane, because + /// pub/sub ignores databases. Empty keeps the unprefixed layout. Changing it rotates the Data + /// Protection key ring (issued cookies and tokens stop validating) and starts a cold cache. + /// See . + /// + public string KeyPrefix { get; set; } = string.Empty; + /// /// Enable SSL/TLS for Redis connection. If null, uses the connection string default. /// Aspire 13.x defaults Redis to TLS on the primary port; set to false when wiring via diff --git a/src/BuildingBlocks/Caching/Extensions.cs b/src/BuildingBlocks/Caching/Extensions.cs index bdfd138781..c5153db79e 100644 --- a/src/BuildingBlocks/Caching/Extensions.cs +++ b/src/BuildingBlocks/Caching/Extensions.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Core.Common; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Configuration; @@ -52,16 +53,22 @@ public static IServiceCollection AddHeroCaching(this IServiceCollection services var sharedMultiplexer = ConnectionMultiplexer.Connect(redisConfig); services.AddSingleton(sharedMultiplexer); + // Every key below lives under the application's prefix, so applications (and environments) + // sharing one Redis server never read each other's entries. InstanceName covers all of + // HybridCache's L2 traffic, including its tag-invalidation records. + string keyPrefix = RedisKeyPrefix.Normalize(cacheOptions.KeyPrefix); + services.AddStackExchangeRedisCache(options => { options.ConnectionMultiplexerFactory = () => Task.FromResult(sharedMultiplexer); + options.InstanceName = keyPrefix; }); // Persist Data Protection keys (auth cookies, reset/confirmation tokens, antiforgery) to // Redis so multi-instance hosts share a key ring and tokens survive rolling restarts. services.AddDataProtection() - .PersistKeysToStackExchangeRedis(sharedMultiplexer, "DataProtection-Keys") + .PersistKeysToStackExchangeRedis(sharedMultiplexer, keyPrefix + "DataProtection-Keys") .SetApplicationName("FSH.Starter"); } diff --git a/src/BuildingBlocks/Core/Common/RedisKeyPrefix.cs b/src/BuildingBlocks/Core/Common/RedisKeyPrefix.cs new file mode 100644 index 0000000000..87be8da3d9 --- /dev/null +++ b/src/BuildingBlocks/Core/Common/RedisKeyPrefix.cs @@ -0,0 +1,29 @@ +namespace FSH.Framework.Core.Common; + +/// +/// The per-application namespace for everything the framework writes to Redis: cache entries, +/// the Data Protection key ring, idempotency locks, quota counters and the SignalR backplane +/// channels. Read from . +/// +/// +/// Without it, every application pointed at one Redis server shares those keys. A database index +/// (defaultDatabase=N) only separates keys: Redis pub/sub is server-wide, so the SignalR +/// backplane of every application still meets on one channel — and the hub's groups +/// (tenant:root, user:{id}) exist in all of them. Give each application and +/// environment its own prefix, e.g. MyApp:prod. +/// +public static class RedisKeyPrefix +{ + /// Configuration key holding the prefix, shared by every Redis consumer. + public const string ConfigurationKey = "CachingOptions:KeyPrefix"; + + /// + /// Normalizes a configured prefix to the form keys are built from: empty when unset (the + /// historical, unprefixed key layout), otherwise the trimmed value ending in exactly one :. + /// + public static string Normalize(string? configured) + { + string value = configured?.Trim().TrimEnd(':') ?? string.Empty; + return value.Length == 0 ? string.Empty : value + ":"; + } +} diff --git a/src/BuildingBlocks/Quota/Extensions.cs b/src/BuildingBlocks/Quota/Extensions.cs index 2e860640d2..308adde0bc 100644 --- a/src/BuildingBlocks/Quota/Extensions.cs +++ b/src/BuildingBlocks/Quota/Extensions.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Core.Common; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -23,6 +24,10 @@ public static IServiceCollection AddHeroQuotas(this IServiceCollection services, .BindConfiguration(nameof(QuotaOptions)); var quotaOptions = configuration.GetSection(nameof(QuotaOptions)).Get() ?? new QuotaOptions(); + if (string.IsNullOrWhiteSpace(quotaOptions.KeyPrefix)) + { + quotaOptions.KeyPrefix = configuration[RedisKeyPrefix.ConfigurationKey]; + } services.TryAddSingleton(TimeProvider.System); services.AddSingleton(quotaOptions); diff --git a/src/BuildingBlocks/Quota/QuotaOptions.cs b/src/BuildingBlocks/Quota/QuotaOptions.cs index 885847963e..b1f6eceed4 100644 --- a/src/BuildingBlocks/Quota/QuotaOptions.cs +++ b/src/BuildingBlocks/Quota/QuotaOptions.cs @@ -17,6 +17,12 @@ public sealed class QuotaOptions /// public string? Redis { get; set; } + /// + /// Prefix for the quota counter keys. When empty, CachingOptions:KeyPrefix is used, so + /// one setting namespaces every key the application writes to Redis. + /// + public string? KeyPrefix { get; set; } + public string DefaultPlan { get; set; } = "free"; /// Plan name → per-resource limit map. Use -1 or long.MaxValue for "unlimited". diff --git a/src/BuildingBlocks/Quota/RedisQuotaService.cs b/src/BuildingBlocks/Quota/RedisQuotaService.cs index 9016e6a05a..f724959db1 100644 --- a/src/BuildingBlocks/Quota/RedisQuotaService.cs +++ b/src/BuildingBlocks/Quota/RedisQuotaService.cs @@ -1,4 +1,5 @@ using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Core.Common; using FSH.Framework.Shared.Multitenancy; using FSH.Framework.Shared.Quota; using Microsoft.Extensions.Logging; @@ -8,7 +9,7 @@ namespace FSH.Framework.Quota; /// /// Redis-backed quota counter. Counter-based resources use atomic INCRBY on a key of the -/// form quota:{tenantId}:{resource}:{YYYYMM} with a TTL that expires shortly after the +/// form {KeyPrefix}quota:{tenantId}:{resource}:{YYYYMM} with a TTL that expires shortly after the /// billing period boundary. Gauge-based resources delegate to /// instances that modules register to report live usage from their own state stores. /// @@ -191,14 +192,14 @@ private string BuildCounterKey(string tenantId, QuotaResource resource) { if (!IsPeriodic(resource)) { - return $"quota:{tenantId}:{resource}"; + return $"{RedisKeyPrefix.Normalize(_options.KeyPrefix)}quota:{tenantId}:{resource}"; } var now = _timeProvider.GetUtcNow(); // Monthly billing period is the coarsest useful window for SaaS; hourly/daily windows can be // added as additional QuotaResource values if needed later. var period = $"{now.Year:D4}{now.Month:D2}"; - return $"quota:{tenantId}:{resource}:{period}"; + return $"{RedisKeyPrefix.Normalize(_options.KeyPrefix)}quota:{tenantId}:{resource}:{period}"; } private DateTimeOffset? GetPeriodResetUtc(QuotaResource resource) diff --git a/src/BuildingBlocks/Web/Idempotency/IdempotencyEndpointFilter.cs b/src/BuildingBlocks/Web/Idempotency/IdempotencyEndpointFilter.cs index 1cddb6c7d4..34ba4d98fb 100644 --- a/src/BuildingBlocks/Web/Idempotency/IdempotencyEndpointFilter.cs +++ b/src/BuildingBlocks/Web/Idempotency/IdempotencyEndpointFilter.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Finbuckle.MultiTenant.Abstractions; using FSH.Framework.Caching; +using FSH.Framework.Core.Common; using FSH.Framework.Core.Localization; using FSH.Framework.Shared.Constants; using FSH.Framework.Shared.Identity.Claims; @@ -124,8 +125,11 @@ public sealed class IdempotencyEndpointFilter : IEndpointFilter // ponytail: the reservation is not renewed while the handler runs, so a handler slower than // ReservationTtl lets a duplicate through (the entry is not stored yet either, so the probe // can't catch it). Add lease renewal if an idempotent endpoint ever runs longer than that. + // The lock goes straight to the multiplexer, so it does not inherit the IDistributedCache + // InstanceName the entry gets — apply the application's key prefix explicitly. var multiplexer = httpContext.RequestServices.GetService(); - var reservationKey = "lock:" + cacheKey; + var keyPrefix = RedisKeyPrefix.Normalize(httpContext.RequestServices.GetService>()?.Value.KeyPrefix); + var reservationKey = keyPrefix + "lock:" + cacheKey; var reservation = await TryReserveAsync(multiplexer, reservationKey, options.ReservationTtl, logger, idempotencyKey).ConfigureAwait(false); if (reservation.Denied) { diff --git a/src/BuildingBlocks/Web/Realtime/HeroRealtimeExtensions.cs b/src/BuildingBlocks/Web/Realtime/HeroRealtimeExtensions.cs index 9657cc36cc..c78e5cb551 100644 --- a/src/BuildingBlocks/Web/Realtime/HeroRealtimeExtensions.cs +++ b/src/BuildingBlocks/Web/Realtime/HeroRealtimeExtensions.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Core.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -24,8 +25,13 @@ public static IServiceCollection AddHeroRealtime(this IServiceCollection service var signalr = services.AddSignalR(); if (!string.IsNullOrWhiteSpace(redis)) { + // Pub/sub is server-wide — a defaultDatabase index does not separate it — so without an + // application prefix every app on the Redis server shares one backplane and receives the + // others' broadcasts to groups they have in common (tenant:root, user:{id}). + string keyPrefix = RedisKeyPrefix.Normalize(configuration[RedisKeyPrefix.ConfigurationKey]); + string channelPrefix = keyPrefix.Length == 0 ? "fsh-signalr" : keyPrefix + "signalr"; signalr.AddStackExchangeRedis(redis, options => options.Configuration.ChannelPrefix = - StackExchange.Redis.RedisChannel.Literal("fsh-signalr")); + StackExchange.Redis.RedisChannel.Literal(channelPrefix)); } services.AddSingleton(); diff --git a/src/Host/FSH.Starter.Api/appsettings.Production.json b/src/Host/FSH.Starter.Api/appsettings.Production.json index 6c40734bbd..70c9536d64 100644 --- a/src/Host/FSH.Starter.Api/appsettings.Production.json +++ b/src/Host/FSH.Starter.Api/appsettings.Production.json @@ -40,7 +40,8 @@ "OriginUrl": "" }, "CachingOptions": { - "Redis": "" + "Redis": "", + "KeyPrefix": "FSH.Starter:prod" }, "HangfireOptions": { "Username": "", diff --git a/src/Host/FSH.Starter.Api/appsettings.json b/src/Host/FSH.Starter.Api/appsettings.json index 9484241d44..47cf9b3af2 100644 --- a/src/Host/FSH.Starter.Api/appsettings.json +++ b/src/Host/FSH.Starter.Api/appsettings.json @@ -65,7 +65,8 @@ "OriginUrl": "https://localhost:7030" }, "CachingOptions": { - "Redis": "" + "Redis": "", + "KeyPrefix": "FSH.Starter" }, "HangfireOptions": { "Username": "", diff --git a/src/Tests/Framework.Tests/Core/RedisKeyPrefixTests.cs b/src/Tests/Framework.Tests/Core/RedisKeyPrefixTests.cs new file mode 100644 index 0000000000..ada5e58f63 --- /dev/null +++ b/src/Tests/Framework.Tests/Core/RedisKeyPrefixTests.cs @@ -0,0 +1,20 @@ +using FSH.Framework.Core.Common; + +namespace Framework.Tests.Core; + +public sealed class RedisKeyPrefixTests +{ + [Theory] + [InlineData(null, "")] + [InlineData("", "")] + [InlineData(" ", "")] + [InlineData(":", "")] + [InlineData("MyApp", "MyApp:")] + [InlineData("MyApp:prod", "MyApp:prod:")] + [InlineData("MyApp:prod:", "MyApp:prod:")] + [InlineData(" MyApp:prod:: ", "MyApp:prod:")] + public void Normalize_Should_YieldEmptyOrExactlyOneTrailingColon(string? configured, string expected) + { + RedisKeyPrefix.Normalize(configured).ShouldBe(expected); + } +} diff --git a/src/Tests/Framework.Tests/Web/IdempotencyEndpointFilterReplayTests.cs b/src/Tests/Framework.Tests/Web/IdempotencyEndpointFilterReplayTests.cs index d908f75666..ca4c445625 100644 --- a/src/Tests/Framework.Tests/Web/IdempotencyEndpointFilterReplayTests.cs +++ b/src/Tests/Framework.Tests/Web/IdempotencyEndpointFilterReplayTests.cs @@ -257,6 +257,32 @@ await filter.InvokeAsync( capturedTtl.ShouldNotBe(options.DefaultTtl); } + [Fact] + public async Task Reservation_Should_LiveUnderTheApplicationKeyPrefix() + { + // The lock goes straight to the multiplexer, bypassing the IDistributedCache InstanceName, + // so the filter must apply CachingOptions.KeyPrefix itself or two apps share lock keys. + var db = Substitute.For(); + RedisKey? capturedKey = null; + db.StringSetAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => { capturedKey = ci.ArgAt(0); return Task.FromResult(true); }); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + services.AddDistributedMemoryCache(); + services.AddSingleton>(Options.Create(new IdempotencyOptions())); + services.AddSingleton>(Options.Create(new CachingOptions { KeyPrefix = "MyApp:prod" })); + services.AddSingleton(RedisMultiplexer(db)); + await using var provider = services.BuildServiceProvider(); + + await new IdempotencyEndpointFilter().InvokeAsync( + new TestFilterContext(NewContext(provider)), + _ => ValueTask.FromResult(TypedResults.Ok(new SampleDto(Guid.NewGuid(), "widget")))); + + capturedKey.ShouldNotBeNull(); + capturedKey.Value.ToString().ShouldStartWith("MyApp:prod:lock:"); + } + // ─── MEDIUM + nit: a Redis fault on reserve/release fails open, never 500s ─────────── [Fact] diff --git a/src/Tests/Framework.Tests/Web/RedisKeyPrefixWiringTests.cs b/src/Tests/Framework.Tests/Web/RedisKeyPrefixWiringTests.cs new file mode 100644 index 0000000000..020d1a3113 --- /dev/null +++ b/src/Tests/Framework.Tests/Web/RedisKeyPrefixWiringTests.cs @@ -0,0 +1,87 @@ +using FSH.Framework.Quota; +using FSH.Framework.Web.Realtime; +using Microsoft.AspNetCore.SignalR.StackExchangeRedis; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Framework.Tests.Web; + +/// +/// Guards the consumers of CachingOptions:KeyPrefix that do not go through +/// IDistributedCache (whose InstanceName the integration tests cover): the SignalR backplane +/// channel and the quota counters. Registration only — nothing here connects to Redis. +/// +public sealed class RedisKeyPrefixWiringTests +{ + private static IConfiguration Config(Dictionary values) => + new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + + private static RedisOptions SignalRRedisOptions(Dictionary values) + { + var configuration = Config(values); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddHeroRealtime(configuration); + using var provider = services.BuildServiceProvider(); + return provider.GetRequiredService>().Value; + } + + [Fact] + public void AddHeroRealtime_Should_NamespaceTheBackplaneChannel_When_KeyPrefixIsSet() + { + var options = SignalRRedisOptions(new() + { + ["CachingOptions:Redis"] = "localhost:6379", + ["CachingOptions:KeyPrefix"] = "MyApp:prod", + }); + + // Pub/sub ignores the database index, so the channel prefix is the only thing keeping two + // applications on one Redis server from receiving each other's hub broadcasts. + options.Configuration.ChannelPrefix.ToString().ShouldBe("MyApp:prod:signalr"); + } + + [Fact] + public void AddHeroRealtime_Should_KeepTheHistoricalChannel_When_KeyPrefixIsEmpty() + { + var options = SignalRRedisOptions(new() + { + ["CachingOptions:Redis"] = "localhost:6379", + }); + + options.Configuration.ChannelPrefix.ToString().ShouldBe("fsh-signalr"); + } + + [Fact] + public void AddHeroQuotas_Should_InheritTheCachingKeyPrefix_When_QuotaHasNone() + { + var configuration = Config(new() + { + ["QuotaOptions:Enabled"] = "false", + ["CachingOptions:KeyPrefix"] = "MyApp:prod", + }); + var services = new ServiceCollection(); + services.AddSingleton(configuration); + services.AddHeroQuotas(configuration); + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService().KeyPrefix.ShouldBe("MyApp:prod"); + } + + [Fact] + public void AddHeroQuotas_Should_PreferItsOwnKeyPrefix_When_Configured() + { + var configuration = Config(new() + { + ["QuotaOptions:Enabled"] = "false", + ["QuotaOptions:KeyPrefix"] = "quota-store", + ["CachingOptions:KeyPrefix"] = "MyApp:prod", + }); + var services = new ServiceCollection(); + services.AddSingleton(configuration); + services.AddHeroQuotas(configuration); + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService().KeyPrefix.ShouldBe("quota-store"); + } +} diff --git a/src/Tests/Framework.Tests/Web/ShippedConfigurationTests.cs b/src/Tests/Framework.Tests/Web/ShippedConfigurationTests.cs index fd30c6c6f9..b711d9b8ca 100644 --- a/src/Tests/Framework.Tests/Web/ShippedConfigurationTests.cs +++ b/src/Tests/Framework.Tests/Web/ShippedConfigurationTests.cs @@ -62,4 +62,18 @@ public void Development_Should_TrustTheTwoLocalSpas_When_TheShippedFilesAreLoade .ShouldBe(["http://localhost:5173", "http://localhost:5174"]); configuration["FrontendOptions:DefaultOrigin"].ShouldBe("http://localhost:5174"); } + + [Fact] + public void Environments_Should_ShipDistinctRedisKeyPrefixes() + { + // Applications and environments commonly share one Redis server. Without a prefix they share + // cache entries, tag invalidations, the Data Protection key ring and — since pub/sub ignores + // the database index — the SignalR backplane. + var development = LoadFor("Development")["CachingOptions:KeyPrefix"]; + var production = LoadFor("Production")["CachingOptions:KeyPrefix"]; + + development.ShouldNotBeNullOrWhiteSpace(); + production.ShouldNotBeNullOrWhiteSpace(); + production.ShouldNotBe(development); + } } diff --git a/src/Tests/Integration.Tests/Tests/Caching/HybridCacheRedisTests.cs b/src/Tests/Integration.Tests/Tests/Caching/HybridCacheRedisTests.cs index 26f9fe87c8..1a6bc2f768 100644 --- a/src/Tests/Integration.Tests/Tests/Caching/HybridCacheRedisTests.cs +++ b/src/Tests/Integration.Tests/Tests/Caching/HybridCacheRedisTests.cs @@ -1,9 +1,11 @@ using System.ComponentModel; using FSH.Framework.Caching; +using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; using Testcontainers.Redis; namespace Integration.Tests.Tests.Caching; @@ -24,13 +26,14 @@ public sealed class HybridCacheRedisTests : IAsyncLifetime public Task DisposeAsync() => _redis.DisposeAsync().AsTask(); - private (HybridCache cache, IDistributedCache distributedCache, ServiceProvider provider) CreateCache() + private (HybridCache cache, IDistributedCache distributedCache, ServiceProvider provider) CreateCache(string? keyPrefix = null) { var services = new ServiceCollection(); var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["CachingOptions:Redis"] = _redis.GetConnectionString(), + ["CachingOptions:KeyPrefix"] = keyPrefix, }) .Build(); services.AddSingleton(config); @@ -127,6 +130,83 @@ public void Redis_Backend_Should_Implement_IBufferDistributedCache() } } + // ─── CachingOptions.KeyPrefix: two applications sharing one Redis server ───────────── + + [Fact] + public async Task KeyPrefix_Should_Isolate_Entries_Between_Applications() + { + var (appA, _, providerA) = CreateCache("app-a:prod"); + var (appB, _, providerB) = CreateCache("app-b:prod"); + await using (providerA) + await using (providerB) + { + await appA.SetAsync("theme:t:root", "from-a"); + + var bRuns = 0; + var fromB = await appB.GetOrCreateAsync( + "theme:t:root", + 0, + (s, ct) => { Interlocked.Increment(ref bRuns); return ValueTask.FromResult("from-b"); }); + + fromB.ShouldBe("from-b"); + bRuns.ShouldBe(1, "app B must not read app A's entry for the same logical key"); + + using var redis = await ConnectionMultiplexer.ConnectAsync(_redis.GetConnectionString()); + var db = redis.GetDatabase(); + (await db.KeyExistsAsync("app-a:prod:theme:t:root")).ShouldBeTrue(); + (await db.KeyExistsAsync("theme:t:root")).ShouldBeFalse(); + } + } + + [Fact] + public async Task KeyPrefix_Should_Isolate_Tag_Invalidation_Between_Applications() + { + // HybridCache records tag invalidations in L2. Unprefixed, app A evicting "permissions" + // also invalidates app B's permission entries on the same Redis server. + var (appB, _, providerB) = CreateCache("app-b:prod"); + await using (providerB) + { + await appB.SetAsync("perm:u:1", "b-perms", tags: ["permissions"]); + } + + var (appA, _, providerA) = CreateCache("app-a:prod"); + await using (providerA) + { + await appA.RemoveByTagAsync("permissions"); + } + + // A fresh instance of app B (empty L1) must still find its entry in L2. + var (appBAgain, _, providerBAgain) = CreateCache("app-b:prod"); + await using (providerBAgain) + { + var runs = 0; + var value = await appBAgain.GetOrCreateAsync( + "perm:u:1", + 0, + (s, ct) => { Interlocked.Increment(ref runs); return ValueTask.FromResult("reloaded"); }, + tags: ["permissions"]); + + value.ShouldBe("b-perms"); + runs.ShouldBe(0, "another application's tag invalidation must not evict this one's entries"); + } + } + + [Fact] + public async Task KeyPrefix_Should_Namespace_The_DataProtection_KeyRing() + { + var (_, _, provider) = CreateCache("app-a:prod"); + await using (provider) + { + // Protecting forces the key ring to create (and persist) its first key. + provider.GetRequiredService().CreateProtector("test").Protect("payload"); + + using var redis = await ConnectionMultiplexer.ConnectAsync(_redis.GetConnectionString()); + var db = redis.GetDatabase(); + (await db.KeyExistsAsync("app-a:prod:DataProtection-Keys")).ShouldBeTrue(); + (await db.KeyExistsAsync("DataProtection-Keys")).ShouldBeFalse(); + } + } + [ImmutableObject(true)] private sealed record TestPayload(string Name, int Number); }