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.
This commit is contained in:
2026-08-03 21:11:27 +03:00
parent 2e7f72074f
commit d7f7ebb994
3 changed files with 293 additions and 11 deletions

View File

@@ -110,7 +110,11 @@ pub const Collection = struct {
/// committed and reporting an error for it would be a lie the next open
/// contradicts. Reserving first keeps the fallible half before the log.
fn slab_reserve(self: *Collection, gpa: std.mem.Allocator, len: usize) !void {
if (self.slab_tail + len <= self.slab_end) return;
// A checkpoint can land in the middle of an extent, which freezes the
// page the tail points into. Appending there would store inside the
// durable image, so abandon the rest of the extent and start a fresh
// one. The waste is bounded by one extent per collection per checkpoint.
if (self.slab_tail >= self.pager.stable_bytes() and self.slab_tail + len <= self.slab_end) return;
// A document larger than the standard extent gets one of its own; BSON
// reaches 16 MB and the extent is 8 MiB.
const want_pages: u32 = @intCast(@max(