Skip to content
fullstackhero

Reference

Caching building block

HybridCache (L1 in-memory + L2 Valkey or distributed memory) with OpenTelemetry instrumentation, stampede protection, and a shared multiplexer for DataProtection.

views 0 Last updated

The Caching block wires Microsoft.Extensions.Caching.Hybrid for the whole platform. L1 is in-process memory; L2 is Valkey (a Redis-compatible, BSD-licensed Redis fork) when CachingOptions:Redis is set, otherwise the in-process DistributedMemoryCache. The block decorates the underlying HybridCache with ObservableHybridCache to emit OpenTelemetry metrics + activities, and it shares the IConnectionMultiplexer with DataProtection key persistence so the whole host opens one Valkey connection pool, not one per feature.

What it ships

Extension

  • AddHeroCaching(services, configuration) — wires HybridCache + L2 + the OTel decorator. Reads CachingOptions.

Decorator

  • ObservableHybridCache(inner) — wraps the framework HybridCache to emit:
    • fsh.cache.hits / fsh.cache.misses (OTel counters; L1 and L2 hits are both counted as “hit” — HybridCache doesn’t surface which layer served the read)
    • fsh.cache.invalidations (OTel counter; RemoveAsync + RemoveByTagAsync)
    • fsh.cache.factory.duration (OTel histogram, ms; recorded only on a miss)
    • Activities under ActivitySource("FSH.Caching") with cache.system, cache.key, cache.hit (or cache.tag) tags.

Options

  • CachingOptions:
    • Redis — connection string; empty falls back to in-memory DistributedMemoryCache
    • EnableSsl — TLS toggle (defaults to inference from the connection string)
    • DefaultExpiration — TimeSpan; default 1 hour, applies to L1 + L2
    • DefaultLocalCacheExpiration — TimeSpan; default 2 minutes; bounds cross-node staleness after RemoveByTag on peers
    • MaximumKeyLength — default 1024 chars; longer keys are rejected
    • MaximumPayloadBytes — default 1 MB; larger payloads are silently skipped

Telemetry helper

  • CachingTelemetry — the ActivitySource + meter the decorator emits (both named FSH.Caching, exposed as ActivitySourceName / MeterName constants). Wire them into the OTel pipeline via metrics.AddMeter(...) / tracing.AddSource(...).

How modules consume it

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),
cancellationToken: ct);
}

For tag-based invalidation:

await cache.GetOrCreateAsync(key, factory, tags: ["tenant:" + tenantId.ToString()], cancellationToken: ct);
// later, on tenant config change
await cache.RemoveByTagAsync("tenant:" + tenantId.ToString(), ct).ConfigureAwait(false);

Identity uses HybridCache for per-user permission sets (UserPermissionService, warmed by the RolePermissionSyncer) and impersonation grants (ImpersonationGrantService); Multitenancy caches tenant themes (TenantThemeService) and fronts the tenant EF store with Finbuckle’s DistributedCacheStore, which rides on the same IDistributedCache this block registers.

Configuration

{
"CachingOptions": {
"Redis": "localhost:6379",
"DefaultExpiration": "01:00:00",
"DefaultLocalCacheExpiration": "00:02:00",
"MaximumKeyLength": 1024,
"MaximumPayloadBytes": 1048576
}
}

When Redis is empty the block falls back to in-memory L2 (DistributedMemoryCache) — fine for dev/test, not for multi-instance production. Always set Valkey (or another distributed cache) for production.

How to extend

Use the shared multiplexer for your own Valkey work

The block exposes the IConnectionMultiplexer through DI; resolve it and reuse instead of opening a second pool:

public sealed class MyRedisService(IConnectionMultiplexer redis)
{
public async Task<string?> ReadAsync(string key)
{
var db = redis.GetDatabase();
return await db.StringGetAsync(key);
}
}

Add another decorator

Decorate the registered HybridCache with another wrapper to add e.g. circuit-breaker logic. Pull the existing descriptor out of the service collection, register your decorator, and chain — ObservableHybridCache does this exact dance for OTel.

Pre-warm on startup

Resolve HybridCache from a hosted service and GetOrCreateAsync your hot keys before traffic arrives. The Identity module’s RolePermissionSyncHostedService is the template.

Gotchas

  • L1 has no backplane. After RemoveByTag on one instance, peer instances’ L1 copies aren’t invalidated. The DefaultLocalCacheExpiration (2 min default) bounds the staleness window — don’t set it too high if you rely on fast invalidation.
  • Payload size cap is silent. Entries larger than MaximumPayloadBytes are skipped (with a warning log), not stored. If your hot-path cache value is suddenly 1.5 MB, lookup will miss every time. Log-search for “payload too large” warnings before assuming the cache is broken.
  • One multiplexer, two consumers. Caching and DataProtection key persistence share the same IConnectionMultiplexer. The SignalR backplane (AddHeroRealtime) reads the same CachingOptions:Redis string but opens its own connection — so a multi-instance host with realtime enabled holds two Valkey connections, not one.
  • DistributedMemoryCache is per-process. Two instances of the API can’t see each other’s cache. This is intentional for test fixtures; if you accidentally ship it to multi-instance prod, multi-tenant cache invalidation breaks. Always check CachingOptions:Redis is set in production config.

Critical files

  • src/BuildingBlocks/Caching/Extensions.cs
  • src/BuildingBlocks/Caching/ObservableHybridCache.cs
  • src/BuildingBlocks/Caching/CachingOptions.cs
  • src/BuildingBlocks/Caching/Telemetry/CachingTelemetry.cs
  • WebFshPlatformOptions.EnableCaching toggle.
  • Multitenancy module — uses HybridCache as the DistributedCacheStore.
  • Chat module — typing-indicator throttle uses the cache.