Deep guide to Redis-backed route cache behavior: key building, dynamic tag specs, scoped invalidation, and performance tuning.
The cache subsystem has three main pieces:
| Component | File | Role |
|---|---|---|
| Manager | internal/core/cache/manager.go |
Key build, Redis get/set, tag version token fetch, tag version bumps |
| CacheRead policy | internal/core/policy/cache.go |
Route middleware for read-through cache |
| CacheInvalidate policy | internal/core/policy/cache.go |
Route middleware for scoped version bumps after successful writes |
Caching is route-level and opt-in.
Required:
CACHE_ENABLED=true
REDIS_ENABLED=trueOptional tuning:
| Env var | Default | Description |
|---|---|---|
CACHE_DEFAULT_MAX_BYTES |
262144 |
Max response body bytes stored when route MaxBytes is not set |
CACHE_FAIL_OPEN |
true in non-prod, false in prod |
Bypass cache on Redis failure (or fail request when false) |
CACHE_TAG_VERSION_CACHE_TTL |
250ms |
Process-local TTL for cached tag version tokens |
Notes:
- TTL is route-level (
CacheReadConfig.TTL). - Read key prefix is
cache:{env}:.... - Tag version key prefix is
cver:{env}:....
cache:{env}:{route_part}:{short_hash}
Example:
cache:prod:/api/v1/projects/{id}:2df708dc47c207792eaf2cf732445d75
The hash part is computed from a canonical string assembled in manager code. The canonical string includes selected VaryBy dimensions plus tag version token.
Canonical parts are appended in deterministic order:
route=...method=...whenVaryBy.Methodproject=...whenVaryBy.ProjectIDuser=...whenVaryBy.UserIDrole=...whenVaryBy.Rolepath.{name}=...for configured path paramsheader.{name}=...for configured headersquery_hash=...for configured query paramsauth=allowedwhen principal exists andAllowAuthenticatedis truetags=...token from resolved tag specs and Redis versions
Then:
SHA-256(canonical)is computed- first 16 bytes are hex-encoded as
short_hash
Static tag arrays were replaced with structured TagSpecs.
TagSpecs []cache.CacheTagSpecTagSpecs []cache.CacheTagSpectype CacheTagSpec struct {
Name string
PathParams []string
ProjectID bool
UserID bool
Literals []cache.CacheTagLiteral
}V1 supports only:
- path params
- auth project id
- auth user id
- literal key/value dimensions
Query/header-derived tag params are intentionally blocked in v1 to avoid cardinality explosion.
VaryBydefines who gets separate cache entries.TagSpecsdefine which entries get invalidated together after writes.
No data bleed is handled by VaryBy.
Freshness on writes is handled by tag version bumps.
- Read route computes effective tag names from
TagSpecsand request context. - Manager fetches current versions from Redis (
MGET cver:{env}:{tag}) and includes token in key hash input. - Write route succeeds (2xx),
CacheInvalidateresolves effective tag names and callsINCRper tag version key. - Next read sees changed tag version token, canonical string changes, key hash changes, cache miss occurs, fresh value is stored.
This is called bump-miss invalidation.
Use precise scopes to avoid over-invalidation.
| Route type | Recommended tag spec |
|---|---|
| Detail endpoint | Name: "project", PathParams: ["id"] |
| Project list endpoint | Name: "project-list", ProjectID: true |
| User self endpoint | Name: "user-profile", UserID: true |
| Cross-entity list | Name: "dashboard-list", ProjectID: true, Literals: [{Key:"view",Value:"summary"}] |
When write can affect both detail and list responses, bump both scopes.
Example for project update:
policy.CacheInvalidate(m.cacheMgr, cache.CacheInvalidateConfig{
TagSpecs: []cache.CacheTagSpec{
{Name: "project", PathParams: []string{"id"}},
{Name: "project-list", ProjectID: true},
},
})This invalidates the updated project detail and project list keys without evicting unrelated projects from other scopes.
policy.CacheRead(m.cacheMgr, cache.CacheReadConfig{
TTL: 30 * time.Second,
TagSpecs: []cache.CacheTagSpec{
{Name: "project-list", ProjectID: true},
},
VaryBy: cache.CacheVaryBy{
ProjectID: true,
QueryParams: []string{"limit", "cursor"},
},
})policy.CacheRead(m.cacheMgr, cache.CacheReadConfig{
TTL: 60 * time.Second,
TagSpecs: []cache.CacheTagSpec{
{Name: "project", PathParams: []string{"id"}},
},
VaryBy: cache.CacheVaryBy{
ProjectID: true,
PathParams: []string{"id"},
},
})policy.CacheInvalidate(m.cacheMgr, cache.CacheInvalidateConfig{
TagSpecs: []cache.CacheTagSpec{
{Name: "project", PathParams: []string{"id"}},
{Name: "project-list", ProjectID: true},
},
})policy.CacheRead(m.cacheMgr, cache.CacheReadConfig{
TTL: 30 * time.Second,
TagSpecs: []cache.CacheTagSpec{
{Name: "user-profile", UserID: true},
},
VaryBy: cache.CacheVaryBy{UserID: true},
AllowAuthenticated: true,
})Cache write is bypassed when:
- Method is not allowed (
Methods, default GET/HEAD) - Status is not cacheable (
CacheStatuses, default 200) - Body exceeds
MaxBytes - Response has
Set-Cookie - Response is streaming/hijacked
Auth safety rule still applies:
- authenticated route cache requires
VaryBy.UserIDorVaryBy.ProjectIDunlessSharedAuthenticatedis set to true for invariant authenticated responses.
On Redis failures:
- fail-open: bypass cache and continue handler
- fail-closed: return dependency-unavailable response
In prod/prodution-like environments, startup lint rejects CACHE_FAIL_OPEN=true when cache is enabled.
Per-route override:
failOpen := false
policy.CacheRead(m.cacheMgr, cache.CacheReadConfig{
TTL: 30 * time.Second,
FailOpen: &failOpen,
})Cache outcomes emitted as metrics labels:
- hit
- miss
- set
- bypass
- error
Inspect keys:
redis-cli KEYS "cache:dev:*"
redis-cli KEYS "cver:dev:*"Check one version key:
redis-cli GET "cver:dev:project|path.id=proj_123"Force a manual bump:
redis-cli INCR "cver:dev:project|path.id=proj_123"- static key parts and normalized tag specs are prepared at route registration
- route label is memoized per resolved route pattern in policy runtime
- tag version tokens are cached in-process for
CACHE_TAG_VERSION_CACHE_TTL - successful bump clears process-local token cache immediately
Tune CACHE_TAG_VERSION_CACHE_TTL low for very high-cardinality dynamic tags.