jonnify·bcs /bcs/cache/cache-aside/the-jittered-clock

B4.1 · dive 3 — the jittered clock

Rows filled together must not expire together.

If a cohort of rows expires at one instant, the TTL itself schedules the next herd. Every insert draws its expiry from ttl ± ttl·jitter, and the committed drill shows the spread doing its work: 400 rows filled in 24 ms expire 138 ms apart at jitter 0.2 — no synchronized re-herd — while the zero-jitter cohort spreads only its own fill walltime, the control that proves the treatment. The sweeper then reclaims on its tick, and the size bound degrades to pass-through, never to failure.

Source: content/bcs4.1.md, quoting bcs_rung_4_1_check.out; the module is committed at runtimes/elixir/lib/echo_cache/table.ex.

The jitter spread, to scale. The two committed cohorts and the sweeper's tick on one millisecond axis — bar widths are computed live from the record's figures. Select a row.
jitter 0.2 fill 24 ms · expiry spread 138 ms — no synchronized re-herd jitter 0.0 fill 4 ms · spread 5 ms — jitter added nothing there the sweeper a 100 ms tick · swept 400, table size 0
readout · Select a row. Two cohorts of 400 rows each — the treatment and its control — and the tick that reclaims the cohort; the lines are frozen in §1 below.
§1

The transcript

bcs_rung_4_1_check.out · verbatim · this dive reads the jitter and bound lines
content/echo_data/runtimes/elixir/bcs_rung_4_1_check.out · the slice this dive readsfrozen
…
derive (jitter): ttl 300 ms at jitter 0.2 spreads expiry uniformly across plus-minus 60 ms -- 400 rows filled in one fast pass should spread at least 70 ms beyond their fill walltime, approaching 120; a jitter 0.0 cohort's spread can never exceed its own fill walltime -- jitter adds nothing; the sweeper on a 100 ms tick then reclaims the whole cohort without a single read
E5 jitter ok -- 400 rows filled in 24 ms expire 138 ms apart at jitter 0.2 -- no synchronized re-herd -- while the jitter 0.0 cohort spreads 5 ms across a 4 ms fill: jitter added nothing there; the sweeper then reclaimed the whole cohort on its tick (swept 400, table size 0) with not one read paying the cleanup
derive (bound): refdata declares max_size 100 with a 60 s ttl, so nothing expires to reclaim -- 49 more fills fit beside the 51 live rows, then every further fill must serve its caller and skip the insert
E6 bound ok -- the declaration holds: size capped at 100 of 100, 101 fills served their callers and skipped the insert -- a full cache is a stat, never an error -- and the writer path lands one value in both layers
PASS 6/6

The elided lines hold the header, E1–E2, and the herd and speed drills — dive 1 and dive 2 read them, and the module hub freezes the record whole. The zero-jitter cohort is the record's own control: its spread can never exceed its own fill walltime, so the 138 ms spread of the treated cohort is the jitter's work and nothing else's.

§2

The sweeper, and two clocks

content/bcs4.1.md · What · Decisions · When

Dead rows leave whether or not anyone reads them again. The sweeper reclaims on its tick — the committed line: the sweeper then reclaimed the whole cohort on its tick (swept 400, table size 0) with not one read paying the cleanup — a one-pass select_delete over the table. Valkey does the same on its side with on-access and active expiration — two layers each keeping their own house.

The decision is the series' two-clocks law, now in the cache: L1 expiry runs on the BEAM's monotonic clock; L2 expiry runs on the server's clock via PX. Each layer is sovereign over its own staleness, and neither trusts the other's watch. From the chapter's When: size the jitter to the herd you fear — 0.1–0.2 spreads a cohort across a fifth of its TTL, which is enough to turn a synchronized re-fill into a trickle.

§3

The bound

content/bcs4.1.md · The bound · Boundaries

A full cache is a stat, never an error. With max_size 100 declared and nothing expired to reclaim, the record shows size capped at 100 of 100, 101 fills served their callers and skipped the insert — every caller got its value, the table held its declaration, and the overflow is visible in full_skips rather than in anyone's latency alarm. No eviction cleverness in this chapter: "an LRU would tax every read with touch-tracking", and the read path's purity is the product. Smarter eviction, if ever wanted, is a follow-up knob under its own gate.

The boundaries travel with the gate: this chapter's staleness bound is the TTL and nothing sharper — a write elsewhere becomes visible here only when the clock says so, and surfaces that cannot accept that wait for the versioned invalidation of B4.2 · Coherence by Mint Time. The L1 is per-node by design — two nodes warm independently, and cross-node agreement is exactly the coherence problem this chapter does not claim to solve.

The bound walk. Four committed stations from the record's bound drill — the live sum of the cohort is computed at the second station. Every figure is the derive line's or E6's.
readout · Walk the drill. A declared bound of 100, 51 live rows, and nothing expiring to reclaim — the declaration holds at every station.

Static record — the walk in the committed order: refdata declares max_size 100 with a 60 s ttl · 49 more fills fit beside the 51 live rows · size capped at 100 of 100 · 101 fills served their callers and skipped the insert · the writer path lands one value in both layers.

§4

References

sources · related

Sources

  • Erlang/OTP — etsselect_delete match-specification deletion: the sweeper's one-pass reclaim
  • Valkey — EXPIREexpiration semantics and the server's two reclamation paths, on-access and active cycles
  • Valkey — SETthe PX option: the L2 row written with its expiry in one command

Related