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:
2026-08-03 19:23:17 +03:00
parent 90de7820da
commit 491a4d0a6a
7 changed files with 320 additions and 275 deletions

View File

@@ -25,7 +25,7 @@ fn check_range(
facts: []const Fact,
alive: []const bool,
) !void {
var out: std.ArrayListUnmanaged([]const u8) = .empty;
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;
@@ -54,7 +54,7 @@ pub fn main() !void {
const rand = prng.random();
const N = 30_000;
var ids: std.ArrayListUnmanaged([]u8) = .empty;
var ids: std.ArrayListUnmanaged(u64) = .empty;
var facts: std.ArrayListUnmanaged(Fact) = .empty;
var alive: std.ArrayListUnmanaged(bool) = .empty;
var pairs: [2]bson.Pair = undefined;
@@ -63,7 +63,7 @@ pub fn main() !void {
var keys = [_]index.IndexKey{ .{ .path = "a", .descending = false }, .{ .path = "b", .descending = false } };
var ix = try index.Index.init(gpa, "ab", &keys, false, false, null);
for (0..N) |i| {
const id = try std.fmt.allocPrint(gpa, "id{d}", .{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);
@@ -96,7 +96,7 @@ pub fn main() !void {
// 2. Incremental inserts, random order (splits + rebalancing-free path).
const M = 20_000;
for (0..M) |i| {
const id = try std.fmt.allocPrint(gpa, "new{d}", .{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);
@@ -156,7 +156,7 @@ pub fn main() !void {
// 4. Equality lookups still exact.
for (0..300) |_| {
const a = rand.intRangeAtMost(i32, 0, 99);
var out: std.ArrayListUnmanaged([]const u8) = .empty;
var out: std.ArrayListUnmanaged(u64) = .empty;
defer out.deinit(gpa);
try ix.lookup_eq(gpa, &.{.{ .int32 = a }}, &out);
var expected: usize = 0;
@@ -208,8 +208,8 @@ pub fn main() !void {
pairs[1] = .{ .key = "b", .value = .{ .int32 = 42 } };
const d2 = try doc_of(gpa, &pairs);
defer gpa.free(d2);
_ = try ix.add_doc(gpa, d2, "final", false);
var out: std.ArrayListUnmanaged([]const u8) = .empty;
_ = 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;