R1.01.2 · Cache-aside — dive 2
Invalidate, in the right order.
A write clears the cached key with an explicit DEL. The order matters: write the
source first, delete the key second, or a stale value comes back.
Grounding: echo_store/table.ex:171 (invalidate, verbatim) · coherence.ex:54,75 (the version-safe coherence_drop) · bcs.4.md (the cache surface).
Static verdicts — write, then DEL → final cache empty or new, never stale
· DEL, then write → a concurrent read can SET the old value after the
DEL, resurrected and stale until TTL.
Delete the key, do not rewrite it
cache-aside.md.txt · Mitigating StalenessOn a write to the source, cache-aside removes the cached key with DEL. It does not write the
new value into the cache. The reasoning is plain: the source of record is authoritative, so the cleanest
move is to discard the stale copy and let the next read fill a fresh one — the same miss-fill path the
first dive built.
Updating the cache on a write instead of deleting it is tempting and worse. It duplicates the source’s
write logic in the cache layer, and it widens the race surface: two writes that interleave can leave the
cache holding a value neither write intended. DEL sidesteps all of that. An absent key is never
stale; it only forces one extra source read on the next request.
The other reason to prefer DEL is recovery. If the delete is the last step and it runs, the
cache is correct. If a value-write were the last step and it raced, the cache would be confidently wrong.
Deleting fails safe; rewriting can fail stale.
- DEL key
- Remove the cached key. Returns 1 if it existed, 0 if it was already absent. The next read misses and re-fills.
- invalidate
- The act of removing the cached copy on a write, so the source of record stays the only authority.
- write-then-invalidate
- Commit the source first, then
DEL. The order that prevents a concurrent read from caching the pre-write value. - resurrection
- A stale value written back into the cache after the source already changed — the failure the wrong order causes.
This dive removes the key on a write. The TTL that backstops a DEL that never runs
is the next dive — TTL & staleness.
The resurrection race, step by step
cache-aside.md.txt · the write raceThe danger is a read that overlaps a write. A reader misses, reads the source, and fills the cache. A writer changes the source and deletes the key. If the reader’s fill lands after the writer’s delete, the cache ends up holding the value the reader read — which, if it read before the write committed, is the old one. That stale value then sits in the cache until its TTL expires.
Step through the interleaving below. The same four operations — the writer’s source-write and
DEL, the reader’s GET and SET — run in a chosen order. The
readout names the value left in the cache at the end and whether it is fresh or resurrected.
Static verdicts — fill before DEL → the delete is last, final
cache empty, fresh · fill after DEL → the SET is last, the old value is
resurrected, stale until TTL.
Write the source first, delete the key second. A reader’s fill that lands after the delete caches whatever it read — so the delete must be the last word.
Invalidation on EchoStore
echo_store/table.ex:171 · coherence.ex:54EchoStore offers two invalidations. The unconditional admin verb is Table.invalidate/3
— a DEL on the L2 key plus an :ets.delete on L1, dropping one name from both
layers. The web app receives a success or a typed error; it never holds the cached key or its order. The
functional-Elixir craft behind the write is taught by the
/elixir state chapter.
# Table.invalidate/3 (table.ex:171) — the admin verb: drop both layers unconditionally. def handle_call({:invalidate, id}, _from, state) do l2 = Keyspace.key(state.table, id) Connector.command(state.conn, ["DEL", l2]) # L2: the Valkey key :ets.delete(state.name, id) # L1: the near-cache row {:reply, :ok, state} end
The sharper tool closes the resurrection race by construction. Coherence.drop_l2/4
(coherence.ex:75) runs one atomic Lua script, coherence_drop, that deletes the L2 row
only when the incoming version is newer than the version framed into the stored value
— one transition, one script, so a late stale invalidation can never erase a newer row:
# coherence_drop (coherence.ex:54) — delete L2 only when the incoming version is newer.
local cur = redis.call('GET', KEYS[1])
if not cur then return 0 end
if string.sub(ARGV[1], 4, 14) > string.sub(cur, 4, 14) then
redis.call('DEL', KEYS[1])
return 1
end
return 0
Notes on ValkeyA Lua script runs atomically: its effects land together
or not at all, so the read-compare-delete of coherence_drop cannot interleave with another writer
— valkey.io/topics/programmability.
Both layers, dropped on the write
The invalidation is not assumed — DEL removes the L2 key
(redis.io/commands/del) and the
ecc:{<table>}:<id> hash-tag key lands one cache in one cluster slot. The committed
cache record:
fetch source :hit | :l2 | :fill ← L1 ETS, L2 Valkey, or loader warm read hit, owner untouched L1 hit reads 1311621 / s ← 762 ns each
On a write, commit the source first, then delete the cached key. An empty key is never stale; the next read re-fills it. Deleting fails safe where rewriting can fail stale.
Table.invalidate drops both layers; Coherence.drop_l2 deletes L2 only on a
newer version, so a late stale invalidation can never erase a newer row.
Invalidation is a delete, and the delete comes last: source first, DEL second, so a
concurrent read cannot resurrect the value the write replaced.
Recap — delete, in order
next → R1.01.3A write invalidates the cache with DEL, not a rewrite, and commits the source before it
deletes. That order keeps a concurrent read from caching the pre-write value. An empty key is never stale;
the miss-fill path re-fills it fresh. What remains is the case where the DEL never runs at all
— the next dive backstops that with a TTL.
References
sources · relatedSources
- Redis — DEL — remove one or more keys; returns the count removed. The invalidate move on a write.
- Redis — GET — the read that misses on a deleted key and triggers the re-fill.
- Valkey — Programmability — atomic script execution: the basis of the version-safe conditional drop.
- Sanfilippo, S. — antirez weblog — the Redis creator on ordering, races, and treating the cache as derived state.
Related in this course
- R1.01 · Cache-asidethe module hub
- R1.01.1 · GET / SET PX miss-fillthe read path the re-fill uses
- R1.01.3 · TTL & stalenessthe next dive
- R1 · Cachingthe chapter
- /echomq/cacheEchoStore coherence drops the stale copy, in depth
- /elixir · Statethe functional-Elixir craft behind the write