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.

Previously

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

  1. Watch
  2. Try it
  3. Predict
  4. Capture
BEFORE — immutable + active001.data001.dataimmutableuser:42 = alice (superseded by later write)use… = alicart:9 = [3,7] (superseded by later write)car… = [3,user:42 = alice2 (superseded by later write)use… = aliuser:42 = alice3 (live)use… = alisess:7 = S1 (superseded by later write)ses… = S1002.data002.dataimmutableold:1 = TOMB (delete marker)old… = ∅cart:9 = [3,7,9] (superseded by later write)car… = [3,cart:9 = [3,7,9,11] (live)car… = [3,sess:7 = S2 (superseded by later write)ses… = S2003.data003.dataimmutableuser:42 = alice-old (superseded by later write)use… = alistale = TOMB (delete marker)sta… = ∅sess:7 = S3-old (superseded by later write)ses… = S3-ghost = g (superseded by later write)gho… = gsess:7 = S4 (live)ses… = S4004.data (active)004.data(active)active · acceptingwriterevt:101 = click (live)evt… = clievt:102 = scroll (live)evt… = screvt:103 = purchase (live)evt… = purlivedeadtombdead bytes 60% of total — merge becomes eligible above the trigger thresholdDEAD BYTES60%merge: idleAFTER — merged + hint + activemerged-001.datamerged-001.datamerge outputmerge output004.data (active)004.data(active)active · acceptingwriterevt:101 = click (live)evt… = clievt:102 = scroll (live)evt… = screvt:103 = purchase (live)evt… = purSHARED KEYDIRpre-swapkeybefore → afteruser:42: 001@240 → merged-001@0 (pending)user:42001@240→merged-001@0cart:9: 002@96 → merged-001@56 (pending)cart:9002@96→merged-001@56sess:7: 003@312 → merged-001@128 (pending)sess:7003@312→merged-001@128dead bytes 60% — merge triggers at 60%MERGE TRIGGERdead 60%· trigger 60%above trigger — merge eligiblebelow trigger — merge waits0 live writes accepted during merge (window: always)LIVE WRITES DURING MERGEwindow: always0active file keeps accepting writes · never blockedDISK USAGEbefore 2.7 MB → after 2.7 MB (0% reclaimed)before2.7 MBafter2.7 MBreclaimed: 0 Bmerge idle — dead bytes accumulating
What to watch for

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.

Continue unlocks when the animation finishes.
Implementation

Highlighted lines are the ones running in the diagram right now.

Bitcask.should_merge
the trigger — operator-tunable, OR of two conditions
def should_merge(stats, cfg, clock):
if not within_window(clock, cfg.merge_window):
return False
frag = stats.dead_bytes / stats.total_bytes
if frag >= cfg.frag_merge_trigger:
return True # default 60%
if stats.dead_bytes >= cfg.dead_bytes_merge_trigger:
return True # default 512 MB
return False
Bitcask.merge
scan immutables, emit deduped copy + hint, swap, delete
def merge(files, keydir, active_file):
# scope: immutable files only — active file excluded
inputs = [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)
Bitcask.live_record
a record is live iff the keydir still points at exactly it
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 elsewhere
return (
entry.file_id == rec.file_id
and entry.value_pos == rec.value_pos
)
Bitcask.atomic_keydir_swap
every key flips at one tick — never half
def atomic_keydir_swap(keydir, new_pointers):
with keydir.write_lock: # held only for the swap
for 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:
continue
if 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

Built with Arqly
Every scene in Build a Bitcask-style KV store builds on the one before it.All 9 Build a Bitcask-style KV store scenes