Skip to content

The dynamic layer as files of entries and a map of keys - #97

Draft
PaulSnow wants to merge 58 commits into
entries-written-oncefrom
dyna-heap
Draft

PaulSnow wants to merge 58 commits into
entries-written-oncefrom
dyna-heap

Conversation

@PaulSnow

Copy link
Copy Markdown
Contributor

Step 1 of the entries-written-once proposal (#96), stacked on it and on the platform (#95). Draft: the dynamic layer alone measures as intended; the permanent layer (step 2) is not started, and under the full load the seal is still set by the permanent merges.

What it is (database/heap.go, opened with NewKVShardHeapN / NewKV2Heap, recognised on open by its directory; bdbench -dyna-heap):

  • Entries [len][height][key][value][crc] at exact aligned length, appended to fixed-size data files; a key is (file, offset, length) in an in-memory map. A block appends to the current file; the mover appends to a file of its own, so their barriers never share an inode.
  • A rewrite within the block reuses its slot when the entry keeps its size; across blocks it appends and the old slot is dead where it lies. A slot the durable index names is never overwritten.
  • The mover (on the adapter's cadence) takes the deadest files — once half dead, or whatever their ratio while dead exceeds live, which bounds the heap at twice its live set — copies their live entries, syncs its own copies, and marks a file with nothing live for deletion by the sync after the delta naming the copies. It holds the shard lock only to pick, to plan a chunk, and to name; never across a read, a write, an fsync or a CRC.
  • The index is generations (index-G.log): a snapshot record starts a generation in its own file (written aside, fsynced, renamed, directory fsynced), deltas append to it, the previous generation is removed. No delta is ever truncated away.
  • Repair reads the keys from the data: highest committed copy wins, entries above the committed height dropped.
  • KV2 asks its dynamic layer through a small interface; sharding and the adapter are unchanged.

Measured (platform, nine stores on one NVMe, dynamic layer only, five minutes): seal p50 53–61 ms every minute (segment layer alone: 33–38 ms, but with a compaction storm in minute 4: seal max 1.5 s, 58 blocks missed); read p99 1–2 µs (segments: 11–13); maintenance ~10 s/min (segments: 22–110); store 3.8 GB at five minutes (segments 3.1); zero wrong answers. The remaining tail (p90 150–250 ms in the minutes the mover copies most) follows the mover's fsync volume in the device queue; the last commit shrinks the pass to 4 MB and is not yet measured.

Tests: in-place reuse and one-sync-late deletion; reopen replays the generation and drops the unsynced block; torn log tail; generation switch with an interrupted snapshot; damaged slot; repair from data; shard round trip with rolling and deletion; a put between the mover's copy and its naming; a child process killed mid-block three times over, reopened and repaired to exactly the last durable block. All under -race. The full suite under -race is running.

🤖 Generated with Claude Code

https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5

Paul Snow and others added 30 commits September 16, 2026 16:46
HeapStore (heap.go) is step 1 of the entries-written-once proposal:
entries in a heap file in size-class slots, the key map in memory,
holes reused by class.  A key rewritten within the block that took
its slot is rewritten in place; rewritten in a later block it takes a
hole or the end of the file, and the slot the durable index named
becomes a hole only after the sync that stops naming it -- a crash
leaves every durable entry intact and every torn slot unnamed, and a
checksum catches a torn slot a stale index could name.  The block
sync fsyncs the heap and then appends and fsyncs the block's index
delta; a snapshot on the maintenance cadence bounds the replay.

KV2 asks its dynamic layer through a small interface (dynaLayer), so
a shard opens with either sealed segments or the heap and everything
above is unchanged; the heap is chosen at construction
(NewKVShardHeapN / NewKV2Heap) and recognised on open by its
directory.  bdbench gains -dyna-heap, and its live page is organised
into labelled groups (store, load, schedule, run; protocol path,
maintenance, disk, store).

Tests: in-place reuse within a block and a new slot across blocks with
the hole reused one sync late; reopen replays the log and drops the
unsynced block, cutting the file back; a torn log tail is dropped; a
snapshot empties the log and the replay lands on it; a damaged slot is
a checksum error; a sharded store round-trips through seal, compress,
merge, close and reopen as a heap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
Filling holes wherever they lie was measured on the platform: nine
stores' block syncs wrote 443 MB/s against the segment layer's 117
for the same ingest, because a block's 11k rewrites scattered over a
380 MB heap dirtied a page each and the barrier wrote them all
(run 3).  The commit path cannot afford scattered writes.

Now a block's entries are appended contiguously and the sync is one
sequential fsync per shard; a slot a key stops naming is dead where
it lies; and a bounded cleaner on the maintenance cadence scans up to
HeapCleanBytes from the head, re-appends the entries still live, and
marks the region, which the sync after the delta naming the copies
releases with a punched hole.  The head is in every delta, so a
reopen knows what is released.  The cleaner reports bytes scanned
against bytes moved: the heap's write amplification.

The live page gets a two-second state (last ten seconds of seals and
blocks, maintenance in flight, heap live and dead bytes) beside the
per-minute rows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
Cleaning the oldest region regardless was measured (run 4): with a
skewed key set the oldest region is mostly live cold keys, so every
pass copied most of its 16 MB into one block's sync, and the seal's
p90 reached 1.2 s where the segment layer's was 0.2 s at the same
age.  The heap is now a sequence of HeapRegionBytes regions with live
and dead accounting; a pass takes the region with the most dead bytes
once HeapCleanRatio of it is dead, copies at most HeapCleanBytes of
live entries (the rest next pass), and a region left with nothing
live is punched after the sync that makes its copies durable.  A byte
released never costs more than a byte copied.  The region a block is
appending to is never taken.

bdbench counts the bytes files occupy rather than their length, since
a heap keeps its length and releases regions, and the live state
shows the cleaner's bytes scanned against bytes copied.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
The heap's seal is 3.5x the segment layer's at the same age with the
cleaner idle (run 5, minute 1), so the sync's two barriers are timed
separately and the bytes each fsync covered counted; the platform's
live state shows the averages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
The entry header gains the height of the block that wrote it, so
that with the index files gone a sequential scan rebuilds the key
map: the highest committed copy of a key wins, an entry above the
committed height -- a block whose sync never finished -- is dropped,
and a torn or damaged slot is skipped.  RepairHeapStore takes the
committed height from the store above, rebuilds, and snapshots;
OpenHeapStore refuses a heap with data and no index rather than
opening it empty.  The cleaner's copies carry the height of the pass,
so a repair prefers them to the originals.  A pass now takes several
mostly-dead regions within its copy budget, since one region per
twenty blocks fell behind the append rate (run 5: 11 GB dead against
2 GB live).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
…name

Alone on the disk with nine stores the heap's seal held at 55 ms p50
but the store grew 2.9 GB a minute: the cleaner's 2 MB copy budget
per pass was a fraction of the append rate, and raising it would have
made the copies bigger spikes in the next block's barrier, which is
where they landed.  The pass now writes and fsyncs its copies itself,
outside the shard's lock, taking the lock only to choose the regions
and reserve the copies' slots and again to name them -- a key
rewritten meanwhile leaves its copy dead on arrival -- so its budget
is about its own length (16 MB, 32 regions) and not the barrier's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
…hole

The dynamic layer is now fixed-size data files (HeapFileBytes) and a
key is (file, offset, length).  A block appends to the current file
and the mover to a file of its own, so their barriers never share an
inode -- the mover's fsync was flushing the block's pages and the
block's the mover's.  A file left with nothing live is deleted whole
once the delta naming the mover's copies out of it is durable, which
replaces hole punching and the region table, and the Linux-only
build.  Entries are laid out at their exact aligned length: the size
classes wasted a third of the store and bought nothing without free
lists; an in-place rewrite now requires the same aligned size, since
a shorter one broke the scan a file's later entries depend on.

The index is generations, index-G.log: a snapshot record starts a
generation in a file of its own, written aside, fsynced, renamed
into place and the directory fsynced, then the deltas append to it
and the previous generation is removed.  The old snapshot truncated
the log and rewrote the deltas after it, and a crash in between lost
committed blocks' names.  Snapshots serialize with block syncs
(syncMu) so no delta is in flight into a generation being retired.

The mover's gate is the deadest file once half dead, or whatever its
ratio while dead bytes exceed live, which bounds the heap at twice
its live set.  Get copies the slot under the lock and reads outside
it, retrying once if the file was deleted meanwhile.  Repair scans
the files in order; later in the scan wins a tie in height.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
…lock

Two faults from the first run on files.  Within a pass the mover's
file could roll, leaving the previous one with copies reserved but
not yet written; if that file held any dead bytes the pass's next
pick took it and read past its real length (EOF at store 7, shard
1).  And a file a pass had emptied could be picked again while its
deletion waited on the sync.  A file with copies in flight or a
deletion pending is now never picked, and a pass that fails after
reserving accounts its reservations dead.  The pass also read each
picked file under the shard's exclusive lock -- 16 MB per file, and
the seal's p50 went from 54 to 229 ms in the minute it worked hardest
-- so it now picks under the lock, reads without it (a picked file's
bytes do not change), and plans and names under it again (spec 1.6).

StoreStats gains the heap's own figures (resident index bytes, files,
live and dead bytes, bytes scanned and copied) instead of borrowing
the segment layer's; the no-op knobs leave the dynamic-layer
interface.  Tests: a child process killed mid-block three times over,
reopened and repaired to exactly the last durable block; a put
between the mover's copy and its naming leaves the copy dead on
arrival; all under the race detector.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
Files of entries and a map of keys; the mover, its gate and its lock
discipline with the measurements that set them; index generations;
repair from the data; what the heap alone on the disk measures.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
Planning decoded every entry of each picked file under the shard's
exclusive lock, checksums included: ~128 MB of CRC per pass, and the
seal's p50 was 147 ms in the minute the mover worked.  The read and
the decode now happen together outside the lock; under it the pass
only asks the index which entries are still named and reserves their
copies.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
The planning walk was the last hold: a map lookup per entry, up to a
million entries per pass under the exclusive lock, ~100 ms at a time
(seal p90 461 ms in the minute the size bound put the mover to work).
The walk now takes the lock per 4,096 entries, and the copies' bytes
are laid out outside it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
With planning unlocked between chunks a block sync could begin
mid-pass and capture the mover's file as dirty, so the block fsynced
the mover's 16 MB of copies: each shard sync covered 8.8 MB instead
of 0.7, and the seal's p50 was 193 ms in the first minute (run 6).
A mover reservation now never marks its file dirty; the pass syncs
it itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
With every lock hold gone from the pass, the seal's tail follows the
mover's own fsync volume in the device queue: 16 MB passes put p90 at
150-250 ms in the minutes they ran (run 7).  4 MB per pass releases
more than the soak appends per shard per cadence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
… as it is

From a read of seal.go, segstore.go, blockset.go and indexmerge.go:
what a block costs today (four barriers per shard, two per store),
what a merge copies (bodies, byte-verbatim, because a 48-byte index
record carries no file), and the replacement: files of records never
moved, a 44-byte index record with the file in it, deltas per block
behind the live filters, merge and pack over indexes only, and the
block's deltas appended to the data files so a block is one barrier
round per shard and one per store.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
PermBuckets buckets of sorted runs with a filter each; every block
the next bucket in rotation takes its records from the deltas since
it was last merged as a new run, and runs fold by ratio, so the work
a block does is a fixed slice proportional to what arrived and never
a rewrite of the shard's index.  A merge locks one bucket.  The
window's deltas behind the live filters stay the quick search.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
TestPermSizingSim models one shard at the soak's rate over a day, a
week and a month against the store as it is.  With any fixed bucket
count the largest fold is 1/B of the index and grows with the chain;
a bucket that splits at 32 MB bounds the fold at ~35 MB whatever the
age, and the count follows the index (256 at a day, 1,024 at a week,
4,096 at a month).  Maintenance writes fall four to six times and
none is a body; the pack's 358 MB copy is gone.  Ratio 0.25 stays;
every bucket is merged every 256 blocks; filters live within a budget
and older runs are probed cold.  The proposal carries the table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
A bucket never drained holds 1/B of the whole chain, which is what
the simulation's growing fold was.  The store's pack watermark
already drains history every 1,000 blocks; the buckets cover only
what is above it, hashed keys make their shares even, so a bucket is
~4,000 records and nothing needs to split.  About 180 KB of index a
block per shard against today's 2.5 MB of bodies and index.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
Nine stores started together maintain in lockstep, every 20 blocks
at the same block, and their movers' passes hit the device queue at
once (the 30-minute heap run: seal p90 241 ms in the minute they
coincide, 66 ms otherwise).  Validators on one machine are in
lockstep by consensus, so the soak has the same property.
-maintenance-phase offsets each store's cadence by its share of the
period; the adapter would derive the same offset from the node.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
… a merge

Step 2's building blocks, with nothing that touches an entry's bytes:
a 44-byte record (key, file, offset, length); a sorted run with a
checksum and a filter, written into a run file and looked up
resident or with the filter probed cold; a k-way merge over runs,
newest wins.  The reader rebuilds a run's filter from its stored
byte count, since ByteMask indexes by NumBytes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
PermStore (perm.go): entries appended to data files and never moved;
a block's records sealed as a delta run with its filter; the window
of the last FilterBlocks deltas is what the protocol path reads; a
delta leaving the window feeds 256 buckets by the key's first byte,
merged in rotation (PermBuckets/PermMergeEvery a step) and folded by
ratio; a pack retires every bucket's runs and pending deltas into
one sorted run, the deep history, probed cold.  The manifest is
written aside and renamed; deltas sealed after it replay on open and
a torn one is cut.  Runs carry a height and their file id.  Not yet
wired behind KV2; measured by its test only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
KV2 asks its permanent layer through a small interface (permLayer):
open, close, get, deep get, put, put-if-absent, live count, advance,
the window, the seal's two halves and the merge.  NewKV2Files opens
the heap and the PermStore; OpenKV2 recognises the store by its
perm.json; NewKVShardFilesN builds a sharded one.  KVShard's pack
calls a file-backed shard's own Pack (buckets retire into one run of
keys, no set file), skips attaching sets and dropping history for
it, and its stats and block advance go through the interface.
bdbench -perm-files opens the files store.  A shard round trip
through seal, merge, pack, close and reopen finds permanent keys in
the window, then deep, and dynamic keys at their last value.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
Every hundredth block each shard wrote its whole key map -- 3 MB --
while holding syncMu, so the block's sync waited behind the
snapshot's write and fsync on all nine stores at the same block (seal
p90 276 ms in those minutes with the mover nearly idle).  The
snapshot is now encoded under the map's read lock and written and
fsynced with no lock held; syncMu is taken only to copy the deltas
appended meanwhile after it, rename it into place and switch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
At 64 shards a shard appends ~34 KB a block, its 16 MB file takes
eight minutes to roll, and the current file is never the mover's: no
dead byte was reclaimed and the store grew 2.3 GB a minute.  A file
that has served HeapFileBlocks (64) blocks rolls whatever its size.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
… a merge

