From 491a4d0a6acbe7c01390d3e258ba8b0a5590c61a Mon Sep 17 00:00:00 2001 From: Aleksey Shakhmatov Date: Mon, 3 Aug 2026 19:23:17 +0300 Subject: [PATCH] index: a leaf record's payload becomes the document's slab offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/commands.zig | 18 +- src/db.zig | 79 ++++---- src/fuzz_split.zig | 18 +- src/index.zig | 441 +++++++++++++++++++++++++-------------------- src/spill.zig | 10 +- src/spill2.zig | 15 +- src/stress.zig | 14 +- 7 files changed, 320 insertions(+), 275 deletions(-) diff --git a/src/commands.zig b/src/commands.zig index 3730019..41552bb 100644 --- a/src/commands.zig +++ b/src/commands.zig @@ -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; diff --git a/src/db.zig b/src/db.zig index a75371d..b1bd678 100644 --- a/src/db.zig +++ b/src/db.zig @@ -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; diff --git a/src/fuzz_split.zig b/src/fuzz_split.zig index f626521..d85cd58 100644 --- a/src/fuzz_split.zig +++ b/src/fuzz_split.zig @@ -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; diff --git a/src/index.zig b/src/index.zig index f15d214..e73d412 100644 --- a/src/index.zig +++ b/src/index.zig @@ -23,9 +23,10 @@ //! `Entry.key` is an owned, order-preserving byte encoding of the indexed //! values (see bson.encode_key), so comparing two entries is a memcmp //! rather than a walk over Values that each live in a different document's -//! arena. `Entry.id` still aliases the docs map key; index removal happens -//! at the top of evict_doc (src/db.zig) — the single chokepoint where a -//! document dies — so that aliasing is safe by construction. +//! arena. An entry carries no reference to its document: the leaf record's +//! payload is the document's slab offset, so a candidate produced by a lookup +//! *is* the location of the document, and it is a value rather than a slice +//! that tree mutation could invalidate. //! //! Entries live in a B+tree (item 1 of the performance roadmap) instead of //! one sorted array, so inserting into an already-built index costs O(log n) @@ -60,12 +61,25 @@ pub const IndexKey = struct { descending: bool, }; -/// One index entry. `key` is the gpa-owned encoded form of this document's -/// values for the index's paths, concatenated column by column; `id` -/// aliases the docs map key. +/// One index entry: the gpa-owned encoded form of a document's values for the +/// index's paths, concatenated column by column (see bson.encode_key). +/// +/// Deliberately carries no reference to the document. Every entry one document +/// contributes shares one document, so which document it is belongs on the call +/// that commits the entries, not on each entry -- which also makes it +/// impossible to confuse the offset a replace is *removing* with the one it is +/// *inserting*. It used to hold an `id` slice aliasing the docs map's key, an +/// aliasing that only held because removal happened at the single chokepoint +/// where a document dies (PLAN amendment A3). pub const Entry = struct { key: []const u8, - id: []const u8, +}; + +/// One staged entry during a bulk build. Unlike `Entry` this does carry the +/// document, because a bulk batch spans many of them. +const Staged = struct { + key: []const u8, + off: u64, }; /// Entries built for one document, before they are committed to the index. @@ -147,13 +161,21 @@ comptime { std.debug.assert(@import("builtin").cpu.arch.endian() == .little); } -/// A view of one stored entry yielded by iteration. Both slices alias the -/// tree and are valid only while the tree is not mutated. +/// A view of one stored entry yielded by iteration. `key` aliases the tree and +/// is valid only while the tree is not mutated; `off` is a value and stays +/// valid regardless. pub const EntryRef = struct { key: []const u8, - id: []const u8, + off: u64, }; +/// A leaf record's payload: the document's slab offset, little-endian, so a +/// record is `key ++ off_le` and every leaf slot's `extra` is exactly this. +/// Fixing the payload width is what keeps every byte-accounting site -- +/// `fits`, `record_cost`, `slot_cost`, `balanced_cut`, `repack_keep_prefix` -- +/// unchanged by the switch away from id bytes. +const off_len: u32 = @sizeOf(u64); + pub const Index = struct { name: []const u8, keys: []const IndexKey, @@ -175,7 +197,7 @@ pub const Index = struct { /// Bulk-build staging: entries appended unsorted by append_doc_entries, /// sorted and packed into the tree by finish_bulk. The keys are owned /// by the staging array until the pack consumes them. - staging: std.ArrayListUnmanaged(Entry), + staging: std.ArrayListUnmanaged(Staged), root: u32, first_leaf: u32, leaf_count: u32, @@ -263,7 +285,6 @@ pub const Index = struct { self: *const Index, gpa: std.mem.Allocator, doc: []const u8, - id: []const u8, ) !BuiltEntries { // One arena for the whole call: the collected values and any nested // spines the byte walker materializes (whole-array/document values) @@ -318,7 +339,7 @@ pub const Index = struct { for (0..nkeys) |ci| try bson.encode_key(per_path.items[ci].items[choice[ci]], a, &enc); const key = try gpa.dupe(u8, enc.items); errdefer gpa.free(key); - try out.append(gpa, .{ .key = key, .id = id }); + try out.append(gpa, .{ .key = key }); if (!advance_choice(choice[0..nkeys], limits[0..nkeys])) break; } if (out.items.len > 1) { @@ -385,10 +406,13 @@ pub const Index = struct { /// append is infallible. A spilled record is copied into the slab once, /// when it first enters the tree; moving it between nodes re-uses its /// offset. - fn reserve_overflow(self: *Index, gpa: std.mem.Allocator, entries: []const Entry) !void { + /// + /// `anytype` so it serves both `Entry` (the insert path) and `Staged` (the + /// bulk path); it only ever reads `.key`. + fn reserve_overflow(self: *Index, gpa: std.mem.Allocator, entries: anytype) !void { var overflow_bytes: u64 = 0; for (entries) |e| { - const rec_len: u64 = e.key.len + e.id.len; + const rec_len: u64 = e.key.len + off_len; if (rec_len > inline_limit) overflow_bytes += rec_len; } try self.overflow.ensureUnusedCapacity(gpa, @intCast(overflow_bytes)); @@ -397,9 +421,12 @@ pub const Index = struct { /// Insert pre-built entries (maintaining order) and drain the batch. /// Infallible: reserve_for must have run first. The tree copies each /// key, so ownership stays with the batch and its deinit frees them. - pub fn insert_entries(self: *Index, built: *BuiltEntries) void { + /// `off` is where the document these entries describe lives in the slab. + /// Passed here rather than carried on each entry so a replace cannot mix up + /// the offset it is removing with the one it is inserting. + pub fn insert_entries(self: *Index, built: *BuiltEntries, off: u64) void { for (built.entries.items) |e| { - self.insert_entry(e.key, e.id); + self.insert_entry(e.key, off); } } @@ -416,23 +443,25 @@ pub const Index = struct { self: *Index, gpa: std.mem.Allocator, doc: []const u8, - id: []const u8, + off: u64, enforce_unique: bool, ) !bool { - var built = try self.build_entries(gpa, doc, id); + var built = try self.build_entries(gpa, doc); // Runs on success too: the batch's keys are copied into the tree, // so deinit frees exactly what this call allocated. defer built.deinit(gpa); var duplicate = false; if (self.unique) { - self.check_unique(built.entries.items, id) catch |err| { + // null, not `off`: this document is not in the tree yet, so it + // has no own entries to exclude. See check_unique. + self.check_unique(built.entries.items, null) catch |err| { if (enforce_unique) return err; duplicate = true; }; } if (built.multikey) self.multikey = true; try self.reserve_for(gpa, built.entries.items); - self.insert_entries(&built); + self.insert_entries(&built, off); return duplicate; } @@ -448,13 +477,14 @@ pub const Index = struct { self: *Index, gpa: std.mem.Allocator, doc: []const u8, - id: []const u8, + off: u64, ) !void { - var built = try self.build_entries(gpa, doc, id); + var built = try self.build_entries(gpa, doc); // Runs on success too: the append below moves the keys into the // staging array, leaving only the (now empty) ArrayList buffer. defer built.deinit(gpa); - try self.staging.appendSlice(gpa, built.entries.items); + try self.staging.ensureUnusedCapacity(gpa, built.entries.items.len); + for (built.entries.items) |e| self.staging.appendAssumeCapacity(.{ .key = e.key, .off = off }); if (built.multikey) self.multikey = true; // Key ownership moved into the staging array. built.entries.items.len = 0; @@ -471,7 +501,7 @@ pub const Index = struct { gpa: std.mem.Allocator, enforce_unique: bool, ) error{ DuplicateKeyIndex, OutOfMemory }!bool { - std.mem.sort(Entry, self.staging.items, {}, entry_less); + std.mem.sort(Staged, self.staging.items, {}, staged_less); var duplicate = false; if (self.unique and self.staging.items.len >= 2) { for (self.staging.items[1..], 0..) |cur, prev_i| { @@ -479,9 +509,9 @@ pub const Index = struct { // Sorting puts equal keys next to each other. if (!std.mem.eql(u8, prev.key, cur.key)) continue; // A document's own entries were deduped at build time, so - // equal keys under one id are not a conflict — same rule as - // check_unique's exclude_id. - if (std.mem.eql(u8, prev.id, cur.id)) continue; + // equal keys from one document are not a conflict — the same + // rule as check_unique's exclude. + if (prev.off == cur.off) continue; if (enforce_unique) return error.DuplicateKeyIndex; duplicate = true; } @@ -497,8 +527,7 @@ pub const Index = struct { /// Remove every entry for `id`, in one pass over the leaves. Infallible. /// Used directly by remove_id's own callers and as the fallback when /// regeneration cannot locate entries. - pub fn remove_id(self: *Index, gpa: std.mem.Allocator, id: []const u8) void { - _ = gpa; + pub fn remove_off(self: *Index, off: u64) void { var leaf_id = self.first_leaf; while (leaf_id != 0) { const node = self.page(leaf_id); @@ -507,7 +536,7 @@ pub const Index = struct { var i = node.count; while (i > 0) { i -= 1; - if (std.mem.eql(u8, self.id_of(leaf_id, i), id)) self.leaf_remove(leaf_id, i); + if (self.off_of(leaf_id, i) == off) self.leaf_remove(leaf_id, i); } leaf_id = next; } @@ -526,15 +555,15 @@ pub const Index = struct { /// Infallible by construction: regeneration allocates and can fail, and /// a document the index cannot key contributed nothing to remove, so /// either way it falls back to the scan, which is always correct. - pub fn remove_doc(self: *Index, gpa: std.mem.Allocator, doc: []const u8, id: []const u8) void { - var built = self.build_entries(gpa, doc, id) catch return self.remove_id(gpa, id); + pub fn remove_doc(self: *Index, gpa: std.mem.Allocator, doc: []const u8, off: u64) void { + var built = self.build_entries(gpa, doc) catch return self.remove_off(off); defer built.deinit(gpa); // Sparse index that skipped this document: nothing was inserted. if (built.entries.items.len == 0) return; for (built.entries.items) |want| { // A document that generated entries we cannot find would leave // the index stale, which is worse than being slow. - if (!self.remove_entry(want.key, want.id)) return self.remove_id(gpa, id); + if (!self.remove_entry(want.key, off)) return self.remove_off(off); } } @@ -550,13 +579,13 @@ pub const Index = struct { pub fn check_unique( self: *const Index, new_entries: []const Entry, - exclude_id: ?[]const u8, + exclude: ?u64, ) error{DuplicateKeyIndex}!void { for (new_entries) |e| { var it = self.seek(e.key); while (it.next()) |have| { if (cmp_prefix(e.key, have.key) != .eq) break; - const is_self = exclude_id != null and std.mem.eql(u8, have.id, exclude_id.?); + const is_self = exclude != null and have.off == exclude.?; if (!is_self) return error.DuplicateKeyIndex; } } @@ -566,11 +595,11 @@ pub const Index = struct { /// than `cmp_prefix`, because this answers "is this document present" /// rather than "what is in this key band". On a non-unique index it /// returns the first entry of the band. - pub fn lookup_exact(self: *const Index, key: []const u8) ?[]const u8 { + pub fn lookup_exact(self: *const Index, key: []const u8) ?u64 { var it = self.seek(key); const e = it.next() orelse return null; if (!std.mem.eql(u8, e.key, key)) return null; - return e.id; + return e.off; } // -- search ------------------------------------------------------------- @@ -581,7 +610,7 @@ pub const Index = struct { self: *const Index, gpa: std.mem.Allocator, key: []const bson.Value, - out: *std.ArrayListUnmanaged([]const u8), + out: *std.ArrayListUnmanaged(u64), ) !void { var enc: std.ArrayListUnmanaged(u8) = .empty; defer enc.deinit(gpa); @@ -589,7 +618,7 @@ pub const Index = struct { var it = self.seek(enc.items); while (it.next()) |e| { if (cmp_prefix(enc.items, e.key) != .eq) break; - try out.append(gpa, e.id); + try out.append(gpa, e.off); } } @@ -604,7 +633,7 @@ pub const Index = struct { lo_incl: bool, hi: ?bson.Value, hi_incl: bool, - out: *std.ArrayListUnmanaged([]const u8), + out: *std.ArrayListUnmanaged(u64), ) !void { // Both bounds extend the encoded prefix by one component, so each // is a lower-bound seek (encoded bytes are self-delimiting: the @@ -643,7 +672,7 @@ pub const Index = struct { } else if (cmp_prefix(prefix_key, e.key) != .eq) { break; // band ended } - try out.append(gpa, e.id); + try out.append(gpa, e.off); } } @@ -662,9 +691,9 @@ pub const Index = struct { const node = ix.page(self.leaf); if (self.slot < node.count) { const key = ix.key_of(self.leaf, self.slot); - const id = ix.id_of(self.leaf, self.slot); + const off = ix.off_of(self.leaf, self.slot); self.slot += 1; - return .{ .key = key, .id = id }; + return .{ .key = key, .off = off }; } self.leaf = node.next; self.slot = 0; @@ -739,7 +768,10 @@ pub const Index = struct { const Record = struct { key: []const u8, - id: []const u8 = "", + /// Set for a leaf record: the document's slab offset. `child` is set + /// for an internal separator instead. Never both -- the optional is + /// what tells `store_record` which kind it is holding. + off: ?u64 = null, child: u32 = 0, /// When set, key+id already live in the overflow slab at this /// offset and are referenced rather than copied (moved leaf @@ -861,12 +893,19 @@ pub const Index = struct { } /// The id bytes of leaf slot `i`. - fn id_of(self: *const Index, node_id: u32, i: u32) []const u8 { + /// The slab offset of leaf slot `i`. Leaves only: an internal separator's + /// `extra` is a child node id, not a payload length. + fn off_of(self: *const Index, node_id: u32, i: u32) u64 { const node = self.page(node_id); + std.debug.assert(node.is_leaf == 1); const s = get_slot(node, i); + std.debug.assert(s.extra == off_len); const start = s.off + s.key_len; - if (s.spill) return self.ovf(start, start + s.extra); - return node.buf[@intCast(start)..@intCast(start + s.extra)]; + const bytes = if (s.spill) + self.ovf(start, start + off_len) + else + node.buf[@intCast(start)..@intCast(start + off_len)]; + return std.mem.readInt(u64, bytes[0..off_len], .little); } /// Whether a record of `rec_len` bytes fits `node`: the slot plus, when @@ -881,11 +920,16 @@ pub const Index = struct { /// count), shifting later slots right to make room. Assumes fit. fn store_record(self: *Index, node_id: u32, pos: u32, rec: Record) void { const node = self.page_mut(node_id); - const rec_len: u64 = rec.key.len + rec.id.len; + // A leaf record carries the 8-byte offset; an internal separator + // carries none, and puts its child node id in `extra` instead. The + // optional is the discriminator: the old convention keyed off a + // zero-length payload, which silently aliased onto child id 0. + const payload: u32 = if (rec.off != null) off_len else 0; + const rec_len: u64 = rec.key.len + payload; var s: Slot = .{ .off = 0, .key_len = @intCast(rec.key.len), - .extra = if (rec.id.len > 0) @intCast(rec.id.len) else rec.child, + .extra = if (rec.off != null) off_len else rec.child, .spill = false, ._pad = 0, }; @@ -899,13 +943,17 @@ pub const Index = struct { assert_msg(self.overflow.items.len + rec_len <= self.overflow.capacity, "spilled record overran reserve_overflow's bound"); s.off = self.overflow.items.len; self.overflow.appendSliceAssumeCapacity(rec.key); - if (rec.id.len > 0) self.overflow.appendSliceAssumeCapacity(rec.id); + if (rec.off) |o| { + var buf: [off_len]u8 = undefined; + std.mem.writeInt(u64, &buf, o, .little); + self.overflow.appendSliceAssumeCapacity(&buf); + } s.spill = true; } else { node.data_start -= @intCast(rec_len); const dst = node.buf[node.data_start .. node.data_start + @as(usize, @intCast(rec_len))]; @memcpy(dst[0..rec.key.len], rec.key); - if (rec.id.len > 0) @memcpy(dst[rec.key.len..][0..rec.id.len], rec.id); + if (rec.off) |o| std.mem.writeInt(u64, dst[rec.key.len..][0..off_len], o, .little); s.off = node.data_start; } if (pos < node.count) { @@ -1025,14 +1073,14 @@ pub const Index = struct { } /// Entry position in a leaf, by (key, id). - fn leaf_pos(self: *const Index, leaf_id: u32, key: []const u8, id: []const u8) u32 { + fn leaf_pos(self: *const Index, leaf_id: u32, key: []const u8, off: u64) u32 { const node = self.page(leaf_id); var lo: u32 = 0; var hi: u32 = node.count; while (lo < hi) { const mid = lo + (hi - lo) / 2; const o = std.mem.order(u8, self.key_of(leaf_id, mid), key); - const less = o == .lt or (o == .eq and std.mem.order(u8, self.id_of(leaf_id, mid), id) == .lt); + const less = o == .lt or (o == .eq and self.off_of(leaf_id, mid) < off); if (less) lo = mid + 1 else hi = mid; } return lo; @@ -1112,8 +1160,8 @@ pub const Index = struct { } /// Insert one entry, splitting upward as needed. Infallible. - fn insert_entry(self: *Index, key: []const u8, id: []const u8) void { - if (self.insert_rec(self.root, key, id)) |up| { + fn insert_entry(self: *Index, key: []const u8, off: u64) void { + if (self.insert_rec(self.root, key, off)) |up| { // The root split: a new root with the two halves as children. const new_root = self.alloc_node(); self.page_mut(new_root).first_child = self.root; @@ -1127,11 +1175,11 @@ pub const Index = struct { /// Descend and insert; return the split to promote at the level above, /// or null when the subtree absorbed the record. - fn insert_rec(self: *Index, node_id: u32, key: []const u8, id: []const u8) ?Split { + fn insert_rec(self: *Index, node_id: u32, key: []const u8, off: u64) ?Split { const node = self.page(node_id); if (node.is_leaf == 1) { - if (self.fits(node_id, key.len + id.len)) { - self.store_record(node_id, self.leaf_pos(node_id, key, id), .{ .key = key, .id = id }); + if (self.fits(node_id, key.len + off_len)) { + self.store_record(node_id, self.leaf_pos(node_id, key, off), .{ .key = key, .off = off }); self.entry_count += 1; return null; } @@ -1141,15 +1189,15 @@ pub const Index = struct { // of <= 1 KiB records always fits after that, so the split below // only ever sees a genuinely full leaf with count >= 2. self.repack_keep_prefix(node_id, node.count); - if (self.fits(node_id, key.len + id.len)) { - self.store_record(node_id, self.leaf_pos(node_id, key, id), .{ .key = key, .id = id }); + if (self.fits(node_id, key.len + off_len)) { + self.store_record(node_id, self.leaf_pos(node_id, key, off), .{ .key = key, .off = off }); self.entry_count += 1; return null; } - return self.split_leaf(node_id, key, id); + return self.split_leaf(node_id, key, off); } const child = self.descend_insert(node_id, key); - const res = self.insert_rec(child, key, id) orelse return null; + const res = self.insert_rec(child, key, off) orelse return null; return self.insert_separator(node_id, res); } @@ -1160,19 +1208,19 @@ pub const Index = struct { /// the boundary key may end up on both sides; lookups scan whole key /// bands, so that is fine. The right half moves to a new leaf and its /// first key is promoted. - fn split_leaf(self: *Index, leaf_id: u32, key: []const u8, id: []const u8) Split { + fn split_leaf(self: *Index, leaf_id: u32, key: []const u8, off: u64) Split { // insert_rec reclaims dead bytes before giving up on a page, so the // page is genuinely full here and holds at least two records -- // which is what makes both halves below non-empty. const old_count = self.page(leaf_id).count; std.debug.assert(old_count >= 2); - const pos = self.leaf_pos(leaf_id, key, id); + const pos = self.leaf_pos(leaf_id, key, off); const n = old_count + 1; var costs: [max_slots + 1]u32 = undefined; for (0..n) |m| { costs[m] = if (m == pos) - record_cost(key.len + id.len) + record_cost(key.len + off_len) else self.slot_cost(leaf_id, @intCast(if (m < pos) m else m - 1)); } @@ -1192,14 +1240,14 @@ pub const Index = struct { while (m < n) : (m += 1) { const at: u32 = self.page(right_id).count; if (m == pos) { - self.store_record(right_id, at, .{ .key = key, .id = id }); + self.store_record(right_id, at, .{ .key = key, .off = off }); continue; } const src: u32 = if (m < pos) m else m - 1; const slot = get_slot(self.page(leaf_id), src); self.store_record(right_id, at, .{ .key = self.key_of(leaf_id, src), - .id = self.id_of(leaf_id, src), + .off = self.off_of(leaf_id, src), .spill_off = if (slot.spill) slot.off else null, }); } @@ -1207,7 +1255,7 @@ pub const Index = struct { // new one when that is the half it belongs to. if (pos < mid) { self.repack_keep_prefix(leaf_id, mid - 1); - self.store_record(leaf_id, pos, .{ .key = key, .id = id }); + self.store_record(leaf_id, pos, .{ .key = key, .off = off }); } else { self.repack_keep_prefix(leaf_id, mid); } @@ -1333,13 +1381,13 @@ pub const Index = struct { /// Remove the exact entry (key, id). Equal keys may span several /// leaves, so the entry is found by scanning its key band from the /// lower bound rather than by a straight descent. - fn remove_entry(self: *Index, key: []const u8, id: []const u8) bool { + fn remove_entry(self: *Index, key: []const u8, off: u64) bool { var it = self.seek(key); while (it.next()) |e| { const c = std.mem.order(u8, e.key, key); if (c == .gt) return false; // band ended, not found if (c != .eq) continue; - if (std.mem.eql(u8, e.id, id)) { + if (e.off == off) { self.leaf_remove(it.leaf, it.slot - 1); return true; } @@ -1447,10 +1495,10 @@ pub const Index = struct { var slots: usize = 0; var n: usize = 0; while (n < lit.len) : (n += 1) { - const rec_len: u64 = lit[n].key.len + lit[n].id.len; + const rec_len: u64 = lit[n].key.len + off_len; const inline_bytes: usize = if (rec_len > inline_limit) 0 else @intCast(rec_len); if ((slots + 1) * slot_size + used + inline_bytes > page_data) break; - self.store_record(leaf, @intCast(slots), .{ .key = lit[n].key, .id = lit[n].id }); + self.store_record(leaf, @intCast(slots), .{ .key = lit[n].key, .off = lit[n].off }); slots += 1; used += inline_bytes; } @@ -1631,16 +1679,25 @@ fn default_name(gpa: std.mem.Allocator, key_pairs: []const bson.Pair) ![]u8 { /// exactly, so ordering is a memcmp. Key direction is deliberately not /// applied here: uniqueness and candidate generation are direction /// independent, and sorting handles direction itself. +/// Entry ordering. No tie-break beyond the key: entries no longer carry a +/// document, and within one document's batch equal keys are deduped anyway. pub fn compare_entries(a: Entry, b: Entry) std.math.Order { - const o = std.mem.order(u8, a.key, b.key); - if (o != .eq) return o; - return std.mem.order(u8, a.id, b.id); + return std.mem.order(u8, a.key, b.key); } fn entry_less(_: void, a: Entry, b: Entry) bool { return compare_entries(a, b) == .lt; } +/// Bulk-build ordering: by key, then by document offset so the order is total +/// and the adjacent-pair duplicate scan in finish_bulk sees a document's own +/// equal keys next to each other. +fn staged_less(_: void, a: Staged, b: Staged) bool { + const o = std.mem.order(u8, a.key, b.key); + if (o != .eq) return o == .lt; + return a.off < b.off; +} + /// Order of an encoded search prefix against a stored key, the tree's /// lookup comparator: `.eq` when the key starts with the prefix, so a /// partial key matches a whole band; `.lt` when the key sorts strictly @@ -1671,10 +1728,6 @@ fn advance_choice(choice: []usize, limits: []const usize) bool { return false; } -fn less_ids(_: void, a: []const u8, b: []const u8) bool { - return std.mem.order(u8, a, b) == .lt; -} - // --------------------------------------------------------------------------- // Query planning // --------------------------------------------------------------------------- @@ -1810,7 +1863,7 @@ pub const Plan = struct { pub fn search( self: *const Plan, gpa: std.mem.Allocator, - out: *std.ArrayListUnmanaged([]const u8), + out: *std.ArrayListUnmanaged(u64), ) !void { for (self.lookup_keys.items) |key| { if (self.lo == null and self.hi == null) { @@ -1821,17 +1874,17 @@ pub const Plan = struct { } // Entries come out of the leaves in key order, so a forward scan is // already sorted; a backward one just reads it the other way. This - // must happen before the dedupe pass below, which sorts by id and - // would destroy the order — the conditions that set provides_sort + // must happen before the dedupe pass below, which sorts by offset + // and would destroy the order — the conditions that set provides_sort // are exactly the ones under which that pass is skipped. - if (self.provides_sort and self.backward) std.mem.reverse([]const u8, out.items); + if (self.provides_sort and self.backward) std.mem.reverse(u64, out.items); const may_repeat = self.index.multikey or self.lookup_keys.items.len > 1; if (may_repeat and out.items.len > 1) { - std.mem.sort([]const u8, out.items, {}, less_ids); + std.mem.sort(u64, out.items, {}, std.sort.asc(u64)); var w: usize = 1; for (out.items[1..]) |id| { - if (!std.mem.eql(u8, id, out.items[w - 1])) { + if (id != out.items[w - 1]) { out.items[w] = id; w += 1; } @@ -2060,20 +2113,20 @@ fn simple_index( return Index.init(gpa, "test", keys[0..paths.len], unique, sparse, null); } -/// Look up ids and compare with the expected set. Entry ids alias the -/// caller's storage, so tests pass stable static byte strings as ids. -fn expect_ids( +/// Look up the documents under `key` and compare with the expected offsets. +/// A leaf record's payload is a slab offset now, so tests identify documents by +/// small distinct numbers rather than by byte-string ids. +fn expect_offs( gpa: std.mem.Allocator, ix: *const Index, key: []const bson.Value, - expected: []const []const u8, + expected: []const u64, ) !void { - var out: std.ArrayListUnmanaged([]const u8) = .empty; + var out: std.ArrayListUnmanaged(u64) = .empty; defer out.deinit(gpa); try ix.lookup_eq(gpa, key, &out); - std.mem.sort([]const u8, out.items, {}, less_ids); - try testing.expectEqual(expected.len, out.items.len); - for (out.items, 0..) |id, i| try testing.expectEqualStrings(expected[i], id); + std.mem.sort(u64, out.items, {}, std.sort.asc(u64)); + try testing.expectEqualSlices(u64, expected, out.items); } /// The tree's entries as (key, id) references, in iteration order. @@ -2082,11 +2135,11 @@ fn refs_of(gpa: std.mem.Allocator, ix: *const Index, out: *std.ArrayListUnmanage while (it.next()) |e| try out.append(gpa, e); } -/// EntryRef ordering, mirroring compare_entries: key bytes, then id bytes. +/// EntryRef ordering: key bytes, then the document's slab offset. fn ref_lt(a: EntryRef, b: EntryRef) bool { const o = std.mem.order(u8, a.key, b.key); if (o != .eq) return o == .lt; - return std.mem.order(u8, a.id, b.id) == .lt; + return a.off < b.off; } test "entries sort across numeric types and string/null/objectid" { @@ -2104,17 +2157,17 @@ test "entries sort across numeric types and string/null/objectid" { defer gpa.free(d_nul); const d_oid = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = 5 } }, .{ .key = "a", .value = .{ .object_id = [_]u8{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 } } } }); defer gpa.free(d_oid); - _ = try ix.add_doc(gpa, d_int, "i1", true); - _ = try ix.add_doc(gpa, d_dbl, "i2", true); - _ = try ix.add_doc(gpa, d_str, "i3", true); - _ = try ix.add_doc(gpa, d_nul, "i4", true); - _ = try ix.add_doc(gpa, d_oid, "i5", true); + _ = try ix.add_doc(gpa, d_int, 1, true); + _ = try ix.add_doc(gpa, d_dbl, 2, true); + _ = try ix.add_doc(gpa, d_str, 3, true); + _ = try ix.add_doc(gpa, d_nul, 4, true); + _ = try ix.add_doc(gpa, d_oid, 5, true); // An int64 query finds both the int32 and double entries: compare-equal. - try expect_ids(gpa, &ix, &.{.{ .int64 = 5 }}, &.{ "i1", "i2" }); - try expect_ids(gpa, &ix, &.{.null}, &.{"i4"}); - try expect_ids(gpa, &ix, &.{.{ .string = "b" }}, &.{"i3"}); - try expect_ids(gpa, &ix, &.{.{ .object_id = [_]u8{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 } }}, &.{"i5"}); + try expect_offs(gpa, &ix, &.{.{ .int64 = 5 }}, &.{ 1, 2 }); + try expect_offs(gpa, &ix, &.{.null}, &.{4}); + try expect_offs(gpa, &ix, &.{.{ .string = "b" }}, &.{3}); + try expect_offs(gpa, &ix, &.{.{ .object_id = [_]u8{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 } }}, &.{5}); // Full order: null, 5, 5, "b", oid — iteration respects it (equal // keys tie-break by id, as the leaf position rule does). @@ -2133,13 +2186,13 @@ test "missing field is indexed as null; sparse skips the document" { defer ix.deinit(gpa); const d = try bytes_of(gpa, &.{.{ .key = "_id", .value = .{ .int32 = 1 } }}); defer gpa.free(d); - _ = try ix.add_doc(gpa, d, "m1", true); + _ = try ix.add_doc(gpa, d, 1, true); try testing.expectEqual(@as(usize, 1), ix.count()); - try expect_ids(gpa, &ix, &.{.null}, &.{"m1"}); + try expect_offs(gpa, &ix, &.{.null}, &.{1}); var sp = try simple_index(gpa, &.{"a"}, false, true); defer sp.deinit(gpa); - _ = try sp.add_doc(gpa, d, "m2", true); + _ = try sp.add_doc(gpa, d, 2, true); try testing.expectEqual(@as(usize, 0), sp.count()); } @@ -2150,16 +2203,16 @@ test "multikey expansion indexes the array and its elements" { const d = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "tags", .value = .{ .array = &.{ .{ .string = "a" }, .{ .string = "b" } } } } }); defer gpa.free(d); - _ = try ix.add_doc(gpa, d, "mk1", true); + _ = try ix.add_doc(gpa, d, 1, true); // 3 entries: the array itself, "a", "b". try testing.expectEqual(@as(usize, 3), ix.count()); // Element query. - try expect_ids(gpa, &ix, &.{.{ .string = "a" }}, &.{"mk1"}); - try expect_ids(gpa, &ix, &.{.{ .string = "b" }}, &.{"mk1"}); + try expect_offs(gpa, &ix, &.{.{ .string = "a" }}, &.{1}); + try expect_offs(gpa, &ix, &.{.{ .string = "b" }}, &.{1}); // Whole-array equality — the reason arrays are indexed twice. - try expect_ids(gpa, &ix, &.{.{ .array = &.{ .{ .string = "a" }, .{ .string = "b" } } }}, &.{"mk1"}); - try expect_ids(gpa, &ix, &.{.{ .array = &.{ .{ .string = "b" }, .{ .string = "a" } } }}, &.{}); + try expect_offs(gpa, &ix, &.{.{ .array = &.{ .{ .string = "a" }, .{ .string = "b" } } }}, &.{1}); + try expect_offs(gpa, &ix, &.{.{ .array = &.{ .{ .string = "b" }, .{ .string = "a" } } }}, &.{}); } test "per-document dedup keeps {a: [1,1]} under a unique index" { @@ -2168,7 +2221,7 @@ test "per-document dedup keeps {a: [1,1]} under a unique index" { defer ix.deinit(gpa); const d = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "a", .value = .{ .array = &.{ .{ .int32 = 1 }, .{ .int32 = 1 } } } } }); defer gpa.free(d); - _ = try ix.add_doc(gpa, d, "d1", true); + _ = try ix.add_doc(gpa, d, 1, true); // Entries after dedup: the array itself and one element. try testing.expectEqual(@as(usize, 2), ix.count()); } @@ -2183,7 +2236,7 @@ test "parallel arrays are rejected" { .{ .key = "b", .value = .{ .array = &.{ .{ .int32 = 3 }, .{ .int32 = 4 } } } }, }); defer gpa.free(d); - try testing.expectError(error.ParallelArrays, ix.add_doc(gpa, d, "p1", true)); + try testing.expectError(error.ParallelArrays, ix.add_doc(gpa, d, 1, true)); // One array path is fine. const ok = try bytes_of(gpa, &.{ @@ -2192,7 +2245,7 @@ test "parallel arrays are rejected" { .{ .key = "b", .value = .{ .int32 = 3 } }, }); defer gpa.free(ok); - _ = try ix.add_doc(gpa, ok, "p2", true); + _ = try ix.add_doc(gpa, ok, 2, true); try testing.expectEqual(@as(usize, 3), ix.count()); } @@ -2203,20 +2256,20 @@ test "unique conflict across documents, replace of own entries allowed" { const d1 = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "a", .value = .{ .int32 = 10 } } }); defer gpa.free(d1); - _ = try ix.add_doc(gpa, d1, "u1", true); + _ = try ix.add_doc(gpa, d1, 1, true); const d2 = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "a", .value = .{ .int32 = 10 } } }); defer gpa.free(d2); - try testing.expectError(error.DuplicateKeyIndex, ix.add_doc(gpa, d2, "u2", true)); + try testing.expectError(error.DuplicateKeyIndex, ix.add_doc(gpa, d2, 2, true)); // A replace keeps its own key: remove old entries first (the engine's // evict_doc does this), then add the new ones. const d1b = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "a", .value = .{ .int32 = 20 } } }); defer gpa.free(d1b); - ix.remove_id(gpa, "u1"); - _ = try ix.add_doc(gpa, d1b, "u1", true); - try expect_ids(gpa, &ix, &.{.{ .int32 = 20 }}, &.{"u1"}); - try expect_ids(gpa, &ix, &.{.{ .int32 = 10 }}, &.{}); + ix.remove_off(1); + _ = try ix.add_doc(gpa, d1b, 1, true); + try expect_offs(gpa, &ix, &.{.{ .int32 = 20 }}, &.{1}); + try expect_offs(gpa, &ix, &.{.{ .int32 = 10 }}, &.{}); } test "lookup_exact matches whole keys only" { @@ -2235,7 +2288,7 @@ test "lookup_exact matches whole keys only" { .{ .key = "b", .value = .{ .int32 = 2 } }, }); defer gpa.free(d); - _ = try ix.add_doc(gpa, d, "e1", false); + _ = try ix.add_doc(gpa, d, 1, false); var enc_full: std.ArrayListUnmanaged(u8) = .empty; defer enc_full.deinit(gpa); @@ -2247,7 +2300,7 @@ test "lookup_exact matches whole keys only" { // The full two-column key hits. try testing.expect(ix.lookup_exact(enc_full.items) != null); - try testing.expectEqualStrings("e1", ix.lookup_exact(enc_full.items).?); + try testing.expectEqual(@as(u64, 1), ix.lookup_exact(enc_full.items).?); // Its proper prefix must not, even though seek lands on the same entry. // Mutation check: swapping the eql for cmp_prefix == .eq makes this line // return "e1". @@ -2263,50 +2316,50 @@ test "range bounds inclusive and exclusive" { const gpa = testing.allocator; var ix = try simple_index(gpa, &.{"a"}, false, false); defer ix.deinit(gpa); - const docs = [_]struct { id: []const u8, a: i32 }{ - .{ .id = "r1", .a = 1 }, - .{ .id = "r2", .a = 2 }, - .{ .id = "r3", .a = 3 }, - .{ .id = "r4", .a = 4 }, - .{ .id = "r5", .a = 5 }, + const docs = [_]struct { off: u64, a: i32 }{ + .{ .off = 1, .a = 1 }, + .{ .off = 2, .a = 2 }, + .{ .off = 3, .a = 3 }, + .{ .off = 4, .a = 4 }, + .{ .off = 5, .a = 5 }, }; for (docs) |s| { const d = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = s.a } }, .{ .key = "a", .value = .{ .int32 = s.a } } }); defer gpa.free(d); - _ = try ix.add_doc(gpa, d, s.id, true); + _ = try ix.add_doc(gpa, d, s.off, true); } - var out: std.ArrayListUnmanaged([]const u8) = .empty; + var out: std.ArrayListUnmanaged(u64) = .empty; defer out.deinit(gpa); // 2 <= a < 4 → {2, 3} try ix.lookup_range(gpa, &.{}, .{ .int32 = 2 }, true, .{ .int32 = 4 }, false, &out); try testing.expectEqual(@as(usize, 2), out.items.len); - try testing.expect(std.mem.eql(u8, out.items[0], "r2") or std.mem.eql(u8, out.items[0], "r3")); + try testing.expect(out.items[0] == 2 or out.items[0] == 3); out.clearRetainingCapacity(); // 2 < a <= 3 → {3} try ix.lookup_range(gpa, &.{}, .{ .int32 = 2 }, false, .{ .int32 = 3 }, true, &out); try testing.expectEqual(@as(usize, 1), out.items.len); - try testing.expectEqualStrings("r3", out.items[0]); + try testing.expectEqual(@as(u64, 3), out.items[0]); out.clearRetainingCapacity(); // a > 4 → {5} try ix.lookup_range(gpa, &.{}, .{ .int32 = 4 }, false, null, false, &out); try testing.expectEqual(@as(usize, 1), out.items.len); - try testing.expectEqualStrings("r5", out.items[0]); + try testing.expectEqual(@as(u64, 5), out.items[0]); } -test "empty index and remove_id" { +test "empty index and remove_off" { const gpa = testing.allocator; var ix = try simple_index(gpa, &.{"a"}, false, false); defer ix.deinit(gpa); - var out: std.ArrayListUnmanaged([]const u8) = .empty; + var out: std.ArrayListUnmanaged(u64) = .empty; defer out.deinit(gpa); try ix.lookup_eq(gpa, &.{.{ .int32 = 1 }}, &out); try testing.expectEqual(@as(usize, 0), out.items.len); const d = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "a", .value = .{ .int32 = 1 } } }); defer gpa.free(d); - _ = try ix.add_doc(gpa, d, "e1", true); - ix.remove_id(gpa, "e1"); + _ = try ix.add_doc(gpa, d, 1, true); + ix.remove_off(1); try testing.expectEqual(@as(usize, 0), ix.count()); try ix.lookup_eq(gpa, &.{.{ .int32 = 1 }}, &out); try testing.expectEqual(@as(usize, 0), out.items.len); @@ -2317,13 +2370,13 @@ test "compound index prefix search and range on the next key" { var ix = try simple_index(gpa, &.{ "a", "b" }, false, false); defer ix.deinit(gpa); - const id1 = "c1"; - const id2 = "c2"; - const id3 = "c3"; - const specs = [_]struct { id: []const u8, a: i32, b: i32 }{ - .{ .id = id1, .a = 1, .b = 2 }, - .{ .id = id2, .a = 1, .b = 3 }, - .{ .id = id3, .a = 2, .b = 1 }, + const id1: u64 = 1; + const id2: u64 = 2; + const id3: u64 = 3; + const specs = [_]struct { off: u64, a: i32, b: i32 }{ + .{ .off = id1, .a = 1, .b = 2 }, + .{ .off = id2, .a = 1, .b = 3 }, + .{ .off = id3, .a = 2, .b = 1 }, }; for (specs) |s| { const d = try bytes_of(gpa, &.{ @@ -2332,20 +2385,20 @@ test "compound index prefix search and range on the next key" { .{ .key = "b", .value = .{ .int32 = s.b } }, }); defer gpa.free(d); - _ = try ix.add_doc(gpa, d, s.id, true); + _ = try ix.add_doc(gpa, d, s.off, true); } // Prefix on a only. - try expect_ids(gpa, &ix, &.{.{ .int32 = 1 }}, &.{ "c1", "c2" }); + try expect_offs(gpa, &ix, &.{.{ .int32 = 1 }}, &.{ 1, 2 }); // Full key. - try expect_ids(gpa, &ix, &.{ .{ .int32 = 1 }, .{ .int32 = 3 } }, &.{"c2"}); - try expect_ids(gpa, &ix, &.{ .{ .int32 = 2 }, .{ .int32 = 1 } }, &.{"c3"}); + try expect_offs(gpa, &ix, &.{ .{ .int32 = 1 }, .{ .int32 = 3 } }, &.{2}); + try expect_offs(gpa, &ix, &.{ .{ .int32 = 2 }, .{ .int32 = 1 } }, &.{3}); // Range on the next key: a == 1 and 2 <= b <= 3. - var out: std.ArrayListUnmanaged([]const u8) = .empty; + var out: std.ArrayListUnmanaged(u64) = .empty; defer out.deinit(gpa); try ix.lookup_range(gpa, &.{.{ .int32 = 1 }}, .{ .int32 = 2 }, true, .{ .int32 = 3 }, true, &out); try testing.expectEqual(@as(usize, 2), out.items.len); - try testing.expect(std.mem.eql(u8, out.items[0], "c1") or std.mem.eql(u8, out.items[0], "c2")); + try testing.expect(out.items[0] == 1 or out.items[0] == 2); } test "remove_doc leaves the index identical to a full scan removal" { @@ -2366,17 +2419,14 @@ test "remove_doc leaves the index identical to a full scan removal" { defer by_scan.deinit(gpa); const n = 120; - 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 arrays: [n][3]bson.Value = undefined; var pairs: [n][2]bson.Pair = undefined; var docs: [n][]u8 = undefined; 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 shape = rand.intRangeAtMost(u8, 0, 3); const av = rand.intRangeAtMost(i32, 0, 3); @@ -2423,7 +2473,7 @@ test "remove_doc leaves the index identical to a full scan removal" { defer scan_refs.deinit(gpa); for (order) |i| { by_doc.remove_doc(gpa, docs[i], ids.items[i]); - by_scan.remove_id(gpa, ids.items[i]); + by_scan.remove_off(ids.items[i]); doc_refs.clearRetainingCapacity(); scan_refs.clearRetainingCapacity(); @@ -2438,7 +2488,7 @@ test "remove_doc leaves the index identical to a full scan removal" { }; for (doc_refs.items, scan_refs.items) |x, y| { try testing.expect(std.mem.eql(u8, x.key, y.key)); - try testing.expect(std.mem.eql(u8, x.id, y.id)); + try testing.expectEqual(x.off, y.off); } } for (docs) |b| gpa.free(b); @@ -2459,10 +2509,7 @@ test "incremental inserts and removals stay identical to a brute-force model" { defer ix.deinit(gpa); var model: std.ArrayListUnmanaged(ModelFact) = .empty; - defer { - for (model.items) |m| gpa.free(m.id); - model.deinit(gpa); - } + defer model.deinit(gpa); var live: std.ArrayListUnmanaged(bool) = .empty; defer live.deinit(gpa); @@ -2471,7 +2518,7 @@ test "incremental inserts and removals stay identical to a brute-force model" { // Insert. const a = rand.intRangeAtMost(i32, 0, 30); const b = rand.intRangeAtMost(i32, 0, 30); - const id = try std.fmt.allocPrint(gpa, "id{d}", .{i}); + const id: u64 = @intCast(i + 1); const d = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = @intCast(i) } }, .{ .key = "a", .value = .{ .int32 = a } }, @@ -2479,7 +2526,7 @@ test "incremental inserts and removals stay identical to a brute-force model" { }); defer gpa.free(d); _ = try ix.add_doc(gpa, d, id, false); - try model.append(gpa, .{ .a = a, .b = b, .id = id }); + try model.append(gpa, .{ .a = a, .b = b, .off = id }); try live.append(gpa, true); // Verify equality and one-sided range against the model. @@ -2498,14 +2545,14 @@ test "incremental inserts and removals stay identical to a brute-force model" { .{ .key = "b", .value = .{ .int32 = m.b } }, }); defer gpa.free(d); - ix.remove_doc(gpa, d, m.id); + ix.remove_doc(gpa, d, m.off); live.items[i] = false; try verify_model(gpa, &ix, model.items, live.items, rand); } } /// One document's facts in the incremental-mutation differential. -const ModelFact = struct { a: i32, b: i32, id: []const u8 }; +const ModelFact = struct { a: i32, b: i32, off: u64 }; fn verify_model( gpa: std.mem.Allocator, @@ -2516,7 +2563,7 @@ fn verify_model( ) !void { // lookup_eq over a random value. const a = rand.intRangeAtMost(i32, 0, 30); - 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; @@ -2552,11 +2599,8 @@ test "lookup_range matches a brute-force filter over random data" { var prng = std.Random.DefaultPrng.init(0x5eed_1234); const rand = prng.random(); - var ids: std.ArrayListUnmanaged([]u8) = .empty; - defer { - for (ids.items) |s| gpa.free(s); - ids.deinit(gpa); - } + var ids: std.ArrayListUnmanaged(u64) = .empty; + defer ids.deinit(gpa); var ix = try simple_index(gpa, &.{ "a", "b" }, false, false); defer ix.deinit(gpa); @@ -2569,7 +2613,7 @@ test "lookup_range matches a brute-force filter over random data" { const a = rand.intRangeAtMost(i32, 0, 4); const b = rand.intRangeAtMost(i32, 0, 9); facts[i] = .{ .a = a, .b = b }; - const id = try std.fmt.allocPrint(gpa, "id{d}", .{i}); + const id: u64 = @intCast(i + 1); try ids.append(gpa, id); const d = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = @intCast(i) } }, @@ -2581,7 +2625,7 @@ test "lookup_range matches a brute-force filter over random data" { } _ = try ix.finish_bulk(gpa, false); - var out: std.ArrayListUnmanaged([]const u8) = .empty; + var out: std.ArrayListUnmanaged(u64) = .empty; defer out.deinit(gpa); for (0..600) |case| { @@ -2763,15 +2807,14 @@ test "a churned leaf of large keys splits without promoting from an empty half" const n = 12; var docs: [n][]u8 = undefined; - var ids: [n][]u8 = undefined; + var ids: [n]u64 = undefined; var made: usize = 0; defer for (0..made) |i| { gpa.free(docs[i]); - gpa.free(ids[i]); }; for (0..n) |i| { docs[i] = try str_doc(gpa, i, @intCast('a' + i), 1000); - ids[i] = try std.fmt.allocPrint(gpa, "id{d}", .{i}); + ids[i] = @intCast(i + 1); made += 1; } @@ -2790,7 +2833,7 @@ test "a churned leaf of large keys splits without promoting from an empty half" for ([_]usize{ n - 2, n - 1 }) |i| { var s: [1000]u8 = undefined; @memset(&s, @intCast('a' + i)); - try expect_ids(gpa, &ix, &.{.{ .string = &s }}, &.{ids[i]}); + try expect_offs(gpa, &ix, &.{.{ .string = &s }}, &.{ids[i]}); } } @@ -2803,10 +2846,9 @@ test "a split with lopsided record sizes keeps the new record inside its page" { defer ix.deinit(gpa); var docs: std.ArrayListUnmanaged([]u8) = .empty; - var ids: std.ArrayListUnmanaged([]u8) = .empty; + var ids: std.ArrayListUnmanaged(u64) = .empty; defer { for (docs.items) |d| gpa.free(d); - for (ids.items) |x| gpa.free(x); docs.deinit(gpa); ids.deinit(gpa); } @@ -2815,16 +2857,16 @@ test "a split with lopsided record sizes keeps the new record inside its page" { // Three ~1 KiB keys, all sorting before the short ones ('a' < 'z') ... while (i < 3) : (i += 1) { try docs.append(gpa, try str_doc(gpa, i, 'a', 1000 - i)); - try ids.append(gpa, try std.fmt.allocPrint(gpa, "id{d}", .{i})); + try ids.append(gpa, @intCast(i + 1)); } // ... then short keys, filling the page by slot count ... while (i < 28) : (i += 1) { try docs.append(gpa, try str_doc(gpa, i, 'z', 4)); - try ids.append(gpa, try std.fmt.allocPrint(gpa, "id{d}", .{i})); + try ids.append(gpa, @intCast(i + 1)); } // ... then one more large key, which sorts into the left half. try docs.append(gpa, try str_doc(gpa, i, 'a', 996)); - try ids.append(gpa, try std.fmt.allocPrint(gpa, "id{d}", .{i})); + try ids.append(gpa, @intCast(i + 1)); for (docs.items, ids.items) |d, id| _ = try ix.add_doc(gpa, d, id, false); @@ -2855,8 +2897,7 @@ test "the _id index plan covers equality, ranges and _id sort order" { .{ .key = "v", .value = .{ .int32 = @intCast(i) } }, }); defer gpa.free(d); - const id = try std.fmt.allocPrint(gpa, "d{d}", .{i + 1}); - defer gpa.free(id); + const id: u64 = @intCast(i + 1); _ = try ix.add_doc(gpa, d, id, false); } @@ -2866,34 +2907,34 @@ test "the _id index plan covers equality, ranges and _id sort order" { var p = (try plan(gpa, &ix, &.{}, &f, &.{})).?; defer p.deinit(gpa); try testing.expectEqual(@as(usize, 1), p.key_len()); - var ids: std.ArrayListUnmanaged([]const u8) = .empty; + var ids: std.ArrayListUnmanaged(u64) = .empty; defer ids.deinit(gpa); try p.search(gpa, &ids); try testing.expectEqual(@as(usize, 1), ids.items.len); - try testing.expectEqualStrings("d3", ids.items[0]); + try testing.expectEqual(@as(u64, 3), ids.items[0]); } // The same query as an int64 and a double finds the int32 entry. { const f = [_]bson.Pair{.{ .key = "_id", .value = .{ .int64 = 3 } }}; var p = (try plan(gpa, &ix, &.{}, &f, &.{})).?; defer p.deinit(gpa); - var ids: std.ArrayListUnmanaged([]const u8) = .empty; + var ids: std.ArrayListUnmanaged(u64) = .empty; defer ids.deinit(gpa); try p.search(gpa, &ids); try testing.expectEqual(@as(usize, 1), ids.items.len); - try testing.expectEqualStrings("d3", ids.items[0]); + try testing.expectEqual(@as(u64, 3), ids.items[0]); } // A range on _id yields the band in index order. { const f = [_]bson.Pair{.{ .key = "_id", .value = .{ .doc = &.{.{ .key = "$gte", .value = .{ .int32 = 3 } }} } }}; var p = (try plan(gpa, &ix, &.{}, &f, &.{})).?; defer p.deinit(gpa); - var ids: std.ArrayListUnmanaged([]const u8) = .empty; + var ids: std.ArrayListUnmanaged(u64) = .empty; defer ids.deinit(gpa); try p.search(gpa, &ids); try testing.expectEqual(@as(usize, 3), ids.items.len); - try testing.expectEqualStrings("d3", ids.items[0]); - try testing.expectEqualStrings("d5", ids.items[2]); + try testing.expectEqual(@as(u64, 3), ids.items[0]); + try testing.expectEqual(@as(u64, 5), ids.items[2]); } // sort({_id: 1}) with no filter: a full index scan that supplies the // order — the plan the sort planner needs to stop materializing. @@ -2902,12 +2943,12 @@ test "the _id index plan covers equality, ranges and _id sort order" { var p = (try plan(gpa, &ix, &.{}, &.{}, &sort)).?; defer p.deinit(gpa); try testing.expect(p.provides_sort and !p.backward); - var ids: std.ArrayListUnmanaged([]const u8) = .empty; + var ids: std.ArrayListUnmanaged(u64) = .empty; defer ids.deinit(gpa); try p.search(gpa, &ids); try testing.expectEqual(@as(usize, 5), ids.items.len); - try testing.expectEqualStrings("d1", ids.items[0]); - try testing.expectEqualStrings("d5", ids.items[4]); + try testing.expectEqual(@as(u64, 1), ids.items[0]); + try testing.expectEqual(@as(u64, 5), ids.items[4]); } // sort({_id: -1}): backward. { @@ -2915,11 +2956,11 @@ test "the _id index plan covers equality, ranges and _id sort order" { var p = (try plan(gpa, &ix, &.{}, &.{}, &sort)).?; defer p.deinit(gpa); try testing.expect(p.provides_sort and p.backward); - var ids: std.ArrayListUnmanaged([]const u8) = .empty; + var ids: std.ArrayListUnmanaged(u64) = .empty; defer ids.deinit(gpa); try p.search(gpa, &ids); - try testing.expectEqualStrings("d5", ids.items[0]); - try testing.expectEqualStrings("d1", ids.items[4]); + try testing.expectEqual(@as(u64, 5), ids.items[0]); + try testing.expectEqual(@as(u64, 1), ids.items[4]); } // A filter naming no _id field leaves the _id index unusable. { diff --git a/src/spill.zig b/src/spill.zig index cf44ba5..edb7a6c 100644 --- a/src/spill.zig +++ b/src/spill.zig @@ -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; diff --git a/src/spill2.zig b/src/spill2.zig index 4820dc5..391f726 100644 --- a/src/spill2.zig +++ b/src/spill2.zig @@ -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; diff --git a/src/stress.zig b/src/stress.zig index 01c45b7..38354fb 100644 --- a/src/stress.zig +++ b/src/stress.zig @@ -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;