R1.01.3 · Cache-aside — dive 3
TTL, the staleness backstop.
The TTL is a safety net. It caps how long a stale value can live when an invalidation is missed, and it bounds the window between a write and its expiry.
Grounding: echo_store/table.ex:484 (expires_at, jittered) · table.ex:350 (the sweeper) · table.ex:290 (SET … PX) · bcs.4.md (PTTL on the server’s clock).
Static verdicts — fresh (0–120) → cache matches the source ·
stale window (120–300) → the missed DEL leaves the old value · expired (300+)
→ the key is gone, the next read re-fills fresh.
A TTL no key can outlive
cache-aside.md.txt · The Staleness ProblemThe previous dive cleared the cache with an explicit DEL on every write. That invalidation
can fail: a writer crashes between the source commit and the DEL, a delete is dropped, an event
is missed. Without a backstop, the stale key would live forever — the unbounded SET the
first dive warned against.
The TTL is the backstop. Because every L2 row was written with SET … PX ttl, it carries an
expiry from birth. If the DEL runs, the key is gone immediately. If the DEL never
runs, the key still expires at its TTL, and the next read misses and re-fills with the current source value.
The TTL does not make the cache correct sooner; it caps how long it can be wrong.
That cap is a design dial. A short TTL bounds staleness tightly but raises the miss rate, so the source is read more often. A long TTL serves more from cache but lets a missed invalidation linger. Cache-aside does not remove staleness; it lets the TTL set the worst case.
- TTL
- The time-to-live a key is filled with. After it elapses, Valkey removes the key and the next read misses.
- backstop
- The role the TTL plays: the upper bound on staleness when an explicit
DELis missed. - staleness window
- The span from a source write to the removal or expiry of its cached copy — how long a read can return the old value.
- PTTL key
- The command that reports a key’s remaining time-to-live, in milliseconds; useful to confirm a key carries an expiry at all.
This dive bounds the staleness an invalidation can leave. The read path that fills with the TTL
is the first dive; the DEL is the second.
Bounding the worst case
cache-aside.md.txt · the worst caseThe worst case is the longest a read can return stale data. With a working invalidation, that window is
the time from the source write to the DEL — usually small. With a missed invalidation, the
window stretches to the key’s remaining TTL at the moment of the write. The bound is the smaller of
those two: whichever removes the stale key first.
Set a TTL and whether the DEL ran below. The readout computes the worst-case staleness window
from those two facts. A working DEL shrinks the window to the delete latency; a missed one
leaves the TTL as the only cap.
Static verdicts — DEL ran → window is the delete latency, capped
by the remaining TTL · DEL missed → window is the remaining TTL, then the key expires
and the next read re-fills.
The staleness window is the smaller of the delete latency and the key’s remaining TTL. A
working DEL shrinks it to near zero; a missed one leaves the TTL as the only cap.
TTL and staleness on EchoStore
echo_store/table.ex:484 · :350 · :290EchoStore fills every L2 row with SET … PX ttl_ms (table.ex:290), so the cache tolerates a
missed invalidation. Two mechanisms keep the floor honest. First, expiry is jittered:
expires_at/1 (table.ex:484) draws each L1 row’s deadline from ttl ± ttl·jitter,
so a cohort filled together never expires together and the TTL itself cannot schedule the next herd. Second,
a :sweep sweeper (table.ex:350) reclaims expired L1 rows on a fixed tick with a
select_delete, so dead rows leave whether or not anyone reads them again. The web app receives
only the value or a typed error; the staleness window is an engine concern. The functional-Elixir craft
behind the cache is taught by the /elixir state chapter.
# expires_at/1 (table.ex:484) — each row's deadline drawn from ttl ± ttl·jitter.
defp expires_at(spec) do
base = System.monotonic_time(:millisecond) + spec.ttl_ms
spread = trunc(spec.ttl_ms * spec.jitter)
if spread == 0, do: base, else: base + :rand.uniform(2 * spread + 1) - spread - 1
end
The TTL bounds the worst case the cache will serve stale: at most the row’s remaining time-to-live if the invalidation is missed, and the delete latency if it lands. How the engine chooses the TTL and the jitter for a table is taught by /elixir, not repeated here.
Notes on ValkeyThe L2 row expires on the server’s own clock via
PX; PTTL reads the remaining lifetime in milliseconds, and the server reclaims on both
on-access and active cycles —
valkey.io/commands/expire.
PTTL, against live Valkey
The expiry is not assumed — SET key value PX <ms> writes the value and its
deadline in one command (valkey.io/commands/set),
and the committed record reads the remaining lifetime off the server’s own clock:
L2 write SET … PX ← value and expiry in one command PTTL 300 ms of 300 ← the server's own clock jittered expiry 138 ms apart ← a cohort never expires together sweeper swept 400, size 0
Fill every key with a TTL so a missed invalidation cannot leave a permanently stale value. The TTL caps
the staleness window; the explicit DEL closes it sooner when it lands.
SET … PX ties every L2 row to its deadline; expires_at jitters the L1 deadline
(ttl ± ttl·jitter) and the sweeper reclaims, so no cohort expires together.
The TTL is the floor under correctness: even with no invalidation, a cache-aside key is only stale for its remaining time-to-live, and never longer.
Recap — bound, do not remove
closes R1.01Cache-aside accepts staleness and bounds it. Every L2 row carries a TTL from its SET … PX
fill, so a missed DEL still expires; the staleness window is the smaller of the delete latency
and the remaining TTL. A short TTL bounds staleness tightly at a higher miss rate; a long one serves more
from cache. That trade-off — fill on a miss, delete on a write, expire as a backstop — is the
whole of cache-aside.
This closes R1.01 · Cache-aside. Return to the module hub for the three dives, or to the Caching chapter for write-through and the rest of the read-path family.
References
sources · relatedSources
- Valkey — SET — fill a key with a value and the
PXtime-to-live; the source of the TTL every L2 row carries. - Redis — DEL — the explicit invalidation the TTL backstops when it is missed.
- Valkey — EXPIRE — expiration semantics and the server’s two reclamation paths, the L2-side counterpart of the sweeper.
- Sanfilippo, S. — antirez weblog — the Redis creator on expiry, eviction policy, and bounding the cost of stale data.
Related in this course
- R1.01 · Cache-asidethe module hub
- R1.01.1 · GET / SET PX miss-fillthe read path that fills with the TTL
- R1.01.2 · Explicit invalidationthe DEL the TTL backstops
- R1 · Cachingthe chapter
- /echomq/cacheEchoStore jittered TTL and staleness, in depth
- /elixir · Statethe functional-Elixir craft behind the cache