Build Redis (10 scenes)
Scene 5.5 · TTL and cleanup — lazy, active, and the freer thread
Passive + 10Hz active sampler expire keys; DEL of a big value freezes the loop unless `lazyfree-*`/UNLINK offloads to the background freer thread. Cache stampede + jitter / SET NX rebuilder lock.
Previously

Eviction is the memory-pressure path. TTL is the clock path — keys leave because their time is up, not because Redis ran out of room. Same lazyfree lane, different trigger.

Scene 5.5
TTL and cleanup — lazy, active, and the freer thread
Diagram
Top: a 10Hz active-expire ticker that flashes every 100ms — it samples 20 random TTL'd cells from the keyspace grid below and drains expired ones to a 'reclaimed' lane on the right. Bottom: the event-loop bar (blue = serving, red = stalled on a big DEL) and a background freer-thread queue that UNLINK / lazyfree-* feeds into. A separate stampede panel lights up when many clients miss a TTL'd key at the same instant.
active-expire · 10 Hzsample 20 random TTL'd keys per tick · resample if >25% expiredTHIS TICK0 resamplesSAMPLED0 keysKEYSPACE · 24 keys · 12 with TTLsolid border = TTL'd (sampler-eligible) · dashed = no TTLsmallsession:42smallrate:97smalllock:txsmallfeed:88smallcart:9smalluser:42smallcache:homesmallcache:profilesmallws:7smallperm:77smalltag:betasmallotp:51smalluser:73no TTLsmallsettingsno TTLsmallcountryno TTLsmalllangno TTLsmallcityno TTLsmallpolicyno TTLsmallsku:103no TTLsmalltopologyno TTLsmallcfg:flagno TTLsmallshard:1no TTLsmallflag:expno TTLsmallrule:99no TTLRECLAIMED · this tick0keys freedEXPIRED-BUT-RESIDENTlogically gone, still occupies bytes0drains as sampler ticksPASSIVE EXPIREGET on an expired key:delete inline · return nilEVENT LOOPserving 3 clientsone command at a time · single threadBACKGROUND FREER THREADUNLINK / lazyfree-* offloadqueue depth · 0 jobsidleidleidleidleidleCLIENTS WAITING · 3Client A · GET user:42servedwait 0 msClient B · GET cart:9servedwait 0 msClient C · SET pingservedwait 0 ms
Active-expire ticker fires at 10 Hz. Each pulse samples a random subset of the TTL'd cells (4 in this demo; 20 in production).
The 10Hz active-expire ticker pulses across the top. Each pulse samples a small random subset of the TTL'd cells (production Redis: 20 against millions of keys; this demo: 4 against 12 — see the pseudocode for the production constant). Expired cells in the sample drain to the right-side counter; expired cells the sampler missed pile up on the EXPIRED-BUT-RESIDENT lag gauge. A passive GET on an expired key deletes it inline. Replicas don't actively expire — they wait for the master's DEL.
Implementation
activeExpireCycle
10Hz tick: sample 20 TTL'd keys, resample if >25% expired
1def activeExpireCycle(): # runs every 100ms
2 for round in range(max_rounds):
3 sample = random_sample(db.expires, n=20)
4 expired = [k for k in sample
5 if k.expire_at <= now()]
6 for k in expired:
7 deleteOrLazyfree(k) # lazyfree-lazy-expire
8 ratio = len(expired) / len(sample)
9 if ratio <= 0.25: return # under threshold, rest
10 if cpu_used() > active_expire_budget: return
11 # else: resample inside the same 100ms tick
lookupKey (passive expire)
every GET checks the TTL; expired ⇒ delete inline, return nil
1def lookupKey(db, key):
2 entry = db.get(key)
3 if entry is None: return None
4 if entry.has_ttl and entry.expire_at <= now():
5 deleteOrLazyfree(entry) # passive path
6 return None # GET returns nil
7 return entry.value
deleteOrLazyfree
DEL stalls the loop; UNLINK / lazyfree-* hands off to bg thread
1def deleteOrLazyfree(entry):
2 cost = estimate_free_cost(entry) # ~elements to walk
3 offload = (cost > LAZYFREE_THRESHOLD
4 or lazyfree_for(entry.callsite))
5 if offload:
6 unlink_from_keyspace(entry) # O(1) on the loop
7 background_freer_queue.push(entry)
8 else:
9 walk_and_free(entry) # WHOLE struct, on the loop