Conversation
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
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
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
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
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 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
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
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 withNewKVShardHeapN/NewKV2Heap, recognised on open by its directory;bdbench -dyna-heap):[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.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.KV2asks 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-raceis running.🤖 Generated with Claude Code
https://claude.ai/code/session_015wUZmWRdAPtcfFgYrLJCc5