Commit Graph

87 Commits

Author SHA1 Message Date
A.Shakhmatov
7f426cdd33 tests/spec: a collection entity gets the options it was declared with
`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.
2026-08-09 13:17:03 +03:00
A.Shakhmatov
bb8cdd964b tests/spec: the scorecard no longer disclaims expectEvents
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.
2026-08-09 13:14:58 +03:00
A.Shakhmatov
54ad124c18 tests/spec: a CSOT-rewritten maxTimeMS cannot be asserted
Every client entity is built with CSOT `timeoutMS` (OP_TIMEOUT_MS, 10 s), and
CSOT overwrites `maxTimeMS` on each command with what is left of that budget.
An expectation of `maxTimeMS: 6000` therefore meets the harness's 10000, and
no amount of engine correctness would change it. Reported unsupported rather
than failed: a FAIL is a claim about the engine, and this is a claim about the
runner.

Refused unconditionally when an expected command mentions `maxTimeMS`, not
only when the two values differ, so it can never become a pass by coincidence.
Exactly one case in the corpus asserts it -- estimatedDocumentCount.json,
"estimatedDocumentCount with maxTimeMS" -- so the whole cost of the hatch is
one case, which is why it is worth taking instead of dropping `timeoutMS`.
That option is not open anyway: `timeoutMS` is what replaced the outer race
that once turned ~190 good cases into phantom timeout FAILs.

This is the only escape hatch in the runner. Everything else is either an
honest FAIL or an enumerated unsupported feature.

159/133/195 becomes 159/132/196: one case, fail to skip, and nothing else
moves.
2026-08-09 13:13:38 +03:00
A.Shakhmatov
97e3e3a556 tests/spec: assert expectEvents
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.
2026-08-09 13:11:43 +03:00
A.Shakhmatov
6560aec915 tests/spec: buffer command-monitoring events per client entity
Plumbing only: a client entity that declares `observeEvents` now gets
`monitorCommands` and a buffer, and nothing reads the buffer. That is the
point of splitting it out -- the totals not moving *is* this commit's test.
Command monitoring changes how the driver builds every command it sends, and
if that alone shifted a result there would be no way to tell it apart from
the assertions landing in the next commit.

193/99/195 before, 193/99/195 after, 175/175 files, 0 errored.

The rules the buffer already enforces, so that the next commit is only about
comparing: `ignoreCommandMonitoringEvents` by command name; sensitive commands
dropped unless `observeSensitiveCommands` says otherwise, with `hello` and
legacy hello inferred sensitive from the driver having redacted them to empty
documents (unified-test-format.md:3070-3075). Neither fires on this corpus --
136 client entities observe `commandStartedEvent`, 6 also
`commandSucceededEvent`, and not one sets either field -- but a rule that only
exists where it is exercised is a rule that will be missing when M7 brings
auth. `cmap` and `sdam` observations are collected by nobody; a test that goes
on to assert them is reported unsupported where it asserts, not where it
declares.

Two things about placement, both load-bearing. Listeners are attached after
`connect()`, so a client's own handshake is not in its own buffer -- measured
rather than assumed: with the buffers dumped, find.json's five cases show
exactly `find`, `getMore`, `getMore` and nothing else. And they are disabled
after the operations and before the outcome check
(unified-test-format.md:3081), plus again unconditionally in the teardown
`finally`, because the outcome check and the teardown both issue commands and
a buffer still growing through them would make the assertion a function of the
harness rather than of the engine.

`MFDB_DUMP_EVENTS=1` prints each case's buffer. That is how the handshake
question above was settled and how a failing event assertion will be triaged.
2026-08-09 13:05:52 +03:00
A.Shakhmatov
afa5c6ef9d tests/spec: $$unsetOrMatches does not change root-ness
`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.
2026-08-09 13:02:36 +03:00
A.Shakhmatov
e66030f43e plan: the eight bugs cleared before the free list
Four of them were not in the plan that started this work -- they were
found by tests written for the three that were, which is M0's gate lesson
arriving a milestone early. The record has to match what happened, or the
next session reads a milestone that looks like it went as designed.

Also records what nobody is fixing yet: `rebuild_collection` frees pages
under only the collection's lock while a concurrent checkpoint may have
snapshotted a catalog that claims them, and the `seq` retry cannot see it
because a rebuild appends no log record. Written down so the free list
does not add a second instance of the same shape.
2026-08-09 12:31:56 +03:00
A.Shakhmatov
332206e5dd db: the slab counts what the appender skips
`slab_used` only ever grew by a document's length, so the two places the
appender writes slab off went uncounted: the gap left when a checkpoint
freezes the page the cursor points into and the cursor resumes at the next
system page, and the tail of an extent abandoned for a document that no
longer fits. Both are real garbage -- only a rebuild gets them back -- and
both were invisible to the trigger that decides whether a rebuild is worth
doing. An abandoned tail can be most of 8 MiB.

`note_skip` counts them into `slab_used` where they happen and hands the
number back for the caller to charge to the engine, which keeps the identity
the last commit established: `dead_bytes` is the sum of
`slab_used - live_bytes` over the collections that exist.

That identity is also why `compact` no longer zeroes `dead_bytes`. A repack
appends through the same slab, so it abandons a tail of its own whenever the
next document does not fit; zeroing was true only if a rebuild leaves nothing
behind, and it does not. `sum_dead_bytes` recomputes it from the collections,
each under its own lock.

Carried with it, because this commit is what exposed it: the four engine
counters get a lock of their own. They are the only engine-wide mutable state
a writer touches while holding nothing but its own collection's lock, so two
writers on different collections reach them with no lock in common -- and the
checkpoint's consistency check read them ordered against nothing, while the
per-collection figures it compares them to were read under each collection's
lock. Before this commit `dead_bytes` moved on nearly every write, so that
check was skipped almost every time; with skips counted it stands still
between checkpoints, the check runs, and it aborted three of eight ReleaseSafe
runs of "a checkpoint runs alongside writers on several collections".

Not mutation-checked, and worth saying so: reverting the lock did not
re-trigger the abort in 24 further runs, and neither did the exact pre-fix
revision in 10. The rate depends on machine load, and a mutation check that
cannot be relied on to go red is not a check. The lock stands on inspection
instead -- an unsynchronized read-modify-write on a counter shared by threads
holding no common lock is a defect whatever its rate -- and the concurrency
test now asserts the identity once everything is quiet, which is the half of
it that does not depend on a race being caught in the act.

Mutation-checked, each red on its own: drop either `note_skip` call in
`slab_reserve`; put `self.dead_bytes = 0;` back in `compact`. The `note_skip`
in `slab_append` is covered by the concurrency test, the only place a publish
lands between a reservation and its append.

165/165 unit tests in ReleaseFast and ReleaseSafe, 82/82 fuzz, e2e 49,
e2e2 concurrent 2 + crash pair, e2e3 16, e2e4 17, e2e6 72, e2e7 86,
crash-fuzz 60 cycles.
2026-08-09 12:30:07 +03:00
e15d7f2ed0 db/pager: the concurrency test writes the way the server does
"a checkpoint runs alongside writers on several collections" drove its
writers through `Engine.lock()` -- the legacy whole-engine lock, which
the server has not used since the locks were decomposed. That serialized
the writers against each other, so the overlap the test is named for
never happened: `write_catalog` takes each collection's lock shared, and
nothing it was racing against took that lock at all.

Drive them the way `commands.zig` dispatch does instead: catalog shared,
then the target collection exclusive. The test then does what it says,
and immediately found something -- two appenders on different
collections calling `bytes_mut` at the same time corrupt the pager's
`dirty` set, which is an unsynchronized hash map. ReleaseSafe aborts in
`getOrPutContextAdapted`; three runs in five.

`dirty` is test-only instrumentation (`track_dirty = builtin.is_test`),
so this is a harness bug rather than a server one -- but it is the one
shared structure on a write path whose writers are otherwise kept apart
by owning different pages, and it needs a lock of its own. Six ReleaseSafe
runs clean afterwards.

163/163 unit tests in ReleaseFast and ReleaseSafe, 82/82 fuzz.
2026-08-09 12:09:47 +03:00
992cc2a5ab db: a dropped collection is reclaimed, not deadened
`free_collection` charged the engine's `dead_bytes` with the dropped
collection's live bytes, and then, three lines down, handed every page
that collection owned back to the pager. A drop therefore asked for a
rebuild -- a full copy of every collection that was left -- to reclaim
space that had already been reclaimed. Its own garbage was wrong the
other way: bytes that died before the drop stayed on the engine's books
after the pages holding them were freed.

Both halves of that are the same statement: `dead_bytes` is the sum of
`slab_used - live_bytes` over the collections that still exist. Make it
so on the drop path, then stop storing it separately at all --
`read_catalog` recomputes it from the collections the catalog lists, so
the watermark's copy is now a hint for anything inspecting the header
rather than a second source of truth. It would be wrong in one specific
way if it stayed one: a collection dropped after the last checkpoint is
gone from the catalog but still charged for in the hint.

`write_catalog` now returns the dead sum beside the live one and the
checkpoint asserts it the same way, under the same quiescence condition.
That is what makes the accounting checkable rather than merely intended.

Mutation-checked three ways, each red on its own: charge the drop again;
delete the subtraction of the collection's own garbage; delete the
accumulation in `read_catalog`.

163/163 unit tests in ReleaseFast and ReleaseSafe, 82/82 fuzz,
e2e 49, e2e2 concurrent 2 + crash pair, e2e3 16, e2e4 17, e2e6 72,
e2e7 86, crash-fuzz 60 cycles.
2026-08-09 11:31:37 +03:00
f8a39a0965 db: the catalog snapshot is read under each collection's lock
`write_catalog` walks every collection's slab extents, byte counters and index
metadata while holding only the *shared catalog* lock -- which is the same lock
a writer holds, taking the collection's lock exclusively. So the snapshot read
structures their owners were free to mutate underneath it.

