Cache-aside, two layers · dive 01
Declared,
not discovered
A cache that is not in the directory does not exist. Every table registers its spec at start, and the directory monitors each owner.
The first law of the near-cache: you know exactly what caches exist on the node because they
declared themselves. EchoStore.Directory holds the roster.
Each EchoStore.Table calls register/3 at init —
and the directory monitors the owner, so a :DOWN drops the row the moment the
cache leaves the node. No scanning, no discovery, no stale entries. The two tiers that back a declared
cache are L1 (a public ETS table in the caller’s process) and L2 (Valkey, keyed
ecc:{table}:id with the table hashtagged for Cluster placement).
The directory
EchoStore.Directory is a GenServer that owns a public, read-concurrent ETS table
(named :echo_store_directory). Every EchoStore.Table
calls Directory.register/3 during init/1,
passing its name, spec, and its own pid as the owner. The directory inserts {name, spec}
and calls Process.monitor(owner). When the owner exits — crash or normal
stop — the :DOWN message arrives and the row is deleted. The roster is
never stale.
EchoStore.tables/0 returns every live cache as
[{name, spec}], sorted. EchoStore.spec(name) returns
{:ok, spec} | :error — :error means the cache
is not declared, which means it does not exist.
EchoStore.Directory · register, monitor, drop
# EchoStore — tables/0 and spec/1 # The directory is the only source of truth for what caches exist. # tables/0 reads the full directory ETS table and sorts the result. # spec/1 is the single-name variant — :error means the cache does not exist. def tables do case :ets.whereis(@directory) do :undefined -> [] _ -> :ets.tab2list(@directory) |> Enum.sort() end end def spec(name) when is_atom(name) do case :ets.whereis(@directory) do :undefined -> :error _ -> case :ets.lookup(@directory, name) do [{^name, spec}] -> {:ok, spec} [] -> :error # absent from the directory = does not exist end end end
The directory is not a cache — it is a guarantee. What is in the directory exists. What is not in the directory does not.
The two tiers
Every declared cache has two tiers. L1 is a public, read-concurrent ETS table created at
init. L2 is the shared Valkey, addressed through
EchoStore.Keyspace.key/2.
The two tiers · select to compare
# EchoStore.Table — init/1 (excerpt) # L1: a public, read-concurrent ETS table created by the owner process. # :public — any process can call :ets.lookup (reads in the caller's process) # :named_table — addressed by the atom name, not a reference # read_concurrency: true — ETS optimises for concurrent readers :ets.new(name, [:set, :public, :named_table, read_concurrency: true]) # EchoStore.Keyspace — key/2 # L2: a Valkey key of the form ecc:{table}:id # The table name is hashtagged ({table}) so every key of one cache lands # on the same CRC16 hash slot — a multi-key operation on one cache never # crosses a slot boundary (CROSSSLOT error avoided by construction). # The id is shape-checked before any key is composed — a malformed id # raises ArgumentError here, never reaching the wire. def key(table, id) when is_binary(table) and is_binary(id) do unless BrandedId.valid?(id) do raise ArgumentError, "invalid branded id in cache key position: #{inspect(id)}" end "ecc:{" <> table <> "}:" <> id end
L1 is fast because it lives in your process. L2 is shared because it lives in Valkey. The hashtag is the bridge — it tells Valkey Cluster where to put everything for this cache.
The keyspace: ecc:{table}:id
The ecc: prefix is a fresh namespace beside emq: (the
bus’s keyspace) — they never overlap. The {table} hashtag is the
Valkey Cluster placement rule: CRC16 is computed over the bytes inside the braces only, so every key
of one cache hashes to the same slot. The id position is shape-checked by
BrandedId.valid?/1 before the key is composed — a malformed id raises
at the Keyspace boundary, never reaches the wire.
The slot assignment follows the
Valkey Cluster specification: CRC16 of the brace
bytes modulo 16384. For a cache named "rooms" all keys are
ecc:{rooms}:RMM… and all land on one slot. A multi-key
GET/SET within one cache is always single-slot.
The {table} hashtag is a placement promise: every key of one cache
lives on one slot. The ecc: prefix is a namespace promise: the cache never
enters the bus’s keyspace.
Pattern & implementation
The pattern
Cache-aside — declared. The application declares the cache tier before using it. The declaration carries the spec — what kind of id it holds, how long, how large, how coherence works. Discovery is replaced by a roster; a missing cache is an error at call time, not a miss in production.
EchoStore implementation
EchoStore.Directory.register(name, spec, owner) — spec carries
kind, ttl_ms, jitter,
max_size, loader, coherence.
EchoStore.spec(name) is the call-time guard. A :DOWN
from the owner removes the row; the roster never goes stale.
The BCS law applied to caches: what exists is what has declared itself. The directory is not a map of what might exist — it is a live view of what does.
References
Sources
- Erlang/OTP — the ets module — the public, read-concurrent L1 table a hit reads in the caller’s process.
- Valkey — Cluster specification — the CRC16 hash-slot rule the
{table}hashtag satisfies. - Söderqvist — A new hash table (Valkey, 2025) — the L2 the near-cache fronts.
- 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.
- The Cache — the chapter landing for pillar III.
- The Protocol — the branded-id gate the Keyspace shape-check extends.
- The Bus — the
emq:keyspace thatecc:stands beside. - The Branded Component System · the Store — the manuscript chapter this module realizes.