brust 2.0.0-alpha.5
Cache adapters / Overview

Cache adapters

A cache adapter adds an optional shared second tier behind brust’s in-process page cache.

It does two jobs:

  1. Store. It keeps rendered pages where every process or isolate can read them.
  2. Broadcast. It tells every other instance when cache.invalidate(...) ran, so they drop the stale entries.

An adapter is a plain TypeScript object. brust ships adapters for Redis and Dragonfly and for Cloudflare KV and Durable Objects, and you can write your own. With no [cache] table in brust.toml, nothing here is active: behaviour and bytes are the same as without this feature.

The two tiers

request -> tier 1: in-process cache (microseconds)  --hit-->  200, x-brust-cache: HIT
              | miss (cacheable route, no bypass, no Set-Cookie)
              v
           tier 2: adapter store.get(key)  --hit-->  copied into tier 1, 200, x-brust-cache: HIT-L2
              | miss, timeout or error
              v
           loader -> jobs -> render -> tier 1 insert -> tier 2 store.set (in the background)
  • Tier 1 is always there: the Rust cache of the process (native) or of the isolate (Workers). The adapter is never called on a tier-1 hit.
  • Tier 2 exists only with an adapter that has a store. Only rendered page bodies are shared. Job results stay in tier 1. A key invalidation (job user keys) is broadcast, but the store ignores it.
  • A cache.invalidate(...) call clears tier 1 of the process that ran it at once. The adapter then deletes the shared entries and broadcasts the event. Other instances replay it into their own tier 1.

When to use one

Use an adapter when:

  • you run several brust processes or hosts and a mutation must show up on all of them quickly;
  • you run on Cloudflare Workers, where every isolate has its own tier 1 and cache.invalidate is isolate-local (see Cache on KV and DO);
  • renders are expensive and you want a warm shared cache after a restart.

Do not use one for a single native process: tier 1 already does the job, and a miss then pays an extra round trip.

An adapter never makes a request fail. A get that errors or exceeds get_timeout_ms counts as a tier-2 miss and the page renders normally.

Configuration

brust.toml
[cache]
adapter = "custom"             # redis | dragonfly | cloudflare-kv | cloudflare-do | custom
module = "lib/cache.ts"        # custom only: a file inside the app
broadcast = "cloudflare-do"    # optional: take the broadcast from another built-in adapter
namespace = "my-app"           # default: package.json "name", else "brust"
get_timeout_ms = 50            # 1..1000; default 50 on native, 100 on Workers
max_body_bytes = 1048576       # 1..67108864; larger pages are not written to tier 2
epoch_interval_ms = 1000       # 250..60000; pull broadcasts only (Workers)

[cache.options]                # passed to a custom adapter's factory
KeyDefaultMeaning
adapternone (no tier 2)One of the five names. Required when [cache] exists.
modulecustom only. The default export is a CacheAdapter or a factory (options) => CacheAdapter. A built-in adapter with module is a build error.
broadcastA built-in name. The store comes from adapter, the broadcast from this one (for example KV store plus Durable Object broadcast).
namespacepackage name1..64 characters from A-Za-z0-9@._/-. Shared entries are scoped by it.
get_timeout_ms50 / 100The most a request waits for tier 2.
max_body_bytes1048576Bodies above it are not written.
epoch_interval_ms1000How often an isolate asks a pull broadcast for new events, at most. Lower means fresher and costlier (see the cost tables on the Cloudflare page).
[cache.redis], [cache.cloudflare]Options of the built-ins: Redis, Cloudflare.
[cache.options]Options of a custom adapter.

Rules the build enforces:

  • Unknown keys are an error.
  • Keys of every shared entry include a build id, a hash of your manifest, templates, jobs.js and actions.js. A deploy therefore starts with an empty tier 2; old entries expire by their TTL. Plan for a cold cache after each deploy.
  • A built-in adapter is an opt-in package. If it is not installed the build says so, for example adapter "redis" needs @brust/cache-redis (bun add @brust/cache-redis).
  • env:NAME values in a built-in’s option table are read from the process environment at start (native). A missing variable stops the start and names the variable, never its value. On --target cloudflare, an env: value is a build error: use a binding. Options of a custom adapter are passed as written; read the environment yourself.
  • Native accepts push broadcasts only. Workers accept pull broadcasts only.