`slab_extents` makes it more than a torn read: it is an ArrayList that
`slab_reserve` appends to, and an append that reallocates leaves the serializer
walking freed memory. What it writes from that walk is the catalog the next open
trusts to find every extent the collection owns.

Now under each collection's lock, shared, taken inside the catalog lock -- the
same order `compact` uses, so no new ordering to reason about.

Not the commit that found the concurrency bugs above; those needed a checkpoint
racing writers, which this lock is orthogonal to. It is the one that makes the
snapshot legal rather than merely lucky.

Verified: `zig build test` in ReleaseFast and ReleaseSafe.
2026-08-09 11:14:17 +03:00
44be427490 db/pager: a document append cannot land in the published image
`slab_reserve` asks `is_unpublished_at` whether the append cursor is still
writable; `slab_append` copies the bytes there. Between them sits the log append
and its fsync, and `publish` clears the entire unpublished set and mprotects the
image. So the answer was routinely stale by the time it was used, and the copy
stored into the durable image.

In ReleaseSafe that is a bus error. In ReleaseFast, where `protect_stable` is
compiled out, there is no fault at all: the store simply overwrites bytes the
last checkpoint published, and the damage surfaces later as a document that
reads back as something else. ReleaseFast is the mode the server ships in.

Present since M0 -- reproduced on f2844e7 with the same test -- and invisible
because nothing paired concurrent writers with a checkpoint. The existing
concurrent suites run against ReleaseFast, where the corruption is silent, and
the unit tests that do run under the protection had no checkpoint racing them.
It has stayed harmless in practice only because a checkpoint fires once per
32 MiB of log; the churn workloads M1 is about to measure change that.

The pager gains an `append_lock`. Appenders hold it shared, so writers on
different collections still proceed concurrently and the lock decomposition
ROADMAP item 5 measured is not given back; `publish` holds it exclusively for
the step that freezes the image, which happens once per checkpoint. Order is
append lock then allocation lock, which is what `mark_appendable` already used.

`slab_append` re-asks the question under that lock and re-arms the cursor if a
checkpoint has published since the reservation. Re-arming has to be infallible,
because this runs after the log record is durable, so `slab_reserve` now
measures its room from the rounded-up cursor rather than the raw one -- under a
system page per extent, and the two share one `appendable_end` so they cannot
disagree about what "there is room" means.

Measured, since this is on the write path: concurrent durable writes, 8 clients
x 1500 inserts at `{w:1, j:true}`, two runs each -- 23779 and 24879 docs/s
before, 23823 and 24452 after. No regression outside run-to-run spread.

Verified: `zig build test` in ReleaseFast and ReleaseSafe, three consecutive
ReleaseSafe runs of the checkpoint concurrency test, `zig build fuzz`, e2e 49,
e2e2 concurrent 2 + crash pair 1/3, e2e3 16, e2e4 17, e2e6 72, e2e7 86,
`crash-fuzz.js` 60 cycles.
2026-08-09 11:13:22 +03:00
bafbc95898 db: a checkpoint publishes only what the log has made durable
`checkpoint` snapshotted `self.seq`, walked the catalog, and then asserted that
the snapshot was at or below `committed_seq`. It is not, whenever a writer
appended before the snapshot and has not finished committing -- between `insert`
and `commit`, or inside `commit` waiting on the leader's fsync. The existing
`self.seq != snapshot_seq` retry does not catch it: nothing appended *during*
the walk, the append was already there when it started.

The window is as wide as an fsync, and it reproduces in seconds: four writers
following the dispatch epilogue's insert-then-commit against a checkpoint loop
trip it on every run. It has stayed hidden because a checkpoint fires once per
32 MiB of log, so the two rarely meet -- which stops being true for exactly the
churn workloads M1 is about to measure.

Publishing there would claim durability for a record still in the log's buffer,
and `truncate_to_header` immediately afterwards would throw it away: the client
gets its acknowledgement, the record is gone. That is the failure the whole
watermark ordering exists to prevent (PLAN D6), and expressing it as an
assertion turned it into a server abort rather than a wrong answer -- which is
the better of the two, but it is not a fix.

Now a retry. `commit` seals every append made so far, so sealing and re-snapshotting
converges in one more round rather than spinning against sustained writes. The
assertion moves to the line above `publish`, where `log_lock` has been held since
the check and `committed_seq` only grows, so it is a tripwire for future edits
rather than a live hazard.

Also here, because the same test found it: the catalog's live-byte sum was
asserted against the engine total *inside* `write_catalog`, where the sum is
accumulated across collections over time while the total moves under it. A
writer landing mid-walk tripped it on a database that was perfectly consistent.
The check moves to the caller and runs only when the engine total did not move
across the walk. What it guards against -- a path that updates one level and not
the other -- is deterministic wherever it exists, so a check that skips under
sustained writes still catches it.

Verified: `zig build test` 162/162 in ReleaseFast and ReleaseSafe, three
consecutive runs of the new concurrency test. Reverting either half reproduces
its own panic within one run.
2026-08-09 11:01:54 +03:00
51eed826fb pager: a checkpoint gives back the generation it replaced
Both streams a publish writes -- the catalog and the free list -- are allocated
into fresh pages every time, so that a crash leaves the previous copy readable.
Nothing ever gave those pages back. A server checkpoints on log volume rather
than on having anything new to say, so an idle database grew its data file
forever, two runs per checkpoint.

The magnitude is not the two pages it looks like: the catalog carries a `u32`
per index node page, so at the tens-of-GB target that is hundreds of KB
abandoned at every checkpoint. It is the same shape as the reclamation bugs the
M0 churn gate found -- a mechanism that works once and never twice -- and it was
invisible for the same reason, that no test ran enough checkpoints to see a
trend.

A publish overwrites the watermark slot of the generation *two* back, since the
two slots hold the new generation and its predecessor. That is the generation
whose streams nothing can reach again, so `Pager` now remembers where the last
two generations put theirs and frees the older pair. Process-local rather than
recorded in the watermark: only a running pager needs to know, because an open
reads the slot it loads and the other slot is its fallback. The pages go through
`free_pages` like anything else, so they are still withheld for two more
generations.

Steady state is therefore a handful of pages in flight, not zero growth, and the
test asserts the number does not track the publish count: forty publishes over
an otherwise idle pager move `alloc_tail` by at most eight pages.

An open derives the loaded generation's page counts from the lengths in its
watermark, which can be one page short for a free-list stream whose final length
fell inside the page its bound reserved. One page, once per open, against an
unbounded leak.

Two existing tests had pinned the leak's arithmetic and now assert the invariant
instead of the number.

Verified: `zig build test` 161/161 in ReleaseFast and ReleaseSafe, `zig build
fuzz`, e2e 49, e2e2 concurrent 2 + crash pair 1/3, e2e3 16, e2e4 17, e2e6 72,
e2e7 86, `crash-fuzz.js` 60 cycles. Mutation-checked: dropping the two frees
takes the growth from a handful of pages to one per publish.
2026-08-09 10:52:15 +03:00
f5471f73fc pager: the free lists are read and written under the allocation lock
`free_pages` appended to `free_pending` with no lock at all, and `write_freelist`
walked all three lists the same way -- while `publish` rotated them under
`alloc_lock`. Both are reachable concurrently in production: the hot caller of
`free_pages` is `page_mut_cow`, which runs under a *collection* lock, and a
checkpoint holds only the shared catalog lock, so copy-on-write in one collection
races a checkpoint and a second collection's copy-on-write freely.

The append race loses or duplicates entries. The read race is worse: an append
that reallocates leaves `write_freelist`'s loop walking freed memory, and it is
walking it to decide which pages are safe to hand out again.

Both now take `alloc_lock`. `write_freelist` holds it across reading the lists
*and* allocating the pages it writes them into, which the mutex being
non-reentrant makes awkward, so `reserve_pages` and `alloc_pages_assume_reserved`
grow `_locked` bodies and thin locking wrappers. A free that lands while the
stream is being written simply waits for the next generation's list -- the page
stays allocated one generation longer, which is the safe direction.

The read race is what the new test actually caught: written to assert only the
append side, it tripped the size assertion added in the previous commit on its
first run, because a concurrent free had grown the list between the bound and the
loop. That is a bug no reading of `free_pages` alone would have found.

The test asserts page *identity* rather than a total, because a publish allocates
its stream off this very list and a plain count is short by however many publishes
found a fit. Every page left on the lists must be one a freer put there, exactly
once. Probabilistic, as any test of a data race is -- it is evidence only when
red. Mutation-checked per the repo's second ground rule: dropping the lock from
`free_pages` crashes it in roughly two runs out of three; five consecutive runs
with the lock in place are green.

Verified: `zig build test` 160/160 in ReleaseFast and ReleaseSafe, e2e 49, e2e2
concurrent 2 + crash pair 1/3, e2e3 16, e2e4 17, e2e6 72, e2e7 86, `crash-fuzz.js`
60 cycles.
2026-08-09 10:44:39 +03:00
5c3a759429 pager: the persisted free list survives allocating its own pages
`write_freelist` captured the entry count, sized the buffer from it, and only
then called `alloc_pages` for the pages it was about to write into. That
allocation goes through `take_free` like any other, and on an exact fit
`take_free` removes the entry it took. The header then claimed one entry more
than the loop wrote, the hash landed eight bytes short of where `read_freelist`
looks for it, and the next open printed "data file free list is corrupt" and
dropped the whole list -- every page on it staying in use forever.

This is the ordinary case, not a corner. The stream is one page whenever the
list is smaller than 511 entries, and a one-page run is the commonest thing on
the list because copy-on-write returns thousands of them per generation. So the
free list was being discarded at essentially every reopen that had anything to
discard, which is the same symptom class as the reclamation bugs the M0 churn
gate found: the mechanism works once and never twice.

The existing two-generation test misses it because its free run is two pages
and the stream asks for one -- shrinking an entry leaves the count right, only
removing one does not.

