jonnify·redis /redis-patterns/caching/cache-aside/miss-fill

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).

Trace a read — hit or miss. Select an outcome to light its path and read the round trips it makes.
application Valkey cacheecc:{quotes}:… the source GET value read SET PX
readout · Select a read outcome. A hit returns straight from the cache; a miss reads the source, then fills with SET PX.

Static verdicts — a hit → one round trip (GET), source not read · a miss → two round trips (GET, then SET … PX), the source read once.

§1

One GET, then a branch

cache-aside.md.txt · How It Works

The 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 nil when 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.

§2

Filling with an expiry, never without

cache-aside.md.txt · Redis Commands Used

The 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.

Read a cache key — cold misses, warm hits. Select a key and a fill TTL; the readout runs the read.
300

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.

§3

Miss-fill on EchoCache

echo_cache/table.ex:391 · launch_flight

Take 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.

the application · the fill, measured against live Valkey

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:

content/bcs4.1.md · the read path, three sources of one answerfrozen
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
the pattern

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.

its EchoCache application

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.

§4

Recap — ask, branch, fill

next → R1.01.2

A 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.

§5

References

sources · related

Sources

Related in this course