Under the full load (files5) the seal climbed from 59 to 209 ms p50
in five minutes: merges and folds appended their runs to the same
run file the seal appends deltas to, so the block's fsync flushed
the merge's writes, and every fold took a barrier of its own -- up
to 256 a merge per shard.  Maintenance now has a run file of its
own (runs-N.dat), the seal's deltas theirs (deltas-N.dat), a merge
syncs its runs and folds once before the manifest names them, and
open replays deltas from the seal's files alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
…ing in the proposal

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
The bound engaged the moment dead bytes passed live and released the
moment they fell back, so nine stores in lockstep all took files at
once and the seal's p90 went to 461-704 ms in those minutes and 70-90
otherwise.  It now engages at dead > 1.5x live, releases below live,
and never takes a file less than a quarter dead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
Every merge ended with the manifest's write, fsync, rename and
directory fsync under the shard's exclusive lock, every twenty blocks
per shard, and the seal waited behind it (files rerun, minute 2: seal
p90 185 ms).  The manifest is now encoded under the lock and written
with it released; a delta sealed meanwhile lies past the offset the
manifest records and is replayed on open.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
Merge took PermBuckets/PermMergeEvery buckets per call -- one -- but
the adapter calls it every twenty blocks, so a bucket was merged
every 5,120 blocks, pending deltas never drained, and each merge read
a growing pile of them (files rerun: seal p50 223 ms by minute 4).
The buckets due now follow the blocks elapsed since the last call,
so every bucket is merged every PermMergeEvery blocks whatever the
caller's cadence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
Paul Snow and others added 28 commits September 16, 2026 20:15
The delta -- the keys the block touched and where they are -- is
appended to the block's data file right behind its entries, as an
entry under a reserved key, so a block is one fsync per shard for the
dynamic layer instead of two; the barrier count is what the device
queue charges for, and the second barrier was a third of the sync.
The index generation file keeps only the snapshot and the replay
point; open scans the data files from that point for delta entries,
verifies the last delta's entries by their checksums before trusting
it (one fsync does not order the delta's bytes after the entries'),
and cuts the newest file back to the last delta or named slot.  A
delta's bytes are live until a snapshot supersedes them and dead
after; a scan steps over a damaged entry rather than stopping at it;
a repair records the end of the data as its replay point so no old
delta is replayed over it.  The crash test passes as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
… per block

