Single-flight & jittered TTL · dive 02
Jittered
expiry
A cohort filled together never expires together.
A fixed TTL is a trap: a set of keys filled at the same millisecond expires at the
same millisecond, and every caller that touches any of them gets a miss simultaneously —
the thundering herd re-forms at the boundary, on a timer. expires_at/1
breaks the synchrony: each row's expiry is drawn from a uniform band around the declared
TTL (ttl ± ttl·jitter), so a cohort's deaths are
spread across time, not stacked at one tick. The sweeper reclaims dead rows on a
fixed sweep_ms tick using :ets.select_delete
with a guard on the expires_at field — no per-row timer, no extra
process per entry, memory bounded by the declaration.
expires_at/1
Every row inserted into L1 — by a flight completing or a put/3-4
call writing through — gets its expiry from expires_at/1. The
function takes the table's declared spec, reads the
monotonic clock in milliseconds, and adds the TTL plus a random offset drawn from
the jitter band.
The declared jitter is validated at init/1 to be
in 0.0..0.5. At 0.0 the spread is zero and every
row gets exactly base. At 0.5 the spread is half
the TTL, so a row may expire anywhere from 0.5·ttl to
1.5·ttl after insertion.
# EchoStore.Table — expires_at/1 # Returns the monotonic-ms timestamp at which this row should be # considered dead. The jitter band is ± spread around base. defp expires_at(spec) do base = System.monotonic_time(:millisecond) + spec.ttl_ms spread = trunc(spec.ttl_ms * spec.jitter) # how far from base to allow if spread == 0 do base # jitter 0.0 → exactly TTL, no randomness (useful in tests) else # :rand.uniform(n) returns 1..n; shift to -spread..(spread-1) # so the expiry is base - spread .. base + spread (approximately) base + :rand.uniform(2 * spread + 1) - spread - 1 end end
The monotonic clock (:millisecond epoch local to this node) is
the right choice here: it never leaps backwards on an NTP adjustment, so an expiry comparison
(now < expires_at) is always valid within a single node's L1 table.
L2 TTL is a separate Valkey PX argument and uses the server clock.
The sweeper
Jitter alone does not remove dead rows. ETS has no built-in TTL eviction: a row past its
expires_at stays in the table until something deletes it. The sweeper
closes the loop.
handle_info(:sweep) is armed at init/1 with
Process.send_after(self(), :sweep, sweep_ms) and re-arms itself at the end
of every pass. It runs :ets.select_delete with a match specification that
guards on the expires_at field (index $1 in the
three-element tuple {id, value, expires_at, version}) and deletes every row
for which expires_at < now.
# EchoStore.Table — handle_info(:sweep) # Deletes every L1 row whose expires_at is before now (monotonic ms). # The match spec tuple is {id, value, expires_at, version}; # $1 binds expires_at; the guard is expires_at < now. # Counts :swept (rows removed) and :sweeps (passes), then re-arms. def handle_info(:sweep, state) do now = System.monotonic_time(:millisecond) removed = :ets.select_delete(state.name, [ {{:_, :_, :"$1", :_}, [{:<, :"$1", now}], [true]} ]) :counters.add(state.spec.counters, counter(:swept), removed) :counters.add(state.spec.counters, counter(:sweeps), 1) Process.send_after(self(), :sweep, state.spec.sweep_ms) # re-arm {:noreply, state} end
The sweeper bounds memory by the declaration: a table with sweep_ms:
1_000 and ttl_ms: 60_000 will never accumulate more than roughly
max_size live rows older than the jittered TTL window. Dead rows are evicted
on a regular clock, not by luck.
The jitter band: before and after
Set the TTL and jitter and compare two cohorts of rows: one with jitter, one without. Without
jitter, every row in the cohort expires at the same tick — a perfect thundering herd at the
boundary. With jitter the expirations spread across the band. The model runs the exact
expires_at/1 arithmetic over a fixed cohort of 20 rows filled at the
same millisecond t=0.
Jitter band · cohort expiry spread
Jitter trades a predictable single-tick expiry storm for a smooth drizzle across a band — a cohort filled together still expires close together, never all at once. The sweeper reclaims them across multiple ticks rather than in one pass.
Pattern & implementation
Add a random offset to each item's TTL so a cohort filled together expires at different times, preventing the mass-miss (thundering herd) that forms at a shared boundary. Redis Patterns Applied · R1 teaches the TTL-staleness and early-refresh patterns; jitter is the simplest form of the same defense.
expires_at/1 draws from base ± spread
using :rand.uniform. The sweeper runs
:ets.select_delete with a guard on the stored
expires_at field on a fixed sweep_ms tick.
No per-row timer, no extra process.
The pattern says: spread expirations. The implementation says: draw from a uniform band at insert time, then reclaim in one select-delete pass per tick.
References
Sources
- Erlang/OTP — the ets module —
:ets.select_deletewith a match spec is the sweeper's primitive. - Valkey — Cluster specification — the hashtag discipline the keyspace uses.
- King — Announcing Snowflake — the version field the row carries alongside its jittered expiry.
- Helland — Life Beyond Distributed Transactions — the entity addressed by a key, kept close to its use.
Related in this course
- Single-flight & jittered TTL — this module's hub.
- One fill per herd — the coalescing law that
expires_at/1feeds. - The full cache degrades —
reclaim/1runs the same select_delete on-demand. - The Cache — the chapter landing.
- Redis Patterns Applied · R1 Caching — TTL-staleness and stampede patterns.