BitzOrcas application caching is more than an IMemoryCache wrapper. The production composition root registers FusionCache through AddBitzOrcasCaching(): memory is L1, while Redis becomes L2 and the multi-instance invalidation backplane when configured. Application code uses ICacheStore and does not depend directly on FusionCache or Redis.
Runtime shape
Application → ICacheStore → FusionCacheStore → L1 memory └──────→ L2 Redis + backplane- Without Redis, the application can run on L1 for development or a single instance.
- During a transient Redis failure, reads normally degrade to misses and the factory can recompute the value; policy can make this stricter.
Requestscope stays inside the current request and bypasses the distributed layer.MemoryCacheStoreis the single-instance fallback and cannot invalidate tags across instances.
Application port
ICacheStore supports reads, read-through creation, writes, key removal, tag removal, and batch operations. CacheResult<T> distinguishes three outcomes:
| Outcome | Meaning | Caller action |
|---|---|---|
| Hit | A non-null value exists | Use it |
| Negative | Absence was cached deliberately | Do not query the source again |
| Miss | The cache has no conclusion | Query the source or use GetOrCreateAsync |
Negative caching protects missing-record lookups from repeated source calls. Keep NegativeTtl materially shorter than the normal TTL.
// ① Focus on the contract and control flow; keep validation, cancellation, and typed errors explicit.var policy = CachePolicy.Short with{ // ② Tenant scope partitions the key; AreaTag and tags support precise invalidation. Scope = CacheScope.Tenant, AreaTag = "catalog", Tags = ["product", $"product:{productId}"]};
// ③ The factory reads the fact source only on a miss and propagates cancellation.var product = await cache.GetOrCreateAsync( key, policy, token => repository.GetAsync(productId, token), cancellationToken);Keys and isolation
Build a CacheKey through ICacheKeyBuilder; do not concatenate ad hoc strings. The standard shape includes application, environment, version, area, scope, and business parts:
{app}:{env}:v{version}:{area}:{scope}:{parts...}Tenant data must use tenant scope and include the trusted tenantId. Keys are limited to 512 UTF-8 bytes. Never embed raw tokens, email addresses, phone numbers, or other sensitive values.
CacheKeyBuilder injects scope from ICurrentUserAccessor rather than caller-supplied identity:
| Scope | Generated segment | Missing context |
|---|---|---|
| Global | none | allowed |
| Tenant | tenant-{TenantId} | throws |
| User | tenant-{TenantId}:user-{UserId} | throws |
| Client | tenant-{TenantId}:client-{ClientId} | throws |
| Request | req-{UUIDv7} | new key per build |
Request scope skips distributed cache and backplane but still uses the generated in-process key. It is not an automatic per-HTTP-request dictionary; retain the same CacheKey to reuse a value.
Choosing a policy
Built-in presets cover common lifetimes: VeryShort for second-scale hot data, Short for ordinary read models, and Medium or Long for slowly changing data. OneTimeTicket and Idempotency express specialized cases and should not be treated as generic presets.
CachePolicy also controls:
TtlandNegativeTtlfor values and known absence.CacheTimeout, with a 500 ms default and a 5-second maximum.JitterRatioto spread expiration and avoid synchronized source load.HideErrors, enabled by default, to recompute on cache failure.AllowStaleOnErrorandStaleTtlfor controlled stale fallback.AreaTagandTagsfor bounded invalidation.
Invalidation after writes
Commit the business transaction first, then remove affected keys or tags. Design tags around business boundaries such as catalog:products; a global tag that clears everything is rarely useful. In a multi-instance deployment, the FusionCache backplane propagates invalidation to other L1 caches.
// Invalidate the entity and list projection only after the write commits.await cache.RemoveAsync(productKey, cancellationToken);await cache.RemoveByTagAsync("catalog:products", cancellationToken);
// Current invalidation failure is best-effort; event handling must be replay-safe.FusionCacheStore supplies real tag and cross-instance behavior. MemoryCacheStore.RemoveByTagAsync does not provide equivalent tag-index semantics. A Memory-only unit test cannot prove production invalidation.
Failure policy
Ordinary queries usually tolerate fail-open behavior: if the cache fails, use the source. Authorization, tickets, and quotas must not inherit that default blindly. Use their dedicated ports or an explicit fail-closed policy. Before allowing stale data, prove that it cannot cross a permission, price, or state boundary.
TryGetAsync returns Miss on non-cancellation errors unless FusionCacheStore.FailClosedOnGet=true. GetOrCreateAsync separately follows CachePolicy.HideErrors. These are distinct controls.
AllowStaleOnError maps to FusionCache fail-safe. Current StaleTtl maps to FailSafeThrottleDuration; verify the effective retention window rather than assuming FailSafeMaxDuration from the property name.
Health, metrics, and fault injection
CacheHealthCheck set/get/removes an L1 probe. No Redis reports Healthy(L1 only). Registered Redis with ping failure reports Degraded. It does not prove a real cross-replica tag broadcast.
replica A caches → replica B commits and invalidates a tag → A receives backplane notification → A next read reloads the new factRelease tests cover Redis-outage stampede, negative entries, tag invalidation, stale behavior, batch concurrency, and reconnect. Observe hit/miss/negative/error, factory duration, and cache latency by area.
Security and consistency decision
Failed permission, Feature, or relation invalidation may extend stale authorization, so combine short TTL, replayable events, and security tests. One-use tickets, idempotency reservations, and quotas still need dedicated atomic storage even when a cache policy has a specialized name.
Area catalog, warmup, and fingerprint
Business caches are no longer only private tags per module. Framework exposes a unified governance surface:
| Concept | Role |
|---|---|
CacheAreaKeys | Stable public area keys (HTTP / Host UI accept only these) |
[BitzCacheArea] | Sniff production consumers; AreaKey must exist in the catalog |
ICacheAreaCatalog | Runtime source of truth: areas, internal tags, warmup support |
ICacheWarmupContributor | Owner warmup for finite key spaces |
CacheContentFingerprint | Content fingerprint after warmup for Host health |
Cache:Warmup | Optional startup warmup; off by default |
The default read path remains cache-aside (GetOrCreateAsync / TryGet + Set). Warmup is an operations aid, not an authorization or business fact source.
Catalog summary
| AreaKey | Warmup | Notes |
|---|---|---|
settings | Yes | Setting-definition projections |
master-data | Yes | Enabled dictionary groups (default culture) |
translations | Yes | Enabled languages × office=0 |
delivery | Yes | Notification delivery channel merge projection |
identity-organization | Yes | Org directory; requires TenantIds |
identity-users | Yes | Active user collaboration profiles (capped per tenant); requires TenantIds |
identity-security | Yes | Lockout / password policies; requires TenantIds |
workflow | No | Domain port IWorkflowCache; Host may only invalidate |
navigation | No | Menu trees vary by user; no full startup warmup |
authorization | No | Permission / Feature / DataScope / ReBAC decisions; never treat as auth facts |
chat-presence | No | Real-time presence; unbounded keys |
Multi-tenant warmup must call ICurrentTenantAccessor.BeginScope before ORM reads / cache writes. Prefer ICacheKeyBuilder.BuildForTenant for explicit tenant keys so Host / Job paths do not depend on ambient users.
Startup warmup configuration
{ "Cache": { "Warmup": { "EnabledOnStartup": false, "AreaKeys": [ "master-data", "translations", "settings", "delivery" ], "TenantIds": [], "StartupMode": "FillMissing", "MaxDegreeOfParallelism": 2, "AreaTimeout": "00:02:00" } }}EnabledOnStartupdefaults tofalseso SaaS cold starts do not stampede the database.- Empty
AreaKeysmeans everySupportsWarmuparea. - Without
TenantIds, only platform-key areas are appropriate (settings / master-data / translations / delivery); identity-* areas are skipped or rejected. StartupMode:FillMissingonly fills holes; full rebuild is Hostfull-rebuild(confirm tokenREBUILD).
Full matrix, prohibitions, and verification live in the repo architecture note docs/architecture/05-cross-cutting/0511-cache-catalog-warmup-fingerprint.md. Operations APIs: Operations cache governance.
Production checklist
- Redis is configured and cache health is green; multi-instance deployments do not use the memory-only fallback accidentally.
- Tenant keys contain the trusted tenant identifier and no sensitive material.
- TTL, negative TTL, jitter, and source budgets have load-test evidence.
- Every write path has corresponding key or tag invalidation.
- Hit, miss, negative-hit, source, error, and latency metrics are monitored.
- New business cache areas are registered with
CacheAreaKeys+[BitzCacheArea]; only finite key spaces register warmup contributors. - If startup warmup is enabled: AreaKeys / TenantIds are bounded, Staging load-tested, and Host
/operations/cacheverified.
See Caching building block for lower-level types and registration details.