Fixed by sizing from an upper bound and counting the entries actually written.
`take_free` never adds an entry, so one allocation is enough and the bound
holds; an assertion pins that the list shrank by at most the one entry the
allocation could have taken.

Found while designing the M1 document free list, which multiplies the traffic
through this path.

Verified: `zig build test` 159/159 in ReleaseFast and ReleaseSafe, `zig build
fuzz`, e2e 49, e2e2 concurrent 2 + crash pair 1/3, e2e3 16, e2e4 17, e2e6 72,
e2e7 86, and `crash-fuzz.js` 60 cycles with the prefix invariant holding.
Mutation-checked per the repo's second ground rule: restoring the
count-before-allocate ordering turns the new test red with the corruption
warning.
2026-08-09 10:34:34 +03:00
f2844e7894 cursors: server-side cursors for find, aggregate and the listing commands
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.
2026-08-04 14:54:27 +03:00
cd88e1a4d1 index/pager: place a split's new sibling positionally, and fix mmap growth alignment
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.
2026-08-04 14:51:56 +03:00
62caf9fefc tests/fuzz: tighten the listIndexes NamespaceNotFound comment
Comment-only cleanup: the six-line rationale restated the scenario twice
(first-cycle crash at prefix 0 = kill during the first in-flight command
on a fresh log) and echoed 'expected state' with 'exactly the case worth
verifying'. Four lines keep all three points: real MongoDB answers
NamespaceNotFound too, it is expected when nothing durable created the
collection, and treating it as a harness error broke verification of that
case.
2026-08-04 08:55:55 +03:00
a9f625f82a tests/fuzz: listIndexes on a namespace the prefix never created is expected
`crash-fuzz.js` aborted with "harness error: MongoServerError: ns not found"
whenever the surviving prefix contained no write that created the collection --
a kill during the first in-flight command on a fresh log. `listIndexes` on a
missing namespace is NamespaceNotFound, which is what real MongoDB answers too,
so the server was right and the harness treated a legitimate state as its own
failure. Worse, it aborted the run instead of verifying that state, which is
exactly the state worth verifying.

Reproduces with `--seed 1234 --rounds 60` and is why seeded runs were unusable;
`--heavy` happened to miss it. Confirmed against the previous commit before
changing anything, so it is the harness and not the engine.

Both seeds now pass 60 cycles.
2026-08-04 00:41:03 +03:00
319a515b89 pager: reuse the data file when there is no checkpoint to honour
`Pager.open` set `alloc_tail` to the end of the existing file -- "everything
already in the file is allocated until a watermark narrows it down". Safe when
a watermark exists. When one does not, it is the opposite of safe: nothing in
the file is referenced, the log is the whole truth and replay is about to
rebuild the slab, the trees and the overflow from it, so every reopen started
allocating *above* the previous copy. With no watermark there is also no free
list, so the old copy was never given back. Linear growth per open, unbounded.

It does not need a crash. A database small enough never to reach the 32 MB
checkpoint threshold never publishes a watermark at all, so *every* clean
reopen took this path:

  20 documents inserted per cycle, 12 reopen cycles
  before   17, 34, 50, 67, 85, 102, 118, 135, 168, 201, 236, 269 MB
  after    17 MB, flat

240 documents in a 269 MB file, heading for `DatabaseTooLarge`. The crash
fuzzer shows the same thing under a real workload -- 60 crash/reopen cycles
with ~460 documents ended at 2735 MB before, 17 MB after, with the prefix
invariant holding either way. That number was sitting in its own output as
`data=2735MB` and reads as normal until you divide it by the document count.

The file is deliberately not truncated. The mapping already covers these pages
and `grow_to` extends the file only when the mapping is too small, so
shortening the file behind a mapping that still spans it would turn a later
write into SIGBUS. Reusing from the front is what the unbounded growth needed;
giving the disk back is a separate change to the same function.

Mutation: leave `alloc_tail` at the file end -- red on the new test, which
opens, writes and closes three times without a checkpoint and requires the
third tail to be within one slab extent of the first.
2026-08-04 00:40:30 +03:00
1814020df9 db/pager: an append resumes inside its extent after a checkpoint
`slab_reserve` and `reserve_overflow` abandoned the rest of their extent
whenever a checkpoint froze the page the tail pointed into, and took a fresh
8 MiB one. The comment called the waste "bounded by one extent per collection
per checkpoint", which is true per checkpoint and says nothing about the sum:
nothing reclaims it except a rebuild, and a rebuild only runs when there is
garbage. A pure-insert workload produces none.

Measured, 40 collections of inserts with incompressible payloads so the log
actually reaches the checkpoint threshold:

  live    data file   log
   29 MB     340 MB   29 MB
   38 MB     542 MB    5 MB   <- checkpoint
   67 MB     681 MB   33 MB
   76 MB    1076 MB    9 MB   <- checkpoint
  115 MB    1357 MB   14 MB   <- checkpoint

11.8x the live data and climbing by ~335 MB per checkpoint (40 x 8 MiB), which
would exhaust the 64 GB address-space reservation after roughly 6 GB of real
data -- and after ~1.2 GB with 200 collections. `DatabaseTooLarge` on a
database that is nowhere near too large.

The fix is what the plan called for and never got: round the cursor up to the
next *system* page and keep the extent. Only the page holding the live tail is
in the published image; the rest of the extent holds nothing referenced by the
image or by an index, so `Pager.mark_appendable` hands it back for appending
(and unprotects it, since it may sit below the stable mark where
`protect_image` made it read-only). System pages rather than 4 KiB ones because
writeback tears at the granularity the kernel manages: a 4 KiB store dirties a
whole 16 KiB page on Apple Silicon, and tearing there would take out the
published bytes sharing it.

Same 40 collections after: 340 MB -> 352 MB across three checkpoints, the ratio
falling monotonically toward the 8 MiB-per-collection floor. 64,000 documents
across 8 collections verified byte-for-byte and after a kill -9. The churn gate
is unchanged at 1.65x, big.js at 4 GB unchanged (4.32 GB file, reopen 0.5 s,
RSS after reopen 130 MB).

Two mutations, verified red: dropping the resume branch (a fresh extent per
checkpoint), and rounding to `page_size` instead of `map_align` (the resumed
append then shares a system page with the published image).
2026-08-04 00:39:44 +03:00
6c7f1f2e77 tests/fuzz: crash-consistency fuzzer (crash-fuzz.js + README)
Black-box SIGKILL fuzzer for the M0 mmap+WAL crash story. Random write
workload through the official driver, kill -9 at a random point, reopen the
same log, verify the recovered state against an in-memory model:

- prefix invariant: recovered state == history[0..m) for some m in
  [acked, sent]; every acked write durable, in-flight commands all-or-nothing
  (group commit), nothing after them may survive
- always-opens (replay never refuses); index presence tied to the prefix and
  find({k:v}) correctness (rebuild after replay); countDocuments
- unexpected server death (Zig panic, replay refusal) is a finding with the
  server log; --verify-exec read-backs updates to separate execution bugs
  from replay bugs; --no-kill for graceful-restart runs; --heavy passes a
  1 MiB compact threshold to fuzz compaction/checkpoint windows

Deterministic via seeded PRNG; failures dump a repro artifact with the seed.
Mutation-checked: over-strict prefix check goes red on lost in-flight ops.

Observation recorded: small churn-heavy DBs can exhaust the pager's 64 GB
address-space reservation (data file grows in >=8 MiB compounding steps and
never shrinks without a rebuild), surfacing as DatabaseTooLarge on writes.
2026-08-04 00:05:09 +03:00
d597597a4c plan/results: record the two post-gate CRUD corrections
The gate results file said the replacement-style-update gap was found and not
fixed, and PLAN said it was left alone. Both were true when written and are not
now, so a reader would take the M0 scorecard for the current one. The M0 figures
stay as measured -- they are the gate result -- with a pointer to
tests/spec/scorecard.txt, which always holds the current number.
2026-08-04 00:03:02 +03:00
21489723a9 db: a write that changes nothing is not a write
`nModified` counted every write, so an update that altered nothing was reported
as a modification. MongoDB counts a document as modified only if applying the
update changed it, and writes no oplog entry when it did not: `$set: {x: 11}`
on a document already holding `x: 11` is matched and not modified. The spec
suite says it plainly -- `bulkWrite` with four updateOne operations expects
matchedCount 2 and modifiedCount 1.

Decided in the engine rather than the command, because that is where the
document is already serialized: the comparison is against the bytes that would
actually be stored, and it lands before the log append, so a no-op costs no log
record, no fsync, no slab bytes and no garbage. `Engine.replace` returns
`Written.modified` or `.unchanged` and `cmd_update` counts the first.

That exposed a second difference. A replacement keeps `_id` at the front, so
replacing a document with itself was a byte-level change whenever `_id` was not
stored first -- and it usually was not: the Node driver fills a missing `_id` by
assigning the property, which in JavaScript appends it, so `insertOne({name,
age})` reaches the server as `{name, age, _id}` and we stored it that way.
MongoDB moves `_id` to the front whatever order it arrives in. Now so does
`serialize_with_id`, for every document rather than only the ones whose `_id` it
generates. Visible to clients as `_id` coming back first, as it does from
MongoDB.

  spec scorecard   161 pass / 131 fail  ->  163 pass / 129 fail
  bulkWrite.json   8 pass / 2 fail      ->  10 pass / 0 fail
  e2e.js           45 checks -> 49

No spec file regressed. Mutation: delete the byte comparison in `upsert`'s
`.replace` arm -- red on the log growing, on the garbage counters moving, and on
`replace` claiming `.modified`.
2026-08-04 00:02:17 +03:00
53f88e6d3b update: replacement-style writes
`replaceOne`, `findOneAndReplace` and `bulkWrite`'s `replaceOne` all failed
with "bad update". `update.apply` rejected any update document whose first key
was not `$`-prefixed, so a replacement document -- which by definition has no
operators -- could not get through at all.

