M1: doc-level free list, sessions, and a spec runner that no longer overstates #1
Reference in New Issue
Block a user
Delete Branch "m1-cursors"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes M1: server-side cursors were already in; this adds the doc-level free
list,
lsid, and the command-monitoring assertions the spec runner wasmissing. 37 commits, in four stages plus a cleanup pass.
Stage 0 — eight reclamation bugs, cleared first
The free list multiplies traffic through exactly the paths these sit on, so
they were fixed as preconditions rather than as work of their own. Three were
found by reading, five by the tests written for the other three. Highlights: a
persisted free list that dropped itself as corrupt whenever it allocated its
own pages; free lists read and written outside the allocation lock; a
checkpoint that never gave back the generation it replaced; a catalog snapshot
read outside the collection lock it describes; a dropped collection charged to
dead_byteson the line before its pages were handed back.Stage 1 —
expectEventsin the spec runner354 of 487 cases declare
expectEventsand the runner read none of them, so acase could send the wrong command entirely and still count as a pass. The
passcolumn has changed meaning — scorecards from before this are notcomparable with ones after. Turning the assertion on cost 34 passes, every
one a defect the result column could not see; the sharpest is that every
"unacknowledged write" case in the corpus had been running an acknowledged
one, because
collectionOptionswere being dropped on the floor. One serverbug fell out of it: the wire version said 8 while the server called itself
4.4.0.
Stage 2 —
lsidacceptedParsed, validated, and deliberately doing nothing;
txnNumberrefused ratherthan silently applied non-transactionally. Every error code measured against
mongod 8.3.7 through raw OP_MSG rather than recalled — which corrected three of
the plan's assumptions, one of which (unknown fields inside
lsidarerejected) would have shipped as a divergence nothing in the test corpus could
catch. Replies are byte-identical to mongod's for every
lsidandendSessionsshape, with five documented divergences.Stage 3 — the doc-level free list
A collection's slab carries a dense map of dead bytes per system page (two
bytes per window, 2.7 MB for a 21 GB slab), and a checkpoint hands back every
window with nothing live left in it. Counting is the whole liveness test:
evict_docremoves a document's index entries before marking its bytes dead,so "no live bytes in this window" and "nothing references these bytes" are the
same statement. Reclamation lives inside
checkpointso the run split andthe
free_pagesbecome durable under onepublish— no new record type, nocatalog version bump, no replay path.
The result has two halves and both are recorded. The mechanism works: 934 MB
reclaimed over the update run, occupancy at 1.06–1.26x live. The ratio did not
move: 1.94x delete-heavy and 2.46x update-heavy, identical to the pre-Stage-3
binary measured with the same harness.
file / liveis a high-water markbecause the file never shrinks, and the mark is set in round one by the one
thing reclamation cannot avoid — a rebuild needs a whole second copy of the
live data before the first can be freed. So ~2x is the floor of a
rebuild-based design and no threshold reaches it. PLAN amendment A5 names the
successor (incremental compaction through a doc-id → offset indirection layer)
with its costs, and a second, cheaper lever nobody had named: 52% of the
steady-state file is space the database owns and is not using.
D7.4's 1.65x is corrected to 1.94x, and the correction is the harness, not a
regression — the same 1.94x comes out of the binary that predates all of this.
The old ad-hoc version sampled ids to delete blindly, so it deleted fewer
documents than it inserted and measured a collection that was quietly growing.
tests/e2e/churn.jsis committed this time; D7.4 was the only gate blockwithout a
reproduce:line.200-byte documents reclaim nothing, exactly as forecast in advance. That is a
pass, not a fault, and the forecast being written down beforehand is what makes
it a result.
Cleanup pass
Deduplication (
pages_forwritten four times, the 20% rebuild share statedtwice, a linear scan where a binary search existed), one O(every window) assert
on a path live in ReleaseFast turned into an O(1) counter, and the pager's two
allocation policies stopped hand-copying the reservation-claim step — the copy
had already lost two of three preconditions.
Two findings were deeper than cleanup.
note_checkpointwas called fromexactly one place, the tail of
upsert, so a delete armed no checkpoint by anyroute — which is why reclamation only ever ran when the rebuild trigger fired
and the rebuild then reset the window map it would have used. And
deleteMany({_id: {$in: [5000 ids]}})in the harness exceededindex.max_combos, so the planner refused the index and every delete became afull collection scan: the 40k x 16 KiB gate went 57 s -> 6 s and the 150k x
200 B line 483 s -> 3 s, with identical output.
Verification
187/187 unit tests in ReleaseFast and ReleaseSafe (non-optional here:
protect_stableis comptime-off in ReleaseFast), 83/83 fuzz, e2e 49, e2e2concurrent + the crash pair, e2e3 16, e2e4 17, e2e6 72, e2e7 86, crash-fuzz 60
cycles. Spec scorecard 194 / 97 / 196 — byte-identical before and after Stage 3,
which is the intended result for a storage change.
Known, recorded rather than fixed
compact's rebuild walk frees pages under only the collection's lock while aconcurrent checkpoint may have snapshotted a catalog claiming them. Reclamation
is now excluded from that shape by
checkpoint_lock, and the class isdetectable —
write_catalogasserts per run that the pager has not alreadybeen given it, armed everywhere except ReleaseFast and proven to fire. The
original instance still wants its own design pass.
Two bugs, both of which the crash fuzzer surfaced and neither of which any existing test could see. **A split put the new sibling in the wrong slot when separators repeat.** `split_leaf` located the new right sibling with `separator_pos(node, key)`, a search for the promoted key. That agrees with "immediately after `left`" only while separators are distinct. When several children share one -- ten distinct values across thousands of documents, so each value spans dozens of leaves -- `separator_pos` returns the slot after the *whole* equal-key run, which puts the sibling at the end of that run while the leaf chain has it right after `left`. Parent child order then stops matching leaf chain order, and that is the one thing a lookup cannot survive: `descend_lower` picks the last child of the equal run, and `lookup_eq` walks forward from there over keys *smaller* than the one it wants, stops at the first mismatch, and reports nothing. Every entry is present, the chain is correctly ordered, `count()` is right -- and the query returns empty. `crash-fuzz.js` found it after ~700 heavy cycles as `find({k: 3})` returning 0 of 401 documents while every other key was exact. Fixed by `child_slot_after`, which is positional by construction. **mmap growth rounded with a non-power-of-two alignment.** Past 64 MiB the growth chunk becomes a proportion of the current size (`mapped_pages / 8`), which is not a power of two -- and `std.mem.alignForward` asserts that it is. In safe builds that panicked; in ReleaseFast, where the assert is compiled out, it computed `(addr + align - 1) & ~(align - 1)` with a non-power-of-two mask, which can round *down*. A mapping shorter than intended is survivable, but a mapping longer than the file is exactly what this function exists to prevent: a store into a mapped page past end-of-file raises SIGBUS, which no error path catches. `alignForwardAnyAlign` instead. Never noticed because no unit test grew a pager past 64 MiB. Also here, because both bugs were invisible rather than merely unfixed: - `assert_indexes_cover_every_document` (db.zig) checks the index invariant directly -- an index generates candidates and the full filter is re-applied to those, so a missing entry is a missing query result nothing else detects. - `Index.unreachable_key_count` counts keys present in the leaf chain but not reachable by descending from the root, which is precisely the state above: healthy by every other measure. - `Index.dbg_root` dumps parent/chain agreement. Marked TEMPORARY; drop it once the invariant checks have earned their keep. - `crash-fuzz.js` now asks the same question without the index, so a failure says whether the documents are wrong or only the index's answer about them, and reports per-key totals so one lost leaf is distinguishable from an empty index. Verified: `zig build test` in ReleaseFast and ReleaseSafe, and seeded fuzzer runs that previously reproduced the split bug.Every reply came back in a single batch with `cursor.id = 0`, `getMore` was a stub answering an empty `nextBatch` on the literal namespace `test.$cmd`, and nothing read `batchSize`. That caps the useful collection size at what fits in one 48 MiB message, which is the opposite of the tens-of-GB target and the reason M0 made whole-index scans stream: the streaming candidate generator existed with no consumer that could suspend. ## What a cursor is allowed to remember A cursor holds no lock between requests, so everything it saves has to survive arbitrary concurrent mutation. Nothing here is a pointer, and the two things that look like stable addresses are not: `reset_tree` re-creates node ids 0 and 1 as different nodes, and `rebuild_collection` moves every document. Three sources, chosen by query shape, each with a different memory contract: - **stream** -- an index-ordered walk resumed from a `(key, off)` anchor plus a `(leaf, slot)` hint. O(key) state, so this is what lets a cursor walk a collection larger than memory. Survives a rebuild, because a repack changes no key. - **offsets** -- the matched slab offsets a narrowed plan already materialized, 8 bytes each. Killed by a rebuild with `QueryPlanKilled`, because those offsets now name unrelated bytes. - **buffered** -- canonical BSON copies, for a sort no index provides and for aggregate/listing output. Depends on nothing, which is what lets a listing hold a cursor over a `$cmd.*` namespace no collection backs. `Collection.layout_epoch` and `Index.epoch` are the invalidation tokens, both checked as error returns rather than assertions since a client reaches them by keeping a cursor open across maintenance. ## Resume `resume_forward`/`resume_reverse` are O(1) while the hint holds and fall back to an exact-order band walk bounded by `resume_walk_max`. Without the hint, `seek` lands at the *start* of an equal-key band, so `sort({status: 1})` over three distinct values across 10M documents would cost ~5e10 comparisons to drain. Two hazards found by draining a collection while writing to it, neither predictable from reading the code: - A deleted anchor must resume at its *band position*, or the rest of an equal-key band is silently dropped -- most of the collection on a low-cardinality index. Hence `band_index`. - On a **unique** index a same-key entry can only be the anchor rewritten, so resuming at it returned updated documents twice. Observed as duplicate `_id`s while updating underneath a drain. ## Protocol Measured against mongod 8.3.7 rather than recalled, which corrected three assumptions: a bare `getMore` does *not* inherit the find's `batchSize` (4998 of 5000 documents come back), a namespace mismatch is `Unauthorized` (13) not `CursorNotFound`, and `CursorInUse` is 143 not 12051. `internalQueryFindCommandBatchSize` is 101, `cursorTimeoutMillis` 600000, `clientCursorMonitorFrequencySecs` 4. The rule everything follows is **never look ahead**: a batch that met its target leaves the cursor open even when the source is in fact exhausted, so four documents at `batchSize: 2` take three commands. `limit` acts as an EOF source, which is what makes `batchSize == limit` close in one round trip. `skip` is consumed once. `batchSize: 0` returns an empty batch with a live cursor. Cursor ids are `(nonce << 20) | slot`, always positive. The nonce is not decoration: without it a recycled slot serves one client another's documents. Cursors are not connection-pinned, since the driver spec allows a `getMore` on any connection to the same server; they end at exhaustion, `killCursors`, or the idle sweep (a second monitor fiber, separate from the TTL one because the cadences differ by an order of magnitude and a TTL failure must not stop reclamation). The registry is fixed-capacity and evicts the least recently used cursor, whose client sees the same 43 an idle timeout gives. Fixed alongside, because cursors are what expose them: - `listCollections` reported `"<db>."` with an *empty* collection part, which makes the driver throw client-side -- so it would have broken the moment its cursor stopped being id 0. Now `<db>.$cmd.listCollections`, as mongod uses. - `count` ignored `skip` and `limit` entirely. - `wire.end_message` now bounds a reply by the 48 MiB we advertise rather than by `maxInt(u32)`; a reply past what we told the client to expect is not a large reply, it is a desynchronized connection. - Two `codeName` strings were wrong: 72 is `InvalidOptions` (MongoDB has no `InvalidArgument`), and 40324 reports as `Location40324`. ## Verification Unit 160/160 in ReleaseFast and ReleaseSafe; `tests/e2e/e2e7.js` adds 86 cursor checks across five phases (batching/lifecycle/errors, streaming across churn, aggregate+listings+count, expiry+capacity, restart) and is self-contained because cursor behaviour is only observable with non-default flags. No regressions: e2e 49, e2e3 16, e2e4 17, e2e2 2, e2e6 72. Spec 168 pass / 124 fail, +5 against the previous scorecard. Mutation-checked, per the repo's second ground rule: `hint_slot + 1`, the `band_index` off-by-one, both epoch bumps, the id nonce, the at-least-one- document rule, and `stream_shape` returning null each turn the intended test red. One claim was withdrawn rather than kept -- swapping `std.mem.order` for `cmp_prefix` in the band walk changes nothing observable, so the comment now says so instead of asserting a check that does not hold.`special()` passed a hard `false` for `root` into its recursion, so a value standing behind `$$unsetOrMatches` was matched as a nested document even when it sat at the top of an `expectResult`. The spec says the opposite in so many words -- "This operator does not influence whether or not an actual document value is considered a root-level document" (unified-test-format.md:2873, and :2821 for `$$matchesEntity`) -- and that distinction is the whole of the extra-key rule: only a root document may carry keys the expectation does not mention. `root` now threads through `match` -> `special` -> the recursion. From under a key it is always false, which is what it already was; from the top level it is whatever the caller had. 25 cases go from FAIL to pass and none moves the other way. Every one is the same shape -- an `expectResult` of `{$$unsetOrMatches: {acknowledged: false}}` against a driver write result that also carries its counts, or the `insertedId`/`insertedIds` forms of the same thing -- and every one was the runner failing a result the engine had got right. 168/124/195 becomes 193/99/195; the scorecard is rewritten here so the delta belongs to this change alone. Mutation-checked: put the `false` back in the `$$unsetOrMatches` arm and bulkWrite-deleteMany-hint-unacknowledged.json returns to 0 pass, 2 fail. The `$$matchesEntity` arm is the same one-word change on the same sentence of the spec, but the crud corpus does not use that operator once, so it rests on the spec text rather than on a red test. Two consecutive full runs, both 193/99/195, 175/175 files, 0 errored, no lingering timers.The headline is not the delta, it is that `pass` changed meaning. 354 of the 487 cases declare `expectEvents` and until now the runner read none of them, so a case could send the wrong command entirely and still be counted a pass as long as the *result* came back right. The old column was an upper bound by construction. 193/99/195 becomes 159/133/195, and the two numbers are not comparable. Two rules decide how far the assertion reaches, both taken from the spec rather than from what would be convenient: - `command` and `reply` match as *root* documents (unified-test-format.md:1020-1022, :1037-1039). The driver hangs `lsid`, `$db` and `maxTimeMS` off nearly everything it sends; as nested documents essentially the whole corpus would fail on keys no expectation was ever written to mention, and the number would say nothing. - the event list is exact in number and order, not a prefix (unified-test-format.md:3088-3091). 23 cases expect an empty list and a prefix rule would pass every one of them without looking. The assertion runs after the operations, so a wrong result is still reported as a wrong result rather than being masked, and after the listeners are disabled, so the teardown's own commands cannot reach the buffer. `cmap` and `sdam` event types, `ignoreExtraEvents`, and any event field beyond `command`/`reply`/`commandName`/`databaseName` are reported unsupported at the point of assertion. None occurs in this corpus -- all 354 blocks are `eventType: command`, carrying 349 `commandStartedEvent` and 6 `commandSucceededEvent` -- so nothing is being quietly waived. All 34 newly-failing cases, triaged. Not one is a wrong answer from the engine; every one is a command the driver never sent: - 22x `command.writeConcern: missing` -- runner gap, and the sharpest thing this commit found. `buildEntities` drops `collectionOptions` on the floor, so `writeConcern: {w: 0}` never reached the driver and every "unacknowledged write" case in the corpus has been running an acknowledged write. They passed because the results of the two agree. This is precisely the class of error the instrument was built to find, and it was invisible to the result column. - 5x `command.sort.<key>: missing` -- runner gap. The driver holds a sort as a JS `Map` (lib/sort.js), so `Object.keys` on it is empty and the matcher reports every expected key as absent. Measured, not guessed: EJSON prints a `Map` exactly like a document, which is why the dump looks correct. - 4x `command.bypassDocumentValidation: missing` -- unclassified. The option is absent from the wire for the `false` cases; the driver only forwards it when true on some paths (lib/operations/find_and_modify.js:19), and whether the runner also drops it has not been established. - 2x `command.comment: missing` on getMore -- server gap, most likely. The driver gates it on `maxWireVersion >= 9` (lib/operations/get_more.js:43) and this engine advertises 8 while reporting itself as 4.4.0, which is wire 9. The inconsistency is ours. - 1x `command.maxTimeMS: expected 6000, got 10000` -- the CSOT rewrite, dealt with in the next commit. Each of those gets its own commit, and none of them is fixed here: a check and the fix for what the check caught do not belong in one change.The disclaimer was accurate for as long as it stood -- events were not read, so `pass` was an upper bound and saying otherwise would have been a lie about the number. It is now a lie in the other direction, so it goes, replaced by what is actually true: events are compared exactly, in number and in order, which is what makes a pass mean the engine answered correctly *and* was asked the right question. The header says plainly that scorecards recorded before this are not comparable, and enumerates what is still skipped inside events rather than leaving "asserted" to be read as "asserted completely". Two facts in the docs had gone stale and are corrected here because this is the commit that rereads them: - README said `--op-timeout-ms` defaults to 3 s. It has been 10 s since the commit that explains, at length and directly above the constant, why 3 s was wrong. A stale number in exactly the place that warns against tightening it is worse than no number. - `MAX_SCHEMA` is [1, 24]; the comment above it still claimed 1.0-1.9. Totals unchanged at 159/132/196 -- this commit only rewrites prose, and the scorecard is re-recorded so its header matches the runner that produced it.`buildEntities` built every collection as `db.collection(name)` and every database as `client.db(name)`, dropping `collectionOptions` and `databaseOptions` on the floor. 15 collection entities declare a `writeConcern`, 7 a `readConcern`, one a `readPreference` -- and the 15 are all `{w: 0}`, so every "unacknowledged write" case in this corpus has been running an acknowledged write against a driver that was never told otherwise. They passed anyway, because an acknowledged and an unacknowledged write of the same document produce results a `$$unsetOrMatches` expectation accepts either way. Only the command on the wire distinguished them, and nothing was reading the command until the previous commits. This is the first thing the event assertions found, and it is a fair answer to what they cost. Option documents are unwrapped from their BSON types on the way to the driver. The suites are parsed with `relaxed: false`, so `{w: 0}` arrives as an Int32 and the driver gates `writeConcern.w` on `typeof w === 'number'` -- the same trap NUMERIC_OPTIONS already documents for operation options, and a silent one: the option would simply not apply. Wholesale unwrapping is safe here in a way it is not there, since these are settings the driver consumes rather than values an assertion compares. An option key outside the spec's `collectionOrDatabaseOptions` set is reported unsupported rather than ignored, which is the lesson of the bug itself. 159/132/196 becomes 173/118/196. 14 cases fixed, none broken. The other 10 unacknowledged cases now fail differently, and that is progress of a sort: with `w: 0` actually applied, the driver refuses client-side to send `hint` on a delete or findAndModify to a server older than 4.4. This engine reports itself as 4.4.0 with maxWireVersion 8, and 4.4 is wire 9. That inconsistency is ours, it is the same one behind the `comment`-on-getMore failures, and it gets the next commit.A command-monitoring event hands over the command as the driver holds it in memory, and that is not always the shape it puts on the wire: a sort is a JS `Map` (driver lib/sort.js). `Object.keys` on a Map is empty, so the matcher reported every key of an expected sort as missing from a command that in fact carried it -- five cases, all of them the runner's fault and none the engine's. This one is worth the paragraph because of how well it hides. EJSON serializes a Map exactly like a document, so `MFDB_DUMP_EVENTS` prints `"sort":{"_id":1}` next to a failure that says `sort._id` is missing, and the dump -- the tool built for exactly this triage in the commit that added the buffers -- reads as evidence that the matcher is wrong about something else. It took `Object.keys(formatSort({_id: 1}))` returning `[]` to see it. Converted for the comparison only, and at every depth, since a sort also appears inside `updates[i]`. `match` stays a plain reading of the spec's Evaluating Matches with no driver knowledge in it. Mutation-checked: pass the event's own value through and findOne.json "FindOne with filter, sort, and skip" goes red again with the original message. 189/102/196 becomes 194/97/196.Inert on its own: nothing is reclaimed yet and no behaviour changes. What changes is that a collection can now answer *where* its garbage is, which is the precondition for handing any of it back. A slab extent becomes a `SlabRun`: the same two u32s plus a dense array of dead-byte counters, one per `map_align` window. The window is the unit because it is the smallest thing that can be given back at all -- `mark_appendable` refuses an unaligned start and `protect_stable` rounds outwards -- so a counter never exceeds `map_align` and its width follows from that. Two bytes per window is the entire memory cost: 2.7 MB for a 21 GB slab on 16 KiB pages. The shapes that track dead *documents* instead (an interval set, a free-run list) cost gigabytes at the 200-byte document scale of D7.3, and would make `evict_doc` allocate after the write is already committed, which is a failure with nowhere to go. `mark_dead` is therefore infallible, and is called from the two places slab dies: `evict_doc`, after every index entry naming the bytes is gone, and `note_skip`, for what the appender writes off at a checkpoint or when it abandons the tail of an extent. Ordering `mark_dead` last in `evict_doc` is what will make reclamation by counting alone sound -- a window reaches `map_align` dead only once every document touching it has been through there. The run list is now sorted by page number rather than allocation order. That was free while an extent could only be appended to; a recycled run arrives *below* one the collection already owns, and `run_of` is a binary search. Sortedness and non-overlap are asserted at the single point runs enter. Two counters accompany it. `dead_unlocated` holds garbage that has no window: the head and tail of a run outside its whole windows, and -- the larger share -- everything that died before the last restart. It exists so one identity stays exact: sum of window counters + dead_unlocated == slab_used - live_bytes Left side is where, right side is how much; reclamation reads the first and the compaction trigger reads the second, and a drift between them is either a rebuild firing on a clean database or a window handed back with a live document in it. `reclaimed_bytes` is inert here and exists for the churn gate, which cannot otherwise tell "the ratio improved because reclamation worked" from "the ratio improved for another reason". The catalog is byte-identical: still `u32 count, (u32 first, u32 pages)*`, so `catalog_version` stays 1 and there is no second read path. The window map is deliberately not persisted -- an open puts the whole amount into `dead_unlocated` instead. The consequence runs one way: a forgotten dead byte is a window that is not handed back, never a live window that is. Reading inserts sorted rather than appending, so a catalog written before this commit loads into an ordered list. Five tests. The accounting identity across both kinds of death; run edges counted but not placed, driven against `mark_dead` directly since the alignment of a real extent is the allocator's business; documents never straddling a run, over a collection with an oversized document in a run of its own; a run recycled to a lower address keeping the list ordered and findable; and a restart forgetting where the garbage is but not how much. Mutations, each red on its own: drop `mark_dead` from `note_skip` (30824 vs 0), from `evict_doc` (151304 vs 30824), the run-tail branch (16384 vs 12288), the run-head branch (26 tests crash on the underflowed window index), the sorted insert (overlap assert fires), and the `dead_unlocated` line in `read_catalog` (40240 vs 0). 180/180 unit tests in ReleaseFast and ReleaseSafe, 82/82 fuzz, e2e 49, e2e2 concurrent 2 and the crash pair, e2e3 16, e2e4 17, e2e6 72, e2e7 86, crash-fuzz 60 cycles.No behaviour is meant to change and the gate confirms it: 1.94x / 679.2 MB reclaimed / 9 rebuilds and 2.46x / 934.0 MB / 13 on the two 16 KiB lines, 0.0 MB on the 200-byte line, all identical to the numbers recorded for them. Deduplication. `pages_for` was written in db.zig and again in pager.zig and twice more inline; there is now one, public, and the two pre-existing copies call it. `pages_per_map_align` replaces three hand-rolled `map_align / page_size`. `SlabRun.window_first` was a stored field that could never legally disagree with `first` and was maintained by hand at two sites -- now a method. `keep_piece` re-derived `SlabRun.window_count` character for character; it calls it. `insert_run` scanned linearly for a position `run_of` binary-searches for, which made loading a fragmented catalog quadratic; both now go through one `run_lower_bound`. Freeing a run's window map was written four times; one helper. The 20% rebuild share was stated in `note_compact` and again in `wants_rebuild`, with a comment arguing at length that they must be the same number -- `worth_rewriting` makes that structural. Efficiency. The identity assert in `reclaim_windows` called `dead_located()`, an O(every window) walk, and `assert_msg` is live in ReleaseFast -- so it doubled the scan the reclamation was about to make (2.75 MB streamed twice per reclaiming checkpoint at the 21 GB the gate targets). `dead_located` is now a maintained counter, the check is O(1) in every build, and the scan cross-checks it while it is there. `SlabRun.full` lets a run with nothing to give be copied without its counters being read at all, so the common case is O(runs) rather than O(windows). The pager's two allocation policies were hand-copying the claim step, and the copy had already lost two of the three preconditions -- `alloc_slab_run` never checked `pages <= reserved_pages`. Both now go through `claim_locked`. `reclaimed_bytes` moves from Collection to Engine, beside `compactions`, which is how it is read and the only place it can be honest: a life-of-the-process total must not lose a dropped collection's share. Both join `Counters`, so `slab_stats` stops opening `counter_lock` by hand. The ownership assertion in `write_catalog` was gated on `is_test or Debug`, a predicate nothing else in the codebase uses, which left the one silent failure this design can produce unchecked in ReleaseSafe. It is now `!= ReleaseFast`, the line `protect_stable` already draws. Measured: no change to the suite's runtime. Altitude. `note_checkpoint` was called from exactly one place, the tail of `upsert` -- so a delete armed no checkpoint by any route, which is why reclamation only ever ran when the *rebuild* trigger fired and the rebuild then reset the window map it would have used. `remove` and the TTL sweep arm one now, next to the `note_compact` calls that were added for the same omission a milestone ago. `compact`'s leading checkpoint stays, demoted in its comment from the mechanism to the local ordering it actually guarantees. serverStatus reports `allocTailBytes`/`freeReadyBytes` instead of page counts, so the harness stops hard-coding 4096 -- the kind of constant this milestone was blindsided by once already. tests/e2e/churn.js: `deleteMany({_id: {$in: [5000 ids]}})` exceeded `index.max_combos`, so the planner refused the index and every delete became a full collection scan re-filtering each document against 5000 members. That was the entire runtime of the harness. One delete spec per id instead: the 40k x 16 KiB gate goes 57 s -> 6 s, and the 150k x 200 B line 483 s -> 3 s, with identical output. Also: the per-round `countDocuments` is gone (the harness knows the count), and the server log is a bounded ring rather than a rope that grows with everything the server ever said. Reverted from the review: reusing one MongoClient across the startup poll. A client whose first connect fails tears its topology down and every later command on it fails identically, so it turns "not up yet" into "never comes up" -- it broke the first run. The reason is now a comment. 187/187 unit tests in ReleaseFast and ReleaseSafe, 83/83 fuzz, e2e 49, e2e2 concurrent 2 and the crash pair, e2e3 16, e2e4 17, e2e6 72, e2e7 86, crash-fuzz 60 cycles.