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.
108 lines
4.2 KiB
Zig
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 });
|
|
}
|