MongoDB decides on the first field and nothing else: `$`-prefixed means
operators, anything else means the document *is* the new content. An empty
document is a replacement too, and a legal one. `is_replacement` says which,
`apply_replacement` does the work, and because all three call sites already go
through `apply`, that one branch covers the update command, findAndModify and
the upsert builder.

What a replacement means, precisely:

  - every field is replaced except `_id`, which is immutable and keeps its
    position at the front, where it is stored and where the `_id_` index
    descends on it;
  - a replacement may restate the same `_id` but not a different one -- that
    is `ImmutableId`, because otherwise a rewrite would silently change a
    document's identity while the index entry kept the old key;
  - when the target has no `_id` yet, the replacement supplies it. That is the
    upsert path: `build_upsert_doc` seeds a document from the filter's
    equalities, so `replaceOne({_id: 99}, {u: 1}, {upsert: true})` inserts
    `{_id: 99, u: 1}` and not a generated ObjectId;
  - a mixed document is refused from either side, rather than guessed at.

Two options on update specs are refused rather than ignored:

  - `multi` with a replacement (FailedToParse). A replacement describes one
    document; applying it to many would leave every match identical apart from
    its `_id`.
  - `sort`, a MongoDB 8.0 addition this server does not implement. Ignoring it
    is the worst of the three answers -- `sort` chooses *which* match to write,
    so the client would silently get a different document than it asked for.

  spec scorecard   131 pass / 161 fail  ->  161 pass / 131 fail
  e2e.js           35 checks -> 45

Sixteen spec files improved and none regressed. The two `-sort` files briefly
did: they had been passing on their "server-side error" case, which our
"bad update" failure satisfied by accident, and passing for the wrong reason is
how a gap survives a scorecard.

Five mutations, each verified red: seeding the replacement from the old pairs,
dropping the `_id` comparison, dropping the `_id` a replacement supplies, and
removing the mixed-document guard from either loop.

Note `nModified` is still wrong for a write that changes nothing -- MongoDB
counts a document as modified only if applying the update altered it. That is
the remaining bulkWrite failure and is fixed next, separately.
2026-08-03 23:52:22 +03:00
504179acd1 results: the M0 gates, measured
PLAN D7's six items, with the numbers and the command that reproduces each in
tests/e2e/results/m0-gates.txt. Unit tests green in both optimize modes, the
whole e2e matrix green, the spec scorecard byte-identical at 131/161/195, and
the large smoke run at the scale D7.3 asked for:

  21.47 GB collection (1,310,720 x 16 KiB)
  data file                 21.75 GB      (+1.3% over the documents)
  log after the load        2.5 MB        (checkpoints reclaim it)
  kill -9 then reopen       0.5 s         (0.5 s at 4 GB too -- flat)
  RSS after reopen          237 MB        (1.1% of the data)
  count after restart       1,310,720     last document byte-intact
  acked writes after kill   200/200

That is the milestone's claim, measured: an open costs the working set rather
than the size of the database. Before M0 the same measurement was 523 MB
resident for a 512 MB database, because recovering each document's `_id` meant
reading every document at open.

Two gates need reading rather than a tick, and m0-gates.txt says so where a
reader would otherwise take a tick for granted.

The churn gate settles at 1.65x live data (delete-heavy) to 2.47x
(update-heavy), flat, above the ~1.3x amendment A2 hoped for. Rebuild-only
reclamation cannot reach that: it needs a whole second copy of the live data
before the first can be freed. The gate existed to decide whether doc-level
free lists are needed after M0, and that is the answer.

Benchmark parity holds for every read and latency row inside the run-to-run
spread, and bulk insert regresses 24% (732 -> 555 MB/s), reproducibly across
three runs. Risk 1 as written: document bytes now reach the disk uncompressed
on top of the LZ4 log. createIndex improves 62% from the same change.

Three measurement bugs fixed while running the gates, because each would have
put a false number in the README:

  - `compare-run.sh` measured "db on disk" as `du` of the log alone against
    `du` of mongod's whole dbpath. It reported 20 MB for a 1 GB collection --
    the documents had moved to <db>.data. Honest figure, measured: 914 MB of
    allocated blocks against mongod's compressed 85 MB.
  - `big.js` counted "compaction events" as "the log shrank", which is a
    *checkpoint* now. It claimed 12 compaction rewrites during a pure insert
    load, which has no garbage to compact.
  - `big.js` labelled peak RSS "in-memory engine: docs live in RAM" and its
    summary said the collection was held "fully in RAM". Both were true of the
    engine this milestone replaced.

README: the storage section described an all-in-RAM engine; the comparison
table mixed one old run's body with three new rows; and `findOne({_id})` was
documented as a full scan for integer ids, which the ordered `_id_` index made
false (2 ms against 55 s for a scan of the same 21.5 GB collection). The table
is now best-of-three for both servers, with the measured variance stated, since
two runs of the same binary moved the sub-10 ms rows by 27-51%.
2026-08-03 23:09:43 +03:00
4b70ce6da9 pager: a page reservation belongs to its consumer, not to the pager
The promise `reserve_pages` makes was a single counter on the pager, and the
first concurrent benchmark since the data file landed aborted the server on
it, reliably, at four clients:

  assertion failed: page allocation overran reserve_pages' promise
    src/index.zig:955 in alloc_node
    src/db.zig:794  in upsert

Two upserts on different collections hold different collection locks, so they
run at the same time. Each ends by dropping "whatever is still promised" --
and `release_reservation` zeroed the shared counter, so the first to publish
released the second's promise while the second was still between its log
append and its supposedly infallible allocation. The tripwire fired, which is
the good outcome; the bad one is a growth that never happened and a store past
the mapped end.

