jonnify·redis /redis-patterns/caching/workshop/cache-the-catalog

R1.07.1 · Caching workshop — stage 1

Cache the catalog.

Put a cache-aside read in front of one real consumer surface — the emoji set Codemojex.Guesses.submit/3 checks every guess against. One read path, an L1 ETS hit or a single-flight fill.

Grounding: echo_store/table.ex (fetch/3, launch_flight) · echo_store/keyspace.ex · codemojex/game.ex (Codemojex.Cache.fetch_set) · bcs.4.md (the gate, Valkey).

Three sources of one answer. Select a source to read how fetch/3 resolves it and what it costs.
L1 · ETS hitcaller process miss L2 · GETecc:{cm_emojisets}:id nil fillloader → SET PX EchoStore.Table.fetch/3 — :hit | :l2 | :fill one fetch, three sources, in order
readout · Select a source. A read resolves from L1, then L2, then the loader — in that order, returning the source it answered from.

Static verdicts — L1 hit → :ets.lookup, no owner call · L2 hit → GET frames the version off the front · fill → loader, then SET … PX, both layers.

§1

Two reads, one declared cache

echo_store/table.ex · keyspace.ex

The cache read resolves an emoji set by its branded id. EchoStore.Table.fetch/3 is the whole surface, and it answers from one of three sources, in order: an L1 hit in the caller’s own process, an L2 hit on the shared Valkey, or a fill through the declared loader. The return is {:ok, value, source} with source :hit | :l2 | :fill — the source tag is observability, not ceremony.

The key form is the cache’s own: ecc:{cm_emojisets}:<id>, built by EchoStore.Keyspace.key/2 as "ecc:{" <> table <> "}:" <> id. The table name is hash-tagged inside the braces so every key of one cache lands in one cluster slot the day clustering arrives — a fresh prefix beside the bus’s emq:, never inside it. The id in the key’s value position is checked for shape before any key is composed; a malformed name never reaches the wire.

The read path never enters the table’s owner process on a hit. A hit is a caller-side :ets.lookup against a public, read-concurrent table, plus the kind gate and one atomic counter bump. The owner is consulted only on a miss — and that is the second law: one fill per herd, the next stage’s territory by construction.

fetch/3
The read. Returns {:ok, value, :hit | :l2 | :fill}, {:error, :kind} for a wrong-namespace id, or the loader’s error.
ecc:{cm_emojisets}:<id>
The L2 key form (Keyspace.key/2), the table name hash-tagged for one cluster slot.
the kind gate
byte_size(id)==14 and binary_part(id,0,3)==kind and BrandedId.valid?(id) — refused at the door, before either layer.
L1 ETS
The hit path: a public, read-concurrent table read in the caller’s process at memory speed.

Notes on ValkeyThe L2 row is written with SET … PX — value and expiry in one atomic command — so the second layer expires itself even if every node forgets; the committed gate reads PTTL 300 ms of 300 — valkey.io/commands/set.

§2

Lazy fills, read by read

echo_store/table.ex:391 · bcs.4

A cold cache fills itself from real traffic. The first guess against an emoji set misses, and fetch/3 routes through the owner to a single-flight fill — launch_flight/2 runs GET ecc:{cm_emojisets}:<id>; on {:ok, nil} it calls the declared loader, then writes both layers with SET … PX. Every read after it — until the row expires or is dropped — hits L1 in its own process. The cost of a cold start is a number that falls as sets warm: the loader runs once per fill.

Run a short trace of emoji-set reads below against a cache that starts empty. The first touch of each set is a miss and a fill; a repeat touch within the window is an L1 hit. The readout counts the loader runs the trace caused and the round trips, so the cold-start cost is a number you can see fall as sets warm.

Run a read trace. Cold misses warm into L1 hits; the readout counts the loader runs.
8

A cold cache warms read by read: the first touch of each emoji set misses and fills with SET … PX; every later touch hits L1 at ETS speed. The L1 hit is 40 times cheaper than the L2 round trip it replaces.

§3

The emoji-set read path on EchoStore

codemojex/game.ex · echo_store/table.ex

Take one read: Codemojex.Guesses.submit/3 resolves the round’s emoji set to validate a guess. The call is EchoStore.Table.fetch/3 against the :cm_emojisets table. On a hit it returns from L1 ETS; on a miss the owner launches a single flight that checks L2, falls through to the loader, writes both layers, and replies to the caller — and to any waiter that coalesced behind it — with the one answer. The play API receives the emoji set or, for a code the round’s keyboard does not expose, {:error, :bad_guess} from EmojiSet.valid_guess?/2; the hit-or-miss branch lives in the cache.

# The emoji-set read path — one fetch/3, three sources; the source tag is observability.
{:ok, set, source} = EchoStore.Table.fetch(:cm_emojisets, set_id)
# source :: :hit | :l2 | :fill

# Inside the owner, on a miss — launch_flight/2 (echo_store/table.ex:391):
case Connector.command(conn, ["GET", l2]) do   # l2 = "ecc:{cm_emojisets}:" <> set_id
  {:ok, nil} ->                                  # miss: run the loader, then fill both layers
    {:ok, value, version} = run_loader(loader, set_id)
    {:ok, "OK"} = Connector.command(conn, ["SET", l2, version <> value, "PX", ttl_ms])
    {:fill, value, version}

  {:ok, <<version::binary-14, value::binary>>} -> # L2 hit: frame the version off the front
    {:l2, value, version}
end

The L2 value is framed — a 14-byte branded version prefix in front of the bytes — so a later read can compare the version newer-wins. Both layers are written under one TTL, on the server’s clock for L2 and a jittered monotonic clock for L1. How the loader reads and shapes the source is /elixir, not repeated here.

the application · the read at ETS speed, gated

EchoStore.Table against live Valkey

The hit path and its cost are measured, not assumed — the committed EchoStore gate against the live engine:

bcs.4.md · the EchoStore gate, against live Valkeyfrozen
L1 ETS hit                    1311621 reads/s   ← 762 ns each, caller-side :ets.lookup
L2 GET                        31 us          ← the L1 hit is 40 times cheaper
L2 row written with           SET … PX       ← PTTL 300 ms of 300
the read path                 :hit | :l2 | :fill
the pattern

Cache-aside read: check the cache, and on a miss run the loader and fill. The cache fills lazily, one requested key at a time, and a hit never pays the round trip.

its EchoStore application

EchoStore.Table.fetch/3 runs that read path over the emoji set Codemojex.Guesses.submit/3 checks — :hit | :l2 | :fill — and a wrong-namespace id is refused at the door.

One read path serves every emoji set: one fetch/3, three sources, and a cache that warms from real traffic.

§4

Recap — the base layer is in place

next → R1.07.2

The emoji set now reads through EchoStore. Every lookup is one fetch/3: an L1 ETS hit, an L2 hit, or a single-flight fill that runs the loader and writes both layers with SET … PX. The cache warms lazily and every L2 fill carries a TTL. What it does not yet do is react to a change — a room re-templated with a new emoji set keeps serving its old cached copy until the TTL expires. The next dive drops the L1 row on a change and resolves newer-wins.

§5

References

sources · related

Sources

  • Valkey — SET — SET … PX sets a value and its TTL in one atomic command; the L2 fill of the single-flight load.
  • Valkey — Topics — the engine the EchoStore gate is measured against, Valkey.
  • Redis — GET — the read that opens the L2 path; returns the framed value or nil on a miss.
  • Sanfilippo, S. — antirez weblog — the Redis creator on expiry and treating cached values as a disposable copy.

Related in this course