Files
MultiforaDB/src/fuzz_split.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

155 lines
5.6 KiB
Zig

//! Randomised differential over Index with wildly varying key sizes.
//!
//! The tree's split logic is where record sizes and slot counts interact:
//! a page can be full of bytes or full of slots, and the record that caused
//! the split has to fit the half it lands in. Fixed-shape tests never mix
//! those, so this hammers the same index with keys from 4 bytes to past the
//! inline limit, interleaves removals (which leave dead bytes behind), and
//! checks the whole tree against a model.
//!
//! Run: zig test src/fuzz_split.zig
const std = @import("std");
const bson = @import("bson.zig");
const index = @import("index.zig");
const pgr = @import("pager.zig");
const testing = std.testing;
fn make_doc(gpa: std.mem.Allocator, i: usize, s: []const u8) ![]u8 {
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
try bson.write_doc(&.{
.{ .key = "_id", .value = .{ .int32 = @intCast(i) } },
.{ .key = "s", .value = .{ .string = s } },
}, gpa, &out);
return out.toOwnedSlice(gpa);
}
const Doc = struct {
s: []u8,
bytes: []u8,
off: u64,
live: bool,
};
/// 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.
/// The Threaded is intentionally leaked: it must outlive the pager's io, and
/// these harnesses are one-shot processes. fuzz_split runs under the testing
/// allocator, which checks for leaks, so it uses the page allocator here.
fn harness_pager(gpa: std.mem.Allocator, name: []const u8) !*pgr.Pager {
const threaded = try std.heap.page_allocator.create(std.Io.Threaded);
threaded.* = .init_single_threaded;
const io = threaded.io();
const path = try std.fmt.allocPrint(std.heap.page_allocator, ".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;
}
fn run(seed: u64, ops: usize, max_len: usize) !void {
const gpa = testing.allocator;
var prng = std.Random.DefaultPrng.init(seed);
const rand = prng.random();
const pg = try harness_pager(gpa, "fuzz_split");
defer {
pg.deinit();
gpa.destroy(pg);
}
var ix = try index.Index.init(gpa, pg, "s_1", &.{.{ .path = "s", .descending = false }}, false, false, null);
defer ix.deinit(gpa);
var docs: std.ArrayListUnmanaged(Doc) = .empty;
defer {
for (docs.items) |d| {
gpa.free(d.s);
gpa.free(d.bytes);
}
docs.deinit(gpa);
}
var live: usize = 0;
for (0..ops) |op| {
if (live > 0 and rand.uintLessThan(u32, 100) < 35) {
// Remove a random live document.
var pick = rand.uintLessThan(usize, live);
for (docs.items) |*d| {
if (!d.live) continue;
if (pick == 0) {
ix.remove_doc(gpa, d.bytes, d.off);
d.live = false;
live -= 1;
break;
}
pick -= 1;
}
} else {
// Insert a document whose key length is drawn from a mix of
// tiny, around the inline limit, and past it (spilled).
const len = switch (rand.uintLessThan(u32, 10)) {
0...4 => rand.intRangeAtMost(usize, 1, 16),
5...7 => rand.intRangeAtMost(usize, 900, 1100),
else => rand.intRangeAtMost(usize, 1100, max_len),
};
const s = try gpa.alloc(u8, len);
errdefer gpa.free(s);
// A small alphabet so keys collide and share prefixes.
for (s) |*c| c.* = 'a' + rand.uintLessThan(u8, 4);
const id: u64 = @intCast(op + 1);
const bytes = try make_doc(gpa, op, s);
errdefer gpa.free(bytes);
_ = try ix.add_doc(gpa, bytes, id, false);
try docs.append(gpa, .{ .off = id, .s = s, .bytes = bytes, .live = true });
live += 1;
}
if (op % 25 != 0 and op != ops - 1) continue;
// The tree holds exactly the live entries, in key order.
try testing.expectEqual(live, ix.count());
var seen: usize = 0;
var prev: []const u8 = "";
var it = ix.iter();
while (it.next()) |e| : (seen += 1) {
try testing.expect(std.mem.order(u8, prev, e.key) != .gt);
prev = e.key;
}
try testing.expectEqual(live, seen);
// Every live document is reachable by a descent, not just by
// walking the leaf chain: a bad separator breaks only the descent.
var found: std.ArrayListUnmanaged(u64) = .empty;
defer found.deinit(gpa);
for (docs.items) |d| {
if (!d.live) continue;
if (rand.uintLessThan(u32, 100) >= 10) continue; // sample
found.clearRetainingCapacity();
try ix.lookup_eq(gpa, &.{.{ .string = d.s }}, &found);
var hit = false;
for (found.items) |got| {
if (got == d.off) hit = true;
}
if (!hit) {
std.debug.print("seed {d} op {d}: off {d} (key len {d}) not found by descent\n", .{
seed,
op,
d.off,
d.s.len,
});
return error.EntryUnreachable;
}
}
}
}
test "mixed key sizes with removals" {
for (0..6) |k| try run(@intCast(k + 1), 1500, 8000);
}
test "keys clustered around the inline limit" {
for (0..4) |k| try run(@intCast(k + 100), 1200, 1200);
}