Runtimes

Native (brust start)Workers
Where the adapter runsin every Bun workerin the Worker (bundled into brust-app.js)
Broadcast received byworker 0 of each processeach isolate, by polling
Write to tier 2background; dropped when no worker is freewaitUntil, after the response

Semantics per backend

What each backend gives:

Redis / DragonflyKV aloneDurable ObjectKV + Durable ObjectCustom
Runs onBunWorkersWorkersWorkerswhat runtimes says
Storeshared bodies, expire by TTLshared bodies, long-TTL onlyoptional (do_store), 16 shardsKV bodiesoptional
Stale-write guardexactnone: a stale body can live up to its TTLexactfloors in each isolatewhatever you implement
Broadcastpush, immediatenonepull, ordered ringpull, ordered ringpush or pull
Other instances after cache.invalidateabout pub/sub latencyup to the TTLwithin about epoch_interval_ms plus one round tripsamedepends
On failurefail-open; a gap clears tier 1fail-openfail-open; a restart clears tier 1 oncefail-open

KV is a long-TTL store, not an invalidation channel. KV writes are visible in other locations “up to 60 seconds” later (source C9 on the Cloudflare page), and KV has no compare-and-set, so brust cannot refuse a stale write. The KV adapter therefore refuses to store pages with a TTL below 300 seconds by default, and the docs advise pairing it with the Durable Object broadcast (broadcast = "cloudflare-do"). Alone, a deleted page can still be read for a while from other locations.

A lost invalidation (publish failed, or the broadcast was down) leaves other instances stale for at most the page’s TTL. Gaps the adapter can detect clear tier 1 instead. One blind spot on native: if the last event is lost and nothing follows it, no gap is seen until the next event, so the bound is the TTL.

Failure behaviour and stats

Everything on the request path has a budget and fails open:

OperationOn the request path?BudgetOn failure
tier-1 probeyesmicrosecondsnone
store.get (tier-1 miss)yesget_timeout_mstier-2 miss; no write follows
pull poll (since)Workers: the first request after epoch_interval_ms, one at a time500 msserve tier 1; poll_errors +1
store.setnonative: dropped if no worker is free; Workers: waitUntilcounted, never an error to the client
store.invalidate and publishnopublish_errors +1; others stale up to the TTL

A tier-2 hit answers with x-brust-cache: HIT-L2; a tier-1 hit stays HIT. A stored page’s TTL is what is left of its original TTL, so a page never lives longer in total than its route’s ttl_seconds.

With an adapter configured, /_brust/cache/stats gains an l2 object. Without one, the JSON is unchanged. On Workers the path needs [cloudflare] stats = true, and the numbers are per isolate.

/_brust/cache/stats
"l2": { "adapter": "custom", "hits": 0, "misses": 0, "timeouts": 0, "errors": 0, "rejected_floor": 0,
        "sets": 0, "set_refused": 0, "set_dropped": 0, "set_oversize": 0, "published": 0, "publish_errors": 0,
        "received": 0, "skipped_own": 0, "resets": 0, "epoch_polls": 0, "poll_errors": 0 }
CounterMeaning
hits, missestier-2 lookups that found or did not find a valid page
timeouts, errorslookups that exceeded get_timeout_ms, or failed
rejected_floorpages found but discarded because an invalidation newer than the page was already seen (Workers)
sets, set_refused, set_dropped, set_oversizewrites done, refused by the store’s guard, dropped for lack of a free worker, skipped for size
published, publish_errorsinvalidations broadcast, and broadcasts that failed
received, skipped_ownevents replayed into tier 1, and events skipped because this instance sent them
resetstimes tier 1 was cleared because of a gap, a new gen, or falling behind
epoch_polls, poll_errorspull polls made and failed

What v1 does not do

  • No stampede protection. When a page expires, every instance that misses renders it. There is no cross-instance lock or render coalescing. A process-local single-flight is a possible follow-up.
  • No entry signing. The store is trusted. Entries are scoped by namespace and build id only. If other parties can write to your Redis, they can put pages into your cache. Restrict access to it.
  • No sharing of job results. Only page bodies are shared.
  • No streaming bodies.
  • No Redis on Workers. There is no TCP client a Worker can rely on. Use a custom adapter over an HTTP-based service.
  • No KV or Durable Object adapter on native.