index: a leaf record's payload becomes the document's slab offset
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).
This commit is contained in:
@@ -26,9 +26,9 @@ fn make_doc(gpa: std.mem.Allocator, i: usize, s: []const u8) ![]u8 {
|
||||
}
|
||||
|
||||
const Doc = struct {
|
||||
id: []u8,
|
||||
s: []u8,
|
||||
bytes: []u8,
|
||||
off: u64,
|
||||
live: bool,
|
||||
};
|
||||
|
||||
@@ -43,7 +43,6 @@ fn run(seed: u64, ops: usize, max_len: usize) !void {
|
||||
var docs: std.ArrayListUnmanaged(Doc) = .empty;
|
||||
defer {
|
||||
for (docs.items) |d| {
|
||||
gpa.free(d.id);
|
||||
gpa.free(d.s);
|
||||
gpa.free(d.bytes);
|
||||
}
|
||||
@@ -58,7 +57,7 @@ fn run(seed: u64, ops: usize, max_len: usize) !void {
|
||||
for (docs.items) |*d| {
|
||||
if (!d.live) continue;
|
||||
if (pick == 0) {
|
||||
ix.remove_doc(gpa, d.bytes, d.id);
|
||||
ix.remove_doc(gpa, d.bytes, d.off);
|
||||
d.live = false;
|
||||
live -= 1;
|
||||
break;
|
||||
@@ -77,12 +76,11 @@ fn run(seed: u64, ops: usize, max_len: usize) !void {
|
||||
errdefer gpa.free(s);
|
||||
// A small alphabet so keys collide and share prefixes.
|
||||
for (s) |*c| c.* = 'a' + rand.uintLessThan(u8, 4);
|
||||
const id = try std.fmt.allocPrint(gpa, "id{d}", .{op});
|
||||
errdefer gpa.free(id);
|
||||
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, .{ .id = id, .s = s, .bytes = bytes, .live = true });
|
||||
try docs.append(gpa, .{ .off = id, .s = s, .bytes = bytes, .live = true });
|
||||
live += 1;
|
||||
}
|
||||
|
||||
@@ -101,7 +99,7 @@ fn run(seed: u64, ops: usize, max_len: usize) !void {
|
||||
|
||||
// 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([]const u8) = .empty;
|
||||
var found: std.ArrayListUnmanaged(u64) = .empty;
|
||||
defer found.deinit(gpa);
|
||||
for (docs.items) |d| {
|
||||
if (!d.live) continue;
|
||||
@@ -110,13 +108,13 @@ fn run(seed: u64, ops: usize, max_len: usize) !void {
|
||||
try ix.lookup_eq(gpa, &.{.{ .string = d.s }}, &found);
|
||||
var hit = false;
|
||||
for (found.items) |got| {
|
||||
if (std.mem.eql(u8, got, d.id)) hit = true;
|
||||
if (got == d.off) hit = true;
|
||||
}
|
||||
if (!hit) {
|
||||
std.debug.print("seed {d} op {d}: id {s} (key len {d}) not found by descent\n", .{
|
||||
std.debug.print("seed {d} op {d}: off {d} (key len {d}) not found by descent\n", .{
|
||||
seed,
|
||||
op,
|
||||
d.id,
|
||||
d.off,
|
||||
d.s.len,
|
||||
});
|
||||
return error.EntryUnreachable;
|
||||
|
||||
Reference in New Issue
Block a user