jonnify·redis /redis-patterns/caching/cache-aside/invalidation

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

Pick an order — which one resurrects the stale value. Select an ordering to light its timeline and read the value left in the cache.
time → writer reader write srcDEL key write, then DEL → cache empty, next read re-fills fresh DEL keySET old DEL, then write → a read between them re-caches the stale value
readout · Select an ordering. Write-then-DEL leaves the cache empty; DEL-then-write can leave the stale value behind.

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.

§1

Delete the key, do not rewrite it

cache-aside.md.txt · Mitigating Staleness

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

§2

The resurrection race, step by step

cache-aside.md.txt · the write race

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

Step the interleaving — where does the fill land. Select an interleaving and step through; the readout names the final cached value.
4

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.

§3

Invalidation on EchoStore

echo_store/table.ex:171 · coherence.ex:54

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

the application · the cache surface, gated against live Valkey

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:

bcs.4.md · the cache surface, three sources of one answerfrozen
fetch source                  :hit | :l2 | :fill   ← L1 ETS, L2 Valkey, or loader
warm read                     hit, owner untouched
L1 hit reads                  1311621 / s   ← 762 ns each
the pattern

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.

its EchoStore application

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.

§4

Recap — delete, in order

next → R1.01.3

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

§5

References

sources · related

Sources

Related in this course