Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/rules/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .agents/rules/realtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppHub>` 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.

Expand Down
11 changes: 11 additions & 0 deletions src/BuildingBlocks/Caching/CachingOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ public sealed class CachingOptions
/// <summary>Redis connection string. If empty, the L2 distributed cache falls back to in-memory.</summary>
public string Redis { get; set; } = string.Empty;

/// <summary>
/// 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. <c>MyApp:prod</c>, whenever applications share a Redis
/// server — a <c>defaultDatabase</c> 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 <see cref="FSH.Framework.Core.Common.RedisKeyPrefix"/>.
/// </summary>
public string KeyPrefix { get; set; } = string.Empty;

/// <summary>
/// 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 <c>false</c> when wiring via
Expand Down
9 changes: 8 additions & 1 deletion src/BuildingBlocks/Caching/Extensions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using FSH.Framework.Core.Common;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Configuration;
Expand Down Expand Up @@ -52,16 +53,22 @@ public static IServiceCollection AddHeroCaching(this IServiceCollection services
var sharedMultiplexer = ConnectionMultiplexer.Connect(redisConfig);
services.AddSingleton<IConnectionMultiplexer>(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<IConnectionMultiplexer>(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");
}

Expand Down
29 changes: 29 additions & 0 deletions src/BuildingBlocks/Core/Common/RedisKeyPrefix.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace FSH.Framework.Core.Common;

/// <summary>
/// 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 <see cref="ConfigurationKey"/>.
/// </summary>
/// <remarks>
/// Without it, every application pointed at one Redis server shares those keys. A database index
/// (<c>defaultDatabase=N</c>) 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
/// (<c>tenant:root</c>, <c>user:{id}</c>) exist in all of them. Give each application and
/// environment its own prefix, e.g. <c>MyApp:prod</c>.
/// </remarks>
public static class RedisKeyPrefix
{
/// <summary>Configuration key holding the prefix, shared by every Redis consumer.</summary>
public const string ConfigurationKey = "CachingOptions:KeyPrefix";

/// <summary>
/// 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 <c>:</c>.
/// </summary>
public static string Normalize(string? configured)
{
string value = configured?.Trim().TrimEnd(':') ?? string.Empty;
return value.Length == 0 ? string.Empty : value + ":";
}
}
5 changes: 5 additions & 0 deletions src/BuildingBlocks/Quota/Extensions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using FSH.Framework.Core.Common;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
Expand All @@ -23,6 +24,10 @@ public static IServiceCollection AddHeroQuotas(this IServiceCollection services,
.BindConfiguration(nameof(QuotaOptions));

var quotaOptions = configuration.GetSection(nameof(QuotaOptions)).Get<QuotaOptions>() ?? new QuotaOptions();
if (string.IsNullOrWhiteSpace(quotaOptions.KeyPrefix))
{
quotaOptions.KeyPrefix = configuration[RedisKeyPrefix.ConfigurationKey];
}

services.TryAddSingleton(TimeProvider.System);
services.AddSingleton(quotaOptions);
Expand Down
6 changes: 6 additions & 0 deletions src/BuildingBlocks/Quota/QuotaOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ public sealed class QuotaOptions
/// </summary>
public string? Redis { get; set; }

/// <summary>
/// Prefix for the quota counter keys. When empty, <c>CachingOptions:KeyPrefix</c> is used, so
/// one setting namespaces every key the application writes to Redis.
/// </summary>
public string? KeyPrefix { get; set; }

public string DefaultPlan { get; set; } = "free";

/// <summary>Plan name → per-resource limit map. Use -1 or long.MaxValue for "unlimited".</summary>
Expand Down
7 changes: 4 additions & 3 deletions src/BuildingBlocks/Quota/RedisQuotaService.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -8,7 +9,7 @@ namespace FSH.Framework.Quota;

/// <summary>
/// Redis-backed quota counter. Counter-based resources use atomic <c>INCRBY</c> on a key of the
/// form <c>quota:{tenantId}:{resource}:{YYYYMM}</c> with a TTL that expires shortly after the
/// form <c>{KeyPrefix}quota:{tenantId}:{resource}:{YYYYMM}</c> with a TTL that expires shortly after the
/// billing period boundary. Gauge-based resources delegate to <see cref="IQuotaGaugeProvider"/>
/// instances that modules register to report live usage from their own state stores.
/// </summary>
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<IConnectionMultiplexer>();
var reservationKey = "lock:" + cacheKey;
var keyPrefix = RedisKeyPrefix.Normalize(httpContext.RequestServices.GetService<IOptions<CachingOptions>>()?.Value.KeyPrefix);
var reservationKey = keyPrefix + "lock:" + cacheKey;
var reservation = await TryReserveAsync(multiplexer, reservationKey, options.ReservationTtl, logger, idempotencyKey).ConfigureAwait(false);
if (reservation.Denied)
{
Expand Down
8 changes: 7 additions & 1 deletion src/BuildingBlocks/Web/Realtime/HeroRealtimeExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using FSH.Framework.Core.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
Expand All @@ -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<IPresenceTracker, PresenceTracker>();
Expand Down
3 changes: 2 additions & 1 deletion src/Host/FSH.Starter.Api/appsettings.Production.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
"OriginUrl": ""
},
"CachingOptions": {
"Redis": ""
"Redis": "",
"KeyPrefix": "FSH.Starter:prod"
},
"HangfireOptions": {
"Username": "",
Expand Down
3 changes: 2 additions & 1 deletion src/Host/FSH.Starter.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@
"OriginUrl": "https://localhost:7030"
},
"CachingOptions": {
"Redis": ""
"Redis": "",
"KeyPrefix": "FSH.Starter"
},
"HangfireOptions": {
"Username": "",
Expand Down
20 changes: 20 additions & 0 deletions src/Tests/Framework.Tests/Core/RedisKeyPrefixTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<IDatabase>();
RedisKey? capturedKey = null;
db.StringSetAsync(Arg.Any<RedisKey>(), Arg.Any<RedisValue>(), Arg.Any<TimeSpan?>(), Arg.Any<When>())
.Returns(ci => { capturedKey = ci.ArgAt<RedisKey>(0); return Task.FromResult(true); });
var services = new ServiceCollection();
services.AddLogging();
services.AddLocalization(o => o.ResourcesPath = "");
services.AddDistributedMemoryCache();
services.AddSingleton<IOptions<IdempotencyOptions>>(Options.Create(new IdempotencyOptions()));
services.AddSingleton<IOptions<CachingOptions>>(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<object?>(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]
Expand Down
87 changes: 87 additions & 0 deletions src/Tests/Framework.Tests/Web/RedisKeyPrefixWiringTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Guards the consumers of <c>CachingOptions:KeyPrefix</c> that do not go through
/// <c>IDistributedCache</c> (whose InstanceName the integration tests cover): the SignalR backplane
/// channel and the quota counters. Registration only — nothing here connects to Redis.
/// </summary>
public sealed class RedisKeyPrefixWiringTests
{
private static IConfiguration Config(Dictionary<string, string?> values) =>
new ConfigurationBuilder().AddInMemoryCollection(values).Build();

private static RedisOptions SignalRRedisOptions(Dictionary<string, string?> values)
{
var configuration = Config(values);
var services = new ServiceCollection();
services.AddLogging();
services.AddHeroRealtime(configuration);
using var provider = services.BuildServiceProvider();
return provider.GetRequiredService<IOptions<RedisOptions>>().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<QuotaOptions>().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<QuotaOptions>().KeyPrefix.ShouldBe("quota-store");
}
}
14 changes: 14 additions & 0 deletions src/Tests/Framework.Tests/Web/ShippedConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading
Loading