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

@@ -453,7 +453,10 @@ pub const Index = struct {
if (rec_len > inline_limit) overflow_bytes += rec_len;
}
if (overflow_bytes == 0) return;
if (self.ovf_tail + overflow_bytes <= self.ovf_end) return;
// Same rule as the document slab: a checkpoint freezes the page the tail
// points into, so a frozen tail means starting a fresh extent rather
// than writing inside the durable image.
if (self.ovf_tail >= self.pager.stable_bytes() and self.ovf_tail + overflow_bytes <= self.ovf_end) return;
// One extent for the whole batch, or a bespoke one when a single
// record is larger than the standard extent (a BSON string reaches
// 16 MB).
@@ -972,8 +975,15 @@ pub const Index = struct {
}
/// The page holding node `id`, for writing.
/// Writable, via copy-on-write: a node inside the published image is copied
/// to a fresh page and its table slot updated, so the durable bytes are
/// never disturbed. Infallible in practice because `reserve_for` reserves
/// the pages a batch can need -- the `catch` here would mean the reservation
/// was short, which its own assert reports first and more precisely.
inline fn page_mut(self: *Index, id: u32) *Node {
return @ptrCast(self.pager.page_mut(self.node_pages.items[id]));
const p = self.pager.page_mut_cow(&self.node_pages.items[id]) catch
@panic("multiforadb: out of pages while writing an index node");
return @ptrCast(p);
}
/// Overflow-slab bytes in `[from, to)`. Slot offsets are u64 because a