Files
MultiforaDB/src/spill2.zig
Aleksey Shakhmatov 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

108 lines
4.2 KiB
Zig

// Dev stress test: spilled records through leaf splits and internal levels.
// zig run -O ReleaseFast src/spill2.zig
// 5000 docs with 2 KiB keys: every record spills; the tree still answers
// exact lookups and deletes half of them.
const std = @import("std");
const index = @import("index.zig");
const pgr = @import("pager.zig");
const bson = @import("bson.zig");
fn doc_of(gpa: std.mem.Allocator, pairs: []const bson.Pair) ![]u8 {
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
try bson.write_doc(pairs, gpa, &out);
return out.toOwnedSlice(gpa);
}
/// A throwaway data file for this harness. The B+tree's pages live in the data
/// file now, so a standalone harness has to provide one.
fn harness_pager(gpa: std.mem.Allocator, name: []const u8) !*pgr.Pager {
const threaded = try gpa.create(std.Io.Threaded);
threaded.* = .init_single_threaded;
const io = threaded.io();
const path = try std.fmt.allocPrint(gpa, ".zig-cache/{s}.data", .{name});
std.Io.Dir.cwd().deleteFile(io, path) catch {};
const pg = try gpa.create(pgr.Pager);
pg.* = try pgr.Pager.open(gpa, io, path, .{});
return pg;
}
pub fn main() !void {
const gpa = std.heap.page_allocator;
var keys = [_]index.IndexKey{.{ .path = "tag", .descending = false }};
var ix = try index.Index.init(gpa, try harness_pager(gpa, "spill2"), "tag", &keys, false, false, null);
// 5000 docs, each with a 2 KiB key: spills on every record, forcing
// leaves and internal nodes to hold overflow references.
const N = 5000;
var ids: std.ArrayListUnmanaged(u64) = .empty;
defer ids.deinit(gpa);
var pairs: [2]bson.Pair = undefined;
var buf = try gpa.alloc(u8, 2000);
defer gpa.free(buf);
var facts: std.ArrayListUnmanaged(struct { key: []u8 }) = .empty;
defer {
for (facts.items) |f| gpa.free(f.key);
facts.deinit(gpa);
}
for (0..N) |i| {
for (buf) |*c| c.* = 'x';
// unique suffix
std.mem.writeInt(u32, buf[0..4], @intCast(i), .little);
const key = try gpa.dupe(u8, buf);
try facts.append(gpa, .{ .key = key });
const id: u64 = @intCast(i + 1);
try ids.append(gpa, id);
pairs[0] = .{ .key = "_id", .value = .{ .int32 = @intCast(i) } };
pairs[1] = .{ .key = "tag", .value = .{ .string = key } };
const d = try doc_of(gpa, &pairs);
_ = try ix.add_doc(gpa, d, id, false);
}
std.debug.print("count={d} leaves={d} depth={d} overflow={d}\n", .{
ix.count(),
ix.leaf_count,
ix.depth,
ix.ovf_tail,
});
if (ix.count() != N) return error.Bad;
// Spot-check exact lookups.
var prng2 = std.Random.DefaultPrng.init(0xabc);
const rand2 = prng2.random();
for (0..300) |_| {
const i = rand2.intRangeAtMost(usize, 0, N - 1);
var out: std.ArrayListUnmanaged(u64) = .empty;
defer out.deinit(gpa);
try ix.lookup_eq(gpa, &.{.{ .string = facts.items[i].key }}, &out);
if (out.items.len != 1 or out.items[0] != ids.items[i]) {
std.debug.print("lookup mismatch at {d}\n", .{i});
return error.Bad;
}
}
// Delete half (random), verify count and no leftover.
var order: std.ArrayListUnmanaged(usize) = .empty;
defer order.deinit(gpa);
for (0..N) |i| if (i % 2 == 0) try order.append(gpa, i);
rand2.shuffle(usize, order.items);
var removed: usize = 0;
for (order.items) |i| {
pairs[0] = .{ .key = "_id", .value = .{ .int32 = @intCast(i) } };
pairs[1] = .{ .key = "tag", .value = .{ .string = facts.items[i].key } };
const d = try doc_of(gpa, &pairs);
ix.remove_doc(gpa, d, ids.items[i]);
removed += 1;
if (ix.count() != N - removed) {
std.debug.print("count mismatch at {d}: {d} != {d}\n", .{ i, ix.count(), N - removed });
return error.Bad;
}
}
for (order.items) |i| {
var out: std.ArrayListUnmanaged(u64) = .empty;
defer out.deinit(gpa);
try ix.lookup_eq(gpa, &.{.{ .string = facts.items[i].key }}, &out);
if (out.items.len != 0) return error.Bad;
}
std.debug.print("SPILL2 OK (leaves={d} depth={d})\n", .{ ix.leaf_count, ix.depth });
}