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.
This commit is contained in:
2026-08-03 20:55:48 +03:00
parent 9dda943f26
commit 2e7f72074f
7 changed files with 293 additions and 91 deletions

View File

@@ -173,9 +173,15 @@ pub const Pager = struct {
file_pages: u32,
/// Pages [0, alloc_tail) have been handed out.
alloc_tail: u32,
/// Headroom promised by `reserve_pages`, so allocation after it is
/// infallible. Never below alloc_tail.
reserved_tail: u32,
/// Pages promised by `reserve_pages` and not yet handed out.
///
/// A count rather than a tail mark, because there is more than one consumer:
/// an upsert reserves tree pages for every index *and* slab room for the
/// document, all before the log append. A tail mark cannot express that --
/// the second reserver overwrites the first one's promise, and then the
/// first one's allocation asserts. Which is exactly what happened, on a
/// 512 MB load, with the tripwire in alloc_node catching it.
reserved_pages: u32,
/// True when this file was created by this open (no checkpoint to load).
fresh: bool,
@@ -252,7 +258,7 @@ pub const Pager = struct {
.mapped_pages = 0,
.file_pages = @intCast(existing_len / page_size),
.alloc_tail = page_first_data,
.reserved_tail = page_first_data,
.reserved_pages = 0,
.fresh = created,
.loaded = .{},
.generation = 0,
@@ -272,7 +278,6 @@ pub const Pager = struct {
// Everything already in the file is allocated until a watermark
// narrows it down.
self.alloc_tail = @max(page_first_data, self.file_pages);
self.reserved_tail = self.alloc_tail;
try self.load_watermark();
}
return self;
@@ -332,10 +337,7 @@ pub const Pager = struct {
/// Hand out `n` contiguous pages, growing the file if needed.
pub fn alloc_pages(self: *Pager, n: u32) !u32 {
assert(n > 0);
try self.grow_to(self.alloc_tail + n);
// Growing satisfies the promise `alloc_pages_assume_reserved` checks;
// without this it would assert against a reservation nobody made.
self.reserved_tail = @max(self.reserved_tail, self.alloc_tail + n);
try self.reserve_pages(n);
return self.alloc_pages_assume_reserved(n);
}
@@ -348,15 +350,31 @@ pub const Pager = struct {
/// leaves a sparse file, `ls -l` grows and `du` does not. That is what makes
/// a generous reservation cheap.
pub fn reserve_pages(self: *Pager, n: u32) !void {
try self.grow_to(self.alloc_tail + n);
self.reserved_tail = self.alloc_tail + n;
// Additive: room for what is already promised *plus* this. Two
// consumers reserving before the same log append must both be able to
// rely on their promise.
try self.grow_to(self.alloc_tail + self.reserved_pages + n);
self.reserved_pages += n;
}
/// Drop whatever is still promised but unclaimed.
///
/// A reservation is scoped to one write: it is taken before the log append
/// so the publish afterwards cannot fail, and once the publish is done
/// anything unclaimed is dead. Without this the promise accumulates -- a
/// tree reservation covers the worst case of several splits and a typical
/// insert causes none, so `reserved_pages` grew by a handful per write and
/// dragged the file up with it. It showed as a 1.89 GB data file for 512 MB
/// of documents.
pub fn release_reservation(self: *Pager) void {
self.reserved_pages = 0;
}
/// Hand out `n` pages against a previous `reserve_pages`. Infallible.
pub fn alloc_pages_assume_reserved(self: *Pager, n: u32) u32 {
assert(n > 0);
assert_msg(
self.alloc_tail + n <= self.reserved_tail,
n <= self.reserved_pages,
"page allocation overran reserve_pages' promise",
);
assert_msg(
@@ -365,6 +383,7 @@ pub const Pager = struct {
);
const first = self.alloc_tail;
self.alloc_tail += n;
self.reserved_pages -= n;
return first;
}
@@ -483,7 +502,7 @@ pub const Pager = struct {
self.loaded = wm;
self.generation = wm.generation;
self.alloc_tail = wm.alloc_tail;
self.reserved_tail = wm.alloc_tail;
self.reserved_pages = 0;
// Everything the published image references is off limits to
// writes from here on.
self.stable_pages = wm.alloc_tail;
@@ -830,6 +849,34 @@ test "the file is extended before any page in the range is reachable" {
}
}
test "two consumers reserving before one commit both keep their promise" {
// The reservation is a count, not a tail mark, and this is why. An upsert
// reserves tree pages for every index *and* slab room for the document,
// all before the log append, and both allocations happen after it. With a
// tail mark the second reserver overwrote the first one's promise and the
// first one's allocation then asserted -- which is how this was found, on a
// 512 MB load, by the tripwire in alloc_node.
//
// Mutation check: make reserve_pages assign `alloc_tail + n` instead of
// accumulating, and this goes red.
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 256 << 20);
defer tp.deinit();
const pg = tp.pg();
// Consumer A reserves a few pages, then consumer B reserves a large extent
// and takes it -- exactly the order upsert uses.
try pg.reserve_pages(8);
try pg.reserve_pages(2048);
const b_first = pg.alloc_pages_assume_reserved(2048);
// A's promise must have survived B's reservation *and* B's allocation.
const a_first = pg.alloc_pages_assume_reserved(8);
try testing.expect(a_first >= b_first + 2048);
try testing.expectEqual(@as(u32, 0), pg.reserved_pages);
}
test "a reservation makes the following allocation infallible" {
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();