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.
236 lines
9.0 KiB
Zig
236 lines
9.0 KiB
Zig
// Dev stress test for the index B+tree (not part of the build):
|
|
// zig run -O ReleaseFast src/stress.zig
|
|
// Bulk-builds 30k entries, inserts 20k more one at a time (splits at depth 2),
|
|
// deletes 16.6k randomly (empty-leaf cascades), drains everything, and checks
|
|
// lookups against a brute-force model throughout.
|
|
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);
|
|
}
|
|
|
|
const Fact = struct { a: i32, b: i32 };
|
|
|
|
fn check_range(
|
|
gpa: std.mem.Allocator,
|
|
ix: *const index.Index,
|
|
prefix: bson.Value,
|
|
lo: ?bson.Value,
|
|
hi: ?bson.Value,
|
|
facts: []const Fact,
|
|
alive: []const bool,
|
|
) !void {
|
|
var out: std.ArrayListUnmanaged(u64) = .empty;
|
|
defer out.deinit(gpa);
|
|
try ix.lookup_range(gpa, &.{prefix}, lo, true, hi, false, &out);
|
|
var expected: usize = 0;
|
|
for (facts, 0..) |f, fi| {
|
|
if (!alive[fi]) continue;
|
|
if (f.a != prefix.int32) continue;
|
|
if (lo) |l| if (f.b < l.int32) continue;
|
|
if (hi) |h| if (f.b >= h.int32) continue;
|
|
expected += 1;
|
|
}
|
|
if (out.items.len != expected) {
|
|
std.debug.print("MISMATCH: prefix={d} lo={?d} hi={?d}: got {d}, want {d}\n", .{
|
|
prefix.int32,
|
|
if (lo) |l| l.int32 else null,
|
|
if (hi) |h| h.int32 else null,
|
|
out.items.len,
|
|
expected,
|
|
});
|
|
std.process.exit(1);
|
|
}
|
|
}
|
|
|
|
/// 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 prng = std.Random.DefaultPrng.init(0xBADCAFE);
|
|
const rand = prng.random();
|
|
|
|
const N = 30_000;
|
|
var ids: std.ArrayListUnmanaged(u64) = .empty;
|
|
var facts: std.ArrayListUnmanaged(Fact) = .empty;
|
|
var alive: std.ArrayListUnmanaged(bool) = .empty;
|
|
var pairs: [2]bson.Pair = undefined;
|
|
|
|
// 1. Bulk build an index over N docs.
|
|
var keys = [_]index.IndexKey{ .{ .path = "a", .descending = false }, .{ .path = "b", .descending = false } };
|
|
var ix = try index.Index.init(gpa, try harness_pager(gpa, "stress"), "ab", &keys, false, false, null);
|
|
for (0..N) |i| {
|
|
const id: u64 = @intCast(i + 1);
|
|
try ids.append(gpa, id);
|
|
const a = rand.intRangeAtMost(i32, 0, 99);
|
|
const b = rand.intRangeAtMost(i32, 0, 999);
|
|
try facts.append(gpa, .{ .a = a, .b = b });
|
|
try alive.append(gpa, true);
|
|
pairs[0] = .{ .key = "a", .value = .{ .int32 = a } };
|
|
pairs[1] = .{ .key = "b", .value = .{ .int32 = b } };
|
|
const d = try doc_of(gpa, &pairs);
|
|
try ix.append_doc_entries(gpa, d, id);
|
|
}
|
|
_ = try ix.finish_bulk(gpa, false);
|
|
std.debug.print("bulk: count={d} leaves={d} depth={d} nodes={d}\n", .{
|
|
ix.count(),
|
|
ix.leaf_count,
|
|
ix.depth,
|
|
ix.node_pages.items.len,
|
|
});
|
|
if (ix.count() != N) return error.BadCount;
|
|
|
|
// Random range checks against brute force.
|
|
for (0..500) |_| {
|
|
const a = rand.intRangeAtMost(i32, 0, 99);
|
|
const lo_v = rand.intRangeAtMost(i32, -10, 1009);
|
|
const hi_v = rand.intRangeAtMost(i32, -10, 1009);
|
|
const use_lo = rand.boolean();
|
|
const use_hi = rand.boolean();
|
|
try check_range(gpa, &ix, .{ .int32 = a }, if (use_lo) .{ .int32 = lo_v } else null, if (use_hi) .{ .int32 = hi_v } else null, facts.items, alive.items);
|
|
}
|
|
|
|
// 2. Incremental inserts, random order (splits + rebalancing-free path).
|
|
const M = 20_000;
|
|
for (0..M) |i| {
|
|
const id: u64 = @intCast(100_000 + i);
|
|
try ids.append(gpa, id);
|
|
const a = rand.intRangeAtMost(i32, 0, 99);
|
|
const b = rand.intRangeAtMost(i32, 0, 999);
|
|
try facts.append(gpa, .{ .a = a, .b = b });
|
|
try alive.append(gpa, true);
|
|
pairs[0] = .{ .key = "a", .value = .{ .int32 = a } };
|
|
pairs[1] = .{ .key = "b", .value = .{ .int32 = b } };
|
|
const d = try doc_of(gpa, &pairs);
|
|
_ = try ix.add_doc(gpa, d, id, false);
|
|
}
|
|
std.debug.print("after inserts: count={d} leaves={d} depth={d} nodes={d}\n", .{
|
|
ix.count(),
|
|
ix.leaf_count,
|
|
ix.depth,
|
|
ix.node_pages.items.len,
|
|
});
|
|
if (ix.count() != N + M) return error.BadCount;
|
|
for (0..500) |_| {
|
|
const a = rand.intRangeAtMost(i32, 0, 99);
|
|
const lo_v = rand.intRangeAtMost(i32, -10, 1009);
|
|
const hi_v = rand.intRangeAtMost(i32, -10, 1009);
|
|
const use_lo = rand.boolean();
|
|
const use_hi = rand.boolean();
|
|
try check_range(gpa, &ix, .{ .int32 = a }, if (use_lo) .{ .int32 = lo_v } else null, if (use_hi) .{ .int32 = hi_v } else null, facts.items, alive.items);
|
|
}
|
|
|
|
// 3. Delete every 3rd doc in random order (empty-leaf cascades,
|
|
// one-child internals).
|
|
var order: std.ArrayListUnmanaged(usize) = .empty;
|
|
for (0..N + M) |i| if (i % 3 == 0) try order.append(gpa, i);
|
|
rand.shuffle(usize, order.items);
|
|
for (order.items) |i| {
|
|
const a = facts.items[i].a;
|
|
const b = facts.items[i].b;
|
|
pairs[0] = .{ .key = "a", .value = .{ .int32 = a } };
|
|
pairs[1] = .{ .key = "b", .value = .{ .int32 = b } };
|
|
const d = try doc_of(gpa, &pairs);
|
|
ix.remove_doc(gpa, d, ids.items[i]);
|
|
alive.items[i] = false;
|
|
}
|
|
std.debug.print("after deletes: count={d} leaves={d} depth={d} nodes={d}\n", .{
|
|
ix.count(),
|
|
ix.leaf_count,
|
|
ix.depth,
|
|
ix.node_pages.items.len,
|
|
});
|
|
if (ix.count() != (N + M) - (N + M) / 3 - 1) return error.BadCount;
|
|
for (0..500) |_| {
|
|
const a = rand.intRangeAtMost(i32, 0, 99);
|
|
const lo_v = rand.intRangeAtMost(i32, -10, 1009);
|
|
const hi_v = rand.intRangeAtMost(i32, -10, 1009);
|
|
const use_lo = rand.boolean();
|
|
const use_hi = rand.boolean();
|
|
try check_range(gpa, &ix, .{ .int32 = a }, if (use_lo) .{ .int32 = lo_v } else null, if (use_hi) .{ .int32 = hi_v } else null, facts.items, alive.items);
|
|
}
|
|
|
|
// 4. Equality lookups still exact.
|
|
for (0..300) |_| {
|
|
const a = rand.intRangeAtMost(i32, 0, 99);
|
|
var out: std.ArrayListUnmanaged(u64) = .empty;
|
|
defer out.deinit(gpa);
|
|
try ix.lookup_eq(gpa, &.{.{ .int32 = a }}, &out);
|
|
var expected: usize = 0;
|
|
for (facts.items, 0..) |f, fi| {
|
|
if (alive.items[fi] and f.a == a) expected += 1;
|
|
}
|
|
if (out.items.len != expected) {
|
|
std.debug.print("EQ MISMATCH a={d}: got {d} want {d}\n", .{
|
|
a,
|
|
out.items.len,
|
|
expected,
|
|
});
|
|
return error.BadCount;
|
|
}
|
|
}
|
|
|
|
// 5. Delete everything (empty-leaf cascades, one-child internals),
|
|
// then verify the tree still works for fresh inserts.
|
|
var live: std.ArrayListUnmanaged(usize) = .empty;
|
|
defer live.deinit(gpa);
|
|
for (facts.items, 0..) |_, i| if (alive.items[i]) try live.append(gpa, i);
|
|
rand.shuffle(usize, live.items);
|
|
var remaining = live.items.len;
|
|
for (live.items) |i| {
|
|
const a = facts.items[i].a;
|
|
const b = facts.items[i].b;
|
|
pairs[0] = .{ .key = "a", .value = .{ .int32 = a } };
|
|
pairs[1] = .{ .key = "b", .value = .{ .int32 = b } };
|
|
const d = try doc_of(gpa, &pairs);
|
|
ix.remove_doc(gpa, d, ids.items[i]);
|
|
remaining -= 1;
|
|
if (ix.count() != remaining) {
|
|
std.debug.print("COUNT MISMATCH during drain: {d} != {d}\n", .{
|
|
ix.count(),
|
|
remaining,
|
|
});
|
|
return error.BadCount;
|
|
}
|
|
}
|
|
std.debug.print("after full drain: count={d} leaves={d} depth={d} nodes={d}\n", .{
|
|
ix.count(),
|
|
ix.leaf_count,
|
|
ix.depth,
|
|
ix.node_pages.items.len,
|
|
});
|
|
if (ix.count() != 0) return error.BadCount;
|
|
// The drained tree still accepts and finds entries.
|
|
pairs[0] = .{ .key = "a", .value = .{ .int32 = 7 } };
|
|
pairs[1] = .{ .key = "b", .value = .{ .int32 = 42 } };
|
|
const d2 = try doc_of(gpa, &pairs);
|
|
defer gpa.free(d2);
|
|
_ = try ix.add_doc(gpa, d2, 999_999, false);
|
|
var out: std.ArrayListUnmanaged(u64) = .empty;
|
|
defer out.deinit(gpa);
|
|
try ix.lookup_eq(gpa, &.{.{ .int32 = 7 }}, &out);
|
|
if (out.items.len != 1) return error.BadCount;
|
|
var it = ix.iter();
|
|
if (it.next() == null) return error.BadCount;
|
|
if (it.next() != null) return error.BadCount;
|
|
|
|
std.debug.print("STRESS OK\n", .{});
|
|
}
|