Cache-aside, two layers · dive 02
The cache-aside read
The read path never enters the owner. A hit is a caller-side lookup. A miss is the only owner call — and the answer is always tagged with where it came from.
fetch/3 is the single entry point for every cache read. Before either
layer is touched, the kind gate runs: wrong namespace, wrong size, invalid Base62 —
refused at the door. A hit is a :ets.lookup in the caller’s own process,
costing nothing but the lookup. Only a miss reaches the owner, which runs a single-flight fill —
a GET ecc:{table}:id against L2, then the declared loader.(id)
if L2 is also empty. The answer is tagged with the source: :hit,
:l2, or :fill. The L2 value is the frame
version <> value — split on read so the answer carries its own
mint-time version.
The kind gate
Every EchoStore.Table declares a kind — a 3-byte
namespace that every id stored in it must carry as its first 3 bytes. gate/2
enforces this before either layer is touched.
# EchoStore.Table — gate/2 (private) # The kind law runs before any layer is touched. # Three conditions, all must hold: # 1. id is a binary # 2. byte_size(id) == 14 (a branded id is always 14 bytes) # 3. the first 3 bytes match the table's declared kind (e.g. "RMM") # 4. BrandedId.valid?/1 passes (valid Base62 encoding of a snowflake) # A wrong-namespace id returns {:error, :kind} — never touches L1 or L2. defp gate(kind, id) do if is_binary(id) and byte_size(id) == 14 and binary_part(id, 0, 3) == kind and BrandedId.valid?(id) do :ok else {:error, :kind} end end
The kind law is the BCS contract enforced at the cache boundary: an id that does not belong to this cache is refused before any tier is touched. This is not a runtime check — it is the architecture.
fetch/3 annotated
The full read path, extract-and-annotated from the real source. The L1 lookup is in the caller’s process; the owner call is on the miss path only.
# EchoStore.Table — fetch/3 # The read path. timeout defaults to 10_000 ms (the GenServer.call timeout). def fetch(name, id, timeout \\ 10_000) do case EchoStore.spec(name) do :error -> # The cache is not in the directory — it does not exist. {:error, :no_such_cache} {:ok, spec} -> # Kind gate: wrong namespace → refused at the door, never touches L1/L2. with :ok <- gate(spec.kind, id) do now = System.monotonic_time(:millisecond) case :ets.lookup(name, id) do [{^id, value, expires_at, _version}] when now < expires_at -> # L1 hit: the row exists and has not expired. # This runs in the CALLER'S process — no GenServer call, no mailbox. # Reads scale with the number of schedulers, not one owner mailbox. :counters.add(spec.counters, @counters[:hits], 1) {:ok, value, :hit} _ -> # Miss (absent or expired): ask the owner to fill. # The owner coalesces concurrent misses on the same id onto one flight. :counters.add(spec.counters, @counters[:misses], 1) GenServer.call(name, {:fill, id}, timeout) end end end end
On a miss, the owner’s launch_flight/2 runs a
spawn_monitord task that issues GET ecc:{table}:id
to Valkey. If the result is {:ok, nil} it runs the declared loader and
SETs both layers. If the result is a binary it splits the
version <> value frame and inserts L1. Every waiter on the same id
receives the one answer; a flight crash fails all waiters with
{:error, {:flight_crashed, reason}} — no caller wedges.
The owner is the miss handler, not the read handler. A hit scales freely; a miss is serialised exactly as much as needed — once per id, not once per caller.
Read simulator — source and gate
Pick an id and a scenario. The simulator runs the read logic over a fixed dataset — a small cache with three known entries and one empty slot — and shows the source tag and the gate verdict. All pure JS, no network.
fetch/3 · the read path in order
fetch/3 simulator · fixed dataset, kind = “RMM”
Three outcomes, one entry point. The kind gate fires first — no layer is touched for a wrong-namespace id. An expired row is a miss — monotonic time is the truth, not presence in the table.
Pattern & implementation
The pattern
Cache-aside read. The application reads the cache first. On a miss it goes to the source of truth, fills the cache, and returns the value. The cache is a tier, not the authority. The application decides what to cache and how to fill it.
EchoStore implementation
fetch/3 tries L1 (caller-side :ets.lookup),
then on a miss the owner runs a single flight: GET ecc:{table}:id →
loader.(id) if nil. The answer is tagged
:hit | :l2 | :fill. The kind gate enforces the namespace contract at the
door. The L2 frame version <> value makes every cached value
self-describing.
The pattern says “try the cache, fill on a miss.” The implementation says who tries the cache (the caller, for free), who fills it (the owner, once per id), and what a fill carries (a version the write already placed in the frame).
References
Sources
- Erlang/OTP — the ets module — the public, read-concurrent L1 table
fetch/3tries first, in the caller’s process. - Valkey — GET — the single command a flight issues to probe L2.
- Valkey — SET — the command a fill uses to write back to L2 with
PX ttl_ms. - King — Announcing Snowflake (2010) — the mint-time id the L2 frame carries as
version. - Helland — Life Beyond Distributed Transactions — the entity addressed by a key, cached close to its use.
Related in this course
- Cache-aside, two layers — the module hub.
- Declared, not discovered — the directory and the two tiers this read path runs over.
- Writing both layers — how the version that fills the L2 frame gets there.
- The Cache — the chapter landing for pillar III.
- The Branded Component System · the Store — the manuscript chapter this module realizes.