Caching
How the kit caches: HybridCache over Redis or memory, the key and tag conventions, what is cached and for how long, adding a cached read to a slice, tests, Redis sizing and eviction, and what happens while Redis is down.
The api and the worker cache through HybridCache: each process keeps a first level in memory, and Redis is the second level every replica shares. A read goes through the cache; the write that changes the data drops the entries by tag, so the next read goes to the database. The package is packages/caching.
Keys and tags
Every key is built by ColdenCacheKeys, so it carries the area and the organization it belongs to; every entry has tags, by which a write drops it.
| Helper | Produces | Use |
|---|---|---|
ColdenCacheKeys.ForTenant(area, tenantId, name) | colden:{area}:{tenantId}:{name} | data of one organization |
ColdenCacheKeys.Global(area, name) | colden:{area}:global:{name} | data shared by all organizations |
CacheTags.ForTenant(tenantId) | tenant:{tenantId} | everything of one organization |
CacheTags.ForEntity<T>(id), CacheTags.ForEntity(type, id) | entity:{type}:{id} | one entity (a person: entity:user:{identity}) |
An organization's data never sits under a global key, and a key never holds what another organization may not read. RemoveByTagAsync drops the tag's entries in Redis and in the process that made the change; another replica may serve its memory copy until the copy's local lifetime ends, which is why the kit keeps local lifetimes at a minute.
What is cached
| Entry | Key | Dropped by | Lifetime (memory / Redis) |
|---|---|---|---|
| The signed-in person with their memberships | colden:users:global:external:{identity} | a membership change, the identity's email or name, account deletion, make-superadmin | 1 min / 10 min |
GET /v1/me | colden:me:global:{userId} | the same, a profile change, a change of one of their organizations | 1 min / 10 min |
| An organization's state (the suspension check) | colden:organizations:{organizationId}:status | suspension, reinstatement, deletion | 1 min / 10 min |
GET /v1/organizations/{organizationId} | colden:organizations:{organizationId}:profile | a change of its name, slug, logo or settings, suspension, deletion | 1 min / 10 min |
| The feature flags, an organization's overrides | colden:feature-flags:global:all, colden:feature-flags:{organizationId}:overrides | any change of a flag or an override | 1 min / 10 min |
| Stripe's prices of the plan catalog | colden:billing:global:prices:{fingerprint} | their lifetime | Billing:PriceCacheMinutes (60) |
The identity server's sessions (whoami) | per session token | the lifetime | Ory:SessionCacheSeconds (60) |
An entry that names no lifetime takes Caching:DefaultLocalExpirationMinutes (5) in memory and Caching:DefaultDistributedExpirationMinutes (30) in Redis. Keep a lifetime short when no write can drop the entry (data that changes elsewhere), and longer when every write drops it.
Adding a cached read to a slice
-
Name the key with
ColdenCacheKeysand choose the tags: the organization's tag for its data, an entity's tag when one record's change must drop it. -
Read through the cache, with the state the factory needs passed in (no closure), so the factory is static:
var summary = await cache.GetOrCreateAsync( ColdenCacheKeys.ForTenant("projects", tenantId, "summary"), (Db: db, Tenant: tenantId), static async (state, token) => await ProjectSummary.ReadAsync(state.Db, state.Tenant, token), new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(10), LocalCacheExpiration = TimeSpan.FromMinutes(1) }, [CacheTags.ForTenant(tenantId), "projects"], cancellationToken); -
Drop the tags in every write that changes the data, after it commits:
await cache.RemoveByTagAsync("projects", cancellationToken). -
Cache a value that serializes as JSON (a record), never an entity the context tracks, and nothing that depends on the request (the caller's role, the session's assurance): set those on the copy you return.
-
Prove it with
EfCommandCounter: the second read issues no query, and the write's next read shows the change (asHotReadPathTestsdoes).
Tests and local runs
Caching:Provider Memory keeps the second level in the process: the test hosts use it, and so can a host run without Redis. The commands of the api binary run with it too. Each test host has its own cache, so tests never see each other's entries.
Redis sizing and eviction
Redis holds the second level of the cache and the rate limiter's windows; everything in it has a lifetime and can be rebuilt, so the in-cluster Redis keeps nothing on disk (--save "", no append-only file) and evicts the least recently used keys when it is full (redis.maxmemory, redis.maxmemoryPolicy allkeys-lru). The chart's default gives it 200 MB within a 256 MiB container limit, enough for tens of thousands of organizations' entries; raise both together, and keep maxmemory about 80 % of the limit. An external Redis (Azure Cache, ElastiCache, a managed Redis) needs the same policy and no persistence.
Watch the evictions (evicted_keys in INFO stats): a steady count means the cache is too small, and the database takes the reads the cache misses.
While Redis is down
The hosts start and keep serving while Redis is down: the client reconnects on its own (AbortOnConnectFail off), HybridCache serves from each process's memory and reads the database on a miss, and the rate limiter lets requests through. What degrades:
- each replica caches on its own, so an entry dropped on one replica can be served by another until its memory lifetime ends (a minute);
- the database takes more reads;
- the rate limits stop counting until Redis is back.
The redis check reports Degraded on /health/ready, which keeps the pods in service; alert on it.