Merge — GC without stopping writes
Merge scans only immutable files, writes a deduplicated copy with just the live record per key, then atomically retargets keydir pointers — the active file is never touched and writes never block.
RAM held one entry per live key. Disk held every record ever written. Merge is what makes the disk remember its dead — without ever asking the writer to slow down.
Scene 05
Merge — GC without stopping writes
- Watch
- Try it
- Predict
- Capture
Three immutable files at ~60% dead bytes — overwrites and tombstones marked dead in red. Below them, the active file is still accepting appends (the 'live writes during merge' counter ticks throughout). AFTER will hold a single merged data file with its hint sibling — only the latest record per key. Watch merge walk idle → scanning → emitting → swap → done; every keydir row flips at the swap tick, never half.
Highlighted lines are the ones running in the diagram right now.
def should_merge(stats, cfg, clock):if not within_window(clock, cfg.merge_window):return Falsefrag = stats.dead_bytes / stats.total_bytesif frag >= cfg.frag_merge_trigger:return True # default 60%if stats.dead_bytes >= cfg.dead_bytes_merge_trigger:return True # default 512 MBreturn False
def merge(files, keydir, active_file):# scope: immutable files only — active file excludedinputs = [f for f in files if f is not active_file]out = open_new_data_file()hint = open_new_hint_file()new_pointers = {}for f in inputs:for rec in f.scan():if live_record(rec, keydir):pos = out.append(rec)hint.append(rec.key, pos, rec.vsz)new_pointers[rec.key] = (out.id, pos, rec.vsz)fsync(out); fsync(hint)atomic_keydir_swap(keydir, new_pointers)for f in inputs: delete(f)
def live_record(rec, keydir):entry = keydir.get(rec.key)if entry is None:return False # tombstoned or never existed# newer write would have moved the pointer elsewherereturn (entry.file_id == rec.file_idand entry.value_pos == rec.value_pos)
def atomic_keydir_swap(keydir, new_pointers):with keydir.write_lock: # held only for the swapfor key, ptr in new_pointers.items():current = keydir.get(key)# a concurrent put may have moved the key into the# active file while we were emitting — keep that.if current is None:continueif current.file_id == new_pointers_origin(key):keydir[key] = ptr# writers were never blocked from appending to active_file
Where this sits in Build a Bitcask-style KV store
Scene 05 of 9, in the Limits act — RAM is paid per key; merge gives back what's dead.. Merge scans only immutable files, writes a deduplicated copy, then atomically retargets keydir pointers. The active file is never touched.
Up next. Merge is the GC and it's safe across crashes. But if the process dies, the in-memory keydir is gone — and the GC's other side product, the hint file, is the only thing that makes restart fast.
All 9 scenes in Build a Bitcask-style KV store · Every curriculum