Accepted
ADR 0012 — Surrogate-key cache and global cache epoch
Named surrogate keys for targeted purges, plus a cache epoch for cross-colo invalidation without a zone
Cusp's public render path is a hybrid pipeline: Cloudflare's Worker-runtime Cache API in front of pre-rendered HTML in R2, with live rendering as the fall-through. Two mechanisms keep the cache correct: a named surrogate-key namespace for targeted purges, and a global cache epoch for site-wide invalidation that has to reach every colo.
Surrogate keys: a named namespace
Every cached HTML response carries one or more surrogate keys. The namespace is fixed so contributors never invent ad-hoc keys per route. Keys are cumulative — a document page carries its document key, its collection listing key, plus the site-wide theme, settings, and nav keys and a key per tag.
- doc:{id} — the document's page and version list/detail. Purged on publish, unpublish, republish, or delete of that document.
- listing:{collection} — collection index pages. Purged on any publish, unpublish, or delete in the collection.
- tag:{slug} and taxonomy:{slug} — tag and taxonomy pages. Purged when terms change.
- sitemap and feed — the sitemap and RSS endpoints. Purged on any change to the set of published documents.
- theme, settings, nav — stamped on every cached HTML response, because they affect every page. Purged on theme switch, render-affecting settings save, or menu change.
- plugin:{pluginId} — pages a plugin contributes to. Purged when the plugin is uninstalled.
Purge mechanism
Purges use the Worker-runtime Cache API together with a KV registry, not the Enterprise-only HTTP Tag Purge API. When a response is cached, its URL is appended to a KV registry entry under cache-keys:{key} for each surrogate key it carries. To purge a key, the system reads its registry entry and deletes each URL from the cache. This works on the free and paid plans alike.
The shared purge helpers live in src/worker/render/purge.ts. purgePublicArtifacts handles per-path invalidation from the publish workflow's final step and the unpublish route; purgeAllPrerenders drains the entire rendered/ R2 prefix for site-wide changes (theme activation, theme-token writes). Both purge R2 first, then the Cache API, then walk the KV registry — the reverse order would let an in-flight miss re-cache stale R2 content. The registry is best-effort: a missed write heals within the stale-while-revalidate window.
Per-route cache lifetimes
- Public document HTML — public, max-age=0, must-revalidate, s-maxage=3600, stale-while-revalidate=604800. The browser revalidates every navigation; the edge holds it for an hour.
- Public listing HTML — public, max-age=0, must-revalidate, s-maxage=600, stale-while-revalidate=86400.
- Sitemap and feed — public, max-age=3600, stale-while-revalidate=86400.
- Headless API JSON — private, no-store. The surface is token-scoped and scope-filtered per token, so a response is never shared at the edge and is not held for reuse at all.
- Content-hashed media — public, max-age=31536000, immutable (no surrogate key; the URL is content-addressed).
- Authenticated and preview-token responses — private, no-store (never cached).
The global cache epoch: cross-colo invalidation without a zone
The Cache API's delete is colo-local: caches.default.delete() only evicts in the colo that ran the deleting request. Behind a Cloudflare zone you can issue a zone-level purge that fans out across colos. A default workers.dev deploy has no zone, so a surrogate purge run in one colo never reaches the others.
That gap produces a real failure: after the example-site seed, a colo that cached the empty pre-seed home page keeps serving it, even though R2 and live rendering both hold fresh content. The only lever available without a zone is to change what counts as a cache hit.
The fix is a global cache epoch — a monotonically increasing integer in KV, stamped onto every cached public response as an x-cusp-cache-epoch marker header. The Cache API key stays the bare URL, so the colo-local surrogate purges still target it. On read, a cached entry is served only if its marker matches the current epoch; a mismatch is treated as a miss and the request re-renders. Bumping the epoch makes every colo's prior cache copy mismatch at once.
// src/shared/cache/cache-epoch.ts
// getCacheEpoch(kv) -> reads the int; isolate-memoized ~5s; never throws
// bumpCacheEpoch(kv) -> writes current + 1, updates the memo, returns it
//
// src/worker/routes/public.ts
// epochStamped(res, epoch) -> clone with x-cusp-cache-epoch set, for cache.put
// freshFromCache(cached, epoch) -> serve only if the marker matches, else missWhen the epoch bumps
Only on genuinely site-wide events, because a bump invalidates the whole public cache:
- Completion of the example-site seed and the first-run install.
- Render-affecting settings, nav, and menu changes.
- Theme-token writes and theme activation.
Single-document publishes do not bump the epoch. They keep the fine-grained colo-local surrogate purge plus stale-while-revalidate, which is the right cost for a per-document change.
Consequences
- Site-wide events invalidate the public cache in every colo within the memo window plus KV's consistency lag, even with no zone.
- Hot-path cost is roughly one KV read per isolate per memo window (~5s); the memo absorbs the rest. A failed read degrades to the last-known epoch, never a request failure.
- Old-epoch cache entries orphan harmlessly and age out via their per-route TTL — no active cleanup.
- No new binding or infrastructure — the epoch lives in the existing KV namespace under cache:epoch.
Read the rendering and cache noteConsidered and rejected: a zone-wide purge on every publish (defeats the cache, including media, and needs a zone Cusp does not assume); Enterprise HTTP Tag Purge as the default (not available on the plans Cusp targets); and serving from R2 on every request with no edge cache (regresses performance even on a hit).