R1.01.1 · Cache-aside — dive 1
The read path: miss-fill.
Ask Valkey first. On a hit, serve it. On a miss, read the source and write it back with an expiry, so the next read is a hit.
Grounding: echo_cache/table.ex:391 (launch_flight, verbatim) · keyspace.ex (the ecc: key) · content/bcs4.1.md (the cache surface, against live Valkey).
Static verdicts — a hit → one round trip (GET), source not read
· a miss → two round trips (GET, then SET … PX), the source read once.
One GET, then a branch
cache-aside.md.txt · How It WorksThe read path has exactly one decision: did GET return a value or nil. A value
is a hit — serve it and stop; the source is never touched. A nil is a
miss — read the source of record, then write the value back to Valkey with an expiry
so the cache is warm for the next read.
The fill step is the half that makes the cache earn its keep. Without it, every read would be a miss and Valkey would be dead weight in front of the source. With it, the first read of a key pays the source cost once and every read after it — until the key expires or is invalidated — is served from the cache.
That is why the strategy is called lazy loading: nothing is cached until it is asked for. The cache fills on demand, key by key, driven by real reads — not by a warm-up job that loads data no one requests.
- GET key
- The read. Returns the cached value, or
nilwhen the key is absent or has expired. - hit
- A present value. Serve it; the source of record is not read.
- miss
- A
nil. Read the source, then fill the cache. - SET key value PX ms
- The fill. Write the value and a time-to-live in one atomic command so the key cannot outlive its expiry.
This dive shows the read path filling the cache. How the cache is cleared on a write is the next dive — explicit invalidation.
Filling with an expiry, never without
cache-aside.md.txt · Redis Commands UsedThe fill is one command, and the choice that matters is the expiry. SET key value PX ms sets
the value and a time-to-live together, so the key is born with a deadline. A plain SET with no
expiry fills the cache forever — a key that no invalidation reaches becomes a permanent stale value,
the failure the third dive measures.
Pick a key from the catalog below to run the read. The readout shows the GET result, whether
the source was read, and the SET … PX that fills the cache on a miss. A cold key misses and
fills; a warm key hits and serves.
Static verdicts — a warm key → L1 hit, no source read, no fill · a cold
key → miss, the loader runs once, then SET … PX fills both layers.
A read is one GET and a branch: hit and serve, or miss, read the source, and
SET … PX with a TTL. The cache fills lazily, one requested key at a time.
Miss-fill on EchoCache
echo_cache/table.ex:391 · launch_flightTake one read: a screen asks for a quote. The caller calls EchoCache.Table.fetch/3. A hit is
an :ets.lookup in the caller’s own process; on a miss the owner launches a flight. The
flight is the real miss-fill: GET ecc:{table}:id, and on {:ok, nil} it calls the
declared loader, then writes both layers with SET … PX. The caller receives
{:ok, value, :fill} — or {:ok, value, :l2} when the L2 row was already
present.
# launch_flight/2 (table.ex:391), trimmed — GET, fall through to the loader, fill with PX. case Connector.command(conn, ["GET", l2]) do {:ok, nil} -> # miss: read the source, then fill {:ok, value, version} = loader.(id) Connector.command(conn, ["SET", l2, version <> value, "PX", Integer.to_string(ttl)]) {:fill, value, version} {:ok, <<version::binary-14, value::binary>>} -> # L2 hit: the framed value {:l2, value, version} end
The key is ecc:{<table>}:<id> (EchoCache.Keyspace.key/2), the table
name hash-tagged so the entity lands in one cluster slot. The PX ties the L2 row to its expiry
so a key the next dives never clear still cannot live forever. How the loader reads the source is taught by
/elixir, not repeated here.
Notes on ValkeySET … PX writes the value and its expiry
in one command, so a crash between two commands can never leave an immortal key —
valkey.io/commands/set.
Three sources of one answer
The miss-fill is not assumed — EchoCache.Table.fetch/3 reports its source, and the
committed record walks one name through all three layers against live Valkey:
cold read fill ← loader ran 1, both layers written warm read hit ← caller-side ETS, owner untouched L1 hit reads 1311621 / s ← 762 ns each L2 GET 31 us ← the round trip a hit avoids
Read the cache first; on a miss, read the source and write the value back with an expiry. The cache fills lazily, paying the source cost once per key per TTL.
launch_flight runs GET ecc:{table}:id; a miss calls the loader and fills with
SET … PX. fetch returns the source — :hit | :l2 | :fill.
Miss-fill is the half of cache-aside that warms the cache: one read, one branch, and a fill with an expiry that every later key inherits.
Recap — ask, branch, fill
next → R1.01.2A cache-aside read is one GET and one decision. A hit serves the cached value; a miss reads
the source and fills Valkey with SET … PX so the next read hits. The fill always carries a TTL,
never an unbounded SET. The next dive removes a key on a write — and gets the ordering
right so a concurrent read cannot put the stale value back.
References
sources · relatedSources
- Redis — GET — the read that opens every cache-aside lookup; returns the value or nil on a miss.
- Valkey — SET — set a key with a value and the
PXtime-to-live in one command; the miss-fill move. - Redis — Documentation — strings, expiry, and the lazy-loading read pattern in context.
- Sanfilippo, S. — antirez weblog — the Redis creator on expiry and treating cached values as disposable.
Related in this course
- R1.01 · Cache-asidethe module hub
- R1.01.2 · Explicit invalidationthe next dive
- R1 · Cachingthe chapter
- /echomqthe EchoMQ protocol behind the connector
- /elixir · Statethe functional-Elixir craft behind the loader