PLAN amendment A3. The B+tree leaf had nowhere to put a document's slab offset -- `Slot.extra` is the payload length for a leaf and the child node id for an internal separator -- which is what blocks the `_id_` tree from becoming the primary lookup once the docs hashmap goes away. A leaf record is now `key ++ offset_le`, so `extra` is always 8 and every byte-accounting site (fits, record_cost, slot_cost, balanced_cut, repack_keep_prefix) is untouched. Records get *smaller*: an ObjectId `_id_` record goes from 26 bytes to 21. `Entry.id` is deleted rather than re-owned. Every entry one document contributes shares one document, so which document it is belongs on the call that commits the entries -- which also makes it impossible to confuse the offset a replace is removing with the one it is inserting. The old field aliased the docs map's key and was only safe because removal happened at the one chokepoint where a document dies; that constraint is gone. Done for secondary indexes too, not just `_id_`. That deletes the per-candidate `coll.docs.get(id)` in scan_sorted outright rather than replacing it with an `_id_` descent, and it is free on the write path because a replace already removes and reinserts every entry in every index. Consequences worth knowing: - lookup_eq/lookup_range/Plan.search yield u64. Those are values, immune to the tree mutation that invalidated the id slices they used to hand back -- which is why ttl_sweep_coll can drop the dupe-and-free dance it needed to survive `remove` freeing the key its entries pointed at. - One safety net is gone. A stale entry used to be swallowed by `docs.get(id) orelse continue`; now it resolves to superseded-but-parseable bytes the re-applied filter might accept. That trades an invisible under-approximation for a visible wrong answer, which is the better failure to have, but it is a trade. - A checkpoint may never renumber slab offsets (already recorded in PLAN §4): every index leaf now holds a physical one. `zig build fuzz` earned its keep immediately -- it caught the API break in all four B+tree harnesses, which `zig build test` cannot see. Benchmarks A/B'd at 256m on one harness, before and after: all rows flat. updateMany and deleteOne+insertOne first looked 10-13% slower, which three repeat runs showed to be single-sample noise (0.70/0.71/0.70 against 0.70).
83 lines
3.1 KiB
Zig
83 lines
3.1 KiB
Zig
// Dev stress test for the overflow slab (records > 1024 bytes):
|
|
// zig run -O ReleaseFast src/spill.zig
|
|
// Keys straddling the spill threshold (10 B .. 100 KB) through insert,
|
|
// lookup, delete and iteration.
|
|
const std = @import("std");
|
|
const index = @import("index.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);
|
|
}
|
|
|
|
pub fn main() !void {
|
|
const gpa = std.heap.page_allocator;
|
|
var prng = std.Random.DefaultPrng.init(0x1234_5678);
|
|
const rand = prng.random();
|
|
|
|
var keys = [_]index.IndexKey{.{ .path = "tag", .descending = false }};
|
|
var ix = try index.Index.init(gpa, "tag", &keys, false, false, null);
|
|
|
|
// Keys straddling the spill threshold: inline, exactly at the limit,
|
|
// just over, and one very long. Each id is a short static string.
|
|
const lens = [_]usize{ 10, 1023, 1024, 1025, 2000, 100_000 };
|
|
var strings: [lens.len][]u8 = undefined;
|
|
var docs: [lens.len][]u8 = undefined;
|
|
var pairs: [2]bson.Pair = undefined;
|
|
for (lens, 0..) |len, i| {
|
|
strings[i] = try gpa.alloc(u8, len);
|
|
for (strings[i]) |*c| c.* = 'a' + @as(u8, @intCast(rand.intRangeAtMost(u8, 0, 25)));
|
|
// add a distinguishing suffix so keys are unique
|
|
std.mem.copyForwards(u8, strings[i][len - 4 ..], &[_]u8{ @intCast(i), 0xff, 0x00, 0x00 });
|
|
pairs[0] = .{ .key = "_id", .value = .{ .int32 = @intCast(i) } };
|
|
pairs[1] = .{ .key = "tag", .value = .{ .string = strings[i] } };
|
|
docs[i] = try doc_of(gpa, &pairs);
|
|
_ = try ix.add_doc(gpa, docs[i], @intCast(i + 1), true);
|
|
}
|
|
std.debug.print("count={d} leaves={d} depth={d} overflow={d}\n", .{
|
|
ix.count(),
|
|
ix.leaf_count,
|
|
ix.depth,
|
|
ix.overflow.items.len,
|
|
});
|
|
if (ix.overflow.items.len < 100_000) return error.NoSpill;
|
|
|
|
// Every entry is found by exact key.
|
|
for (lens, 0..) |_, i| {
|
|
var out: std.ArrayListUnmanaged(u64) = .empty;
|
|
defer out.deinit(gpa);
|
|
try ix.lookup_eq(gpa, &.{.{ .string = strings[i] }}, &out);
|
|
if (out.items.len != 1) {
|
|
std.debug.print("lookup {d} got {d}\n", .{ i, out.items.len });
|
|
return error.Bad;
|
|
}
|
|
if (out.items[0] != i + 1) return error.Bad;
|
|
}
|
|
|
|
// Delete the spilled ones and the inline ones alternately.
|
|
for (lens, 0..) |_, i| {
|
|
if (i % 2 == 0) continue;
|
|
ix.remove_doc(gpa, docs[i], @intCast(i + 1));
|
|
}
|
|
if (ix.count() != 3) return error.Bad;
|
|
for (lens, 0..) |_, i| {
|
|
if (i % 2 == 0) continue;
|
|
var out: std.ArrayListUnmanaged(u64) = .empty;
|
|
defer out.deinit(gpa);
|
|
try ix.lookup_eq(gpa, &.{.{ .string = strings[i] }}, &out);
|
|
if (out.items.len != 0) return error.Bad;
|
|
}
|
|
// Iteration still sees the survivors in order.
|
|
var it = ix.iter();
|
|
var seen: usize = 0;
|
|
while (it.next()) |e| {
|
|
seen += 1;
|
|
_ = e;
|
|
}
|
|
if (seen != 3) return error.Bad;
|
|
std.debug.print("SPILL OK (seen={d})\n", .{seen});
|
|
}
|