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

@@ -753,18 +753,22 @@ fn scan_sorted(
var n: usize = 0;
// Index plan (the implicit _id_ index first, then the secondaries):
// candidates in index order, re-filtered. The returned ids alias the
// docs map keys, valid under the read lock.
// candidates in index order, re-filtered. A candidate *is* a slab offset
// now, so the map lookup that used to translate an id into one is gone --
// and so is the accidental safety net it provided: a stale entry used to be
// dropped silently by `orelse continue`, where now it resolves to
// superseded-but-parseable bytes that the re-applied filter might accept.
// Loud beats silent: a wrong answer a test can see beats a missing
// candidate nothing can.
if (try index.plan(ctx.gpa, &coll.id_index, coll.indexes.items, filter, sort)) |p| {
var plan = p;
defer plan.deinit(ctx.gpa);
var ids: std.ArrayListUnmanaged([]const u8) = .empty;
defer ids.deinit(ctx.gpa);
try plan.search(ctx.gpa, &ids);
var offs: std.ArrayListUnmanaged(u64) = .empty;
defer offs.deinit(ctx.gpa);
try plan.search(ctx.gpa, &offs);
if (sorted) |flag| flag.* = plan.provides_sort;
if (plan.provides_sort) lim = limit;
for (ids.items) |id| {
const off = coll.docs.get(id) orelse continue;
for (offs.items) |off| {
if (!try query.matches_bytes(ctx.gpa, filter, coll.doc_bytes(off))) continue;
if (out) |list| try list.append(ctx.gpa, off);
n += 1;

View File

@@ -276,9 +276,10 @@ pub const Engine = struct {
/// Drop the document stored under `id_key`, freeing it and its key.
/// No-op when the id is absent. This is the single chokepoint where a
/// document dies, so index entries are removed here — while the
/// document and the docs map key are both still alive, which is what
/// keeps `Entry.id`'s aliasing of that key safe.
/// document dies, so index entries are removed here, keyed by the slab
/// offset the map hands back. It used to matter that the map key was still
/// alive at this point, because entries aliased it; entries carry an
/// offset now, so that constraint is gone.
///
/// The document itself is handed to the index: entries are located by
/// regenerating them from it, which is far cheaper than scanning.
@@ -287,8 +288,10 @@ pub const Engine = struct {
// Resolve the bytes before any mutation; the slab is untouched by
// index removal, so the slice is safe for the call.
const old_bytes = coll.doc_bytes(old.value);
coll.id_index.remove_doc(self.gpa, old_bytes, old.key);
for (coll.indexes.items) |ix| ix.remove_doc(self.gpa, old_bytes, old.key);
// Entries are keyed by the document's slab offset now, which is exactly
// what the map just gave us.
coll.id_index.remove_doc(self.gpa, old_bytes, old.value);
for (coll.indexes.items) |ix| ix.remove_doc(self.gpa, old_bytes, old.value);
self.gpa.free(old.key);
// This document's log record (and its slab bytes) just became garbage.
// The fetchRemove above succeeded, so a live document was counted.
@@ -564,14 +567,14 @@ pub const Engine = struct {
// before the log append, inserted infallibly after it. Built
// *first* so it is checked first below -- MongoDB reports _id_
// when a write violates both it and a unique secondary.
var built = try coll.id_index.build_entries(self.gpa, doc_bytes, id_key);
var built = try coll.id_index.build_entries(self.gpa, doc_bytes);
built_list.append(self.gpa, .{ .built = built, .ix = &coll.id_index }) catch |err| {
built.deinit(self.gpa);
return err;
};
}
for (coll.indexes.items) |ix| {
var built = try ix.build_entries(self.gpa, doc_bytes, id_key);
var built = try ix.build_entries(self.gpa, doc_bytes);
built_list.append(self.gpa, .{ .built = built, .ix = ix }) catch |err| {
built.deinit(self.gpa);
return err;
@@ -584,9 +587,11 @@ pub const Engine = struct {
// (PLAN amendment A3) -- and the tree answers it better, since it
// is keyed on the canonical encode_key rather than serialize_value
// (A4). Exclude-self is null for an insert: the document has no
// entries yet, and passing its id would hide precisely the
// same-_id collision this must catch.
const exclude: ?[]const u8 = if (mode == .replace) id_key else null;
// entries yet, and passing its offset would hide precisely the
// same-_id collision this must catch. For a replace it is the
// document's *current* slab offset, since that is what its existing
// entries carry -- the new offset does not exist yet.
const exclude: ?u64 = if (mode == .replace) coll.docs.get(id_key) else null;
for (built_list.items) |*b| {
if (!b.ix.unique) continue;
b.ix.check_unique(b.built.entries.items, exclude) catch {
@@ -620,7 +625,7 @@ pub const Engine = struct {
self.live_docs += 1;
for (built_list.items) |*b| {
if (b.built.multikey) b.ix.multikey = true;
b.ix.insert_entries(&b.built);
b.ix.insert_entries(&b.built, off);
}
stored = true;
self.note_compact();
@@ -739,7 +744,7 @@ pub const Engine = struct {
// ix.deinit frees every appended key. Nothing is persisted.
var doc_it = coll.docs.iterator();
while (doc_it.next()) |entry| {
try ix.append_doc_entries(self.gpa, coll.doc_bytes(entry.value_ptr.*), entry.key_ptr.*);
try ix.append_doc_entries(self.gpa, coll.doc_bytes(entry.value_ptr.*), entry.value_ptr.*);
}
_ = try ix.finish_bulk(self.gpa, true);
@@ -818,14 +823,13 @@ pub const Engine = struct {
) !usize {
try coll.lock.lock(self.io);
defer coll.lock.unlock(self.io);
// Ids are duped rather than aliased: `remove` frees the docs-map key
// that `Entry.id` points at, which would leave the rest of the batch
// pointing into freed memory.
var ids: std.ArrayListUnmanaged([]u8) = .empty;
defer {
for (ids.items) |id| self.gpa.free(id);
ids.deinit(self.gpa);
}
// Offsets, collected before any removal. They are values, so unlike
// the id slices this used to dupe -- which aliased a docs-map key that
// `remove` would free out from under the rest of the batch -- there is
// nothing to own here. Collect-then-remove still matters, because the
// iterator below aliases tree pages that removal reshapes.
var offs: std.ArrayListUnmanaged(u64) = .empty;
defer offs.deinit(self.gpa);
for (coll.indexes.items) |ix| {
const ttl = ix.ttl orelse continue;
@@ -840,28 +844,33 @@ pub const Engine = struct {
while (it.next()) |e| {
const ms = bson.encoded_leading_datetime(e.key) orelse break;
if (@as(i128, ms) > cutoff) break;
try ids.append(self.gpa, try self.gpa.dupe(u8, e.id));
try offs.append(self.gpa, e.off);
}
}
if (ids.items.len == 0) return 0;
if (offs.items.len == 0) return 0;
// One document can be expired by several entries (an array
// of dates) or by several TTL indexes.
std.mem.sort([]u8, ids.items, {}, less_id_bytes);
std.mem.sort(u64, offs.items, {}, std.sort.asc(u64));
var w: usize = 1;
for (ids.items[1..]) |id| {
if (std.mem.eql(u8, id, ids.items[w - 1])) {
self.gpa.free(id);
} else {
ids.items[w] = id;
for (offs.items[1..]) |off| {
if (off != offs.items[w - 1]) {
offs.items[w] = off;
w += 1;
}
}
ids.items.len = w;
offs.items.len = w;
// `remove` works by _id, so recover each one from the document its
// offset names. get_at materializes a spine, hence the arena; the slab
// is untouched by the removals, so the bytes stay valid throughout.
var arena = std.heap.ArenaAllocator.init(self.gpa);
defer arena.deinit();
var removed: usize = 0;
for (ids.items) |id| {
if (try self.remove(db_name, coll_name, id)) removed += 1;
for (offs.items) |off| {
const id_value = (try bson.get_at(arena.allocator(), coll.doc_bytes(off), "_id")) orelse continue;
const id_key = try bson.serialize_value(arena.allocator(), id_value);
if (try self.remove(db_name, coll_name, id_key)) removed += 1;
}
return removed;
}
@@ -1154,7 +1163,7 @@ pub const Engine = struct {
if (ix.count() > 0) return; // defensive
var doc_it = coll.docs.iterator();
while (doc_it.next()) |doc_entry| {
ix.append_doc_entries(self.gpa, coll.doc_bytes(doc_entry.value_ptr.*), doc_entry.key_ptr.*) catch |err| switch (err) {
ix.append_doc_entries(self.gpa, coll.doc_bytes(doc_entry.value_ptr.*), doc_entry.value_ptr.*) catch |err| switch (err) {
error.ParallelArrays => {
std.debug.print(
"multiforadb: WARNING: index '{s}' cannot index an existing " ++
@@ -1205,10 +1214,6 @@ pub const Engine = struct {
}
};
fn less_id_bytes(_: void, a: []const u8, b: []const u8) bool {
return std.mem.order(u8, a, b) == .lt;
}
fn parent_dir(path: []const u8) []const u8 {
const last = std.mem.lastIndexOfScalar(u8, path, '/') orelse return ".";
if (last == 0) return "/";
@@ -1835,7 +1840,7 @@ fn index_count(
const coll = engine.get_collection(db_name, coll_name) orelse return 0;
for (coll.indexes.items) |ix| {
if (std.mem.eql(u8, ix.name, name)) {
var out: std.ArrayListUnmanaged([]const u8) = .empty;
var out: std.ArrayListUnmanaged(u64) = .empty;
defer out.deinit(gpa);
try ix.lookup_eq(gpa, &.{key_value}, &out);
return out.items.len;

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -35,7 +35,7 @@ pub fn main() !void {
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], &[_]u8{ 'i', 'd', @intCast(i + 1) }, true);
_ = 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(),
@@ -47,25 +47,25 @@ pub fn main() !void {
// Every entry is found by exact key.
for (lens, 0..) |_, i| {
var out: std.ArrayListUnmanaged([]const u8) = .empty;
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 (!std.mem.eql(u8, out.items[0], &[_]u8{ 'i', 'd', @intCast(i + 1) })) 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], &[_]u8{ 'i', 'd', @intCast(i + 1) });
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([]const u8) = .empty;
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;

View File

@@ -22,11 +22,8 @@ pub fn main() !void {
// 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([]u8) = .empty;
defer {
for (ids.items) |x| gpa.free(x);
ids.deinit(gpa);
}
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);
@@ -41,7 +38,7 @@ pub fn main() !void {
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 = try std.fmt.allocPrint(gpa, "id{d}", .{i});
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 } };
@@ -61,10 +58,10 @@ pub fn main() !void {
const rand2 = prng2.random();
for (0..300) |_| {
const i = rand2.intRangeAtMost(usize, 0, N - 1);
var out: std.ArrayListUnmanaged([]const u8) = .empty;
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 !std.mem.eql(u8, out.items[0], ids.items[i])) {
if (out.items.len != 1 or out.items[0] != ids.items[i]) {
std.debug.print("lookup mismatch at {d}\n", .{i});
return error.Bad;
}
@@ -87,7 +84,7 @@ pub fn main() !void {
}
}
for (order.items) |i| {
var out: std.ArrayListUnmanaged([]const u8) = .empty;
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;

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;