Files
KohakuHub/docs/development/cache.md
narugo1992 aff9fd47ef perf(cache): add Valkey-based L2 cache infrastructure
Introduces the prerequisite cache layer tracked in #73. No business code
yet consumes the helpers — this is plumbing only, gated by
KOHAKU_HUB_CACHE_ENABLED (default: false). Subsequent issues will adopt
specific cache patterns on top of this foundation.

Why now: every hot read endpoint currently makes 1–3 LakeFS REST calls
plus several Postgres queries per request, with the only existing cache
being a per-process cachetools.TTLCache in fallback/cache.py — useless
across the default 4-worker uvicorn deployment.

Design highlights (full design in docs/development/cache.md):

- Pure cache, no source-of-truth state. Silent-degradation contract:
  every cache call is wrapped in try/except and falls back to L3 when
  Valkey is unreachable. CI runs a dedicated cache-disabled job to
  regression-guard this.
- L1 (per-worker cachetools) restricted to immutable / content-addressed
  data only — multi-worker uvicorn has no portable cross-worker
  invalidation channel, and constraining L1 to "key contains its own
  version" sidesteps that entirely.
- L2 (Valkey) holds everything. Helpers ship with TTL jitter (±15%
  default), two-level singleflight (asyncio.Lock + Valkey SET NX EX),
  negative cache, generation counters, and per-namespace metrics.
- Persistence: RDB on, AOF off, persistent volume. Mode-A
  (lakefs:commit, lakefs:stat, lakefs:list — commit_id-keyed) survives
  restart safely. Mode-B (mutable) namespaces are flushed on every
  Valkey restart by a run_id-based bootstrap coordinator that
  serializes the flush across workers.

Includes:

- src/kohakuhub/cache.py — the helper module (319 stmts, 83% coverage
  via the new test module).
- src/kohakuhub/api/admin/routers/cache.py — admin endpoints exposing
  hit/miss/error counters, Valkey memory state, and bootstrap-flush
  metadata.
- test/kohakuhub/test_cache.py — 34 tests against a real Valkey,
  covering: round-trips, TTL jitter spread, SCAN-based prefix delete
  over >SCAN_BATCH_SIZE keys, two-level singleflight (100 concurrent
  calls fold to 1 fetch), bootstrap flush selectivity (Mode-A survives,
  Mode-B is wiped, exactly), two-worker bootstrap coordination, silent
  degradation when Valkey is disabled OR unreachable, generation
  counters, negative cache, the Mode-B prefix list shape contract.
- docker-compose.example.yml — adds the valkey service with RDB +
  LFU + bind-mounted hub-meta/valkey-data, mirroring the persistence
  pattern of the other stateful services.
- scripts/dev/up_infra.sh / down_infra.sh / reset_local_data.sh —
  Valkey container plumbing for local dev (host port 26379).
- .github/workflows/fullstack-tests.yml — adds valkey to the existing
  matrix services and adds a separate single-Python job
  (backend-tests-cache-disabled) running with KOHAKU_HUB_CACHE_ENABLED=false
  as the silent-degradation contract regression guard.
- docs/development/cache.md — the design doc referenced by the cache
  module's docstrings.

Refs: #73

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 19:21:04 +08:00

175 lines
7.8 KiB
Markdown

# Cache layer
KohakuHub uses a Valkey-based L2 cache (Redis-compatible) to absorb traffic
that would otherwise hit Postgres or LakeFS REST repeatedly. The design has
one non-negotiable rule:
> The cache must never be on the correctness critical path.
If the cache is unavailable, the API stays correct — every read falls through
to the source of truth. The cache is allowed to be late, but never wrong, and
its absence is allowed to be slow, but never an outage.
This document covers what is in the cache, what is not, and the invariants
that callers must respect when integrating with it. Implementation lives in
[`src/kohakuhub/cache.py`](../../src/kohakuhub/cache.py); tests in
[`test/kohakuhub/test_cache.py`](../../test/kohakuhub/test_cache.py).
## Architecture
```
L1: per-worker cachetools.TTLCache (~µs)
Immutable / content-addressed data ONLY.
Mutable data is forbidden in L1 — no cross-worker invalidation channel.
L2: Valkey (~ms)
All cacheable data (mutable + immutable).
Cross-worker shared, cross-worker invalidation automatic.
L3: Postgres / LakeFS REST / S3 — source of truth.
```
L1 is intentionally narrow. The default 4-worker uvicorn deployment cannot
synchronously invalidate per-process state across workers; rather than build
a Pub/Sub fan-out (high complexity, low marginal benefit on top of a 1ms L2
hit), we restrict L1 to data that is provably safe to keep stale across all
workers — namely content-addressed entries whose key contains its own version
identifier.
## Consistency model
Two patterns, applied per data class:
### Mode A — Immutable / content-addressed
Key contains the version. Examples:
- `lakefs:commit:{repo}:{commit_id}` — LakeFS commits are SHA-addressed.
- `lakefs:stat:{repo}:{commit_id}:{path}` — same, scoped by commit.
- `lakefs:list:{repo}:{commit_id}:{prefix}` — same, scoped by commit.
These are correct by construction: the key cannot resolve to a different
value over time. TTL is a memory-pressure tool, not a correctness tool. No
active invalidation is needed; LFU eviction reclaims them when memory tightens.
### Mode B — Mutable, real-time consistency required
Two sub-patterns, picked by fan-out:
**B1. Generation counter** — preferred for entities with many derived caches
(Repository, User, Organization).
- The entity table has a `cache_gen BIGINT NOT NULL DEFAULT 1` column.
- Every mutating `db_operations.*` path bumps `cache_gen` in the same
transaction as the business write.
- Reads embed the current gen in their cache key:
`repo:info:{id}:g{gen}`. Old gens are unreachable; LFU reclaims them.
- Avoids the bug class of "forgot to enumerate one of the derived keys
during invalidation."
**B2. Write-through DEL** — used for narrow-fanout entities (Session, Token,
Branch HEAD).
- The mutator path explicitly `DEL`s the affected key(s) **after** DB commit.
- Order matters: DB commit → cache DEL → response. Inverting that ordering
races concurrent reads back into stale state before the new value is durable.
## Persistence and the boot-time flush
The cache container is started with RDB snapshotting enabled (`--save 300 100`,
`--appendonly no`) and a persistent volume. Two reasons:
1. The bulk of cache value lives in Mode-A immutable keys. Discarding them on
every Valkey restart turns routine maintenance into a synchronized 1k-fanout
LakeFS REST burst — exactly the spike the cache exists to prevent.
2. Mode-B keys CAN go stale across a restart (a write whose `DEL` lands during
Valkey downtime is silently lost), so RDB-restored Mode-B values are
suspect.
Resolution: persist RDB, but **flush all Mode-B namespaces on every Valkey
restart**. The list of Mode-B prefixes is the `MODE_B_PREFIXES` constant in
`cache.py`:
| Survives across restart (Mode A) | Flushed on every Valkey restart (Mode B) |
| --- | --- |
| `lakefs:commit:` | `lakefs:branch:` |
| `lakefs:stat:` | `repo:gen:`, `repo:info:`, `org:gen:`, `org:info:`, `user:gen:`, `user:info:` |
| `lakefs:list:` | `uo:`, `sess:`, `tok:`, `list_gen:`, `list:`, `negative:` |
Restart detection uses Valkey's `INFO server.run_id` (changes on every Valkey
process start). The API stores the last-seen `run_id` *in Valkey itself*
(`cache:bootstrap_run_id`); on connect, the worker that wins
`SET cache:bootstrap_lock 1 NX EX 30` performs the flush and updates the
marker. Other workers wait until the marker matches the current `run_id`
before serving Mode-B reads.
This decouples cache flushes from API restarts: redeploying the API against
a long-running Valkey is a no-op for the flush coordinator; a Valkey upgrade
or pod-eviction triggers exactly one flush across all API workers.
## Stampede / eviction defenses
All five must remain in place; each closes a different failure mode.
1. **TTL jitter ±15%** — applied inside `cache_set_json`, no opt-out. Without
this, a synchronized burst of writes all expire at the same instant.
2. **Two-level singleflight**`asyncio.Lock` per cache key inside the
worker, plus Valkey `SET sf:lock:{key} 1 NX EX 5` across workers. Both
required: per-worker alone leaves N workers racing; cross-worker alone
leaves intra-worker contention.
3. **Negative cache**`cache_set_negative()` writes a sentinel under a short
TTL (15s default). Prevents repeated lookups for nonexistent entities from
hammering L3. Critical: when the entity is later created, the create path
MUST `cache_invalidate(...)` the negative key — otherwise the negative
result hides the new entity until TTL expiry.
4. **Refresh-ahead** — allowed *only* on Mode-A immutable entries with
measured high QPS. Forbidden on Mode-B; serving a stale value while
refreshing in the background defeats write-through consistency.
5. **`maxmemory` + `allkeys-lfu`**, no key allowed without TTL (even
immutable entries: cap at 24h). Memory is bounded; eviction is automatic.
## What is NOT cached
These are deliberately uncached, even though they would benefit from the
performance:
| Data | Why not |
| --- | --- |
| `File` row by `(repo, path)` | Preupload SHA256 dedup is strong-consistency-sensitive; a wrong-side cache miss causes silent skip-of-upload. |
| `Quota` / `used_bytes` | Mutated on every write; caching adds bug surface for no win. |
| Presigned S3 URLs | Already short-TTL by design, per-request, per-user — no shared key. |
| Likes / downloads counters | Already updated asynchronously; an extra cache layer adds inconsistency without latency win. |
## Adding a new cached read
1. Decide the mode (A vs B1 vs B2). If you can't decide, the data is
probably not safe to cache without more thought.
2. Pick a key shape that reflects the mode:
- Mode A: include the version in the key.
- Mode B1: include `:g{gen}` and add a generation bump to every mutator.
- Mode B2: pick a deterministic key and add `cache_invalidate(key)` to
every mutator post-DB-commit.
3. Use `cache_get_or_fetch(key, fetch_fn, ttl=...)` from `cache.py`. Do not
call `redis.asyncio` directly — singleflight, jitter, silent degradation,
and metrics all live in the helper.
4. Add a test that writes the underlying entity and immediately reads through
the cached path; assert the new value is returned. This is the regression
guard for the real-time consistency contract.
## Operating
- Admin endpoint `GET /admin/api/cache/stats` returns hit/miss/error
counters per namespace plus Valkey memory state and the last-flush
metadata.
- `POST /admin/api/cache/metrics/reset` zeros the counters without
touching cache contents.
- `KOHAKU_HUB_CACHE_ENABLED=false` disables the cache layer at startup.
All cache calls then degrade to "miss" silently. CI runs a dedicated job
with this flag set, so the silent-degradation contract is regression-tested
on every push.
## Related issues
- [#73](https://github.com/deepghs/KohakuHub/issues/73) — design issue and
TODO list for follow-up cache integrations beyond infrastructure.