The block's delta run, filter included, is appended to the block's
data file behind its entries under the reserved key the heap's
deltas use, so the permanent layer costs a block one fsync per shard
instead of two; the separate delta files are gone.  The window and
the pending deltas read their runs from the data files; open replays
the deltas after the manifest's point from the data files, verifies
the last one's entries by checksum before trusting it, and cuts the
newest data file back to the last delta admitted.  Maintenance keeps
its own run files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
A block's entries are contiguous in its file and each carries its
key, so the delta is the ranges the block wrote -- the mover's named
copies first, then the block's own appends, so a later write of a
key wins on replay -- and the few copies that arrived dead: a few
dozen bytes a block in place of 44 bytes a record, which was a fifth
of the heap's writes and all of it dead at the next snapshot (the
one-barrier run: store 6.1 GB at five minutes against 3.5).  Replay
scans the ranges; the last delta is trusted only if every entry in
its ranges checks; a damaged entry of a committed block is named all
the same, so a read reports the damage rather than absence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
A pass took at most four files, whether they needed a copy or not,
and at 16 MB a file that paced release at about the rate dead bytes
appeared: the store floated at 5.5 GB with the size bound engaged
and the movers in their heavy mode every few minutes.  A file with
nothing live is now released without a scan, any number a pass; a
pass takes up to eight files or 128 MB scanned, still copying at
most 4 MB.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
A pass scanned each picked file end to end, decoding and checksumming
up to 128 MB a shard to find the few live entries, and nine stores'
passes together pushed gigabytes through the CRC at once: load 27 on
24 cores and every block's seal at 200 ms for the minute (heap runs
with range deltas, minute 2).  The index already knows which slots
are live in a file; a pass now takes them from one walk of the map
under the lock and reads only those entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
… deltas, the index-driven mover

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
The seal rolls to a new data file when one fills, but only the
manifest names data files and the manifest is committed at merges, so
every block sealed into a rolled file was lost on reopen.  Open now
takes unnamed data files in id order past the manifest's next id, and
removes unnamed run files so their ids are free for O_EXCL again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
…nks are the mover's; the mover's budget is a store's

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
…he manifest every eighth merge

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
…y past the manifest, and what the bad minutes were

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
…y the blocks elapsed