This is PLAN risk 3 ("a shared pager makes alloc_tail and free_pending a
global mutex on every insert"), whose mitigation -- private pre-allocated runs
-- was never built. So: `pager.Reservation` is a per-consumer promise, held by
every Index, every Collection (for its doc slab) and the checkpoint, and each
one releases only its own. The pager keeps the sum, which is all `grow_to`
needs. `Engine.release_write_reservations` drops exactly the buckets one
upsert reserved through.

The allocator's own state -- the tail, the total, the free lists, the
unpublished set, file growth -- is now behind `alloc_lock`, taken
uncancelable. It is never held across the log append: that is precisely what
per-consumer reservations buy, and why group commit is unaffected.

  concurrent durable insertOne   4 clients  21697 docs/s   (was aborting)
                                16 clients  30678 docs/s

Mutation: make `release_reservation` zero `self.reserved_pages` again. Red on
the new pager test and on three command tests.
2026-08-03 22:51:15 +03:00
5228ed740a db/pager: reclaim what churn abandons
The churn gate (PLAN D6.2 as amended, D7.4) measured a data file growing
linearly and without bound: 50% churn over six rounds reached 7.2x the live
data and was still climbing when the run was stopped. Three separate bugs,
each of which alone was enough to make reclamation impossible.

**The compaction trigger had been dead since commit 14.** `note_compact`
gated on `log.data_bytes`, which was the right question while the log was
the only copy of the data. A checkpoint now truncates the log, and
`truncate_to_header` zeroes that counter -- so the first gate stopped being
reachable and compaction never fired again. Retarget it at the data file,
where the garbage now lives: `Engine.live_bytes`/`dead_bytes`, in bytes
rather than document counts because a rewrite copies bytes. The engine's
live total is the sum over collections by construction, checked in
`write_catalog`, which walks every collection anyway.

**`stable_pages` is a bound, not a membership test.** `page_mut_cow` asked
`p >= stable_pages`, which is right for tail-bumped pages and wrong for
recycled ones -- they come off the free list *below* the mark and are
nonetheless writable, because two-generation retention means no live image
references them. So every write to a recycled node page copied and freed it
again, and both append cursors (the doc slab, the overflow slab) abandoned
each recycled extent after a single record. Nothing was ever really reused.
Replaced with an exact `unpublished` bit set, cleared at each publish: 32 KiB
per GiB, one load against the 4 KiB copy it avoids.

**First fit let one-page requests dismantle the extents.** Copy-on-write
asks for a single page thousands of times per generation while the doc slab
asks for 2048-page extents; first fit carved a page off the front of the
largest run every time, so the free list drained to empty every generation
with the file still growing by the whole write volume. Best fit keeps the
runs whole -- nothing else wants the one-page holes -- and `publish` now
coalesces adjacent runs, without which the list only ever fragments.

Also: a rebuild publishes twice. One publish moves the abandoned extents
from `pending` to `hold`; the space is not reusable until a second, so the
next rebuild grew the file instead of reusing what the last one freed. Safe
for the reason the delay exists -- what the second publish releases is what
the pre-rebuild image referenced, and that image is no longer the fallback.

Measured, sustained-churn steady state, 40k x 16 KiB documents:

  delete half and refill, 6 rounds   4.10x climbing -> 1.65x flat
  random $set over 5x the collection 3.58x         -> 2.47x flat

Above the 1.3x the amended D6.2 hoped for, and structurally so: a rebuild
needs a whole second copy of the live data before the first can be freed.
The gate's purpose was to decide whether doc-level free lists are needed
post-M0, and this is the answer -- yes, for M1.

Five mutations, each verified red: the numeric mark in `page_mut_cow`, first
fit in `take_free`, dropping `coalesce_free_ready`, dropping
`mark_unpublished`, and dropping the rebuild's second checkpoint.
2026-08-03 22:38:57 +03:00
b20ae92cbf db: drop the docs hashmap; the _id_ index is the lookup
The last structure holding the engine to RAM. At the target scale it cost 64-100
bytes per document -- 10+ GB at 100M documents -- and PLAN D4 rules it out for
exactly that reason.

`_id_` was already an ordered B+tree over the canonical `bson.encode_key`, and
since the leaf payload became a slab offset it has held everything the map did.
So the internal key changes from `serialize_value` to `encode_key` throughout,
`lookup_exact` replaces `docs.get`, and the tree's ordered walk replaces the map's
hash-order iteration in `create_index`, `rebuild_index`, the rebuild and the TTL
sweep -- which reads the slab sequentially where the map read it scattered.
`Collection.doc_count` remains, because the compaction trigger wants a count the
tree cannot give in O(1).

This is what unblocked the milestone's central claim, and the mechanism is worth
naming. Opening from a checkpoint had to rebuild the map, and rebuilding it meant
reading *every document* to recover its `_id` -- which faulted the entire database
in and made "RSS = working set" impossible no matter what else was true. Deleting
the map deleted that scan.

Measured, 512 MB of documents in 16 KB records, reopening from a checkpoint:

  RSS after reopen        523 MB  ->  50 MB
  after 4 point lookups   523 MB  ->  51 MB

The remaining 50 MB is the working set: index pages plus the un-checkpointed log
tail being replayed. A checkpoint immediately before shutdown would shrink it
further; the point is that it tracks what is touched rather than what is stored.

--

Replay now maintains `_id_` as it goes, always, not just after a checkpoint. It
is no longer an optimisation: the tree is the only way the next record can find
the document it supersedes. Secondaries still wait for the bulk build.

And PLAN amendment A4's migration hazard is handled where it actually bites. A
database written before `_id_` was canonical could hold two documents whose `_id`s
compare equal -- int32 1 and int64 1 -- and replaying it now keeps only the later
one. That is MongoDB's semantics and a one-way migration, so replay compares the
superseded document's `_id` bytes with the incoming record's and says so out loud
when they differ, naming the namespace.
2026-08-03 21:58:21 +03:00
148e03ac9f db: compaction becomes a data-file rebuild
`compact` used to re-emit every live document into a fresh log and rename it over
the old one. That is the wrong shape twice over now: the log is not where the data
lives, and a re-emitted record carries a sequence a later watermark can cover,
which would make the next open skip it (PLAN section 4). The log re-emission is
deleted; the checkpoint at the end reclaims the log instead.

What it reclaims is what a checkpoint cannot. A checkpoint publishes the
structures where they already are, and it cannot move a document, because every
index leaf holds that document's physical offset. So reclaiming a replaced
document's bytes means rewriting the documents *and* repacking every index
against the new offsets, together -- which is the whole of `rebuild_collection`.
Documents are copied in _id order, so the new slab reads sequentially afterwards.

Old extents and old node pages go to the free list rather than being reused
immediately, so a crash mid-rebuild simply loses the rebuild: the previous
watermark still describes the previous layout, intact.

Adds `Collection.slab_used`, because `slab_tail` cannot answer "how many bytes
are in use" -- it is an absolute file offset and jumps forward with each new
extent. That is also the number the rebuild trigger wants.

--

The test is the part worth reading. My first version asserted that every document
was still findable and had the replaced contents, and it was nearly useless: two
mutations -- not repacking the indexes at all, and not republishing the docs-map
offsets -- both left it green. Freed extents go on the free list rather than being
overwritten, so a stale offset still reads a perfectly plausible document.

What actually distinguishes a repacked index from a stale one is *where* the
offset points: after a rebuild every live offset must fall inside an extent the
collection currently owns. Asserting that, plus that the index and the map agree,
turns all three mutations red -- including repacking `_id_` but forgetting the
secondaries.
2026-08-03 21:48:04 +03:00
138b7f706f db/storage: reclaim the log once a checkpoint covers it
The point of a lagging checkpoint: a record whose effect the data file already
holds is redundant, so the log can go back to just its header. Without this the
log only grows and every open pays for every write ever made.

Ordering, which is the whole safety argument: publish the watermark, *then*
truncate. The other way round, a crash between them leaves the records gone from
the log and absent from any image. A failed truncation is a warning rather than
an error -- it costs space and replay time, and loses nothing, so it must not
fail a checkpoint that already succeeded.

Also wires checkpointing up, which nothing did before. `note_checkpoint` arms it
when the log passes a threshold, and the write epilogue and the TTL monitor both
claim it -- outside any collection lock, for the same reason compaction runs
there: it takes the log lock. The threshold is separate from the compaction one
on purpose: compaction is about the garbage share of the data, a checkpoint is
about how much replay an open would otherwise do.

--

Two things the tests taught me.

The first version measured the log before the checkpoint and found 16 bytes --
just the header. Appends buffer in the log's open block and only a commit seals
and writes it, so there was nothing on disk to shrink. The test commits first
now, and says why.

And the "no valid watermark" warning fired for every young database, which is
its normal state before the first checkpoint. It now distinguishes a watermark
that was *written and cannot be read* from one that was never written -- warning
about the ordinary case is how people learn to ignore the warning that matters.

Mutation-checked, red: skipping the truncation. Not covered, and the test says so:
moving the truncation before the publish, whose failure mode is a crash landing
between the two. That needs process-level crash injection, which an in-process
test cannot express.
2026-08-03 21:35:03 +03:00
58e645b969 db: checkpoint the engine, and open from it
`Engine.checkpoint()` publishes the current state: commit first, then snapshot
the catalog under the catalog lock, then validate the snapshot against an
unchanged `seq` under the log lock before publishing -- the same bounded-retry
shape compaction has always used. The crash-recovery invariant (PLAN D6) reduces
to that ordering, and it is asserted:
`snapshot_seq <= committed_seq`.

The catalog holds what the pages cannot say for themselves: db and collection
names, slab extents and tails, and for each index its spec, tree position,
overflow extents and id->page table. Written wholesale into fresh pages each
time, never mutated in place, so the previous copy stays valid under the previous
watermark until the new one switches over -- untearable by construction, which is
why there is no incremental update path. Every read is bounds-checked, because
the bytes come off disk and a scrambled catalog must produce an error the caller
can fall back from.

`Log.replay` takes a `from_seq` and skips below it before the BSON parse. The walk
still visits every block, because that is what leaves `end_pos` correct for the
next append; making opens *fast* is the job of truncating the log, next.

A failed catalog load warns, discards what it loaded, and replays the log in
full. The log is untouched at this commit, so that fallback is real rather than
aspirational -- which is the reason to land this before truncation.

--

The docs hashmap is deliberately *not* in the catalog. It is still the
authoritative _id lookup, but putting it there means writing a format the commit
that drops it would only delete again; it is rebuilt by walking the `_id_` tree,
which the data file already holds.

--

One real bug, and it is the interesting part. Replay does not maintain index
entries -- it puts documents in place and lets `build_all_indexes` bulk-pack
afterwards, which is O(n log n) once rather than per record. After a checkpoint
that is wrong: the indexes arrive already populated, `rebuild_index` skips a
non-empty one by design, and every record replayed on top was invisible to every
index. The symptom was a document present in the collection and absent from
`_id_` -- which, once the hashmap goes, means simply absent. Replay now maintains
entries when it opened from a checkpoint, and keeps the bulk path for a full one.

`Engine.seq` is restored, which it never was: it restarted at 0 on every open.
The mutation for it is *not* covered and the test says so rather than implying
otherwise -- the sequence is seeded from the watermark, so it only drifts by the
records replayed on top, and the catalog carries those same records in every
sequence a unit test can reach. Observing the drift needs a crash between a
duplicate-sequence append and the checkpoint that would have captured it. The
line stays because a log without monotonic sequences has no total order.
2026-08-03 21:25:46 +03:00
d7f7ebb994 pager: copy-on-write above the stable mark
The invariant everything else in the crash story rests on (PLAN amendment A1):
no page belonging to the last published image is ever stored into, so recovery
is `image + replay(seq > watermark)` and the image's bytes are exactly what the
watermark described.

`page_mut_cow` takes a *pointer to the owner's page number*. That is the load-
bearing detail: copy-on-write relocates the page, so the owner has to be told,
and a second reference would still aim at the abandoned copy. For the B+tree the
owner is the id->page table slot -- which is precisely why node ids are not page
numbers.

Inert until a checkpoint publishes something, since the stable mark starts at
zero. Append-only consumers keep writing in place, except that a checkpoint
landing mid-extent freezes the page their tail points into, so both slabs now
start a fresh extent rather than writing inside the image. Waste is bounded by
one extent per collection per checkpoint.

The free list is wired into allocation, which it was not before: copy-on-write
abandons every page it touches in every generation, so without reuse the file
grows by `generations x touched_set` without bound. That is the difference
between a free list being defense-in-depth and being a prerequisite (A2).

--

Three things I got wrong on the way, all worth recording.

I added a `p >= stable_pages` assert to `page_mut` and had to take it back out.
A page recycled off the free list *is* below the mark and *is* legitimately
writable -- freed two generations ago, referenced by no live image -- so the page
number alone cannot tell a violation from a reuse. The invariant is enforced the
two ways A1 actually describes: structurally through `page_mut_cow`, and
mechanically through mprotect. The comment says so, since the assert looks like
an obvious thing to add.

The watermark slots needed a narrow exception, because overwriting the inactive
slot is the publication mechanism rather than a violation. It is a separate
non-public accessor that asserts its argument is a slot, so it cannot become a
general escape hatch.

And the mprotect belt: `std.posix.mprotect` does not exist in Zig 0.16, so it is
a libc call. It compiled only in ReleaseFast, where the branch is comptime-
eliminated -- ReleaseSafe caught that immediately, which is the argument for
running both.

What the belt's test asserts is that the protection is really applied, not that a
violating write faults. A SIGSEGV cannot be caught in-process, and the fault is
the OS's behaviour rather than this code's; an mprotect that failed silently
would leave a belt that looks present and does nothing, which is the failure
worth guarding here. Stated in the test rather than implied.

Mutation-checked, all red: COW returning without copying; copying without moving
the slot; copying when already above the mark; never reusing a freed page.
2026-08-03 21:11:27 +03:00
2e7f72074f index: the node arena and overflow slab live in the data file
The last structures move onto the pager, so the whole engine's storage is now
one mapped file plus the WAL.

Node ids are deliberately *not* page numbers. PLAN amendment A1 explains why:
`Node.parent`, `next`, `prev` and an internal slot's `extra` are back-pointers
by id, so copy-on-write moving a page would force every node referring to it to
move as well -- COWing one leaf cascades through the leaf level, one internal
node through its whole subtree. An in-RAM id->page table makes the table slot
the single owner of a page number, so COW has exactly one pointer to fix. It
costs one dependent load per node access and 4 bytes per node, about 5.6 MB at
100M documents, against the 64-100 bytes *per document* this milestone removes.

The overflow slab becomes extents too, so `Slot.off` for a spilled record is an
absolute file offset -- the same change documents went through.

--

Two bugs, both found by measuring rather than by reading, and both worth
recording because the second one would have been invisible until the churn gate.

The reservation was a tail mark, and it cannot be: an upsert reserves tree pages
for every index *and* slab room for the document, all before one log append. The
second reserver overwrote the first one's promise and the first one's allocation
then asserted. Caught on a 512 MB load by the tripwire added in the
`reserve_for` commit, which is the entire reason that assert exists. It is a
count now, and the multi-consumer ordering is pinned by a test.

And a reservation was never released. It is scoped to one write -- taken before
the log append so the publish cannot fail -- but a tree reservation covers the
worst case of several splits while a typical insert causes none, so the promise
accumulated by a handful of pages per write and dragged the file up with it. The
data file was **1.89 GB for 512 MB of documents**; releasing the unclaimed
promise at the end of each write brings it to 551 MB, or 1.08x, which is the
extent slack and the node pages.

--

Measured on one harness, 512 MB / 16 KB docs, against the in-RAM engine this
replaces:

  bulk insert throughput      742.6 MB/s -> 736.4 MB/s
  insertOne (sequential)      0.20 ms    -> 0.22 ms
  createIndex({k: 1})         26.8 ms    -> 16.5 ms
  countDocuments({})          2.1 ms     -> 1.1 ms
  findOne({k: 500}) indexed   0.75 ms    -> 0.56 ms
  find({p: range}).count()    6.6 ms     -> 4.6 ms
  aggregate $group by k       5.8 ms     -> 3.8 ms
  updateMany({k: 7}, {$inc})  1.2 ms     -> 1.0 ms

Reads gain from one contiguous mapping; the two write rows are within noise of
flat. RSS is still unchanged and still cannot improve, for the reason given in
the previous commit: every open replays the whole log and rebuilds everything.

The dev harnesses each open their own data file now. `zig build fuzz` caught all
four of them, again.
2026-08-03 20:55:48 +03:00
9dda943f26 db: documents live in the data file
Collection's slab of malloc'd 8 MiB segments becomes extents in the data file,
and a document's offset becomes an absolute file offset. That makes `doc_bytes`
base + off instead of a binary search over segment starts, and it removes the
hazard the segment list carried: the mapping's base never moves, so a slice into
it cannot be invalidated by growth. phase8 records a dangling-slab-pointer bug
of exactly the shape this deletes.

`slab_reserve` now runs before the log append and `slab_append` after it, and is
infallible. It had no reservation before because appending to an ArrayList could
only fail on OOM; a file-backed slab can also fail on growth, and failing after
the record is durable would report an error for a write the next open produces
anyway.

The data file is still recreated empty on every open and the log still replays in
full, so nothing durable depends on it yet and open/close semantics are
byte-for-byte what they were. The watermark that turns it into a checkpoint comes
next.

--

One bug, and it is worth reading because the milestone keeps producing this
shape. Collection holds a `*Pager`, and `Engine.open` builds an Engine on the
*stack* and returns it by value -- so every pointer taken during replay dangled
the moment it was moved. It surfaced as SIGBUS, then as a corrupt hashmap in the
*second* engine of an unrelated test: nothing resembling its cause. The pager is
heap-allocated now, as `Index` already was, for the same reason.

--

Measured on one harness, 512 MB / 16 KB docs, before and after:

  bulk insert throughput      742.6 MB/s -> 746.7 MB/s
  createIndex({k: 1})         26.8 ms    -> 16.2 ms
  countDocuments({})          2.1 ms     -> 1.1 ms
  findOne({k: 500}) indexed   0.75 ms    -> 0.53 ms
  find({p: range}).count()    6.6 ms     -> 4.1 ms
  aggregate $group by k       5.8 ms     -> 3.7 ms
  insertOne (sequential)      0.20 ms    -> 0.20 ms

Every row equal or faster. The regression this commit was scheduled early to
catch -- document bytes now reaching disk uncompressed on top of the LZ4 log --
did not appear at this size; bulk insert is flat and the reads gain from one
contiguous mapping instead of separately allocated segments.

What did *not* improve, stated plainly because it is the milestone's headline
claim: RSS is unchanged, 552 MB against 563 MB for 512 MB of data. It cannot
improve yet. Every open still replays the whole log and rewrites the whole slab,
so every page is touched and resident regardless of where it lives. "RSS =
working set" only becomes measurable once an open loads a checkpoint instead of
rebuilding, and the honest test for it is the multi-GB reopen in big.js, not this
microbenchmark.
2026-08-03 20:41:08 +03:00
c3e7368477 pager: watermark double buffer, page free list, and the ordering that matters
Still engine-unused; this completes the data file's own machinery so the
structures can move onto it next.

The watermark is what a checkpoint publishes: the log sequence the image covers,
the allocation extent, and where the catalog and free list live. Two slots,
written by generation parity, so a torn write can never leave zero valid
slots -- writing a new generation over the only copy of the old one could. The
newer slot whose hash validates wins, and validation happens *before* the
generation comparison, or a torn slot wins whenever its garbage generation
happens to be larger.

A slot that validates is trusted, so it is checked for sense as well as
integrity: a rehashed slot with an impossible alloc_tail would otherwise be
believed and would describe a file that does not exist.

Both slots unreadable opens anyway, with a loud warning and no checkpoint, which
means a full replay. Ground rule 4: refusing to start costs more than the
warning does.

The free list releases pages two generations after they are freed. That is not
caution -- it is what keeps generation N-1 a usable image, since its pages stay
allocated while N is current, so a torn watermark or a bad catalog can fall back
instead of discarding the database.

--

Two things worth reading, because both were wrong first.

Publish captured alloc_tail *before* writing the free list, so the pages the
free list occupies fell outside the published image and the next open would have
handed them out again while the watermark still pointed at them. Found by the
first test written against it.

And the ordering invariant -- every page a watermark describes is durable before
the watermark that describes it -- was untestable, which is worse than untested.
A kill -9 does not lose page-cache writes, so deleting the sync leaves every
test green while making a real power loss unrecoverable. So the pager now
records, in test builds only, which pages have been written since the last sync,
and a test discards exactly those from the file before reopening. That
simulates the one failure a process kill cannot produce. `track_dirty` is
`builtin.is_test`, so `page_mut` carries no branch in a real build.

Mutation-checked, and the precise result is in the comments because the obvious
mutation is not a violation: publish syncs twice before the watermark, so
removing either call alone is harmless and correctly stays green. Removing both,
or deferring them past the watermark, goes red. Also red: always writing slot A;
accepting an impossible alloc_tail; releasing freed pages a generation early.
Noted as belt-and-braces rather than claimed as covered: the generation-zero
check, which the hash already rejects.
2026-08-03 20:31:59 +03:00
04d56f5b66 pager: the data file, its page allocator and the mmap over a fixed reservation
New src/pager.zig, engine-unused at this commit: the structures move onto it in
the commits that follow, and landing it alone keeps that change reviewable.

The file is an array of 4 KiB pages with a tail-bump extent allocator. PLAN
D6.1 asked for a region table; it cannot be one, because there is a node arena
per index and a document slab per collection, so the region count is dynamic and
unbounded and N contiguous regions cannot all grow at the tail. One page array
means exactly one growth path, so the write-then-extend discipline lives in
exactly one place, and `ls`/`du` stay honest for the backup story.

The mapping is a PROT_NONE anonymous NORESERVE reservation that new file-backed
suffixes are mmap'd into with MAP_FIXED. That is one VMA and zero committed
pages, and it means **the base never moves for the life of the process**, so a
pointer handed out before a growth is still valid after it. The
ArrayList-backed arena this replaces could not promise that -- the promoted-key
scratch buffer in index.zig exists solely to work around it, and phase8 records
a dangling-slab-pointer bug of exactly that shape.

Growth is `setLength` and *then* `mmap`, never the reverse: a store into a
mapped page past end-of-file raises SIGBUS, which no error path can catch. The
accessors assert against `mapped_pages`, so a violation is a panic with a
message instead of a signal.

`page_mut` is deliberately the only way to obtain a writable page. Copy-on-write
hooks in there (commit 12), and funnelling every write through one function is
what makes that a change of one body rather than of every caller.

`sync` is msync + fsync and only the checkpoint calls it. Between checkpoints
dirty pages may sit in the page cache indefinitely, because recovery is
`image + replay(seq > watermark)` and the image's pages are never written
*differently* -- which is what keeps the write path at exactly one fsync, the
WAL's (PLAN amendment A1).

This is the one place in src/ that reaches for std.posix, against the house
style, and the module comment says why: std.Io.File.MemoryMap prefaults by
default, exposes no NORESERVE/FIXED/address hint so it cannot express a
reservation, and its setLength is mremap on Linux and unsupported on darwin.

Mutation-checked, all four red: remapping the prefix at a kernel-chosen address;
dropping the uuid comparison (which is what stops one database's log being
replayed onto another's checkpoint); dropping the header hash comparison; and
moving setLength after mmap. An empty file is treated as absent rather than as
corruption, so a create that died before its header landed still opens.
2026-08-03 20:17:33 +03:00
9390021b1e index/commands: stream whole-index scans; add a reverse leaf iterator
A whole-index read used to materialize every candidate before the caller saw
the first one. At the tens-of-GB target that is a list of every offset in the
collection -- ~160 MB for a countDocuments({}) over 20 million documents --
which defeats the point of moving storage to disk. Cursors are M1, but
*streaming a scan* has to exist now.

`Candidates` is the one loop candidates arrive through, whatever produced them:
a plan's materialized lookups, or the index read end to end. That keeps this
file's governing invariant -- an index only generates candidates, the full
filter is re-applied to every one -- in a single place. A narrowed plan still
materializes, because its multikey/$in dedupe genuinely needs the whole set and
is bounded by selectivity.

`RevIter` walks `Node.prev`, which has always been maintained and which nothing
had ever read: a descending scan materialized the whole index and reversed the
list. `find({}).sort({_id:-1}).limit(20)` becomes O(20).

The unfiltered fallback now walks the _id_ index instead of the docs map. That
is ordered rather than hash-ordered, and it does not depend on a structure that
is about to be deleted.

`Plan.full_scan()` refuses multikey indexes, since one document contributes
several entries there and a stream cannot dedupe what `search` did. The check is
currently redundant -- the planner refuses to order a multikey index anyway --
and is kept because the two guards protect different things. Stated precisely
in both places after checking: the commands.zig test reddens only when *both*
guards are removed, which is what that test actually pins.

--

This also broke e2e6's compaction check, and the fix there is the more
interesting half.

The check required peak/final > 1.4 and got 1.28. The final size was identical
to the byte (2,398,065 vs 2,398,064) -- compaction reclaimed exactly as before
-- and only the peak moved. Isolated to one variable: changing just the order
updateMany({}) walks its matches moves peak/final between 1.65 and 1.28, because
compaction can also fire from the once-per-second TTL monitor and whether one
lands inside the batch shifts the peak a long way while leaving the outcome
unchanged. The threshold was measuring the schedule.

Replaced with `peak > final`, which measures the shape instead: an append-only
log grows monotonically, so its maximum *is* its final size, and a file that
was ever larger than it ended can only have been rewritten.

Worth recording why the obvious alternative does not work. An absolute size
bound cannot distinguish a working compactor here: the payload is one repeated
character, so ~48 MB of records LZ4-compress to ~3 MB whether or not anything is
reclaimed -- with compaction disabled entirely the file still ends at 3.1 MB. I
first wrote the comment claiming that bound was the strong one, then measured it
and found the opposite; `peak > final` is what goes red.
2026-08-03 20:05:38 +03:00
491a4d0a6a index: a leaf record's payload becomes the document's slab offset
PLAN amendment A3. The B+tree leaf had nowhere to put a document's slab
offset -- `Slot.extra` is the payload length for a leaf and the child node id
for an internal separator -- which is what blocks the `_id_` tree from becoming
the primary lookup once the docs hashmap goes away.

A leaf record is now `key ++ offset_le`, so `extra` is always 8 and every
byte-accounting site (fits, record_cost, slot_cost, balanced_cut,
repack_keep_prefix) is untouched. Records get *smaller*: an ObjectId `_id_`
record goes from 26 bytes to 21.

`Entry.id` is deleted rather than re-owned. Every entry one document
contributes shares one document, so which document it is belongs on the call
that commits the entries -- which also makes it impossible to confuse the
offset a replace is removing with the one it is inserting. The old field
aliased the docs map's key and was only safe because removal happened at the
one chokepoint where a document dies; that constraint is gone.

Done for secondary indexes too, not just `_id_`. That deletes the per-candidate
`coll.docs.get(id)` in scan_sorted outright rather than replacing it with an
`_id_` descent, and it is free on the write path because a replace already
removes and reinserts every entry in every index.

Consequences worth knowing:

- lookup_eq/lookup_range/Plan.search yield u64. Those are values, immune to the
  tree mutation that invalidated the id slices they used to hand back -- which
  is why ttl_sweep_coll can drop the dupe-and-free dance it needed to survive
  `remove` freeing the key its entries pointed at.
- One safety net is gone. A stale entry used to be swallowed by
  `docs.get(id) orelse continue`; now it resolves to superseded-but-parseable
  bytes the re-applied filter might accept. That trades an invisible
  under-approximation for a visible wrong answer, which is the better failure
  to have, but it is a trade.
- A checkpoint may never renumber slab offsets (already recorded in PLAN §4):
  every index leaf now holds a physical one.

`zig build fuzz` earned its keep immediately -- it caught the API break in all
four B+tree harnesses, which `zig build test` cannot see.

Benchmarks A/B'd at 256m on one harness, before and after: all rows flat.
updateMany and deleteOne+insertOne first looked 10-13% slower, which three
repeat runs showed to be single-sample noise (0.70/0.71/0.70 against 0.70).
2026-08-03 19:23:17 +03:00
90de7820da tests/spec: MongoDB spec-test runner and the M0 scorecard
PLAN D2 makes the official specification suites the gate for command semantics;
D7.6 asks for the harness to exist at M0 with a recorded baseline. This is that
harness, pinned on both sides -- mongodb/specifications @ 615e0f9 and
mongodb@7.5.0 -- because a scorecard is only comparable across milestones if a
delta cannot be an upstream test change.

It implements the unified format's Evaluating Matches algorithm as written,
including the two rules that decide whether a pass is earned: extra keys are
tolerated only in a root document, and numeric types compare flexibly. Anything
unimplemented is a SKIP with a reason, never a pass, and the one assertion class
not yet checked -- expectEvents, i.e. command monitoring -- is disclosed at the
top of the scorecard so `pass` reads as an upper bound.

First honest run: 131 pass, 161 fail, 195 skip over 175 files, zero timeouts.

Getting there took four attempts, and the failures are documented in the README
because each would have shipped a scorecard claiming a compatibility gap that
did not exist. Two were genuine leaks in this runner (clients left open when a
case timed out; clients registered for cleanup only after `await connect()`,
plus abandoned cases still creating more). The third I misdiagnosed as machine
load. The fourth attempt found the real cause: a leaked catalog lock in the
engine, fixed separately, which alone accounts for the jump from 45 passes to
131.

So the runner carries its own guards: per-operation CSOT timeouts so work is
never abandoned, an active-handle census per file, an end-of-run tripwire for
stray timers, a hard stop if the server dies rather than emitting hundreds of
misleading ECONNREFUSED failures, and --skip/--limit for bisecting a run whose
failures depend on position. The README states the rule plainly -- a long
unbroken tail of timeouts is a harness bug until proven otherwise -- and the two
commands that settle it.

Also fixes bench-run.sh, which copied its report over bench-latest.txt
unconditionally, including after a run that only warned -- so a degraded run
could silently replace the baseline that PLAN D7.5 makes a milestone gate.
2026-08-03 18:58:01 +03:00
e2c25a986b wire/server: honour moreToCome on OP_MSG requests
`Message.flags` was parsed and stored but never read. An OP_MSG request with
moreToCome set is fire-and-forget: the client will not read a reply. Sending one
anyway leaves it unread in the socket, so the next command on that connection
reads the previous command's reply and waits forever for its own.

This is not a corner case. Every unacknowledged write uses it, and the Node
driver sends `endSessions` with `writeConcern: {w: 0}` whenever a client closes
-- so an ordinary application that never asks for w:0 still hits it. Before:

  insertOne({w: 0})            -> ok, acknowledged=false
  countDocuments() (same conn) -> BSON element "cursor" is missing

The command still runs; only the reply is suppressed.

The e2e case pins maxPoolSize to 1, because with a larger pool the driver may
hand the next operation a different connection and hide the bug. It asserts the
connection still works afterwards, which is the part that matters -- not that
the unacknowledged write itself returned.
2026-08-03 18:55:50 +03:00
d867c37d32 commands: stop leaking the catalog lock on a nameless command
dispatch resolved the namespace *after* acquiring the catalog lock, and bailed
out with `orelse return` when either part was missing. A plain return is not an
error return, so it ran neither the errdefer nor the explicit unlocks after the
handler: the catalog lock was held, shared, for the life of the process.

`db.aggregate(...)` reaches it. That sends `{aggregate: 1}`, whose value is a
number, so str_arg returns null.

What made this hard to see is that a leaked *shared* lock is invisible to
readers. ping and listDatabases kept answering in microseconds, and the server
looked perfectly healthy from outside -- an external prober got `ok 15ms`
throughout. Only a write needing the catalog exclusive to create a collection
blocked, so the failure surfaced one command later, on a different connection,
as a client-side timeout with nothing to connect it to its cause. It cost three
invalid spec-test baselines before the driver's own command log showed an
insert sitting for exactly socketTimeoutMS against an idle engine.

Namespace resolution now happens before any lock is taken, and a missing name
is a BadValue reply instead of an empty document (which drivers render as the
uninformative "n/a").

Also fixes the aggregate path it exposed: a missing collection returned a reply
with no `ok` field, where MongoDB answers an empty cursor.

Tested by asserting both halves -- a real error reply, and that a following
write which creates a collection still completes. The second is the lock check.
Mutation-checked: reintroducing the leak reddens that test by name.
2026-08-03 18:55:35 +03:00
aee23cb028 index/db: enforce _id uniqueness through the _id_ index
_id uniqueness was a `coll.docs.contains` probe. The docs hashmap is going
away (PLAN A3), so it has to move to the _id_ tree -- and the tree answers
better, because it is keyed on bson.encode_key, which is canonical where
serialize_value is not. int32 1, int64 1 and double 1.0 are now one _id, as
they are in MongoDB (A4).

_id_ is built and checked first, so a write violating both it and a unique
secondary reports _id_, which is what MongoDB reports. It returns
error.DuplicateKey with `dup_index` left null, which is exactly what
commands.zig's E11000 rendering already treats as "the _id_ index", so the
wire-visible message is unchanged and that file needed no edit.

check_unique's exclude-self became optional and is null on an insert. That was
a latent bug of its own: a replace must ignore its own existing entries, but an
insert has none, and passing the document's id there hides a collision whose
entry carries that same id -- precisely the case _id_ exists to catch. Only
_id_ could reach it, since a secondary collision is between different
documents.

Two corrections found while doing this, both worth reading:

PLAN A4 claimed a database already holding {_id: int32 1} and {_id: int64 1}
loses one on reopen. It does not. Replay evicts through the docs map, keyed on
serialize_value, so both survive; the tree is bulk-built afterwards with
enforcement off, which tolerates duplicate keys and warns. The loss arrives
only with the commit that drops the map, and that is where it needs a
pre-flight scan. Amended.

dispatch_insert asserted only `ok: 1`, but a rejected document comes back as a
writeError alongside it -- so the mixed-type corpus silently shrank from ten
documents to nine when _id_ became unique, and every test over it still passed.
The helper now rejects writeErrors and asserts the inserted count; it caught
the shrink immediately. The corpus keeps an int64 _id on a distinct value, and
the collision it used to stand in for is asserted directly.

Also adds Index.lookup_exact, which the commands that currently probe the docs
map will need. Exact byte equality rather than cmp_prefix, because {a: 1}'s
encoding is a proper prefix of {a: 1, b: 2}'s and a prefix match would claim a
document is present when it is not.

Mutation-checked, all three red: unique=false on id_index; exclude=id_key on
insert; eql -> cmp_prefix in lookup_exact.
2026-08-03 18:15:21 +03:00
f61416f44a index/db: heap-allocate secondary indexes
Collection.indexes held Index by value, so orderedRemove memmoved the whole
~5 KB struct and every *Index already handed out referred to a different index
afterwards -- a query plan's `index` field, or a slice into an index's
promoted-key buffer. The collection's own bookkeeping stayed consistent, which
is why nothing noticed: only a caller holding a pointer across a drop could
see it, and no test did.

The new test does, and it is mutation-checked against the by-value code that
this commit replaces: holding pointers to b_1 and c_1, then dropping a_1, the
b_1 pointer reads "c_1". Now orderedRemove moves 8-byte pointers, the
surviving indexes do not move, and only the removed one is freed.

M0 needs this independently: an Index will own a file mapping once the node
arena moves into the data file, and copying one by value would duplicate that
ownership.

Not done, though the milestone plan listed it: moving Index's inline scratch
and promo buffers out of the struct. Their stated purpose was to keep those
5 KB out of a file-resident Index and to stop the memmove -- but only the node
arena and overflow slab become file-resident, not the Index metadata, and
boxing already fixed the memmove. Moving them would be churn with nothing left
to buy.
2026-08-03 17:21:33 +03:00
411a380d38 commands: fix a remote invalid free in aggregate $sort
Present since at least d4c9b04, found by the new spec-test harness on its first
run. The $sort stage's materialization branch built its document list with the
reply arena and then handed it to `trees`, whose scope-exit deinit -- and the
$match branch above it -- free with the gpa. So a gpa free was handed an
arena-owned pointer. macOS malloc catches it and aborts with SIGTRAP and no
panic text, which is why the symptom read as "the connection closed":

    mfm_free <- Allocator.rawFree
             <- array_list.Aligned(*const bson.Document).deinit
             <- commands.cmd_aggregate

Any pipeline with $sort and no preceding $group reached it, e.g.
aggregate([{$sort: {x: 1}}]) -- so a client could kill the server with one
ordinary query. With a $group first the stream is already in tree form and the
branch is skipped, which is precisely why it survived: every aggregate case in
e2e.js and e2e6.js sorts *after* grouping.

The list buffer now comes from ctx.gpa. The documents stay in the arena on
purpose -- it outlives the command, and only the ArrayList's own allocator has
to match its deinit.

Tests. The unit test uses a bare $sort pipeline, since a $group first would not
reach the branch, and leans on testing.allocator detecting the invalid free
itself rather than on the host allocator noticing -- mutation-checked by
restoring `arena` on the append, which gives `panic: Invalid free`. The e2e case
adds a second command afterwards, because the assertion that matters is not
that the sort returned rows but that the connection is still there.
2026-08-03 17:09:21 +03:00
06504127fb index: route arena access through accessors; tighten reserve_for's bound
Groundwork for M0: the node arena and overflow slab are about to move into an
mmap'd data file where a write to a page belonging to the last durable
checkpoint has to copy that page first (PLAN amendment A1). Two changes make
that a small commit rather than a sixty-site one, plus the reformat of this
file (see the preceding style commit for why it rides along here).

Accessors. Every read of a node page now goes through page(), every write
through page_mut(), and every overflow read through ovf(); nothing else touches
nodes.items or overflow.items. Which of the 55 sites mutate was decided by the
compiler rather than by inspection -- page() returns *const Node, so every
mutating site failed to compile until flipped -- and the result is that the
copy-on-write hook has exactly one home. Records the rule COW will impose
(never hold a *Node across a page_mut of the same id) and the audit showing
today's callers already comply.

Comptime layout asserts. These structures are about to become an on-disk
format, and nothing pinned them. Pinning also surfaced that @sizeOf(Slot) is
32, not the 20 its 160 declared bits suggest -- the backing integer's 16-byte
alignment rounds it up, so 12 of every 32 slot bytes are padding and a node
holds 127 slots where 203 would fit. Pinned, deliberately not fixed: narrowing
the slot changes the fanout and so the on-disk shape of every index, which
belongs in the commit that reshapes leaf records.

reserve_for. The old bound stood in for "levels a batch can add" with n/8,
which is ~125 levels for a 1000-entry batch and demands ~528 MiB of headroom.
Growing by g levels needs at least 2^g entries, so log2_ceil(n+1)+1 bounds it,
giving ~70 MiB for that batch. Harmless as ArrayList capacity; real file growth
once the arena is file-backed. Overrunning the reservation is a buffer overrun
on a path that has already appended to the log and cannot report failure, so
alloc_node and store_record now assert, using assert.zig so the checks survive
ReleaseFast. Mutation-checked by dropping the reservation entirely: six tests
go red with the new message. Worth noting the assert guards the allocation, not
the arithmetic -- ensureUnusedCapacity over-allocates, so a slightly-too-small
bound is masked until the reservation becomes exact.

build.zig gains a `fuzz` step. spill, spill2, stress and fuzz_split were in no
build step and are not in lib.zig's test block, so `zig build test` could not
see an API break in the only coverage for records past the inline limit and for
randomized split/remove interleavings -- exactly what this work puts at risk.
2026-08-03 17:09:03 +03:00
13d7b79f2c plan: amend the M0 decision record for copy-on-write; add AGENTS.md
The M0 implementation review found three of D1-D9 wrong or incomplete. The
originals stay in place with pointers to a new amendments section, so a later
session can see what changed rather than reading a rewritten history.

A1: D4 as written is unsound. The doc and overflow slabs are append-only, so
replay repairs them, but B+tree node pages are mutated in place -- after a
crash the file holds an arbitrary mix of written-back and not-written-back
pages, and once D6.3 truncates the log the data file is the only copy below
the watermark. A half-persisted tree is unrecoverable. So the checkpoint needs
shadow paging: no page below the last watermark's allocation mark is ever
stored into, and the watermark write is the atomic switch. Knock-on: node ids
cannot be page numbers, because Node.parent/next/prev are back-pointers by id
and copy-on-write would cascade; an in-RAM id->page table per index keeps every
persisted id at its current width and gives COW one pointer to fix.

A2: follows from A1 -- a page free list is a prerequisite, not the
defense-in-depth D6.2 assumed, because COW abandons every page it touches in
every epoch. The churn gate stays, retargeted at document garbage.

A3: section 5 step 4's trap was misidentified. store_record already copies
keys, so "entries must own their key bytes" is work that does not need doing.
The real problem is that a leaf record has nowhere to put a slab offset; the
resolution is to make the payload that offset, in every index, and delete
Entry.id rather than re-own it.

A4: making _id_ a unique index keys uniqueness on the canonical encode_key
rather than serialize_value, so int32 1 / int64 1 / double 1.0 collide as they
do in MongoDB -- a compatibility improvement, with a documented one-way
migration hazard for a database that already holds two such documents.

Also records that Engine.seq is never restored on open (harmless today, silent
data loss once a watermark exists), and two bugs the new spec harness found.

AGENTS.md carries the same rules into the operating guide: ground rules grow
from 7 to 9, and the old rule 6 is corrected with a note saying why.
2026-08-03 17:08:41 +03:00
86ae8fa8af style: adopt TigerStyle across src/; add docs/TIGER_STYLE.md
Wrap signatures and long expressions to the 100-column limit and make every
file zig fmt clean. Semantics-preserving throughout: ignoring whitespace and
the trailing commas that wrapping introduces, every file here is byte-identical
to its predecessor, and the one apparent exception is a warning string split
with `++`, which concatenates at comptime to the same bytes.

src/index.zig and src/commands.zig are reformatted in the commits that follow,
because their reformat is interleaved with in-flight changes to them and
separating the two would need the reformat re-derived rather than moved.
2026-08-03 17:08:21 +03:00
d4c9b04f21 rename project to MultiforaDB
Prose and benchmark tables use MultiforaDB; the binary, the CLI usage
line, the log-message prefix and the default database file use
multiforadb.

Two consequences worth noting:

- build.zig.zon's fingerprint is derived from the package name, so it
  had to change with it (Zig refuses to build otherwise). A consumer
  pinning this package by fingerprint needs updating.
- the default --db path is now multiforadb.log, and getCmdLineOpts
  reports it as dbpath. An existing mongo-lite.log has to be passed
  explicitly with --db.

The e2e harness abbreviated the old name as ML_; that is now MFDB_,
including the documented ML_BIN override (MFDB_BIN) and the scratch
file names. MD_ (mongod) is untouched.

compare-run.sh spawned the server by absolute path under a
sandbox/mongo-lite directory that no longer exists; that block already
runs from tests/e2e, so it uses a relative path now.

The archived reports under tests/e2e/results/ keep the old name: they
record what the old binary measured.
2026-08-03 12:35:01 +03:00