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.
155 lines
5.6 KiB
Zig
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);
|
|
}
|