Skip to content

feat(caching): namespace every Redis key and channel with CachingOptions:KeyPrefix - #1450

Open
maxiar wants to merge 1 commit into
fullstackhero:mainfrom
maxiar:feat/redis-key-prefix-upstream
Open

maxiar wants to merge 1 commit into
fullstackhero:mainfrom
maxiar:feat/redis-key-prefix-upstream

Conversation

@maxiar

@maxiar maxiar commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

Every key the framework writes to Redis is unprefixed: cache entries (perm:u:{id}, theme:t:root, theme:default), HybridCache tag invalidations, the DataProtection-Keys ring, idempotency locks and quota:* counters. The SignalR backplane uses a fixed fsh-signalr channel prefix. Two applications built on the kit, or the QA and Production deployments of one app, therefore collide as soon as they point at the same Redis server.

A defaultDatabase=N index in the connection string, which is the usual workaround, only partly helps. It separates keys, but Redis pub/sub is server-wide and ignores the database index. So every app on the server still shares one SignalR backplane. AppHub broadcasts to tenant:{id} and user:{id} groups, and tenant:root exists in every app. A presence or notification broadcast in one app therefore reaches clients connected to another.

Within a shared database, the cache leaks too. An integration test in this PR shows it: when app A calls RemoveByTagAsync("permissions"), app B's permission entries on the same server are evicted as well. Entries for the same logical key (theme:t:root) are read across apps.

Change

This PR adds one setting, CachingOptions:KeyPrefix, and applies it everywhere the framework talks to Redis:

Consumer Before With KeyPrefix = "MyApp:prod"
HybridCache L2 entries + tag records (RedisCacheOptions.InstanceName) theme:t:root MyApp:prod:theme:t:root
Data Protection key ring DataProtection-Keys MyApp:prod:DataProtection-Keys
Idempotency reservation lock (talks to the multiplexer directly) lock:idem:… MyApp:prod:lock:idem:…
Quota counters (QuotaOptions:KeyPrefix overrides, since quota may use its own Redis) quota:… MyApp:prod:quota:…
SignalR backplane channel fsh-signalr MyApp:prod:signalr
  • Normalization. A small shared helper, FSH.Framework.Core.Common.RedisKeyPrefix, normalizes the value to empty or exactly one trailing :. It lives in Core because Caching, Web and Quota all reference Core, while Quota does not reference Caching.
  • Backward compatible. An empty prefix produces exactly today's keys and channel. CacheKeys is unchanged, because the prefix is applied underneath it.
  • Template defaults. The template ships "KeyPrefix": "FSH.Starter" in appsettings.json and "FSH.Starter:prod" in appsettings.Production.json. sourceName renames these per project, so a new scaffold is isolated per app and per environment out of the box.
  • Rollout cost. Setting or changing the prefix on an existing deployment rotates the Data Protection key ring, so issued auth cookies and reset tokens stop validating once. It also starts a cold cache. Projects that upgrade through a template merge should expect that one-time cost.

.agents/rules/caching.md and realtime.md are updated. The docs-site page and changelog entry (golden rule 10) will follow as a PR to fullstackhero/docs once the shape here is agreed.

Verification

dotnet test src/FSH.Starter.slnx   -> every suite passes, including Integration.Tests 793/793 (Testcontainers)

New tests:

  • HybridCacheRedisTests (real Valkey).
    • Two prefixes on one server don't read each other's entries.
    • The raw key in Redis carries the prefix.
    • The Data Protection ring lands under the prefix.
    • Tag invalidation in one app does not evict the other app's entries. As a negative control, I ran this test with both prefixes empty and it fails, which confirms the cross-app eviction exists on main today.
  • RedisKeyPrefixWiringTests.
    • The SignalR channel prefix is fsh-signalr without KeyPrefix and {prefix}:signalr with it.
    • Quota inherits the caching prefix, and its own setting wins when present.
  • IdempotencyEndpointFilterReplayTests. The reservation key starts with the prefix.
  • ShippedConfigurationTests. Development and Production ship distinct, non-empty prefixes.
  • RedisKeyPrefixTests. Normalization cases.

The runs used -p:NuGetAudit=false, because main currently fails restore on the crypto advisories that #1447 fixes.

🤖 Generated with Claude Code

…ons:KeyPrefix

Every key the framework writes to Redis was unprefixed (cache entries, HybridCache
tag invalidations, the Data Protection key ring, idempotency locks, quota counters)
and the SignalR backplane used a fixed `fsh-signalr` channel. Applications or
environments sharing one Redis server collided; a defaultDatabase index does not
help the backplane, because pub/sub ignores the database.

CachingOptions:KeyPrefix (normalized by Core's RedisKeyPrefix) now namespaces all
of them: RedisCacheOptions.InstanceName, the Data Protection list key, the
idempotency reservation, quota counters (QuotaOptions:KeyPrefix overrides) and the
SignalR channel prefix. Empty keeps today's layout. The template ships FSH.Starter /
FSH.Starter:prod, renamed per project by sourceName.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant