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

7.8 KiB

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; tests in 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 DELs 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 singleflightasyncio.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 cachecache_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.
  • #73 — design issue and TODO list for follow-up cache integrations beyond infrastructure.