jonnify·redis /redis-patterns/caching/cache-aside/ttl-staleness

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

The staleness window — fresh, then stale, then expired. Select a zone to read it, or slide the read across the timeline.
fresh stale (DEL missed) SET PX 0 write 120 TTL 300 read 40
40

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.

§1

A TTL no key can outlive

cache-aside.md.txt · The Staleness Problem

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

§2

Bounding the worst case

cache-aside.md.txt · the worst case

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

Compute the worst case — TTL versus a missed DEL. Select whether the delete ran, then set the TTL and the row’s age.
300
120

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.

§3

TTL and staleness on EchoStore

echo_store/table.ex:484 · :350 · :290

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

the application · the L2 expiry, on the server’s clock

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:

bcs.4.md · the L2 row, written with SET … PX, on the server’s clockfrozen
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
the pattern

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.

its EchoStore application

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.

§4

Recap — bound, do not remove

closes R1.01

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

§5

References

sources · related

Sources

  • Valkey — SET — fill a key with a value and the PX time-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