Commit Graph

19 Commits

Author SHA1 Message Date
A.Shakhmatov
a748a3d08c db/pager/tests: cleanup pass over the free list
No behaviour is meant to change and the gate confirms it: 1.94x / 679.2 MB
reclaimed / 9 rebuilds and 2.46x / 934.0 MB / 13 on the two 16 KiB lines,
0.0 MB on the 200-byte line, all identical to the numbers recorded for them.

Deduplication. `pages_for` was written in db.zig and again in pager.zig and
twice more inline; there is now one, public, and the two pre-existing copies
call it. `pages_per_map_align` replaces three hand-rolled `map_align /
page_size`. `SlabRun.window_first` was a stored field that could never legally
disagree with `first` and was maintained by hand at two sites -- now a method.
`keep_piece` re-derived `SlabRun.window_count` character for character; it
calls it. `insert_run` scanned linearly for a position `run_of` binary-searches
for, which made loading a fragmented catalog quadratic; both now go through one
`run_lower_bound`. Freeing a run's window map was written four times; one
helper. The 20% rebuild share was stated in `note_compact` and again in
`wants_rebuild`, with a comment arguing at length that they must be the same
number -- `worth_rewriting` makes that structural.

Efficiency. The identity assert in `reclaim_windows` called `dead_located()`,
an O(every window) walk, and `assert_msg` is live in ReleaseFast -- so it
doubled the scan the reclamation was about to make (2.75 MB streamed twice per
reclaiming checkpoint at the 21 GB the gate targets). `dead_located` is now a
maintained counter, the check is O(1) in every build, and the scan cross-checks
it while it is there. `SlabRun.full` lets a run with nothing to give be copied
without its counters being read at all, so the common case is O(runs) rather
than O(windows).

The pager's two allocation policies were hand-copying the claim step, and the
copy had already lost two of the three preconditions -- `alloc_slab_run` never
checked `pages <= reserved_pages`. Both now go through `claim_locked`.

`reclaimed_bytes` moves from Collection to Engine, beside `compactions`, which
is how it is read and the only place it can be honest: a life-of-the-process
total must not lose a dropped collection's share. Both join `Counters`, so
`slab_stats` stops opening `counter_lock` by hand.

The ownership assertion in `write_catalog` was gated on `is_test or Debug`, a
predicate nothing else in the codebase uses, which left the one silent failure
this design can produce unchecked in ReleaseSafe. It is now `!= ReleaseFast`,
the line `protect_stable` already draws. Measured: no change to the suite's
runtime.

Altitude. `note_checkpoint` was called from exactly one place, the tail of
`upsert` -- so a delete armed no checkpoint by any route, which is why
reclamation only ever ran when the *rebuild* trigger fired and the rebuild then
reset the window map it would have used. `remove` and the TTL sweep arm one
now, next to the `note_compact` calls that were added for the same omission a
milestone ago. `compact`'s leading checkpoint stays, demoted in its comment
from the mechanism to the local ordering it actually guarantees.

serverStatus reports `allocTailBytes`/`freeReadyBytes` instead of page counts,
so the harness stops hard-coding 4096 -- the kind of constant this milestone
was blindsided by once already.

tests/e2e/churn.js: `deleteMany({_id: {$in: [5000 ids]}})` exceeded
`index.max_combos`, so the planner refused the index and every delete became a
full collection scan re-filtering each document against 5000 members. That was
the entire runtime of the harness. One delete spec per id instead: the 40k x
16 KiB gate goes 57 s -> 6 s, and the 150k x 200 B line 483 s -> 3 s, with
identical output. Also: the per-round `countDocuments` is gone (the harness
knows the count), and the server log is a bounded ring rather than a rope that
grows with everything the server ever said.

Reverted from the review: reusing one MongoClient across the startup poll. A
client whose first connect fails tears its topology down and every later
command on it fails identically, so it turns "not up yet" into "never comes
up" -- it broke the first run. The reason is now a comment.

187/187 unit tests in ReleaseFast and ReleaseSafe, 83/83 fuzz, e2e 49, e2e2
concurrent 2 and the crash pair, e2e3 16, e2e4 17, e2e6 72, e2e7 86,
crash-fuzz 60 cycles.
2026-08-09 19:08:15 +03:00
A.Shakhmatov
d492726881 db/pager: the catalog may not claim a page that is on the free list
Reclamation runs as a checkpoint phase, so it frees pages concurrently with
everything else -- and the one failure that arrangement can produce is silent.
A catalog that claims a page already handed to the pager gets that page back
two generations later, written over by somebody else; the crash that falls
back to that generation then reads a document which is no longer there.
Nothing fails at the time, and the `seq` retry cannot see it because neither a
reclamation nor a rebuild appends a log record.

