jonnifyechomq

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.Table owner process Directory GenServer + monitor :echo_store_directory ETS :public :named register/3 insert {name,spec} Process.monitor(owner) :DOWN → delete row tables/0 → [{name, spec}]
# 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

Related in this course