A store's mover rate is HeapStoreCleanBytes per HeapCleanPeriod blocks.
A Compress call takes as many shards as the blocks since the last call
earn, in rotation, each with its share, so a call every block moves a
little on one shard and the copies reach the device as a trickle
rather than every shard's pass in one second.  Snapshots are by block
count, or early when the files held only by their deltas outweigh two
data files; the perm manifest is committed by block count.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
…osed tail

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
…k after a reopen

A permanent value is derived from its key, so any read of the key is
checked for presence and content; a sampled permanent key that is
missing is a mismatch; permanent keys of all ages go through the deep
read the adapter uses.  At the end every store is closed, reopened,
and every sampled key of both layers read back; a run that lost one
fails.  Until now the platform timed permanent reads and tolerated
"not found" on every one of them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
… the manifest names only the run files it keeps

With a merge every block a bucket folds on nearly every call, and a
commit at every fold was two barriers a shard a block.  The manifest
listed every open run file, unreferenced ones included, which the
drop then deleted: a durable manifest could name a file that was gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
… is live in it

The mover's files are named by the deltas as the destination of their
ranges.  When every copy in one died it was released and unlinked
while those deltas still named it; on reopen the replay stopped at
that delta and the derivation deleted the files it then took for
unnamed.  Found by the platform's reopen check.  A file now carries
the bytes of ranges the generation's deltas name in it, is released
only when that is zero as well, and the snapshot that supersedes the
deltas clears it; the pinned-bytes rule counts such files so the
snapshot comes early when they pile up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
…waits unmerged

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
…st be (spec 2.11)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5
The measurement disk had never been trimmed: the root filesystem sits on
a LUKS mapping opened without allow-discards, so `fstrim` skipped it and
the drive still believed every deleted store was live data.  Under a
sustained write load it garbage-collected underneath us, and every fsync
on the box went 5-10x slower for 30-40 seconds at a time.  Half a day's
runs carried minutes like that, and they are not the store's.

Fixed on 2026-09-17: discards allowed on the mapping, a reboot so the
volume above it recomputes its limits, then one `fstrim`, which
discarded 1.5 TiB.

With that done the 30-minute acceptance run of spec 2.11 could finally
be made, and it holds: 16,164 blocks across nine files stores with
maintenance every block, every minute's seal p90 between 42 and 57 ms
against a 100 ms budget, the heap's fsync average flat, no mismatch, and
all nine stores reopened and read every sampled key back with none
wrong.  The device's write ticks stayed at a median of 1.9 s per
two-second window against the 100-500 s of a stall.  The proposal
records it, including the two minutes where the process read from the
drive because the store had outgrown the page cache -- the floor for an
index that does not fit in memory, and not a cost that grows with the
store's age.

The runbook for the disk itself moves out of the repository, at the
user's direction, to the machine notes where computer maintenance is
kept.  Section 2.11 no longer points at a file: it states the
requirement -- the measurement disk must be trimmed, and verified
trimmed, before a run counts -- and says the notes for a given machine
live with that machine.  A spec that depends on a path outside itself
is not a spec.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant