Skip to content
fullstackhero

Concept

Caching

HybridCache (L1 in-memory + L2 Valkey) with OpenTelemetry instrumentation, stampede protection, and tag-based invalidation.

views 0 Last updated

HybridCache is the .NET 10 framework cache primitive. L1 is in-process memory; L2 is Valkey (a Redis-compatible, BSD-licensed Redis fork) — or DistributedMemoryCache for dev. The kit wraps it with ObservableHybridCache so every call emits OpenTelemetry spans plus metrics — fsh.cache.hits, fsh.cache.misses, fsh.cache.invalidations, and fsh.cache.factory.duration.

How it’s wired

// Program.cs (via AddHeroPlatform when EnableCaching = true)
builder.AddHeroPlatform(o => o.EnableCaching = true);

AddHeroCaching reads CachingOptions:Redis from configuration. When set, L2 is RedisCache with a shared IConnectionMultiplexer. When empty, L2 falls back to DistributedMemoryCache (single-process; dev/test only).

Using the cache

Inject HybridCache and call GetOrCreateAsync:

public sealed class GetTenantConfigQueryHandler(HybridCache cache, ITenantConfigRepo repo)
: IQueryHandler<GetTenantConfigQuery, TenantConfig>
{
public ValueTask<TenantConfig> Handle(GetTenantConfigQuery q, CancellationToken ct)
=> cache.GetOrCreateAsync(
$"tenant-config:{q.TenantId}",
async (innerCt) => await repo.LoadAsync(q.TenantId, innerCt).ConfigureAwait(false),
tags: [$"tenant:{q.TenantId}"],
cancellationToken: ct);
}

Three things happen automatically:

  1. L1 hit returns immediately without touching Valkey or the source.
  2. L1 miss / L2 hit populates L1 and returns.
  3. Both miss runs the factory, populates L1 + L2, returns.

The tags array enables targeted invalidation:

// after the tenant's config changes
await cache.RemoveByTagAsync($"tenant:{tenantId}", ct).ConfigureAwait(false);

This evicts every cache entry tagged with that tenant id across L1 + L2.

The kit’s key + tag conventions live in CacheKeys (Caching building block): short prefixed keys like perm:u:{userId}, theme:t:{tenantId}, idem:t:{tenantId}:{key}, and well-known tags — permissions, themes, idempotency, plus CacheKeys.Tags.Tenant(id) / CacheKeys.Tags.User(id) for scoped bulk invalidation. Follow the same shape for your own entries: tenant-scope the key where applicable, and always attach the tenant tag so tenant-wide purges catch your entries too.

Configuration

{
"CachingOptions": {
"Redis": "localhost:6379",
"EnableSsl": false,
"DefaultExpiration": "01:00:00",
"DefaultLocalCacheExpiration": "00:02:00",
"MaximumKeyLength": 1024,
"MaximumPayloadBytes": 1048576
}
}
OptionDefaultPurpose
RedisemptyConnection string; empty falls back to in-memory L2
EnableSslnullOverrides the connection string’s TLS setting; Aspire 13.x defaults Redis to TLS on the primary port, so set false when wiring the plain-TCP endpoint
DefaultExpiration1 hourTTL applied to both L1 + L2
DefaultLocalCacheExpiration2 minutesL1-only TTL; bounds cross-node staleness after RemoveByTag on peers
MaximumKeyLength1024Keys longer than this are rejected
MaximumPayloadBytes1 MBLarger payloads are skipped silently (warning logged)

Shared multiplexer

When CachingOptions:Redis is set, AddHeroCaching connects once and registers a single shared IConnectionMultiplexer used by:

  • The distributed cache (AddStackExchangeRedisCache via a multiplexer factory).
  • Data Protection key persistence (DataProtection-Keys, app name FSH.Starter) — so auth cookies, reset/confirmation tokens, and antiforgery survive rolling deploys across instances.

One connection pool per host for those consumers. The SignalR backplane (realtime) reads the same CachingOptions:Redis connection string but establishes its own connection.

Who uses it in the kit

  • Multitenancy module caches resolved tenants in the distributed cache (Finbuckle’s DistributedCacheStore, 60-minute TTL) and tenant themes in HybridCache (TenantThemeService, with RemoveByTagAsync per-tenant invalidation).
  • Identity module caches user permission sets in HybridCache (UserPermissionService, tag-invalidated; RolePermissionSyncer keeps role-permission state in sync at startup) and impersonation-grant revocation markers.
  • Realtime uses the distributed cache for the 3-second typing-indicator throttle per (channel, user) in AppHub.
  • Idempotency uses it to cache request responses by Idempotency-Key.

Gotchas

  • L1 has no backplane. After RemoveByTagAsync on one instance, peer instances’ L1 copies aren’t invalidated. The DefaultLocalCacheExpiration (default 2 min) bounds staleness — don’t set it too high if you rely on fast cross-node invalidation.
  • Payload size cap is silent. Entries larger than MaximumPayloadBytes are skipped with a warning log, not stored. Lookup will then miss every time. Log-search for “payload too large” warnings before assuming the cache is broken.
  • DistributedMemoryCache is per-process. Two instances of the API can’t see each other’s cache. Always check CachingOptions:Redis is set in production.