So `write_catalog` now asserts it, per run, in test and Debug builds -- three
list scans where every run is being walked anyway. It is proven to fire: have
`reclaim_windows` free the pages and keep the old run list, and the suite
panics on it. This is the double-ownership detector the plan said an enlarged
free list deserves.

And `checkpoint` takes a lock of its own. Two can be in flight -- a writer's
epilogue claims the pending flag while another is inside `compact`, which
checkpoints of its own. The publish was always safe, since it runs under
`log_lock`; the phase in front of it is new. One checkpoint's `reclaim_slabs`
frees pages under a collection's lock that the other's `write_catalog` may
already have serialized, and that is exactly the shape above.

Stated plainly: the argument for the lock is by construction, and no test
reproduces the interleaving -- removing it leaves the new concurrency test
green. What that test does do is run reclamation under two checkpointers and a
writer with the ownership assertion armed, which is the harness that would
catch the argument being wrong.

This is the second instance of the shape PLAN records as still open (a rebuild
frees pages under only the collection's lock while a checkpoint may have
snapshotted a catalog claiming them). Reclamation is now excluded from it;
`compact`'s rebuild walk still is not, and that remains recorded rather than
fixed here.

187/187 unit tests in ReleaseFast and ReleaseSafe, 83/83 fuzz, the full e2e
matrix, crash-fuzz 60 cycles.
2026-08-09 18:08:00 +03:00
A.Shakhmatov
7fe1009243 db/pager: a slab extent comes off the free list when one fits
Without this the previous commit is decorative. Windows go back, the free list
fills up, and the file grows by the whole write volume anyway -- because
nothing asks for the pages in the shape they arrive in.

`take_free` cannot serve a slab extent from reclaimed windows, and that is on
purpose. It is best fit precisely so the thousands of single-page
copy-on-write requests per generation cannot dismantle the large runs; the
consequence is that a 2048-page extent request never matches anything smaller,
and reclamation hands back runs a few windows at a time.

So `alloc_slab_run` is a second policy in the same allocator: at least
`min_pages`, at most `max_pages`, longest available so the collection switches
extents as rarely as possible, ties to the smallest source run so the big ones
stay as whole as they can. It takes a partial run when it cannot have a whole
one and it is allowed to trim a larger one -- there is no cannibalisation to
fear when the request is itself at least 1 MiB, and what it leaves behind is a
run rather than a hole. `take_free` is untouched and its pinned first-fit
mutation test still passes.

What it hands out is aligned to `map_align`, which is not cosmetic. That is
the granularity writeback tears at and the granularity reclamation gives back
at, so a run starting mid-system-page both wastes its first window and shares
a kernel page with whatever holds the rest of it -- for a page still in the
published image, exactly the tearing `mark_appendable` refuses to risk. The
trimmed edges stay on the free list.

The caller's floor is 1 MiB: a shorter extent is exhausted after a handful of
documents and every exhaustion writes off what is left of the one before it.

Measured, in the new engine-level test: delete-and-refill of 400 16 KiB
documents per round, four rounds. The tail stands at 2068 pages after the
first round and 2083 after three more of the same volume -- 15 pages of growth
against 4800 pages written. That is the number the whole milestone is about,
and it is the one Risk 5 in the plan says to check directly rather than
inferring from a ratio.

Three tests. The churn one above. The pager's policy: what it hands out starts
and ends on a system-page boundary, comes out of the long run rather than the
short one and past the long one's unaligned first page, and everything not
handed out is still on the list; a run below the floor is left alone.

Mutations: raise `slab_run_min_pages` to a whole extent and the churn test goes
red (which is also the measurement saying the floor has to stay well under an
extent); drop the alignment and the pager test goes red on an odd-page run;
drop the `usable < min_pages` test and the short run is handed out.

184/184 unit tests in ReleaseFast and ReleaseSafe, 83/83 fuzz, e2e 49, e2e2
concurrent 2 and the crash pair, e2e3 16, e2e4 17, e2e6 72, e2e7 86,
crash-fuzz 60 cycles.
2026-08-09 17:03:42 +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
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
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
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
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
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
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
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
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