//! Secondary indexes: per-collection, single-field and compound, with //! `unique` and `sparse` options, persisted through the log and used by the //! query planner as candidate-id generators. //! //! The governing invariant: an index is only ever used to produce a //! candidate id set; the full filter is re-applied to every candidate. An //! index that over-approximates is merely slow, never wrong. The entire //! correctness risk collapses onto one question — can the index ever //! under-approximate? Every design decision here answers that with *no*: //! //! - Entry generation mirrors field_matches (src/query.zig) exactly, //! indexing the value at the path plus the elements of any array there, //! so whole-array equality can never be missed. //! - A sparse index is never used for a null-valued component ({a: null} //! would otherwise miss docs whose field the sparse index skipped). //! - The _id_ fast path (the docs map) is only used when the queried value's //! compare-equivalence class is serialization-canonical: bson.compare //! treats int32 1, int64 1 and double 1.0 as equal, but serialize_value //! produces different map keys, so a hash lookup would miss. //! - Entry insertion is infallible after the log append (capacity is //! reserved first), so a document can never be live but unindexed. //! //! `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. 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) //! instead of memmoving the array tail. Nodes are fixed 4 KiB pages in a //! flat u32-addressed `ArrayListUnmanaged(Node)`; leaves are doubly linked //! for ordered iteration. Records longer than a quarter page spill to an //! append-only overflow slab (BSON strings reach 16 MB). Deletion does not //! rebalance: leaves may sit underfull until emptied (an emptied leaf is //! unlinked and dropped from its parent), and internal nodes may carry one //! child — both are correct, and a fresh tree is rebuilt from the log on //! every open anyway. const std = @import("std"); const bson = @import("bson.zig"); const query = @import("query.zig"); const pgr = @import("pager.zig"); // Always active, including in the default ReleaseFast build. The tree's hot // inner loops keep std.debug.assert (see assert.zig's module comment); these // guard the reservation bounds, whose violation is a buffer overrun on a path // that has already appended to the log and cannot report failure. const assert_msg = @import("assert.zig").assert_msg; /// MongoDB's compound index field limit. pub const max_index_keys: usize = 32; /// Cap on the $in cartesian product a plan will generate; beyond it the /// planner falls back to a scan. const max_combos: u64 = 100; /// One key in an index spec. `path` is owned by the Index. pub const IndexKey = struct { path: []const u8, descending: bool, }; /// 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, }; /// 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. pub const BuiltEntries = struct { entries: std.ArrayListUnmanaged(Entry), multikey: bool, pub fn deinit(self: *BuiltEntries, gpa: std.mem.Allocator) void { for (self.entries.items) |e| gpa.free(e.key); self.entries.deinit(gpa); } }; // --------------------------------------------------------------------------- // B+tree storage // --------------------------------------------------------------------------- /// Pages in a standard overflow extent: 1 MiB, enough that a batch of spilled /// records rarely needs more than one. const ovf_extent_pages: u32 = (1024 * 1024) / pgr.page_size; const page_size = 4096; /// Bytes of node payload: a 32-byte header plus the slotted region. const page_data = page_size - 32; /// Records longer than a quarter of a node spill to the overflow slab. const inline_limit = page_size / 4; /// Upper bound on the slots one node can hold, since every slot costs at /// least its own size. Bounds the split scratch. const max_slots = page_data / slot_size; /// One slotted-page entry: a key plus either an id length (leaf records) or /// a child node id (internal separators). The key bytes live either inline /// in the node's page (`off` into `buf`) or in the overflow slab (`off` into /// `Index.overflow`). const Slot = packed struct { off: u64, key_len: u32, extra: u32, spill: bool, _pad: u31, }; const slot_size = @sizeOf(Slot); /// A fixed 4 KiB page. Slots grow from the front of `buf`, record bytes /// from the back; slots are kept ordered by key, so a node lookup is a /// binary search over slots. Node ids address `Index.nodes` directly. const Node = extern struct { is_leaf: u32, count: u32, parent: u32, next: u32, prev: u32, first_child: u32, data_start: u32, _rsv: u32, buf: [page_data]u8, }; // These layouts are about to become an on-disk format: M0 maps the node arena // straight out of the data file, with no serialization on page-in, so a page // written by one build must be readable by the next. Nothing pinned them // before, which meant a field added to Node or a change in how Zig lays out a // packed Slot would silently reshape the file. `@sizeOf(Slot)` in particular // is not obvious from its declaration -- a packed struct's size depends on the // alignment of its backing integer, so the 160 declared bits round up. comptime { std.debug.assert(page_size == 4096); std.debug.assert(@sizeOf(Node) == page_size); std.debug.assert(@alignOf(Node) <= page_size); std.debug.assert(page_data == page_size - 32); std.debug.assert(@offsetOf(Node, "buf") == 32); std.debug.assert(@bitSizeOf(Slot) == 160); // 160 declared bits is 20 bytes, but the backing integer's 16-byte // alignment rounds @sizeOf up to 32 -- so 12 of every 32 slot bytes are // padding, and a node holds 127 slots where a 20-byte slot would give it // 203. Pinned rather than fixed: narrowing the slot changes the fanout // and therefore the on-disk shape of every index, which belongs in the // commit that reshapes leaf records, not in a refactor. std.debug.assert(slot_size == 32); std.debug.assert(max_slots * slot_size <= page_data); // Node pages are raw host memory in the data file, so the file is // little-endian-only (the log, which is framed field by field, is not). std.debug.assert(@import("builtin").cpu.arch.endian() == .little); } /// 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, 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, unique: bool, sparse: bool, /// expireAfterSeconds when this is a TTL index, else null. Documents /// whose indexed value is a datetime older than this many seconds are /// deleted by Engine.ttl_sweep (src/db.zig); the index itself only /// carries the setting. Always within /// [0, max_expire_after_seconds] — parse_spec is the only producer. ttl: ?i64, multikey: bool, // -- the tree ---------------------------------------------------------- /// The data file the node pages and the overflow slab live in. pager: *pgr.Pager, /// Node id -> page number. A node is one page, but its id is *not* its page /// number, and PLAN amendment A1 explains why: `Node.parent`, `next`, `prev` /// and an internal slot's `extra` are back-pointers by id, so copy-on-write /// moving a page would force every node referring to it to move too -- /// COWing one leaf would cascade through the whole leaf level, and one /// internal node through its entire subtree. /// /// With this indirection the table slot is the single owner of a page /// number, so copy-on-write has exactly one pointer to fix. It costs one /// dependent load per node access and 4 bytes per node -- about 5.6 MB at /// 100M documents, against the 64-100 bytes *per document* of the hashmap /// this milestone removes. node_pages: std.ArrayListUnmanaged(u32), /// Spilled records live in the data file, in extents this index owns. /// Append-only and never rewritten, so a `Slot.off` into it stays valid for /// the life of the tree. Those offsets are *absolute file offsets* now, /// which is the same change documents went through -- and the reason /// `Slot.off` means two different things depending on `spill` is unchanged, /// only the second meaning moved. ovf_extents: std.ArrayListUnmanaged(pgr.Extent), ovf_tail: u64, ovf_end: u64, /// 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(Staged), root: u32, first_leaf: u32, leaf_count: u32, /// Internal levels above the leaves: 0 when the root is a leaf. depth: u32, /// Total entries, maintained incrementally. entry_count: usize, /// Repack scratch: any single node's record bytes fit here. scratch: [page_data]u8, /// Promoted-key scratch: inline keys being propagated up a split are /// copied here so the reference survives node-array growth. promo: [inline_limit]u8, pub fn init( gpa: std.mem.Allocator, pager: *pgr.Pager, name: []const u8, keys: []const IndexKey, unique: bool, sparse: bool, ttl: ?i64, ) !Index { var self: Index = .{ .name = undefined, .keys = undefined, .unique = unique, .sparse = sparse, .ttl = ttl, .multikey = false, .pager = pager, .node_pages = .empty, .ovf_extents = .empty, .ovf_tail = 0, .ovf_end = 0, .staging = .empty, .root = 0, .first_leaf = 0, .leaf_count = 0, .depth = 0, .entry_count = 0, .scratch = undefined, .promo = undefined, }; self.name = try gpa.dupe(u8, name); const owned_keys = try gpa.alloc(IndexKey, keys.len); var n: usize = 0; errdefer { for (owned_keys[0..n]) |k| gpa.free(k.path); gpa.free(owned_keys); gpa.free(self.name); } while (n < keys.len) : (n += 1) { owned_keys[n] = .{ .path = try gpa.dupe(u8, keys[n].path), .descending = keys[n].descending }; } self.keys = owned_keys; // Slot 0 is a dummy (0 is the null node id); the root is one empty // leaf, so a fresh index is always a valid tree. try self.node_pages.ensureUnusedCapacity(gpa, 2); try pager.reserve_pages(2); self.node_pages.appendAssumeCapacity(pager.alloc_pages_assume_reserved(1)); self.node_pages.appendAssumeCapacity(pager.alloc_pages_assume_reserved(1)); self.page_mut(0).* = empty_node(0); self.page_mut(1).* = empty_node(1); self.root = 1; self.first_leaf = 1; self.leaf_count = 1; return self; } pub fn deinit(self: *Index, gpa: std.mem.Allocator) void { for (self.staging.items) |e| gpa.free(e.key); self.staging.deinit(gpa); self.node_pages.deinit(gpa); self.ovf_extents.deinit(gpa); for (self.keys) |k| gpa.free(k.path); gpa.free(self.keys); gpa.free(self.name); } pub fn count(self: *const Index) usize { return self.entry_count; } // -- entry generation --------------------------------------------------- /// Build the entries one document contributes. Mirrors field_matches /// (src/query.zig): the value at each path plus the elements of any /// array there, so both `{tags: "a"}` element queries and whole-array /// equality on `{tags: ["a","b"]}` are covered. Returns an empty list /// for a sparse index when a path yields no values (the document is /// skipped); a non-sparse index indexes missing fields as null. pub fn build_entries( self: *const Index, gpa: std.mem.Allocator, doc: []const u8, ) !BuiltEntries { // One arena for the whole call: the collected values and any nested // spines the byte walker materializes (whole-array/document values) // live here, so nothing leaks. The finished keys are still // exact-sized gpa copies that BuiltEntries owns. var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const a = arena.allocator(); var per_path: std.ArrayListUnmanaged(std.ArrayListUnmanaged(bson.Value)) = .empty; var multikey = false; var multi_paths: usize = 0; for (self.keys) |k| { var values: std.ArrayListUnmanaged(bson.Value) = .empty; try query.collect_values_bytes(a, doc, k.path, &values, 0); // Index the array itself and each element, like field_matches. const direct = values.items.len; var i: usize = 0; while (i < direct) : (i += 1) { if (values.items[i] == .array) { multikey = true; for (values.items[i].array) |elem| try values.append(a, elem); } } if (direct > 1) multikey = true; if (values.items.len > 1) multi_paths += 1; if (values.items.len == 0) { if (self.sparse) return .{ .entries = .empty, .multikey = false }; try values.append(a, .null); } try per_path.append(a, values); } if (multi_paths > 1) return error.ParallelArrays; // Cartesian product across paths, then dedupe identical entries. var out: std.ArrayListUnmanaged(Entry) = .empty; errdefer { for (out.items) |e| gpa.free(e.key); out.deinit(gpa); } const nkeys = self.keys.len; var limits: [max_index_keys]usize = undefined; for (0..nkeys) |ci| limits[ci] = per_path.items[ci].items.len; var choice: [max_index_keys]usize = undefined; @memset(choice[0..nkeys], 0); // One reused buffer; each finished key is copied out to its own // exact-sized allocation. var enc: std.ArrayListUnmanaged(u8) = .empty; while (true) { enc.clearRetainingCapacity(); 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 }); if (!advance_choice(choice[0..nkeys], limits[0..nkeys])) break; } if (out.items.len > 1) { std.mem.sort(Entry, out.items, {}, entry_less); var w: usize = 1; for (out.items[1..]) |e| { if (compare_entries(out.items[w - 1], e) != .eq) { out.items[w] = e; w += 1; } else { gpa.free(e.key); } } out.items.len = w; } return .{ .entries = out, .multikey = multikey }; } // -- mutation ----------------------------------------------------------- /// Reserve everything `insert_entries` of this batch will need, so that /// insertion — which runs after the log append — is infallible: node /// pages for the worst-case splits of a batch (each entry creates at /// most one leaf plus a bounded share of internal splits; batches are /// sorted, so new leaves are packed, but reserve the trivial bound), /// and the exact overflow bytes (spilled records are copied once). pub fn reserve_for(self: *Index, gpa: std.mem.Allocator, entries: []const Entry) !void { const n: u64 = entries.len; // One entry splits at most one node per level, plus a new root when // the old root is the level that splits: `levels + 1` nodes, where // `levels == depth + 1`. // // A batch can also deepen the tree as it goes, and each added level // costs one more node per remaining entry -- so the bound has to use // the *final* depth. Growing by one level means splitting the root, // which means filling it first, and the smallest root a split can // leave holds one separator: refilling it to a split takes at least // two more arrivals from below, each of which needs a split one // level down. So g added levels take at least 2^g entries, and // `log2_ceil(n+1) + 1` bounds g. // // This replaces an earlier `n/8` stand-in for g, which was ~125 // levels for a 1000-entry batch -- harmless as ArrayList capacity, // but it becomes real file growth once the arena is file-backed // (~528 MiB of demanded headroom for that batch, against ~70 MiB // here). Tightening it further needs an amortized argument rather // than this per-entry one, since no single insertion can split a // full path twice in a row. // // Note what does and does not guard this arithmetic: `alloc_node`'s // assert catches an overrun of the *actual* capacity, and // ensureUnusedCapacity over-allocates geometrically, so a bound // that is slightly too small is usually masked here. (Mutation- // checked by dropping the reservation entirely, which does fire it // -- six tests.) Once the arena is file-backed and the reservation // is exact, that assert becomes a real check on this expression. const growth: u64 = std.math.log2_int_ceil(u64, n + 1) + 1; const extra_nodes: u64 = n * (self.depth + 2 + growth) + 4; // Two reservations, because a node now needs both a page in the file and // a slot in the id->page table, and the insertion after the log append // must not be able to fail on either. try self.node_pages.ensureUnusedCapacity(gpa, @intCast(extra_nodes)); try self.pager.reserve_pages(@intCast(extra_nodes)); try self.reserve_overflow(gpa, entries); } /// Reserve the slab bytes `entries` will spill, so that store_record's /// 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. /// /// `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 + off_len; if (rec_len > inline_limit) overflow_bytes += rec_len; } if (overflow_bytes == 0) return; // Same rule as the document slab: a checkpoint freezes the page the tail // points into, so a frozen tail means starting a fresh extent rather // than writing inside the durable image. // Same reasoning as the document slab: a recycled extent is below the // stable mark and still writable, so ask whether these bytes are in the // published image rather than where they sit. if (self.pager.is_unpublished_at(self.ovf_tail) and self.ovf_tail + overflow_bytes <= self.ovf_end) return; // One extent for the whole batch, or a bespoke one when a single // record is larger than the standard extent (a BSON string reaches // 16 MB). const want_pages: u32 = @intCast(@max( ovf_extent_pages, (overflow_bytes + pgr.page_size - 1) / pgr.page_size, )); try self.pager.reserve_pages(want_pages); const first = self.pager.alloc_pages_assume_reserved(want_pages); try self.ovf_extents.append(gpa, .{ .first = first, .pages = want_pages }); self.ovf_tail = @as(u64, first) << pgr.page_shift; self.ovf_end = self.ovf_tail + (@as(u64, want_pages) << pgr.page_shift); } /// 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. /// `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, off); } } /// Build, check, and insert entries for one document — the whole /// entry-commit protocol in one call, used everywhere a single document /// joins an index (create, rebuild on open). Writes that must reserve /// capacity before a log append use the split build/reserve/insert form /// directly. /// /// With `enforce_unique` false a duplicate is tolerated rather than /// rejected (the rebuild path keeps the index and warns); the return /// value reports whether that happened. pub fn add_doc( self: *Index, gpa: std.mem.Allocator, doc: []const u8, off: u64, enforce_unique: bool, ) !bool { 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) { // 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, off); return duplicate; } /// Append one document's entries to the bulk-build staging array /// without maintaining order. Pairs with `finish_bulk`, which sorts the /// staging once and packs it into leaves bottom-up. /// /// Inserting documents one at a time keeps a sorted array memmoved on /// every entry, so building an index over n documents would move O(n²) /// bytes — that was the whole cost of createIndex on a large /// collection. Staging and packing is O(n log n) and no memmove. pub fn append_doc_entries( self: *Index, gpa: std.mem.Allocator, doc: []const u8, off: u64, ) !void { 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.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; } /// Sort the staging collected by `append_doc_entries`, check a unique /// index for duplicate keys (one adjacent-pair scan), and pack the tree. /// /// With `enforce_unique` false a duplicate is tolerated rather than /// rejected, matching `add_doc`; the return value reports whether that /// happened. /// The error set widened when the overflow slab moved into the data file: /// reserving it can now fail on file growth, not only on OOM. pub fn finish_bulk( self: *Index, gpa: std.mem.Allocator, enforce_unique: bool, ) !bool { 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| { const prev = self.staging.items[prev_i]; // 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 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; } } // Only the slab needs reserving up front: pack_tree grows the node // array itself, so a bulk build no longer reserves a node per entry // when it needs one per leaf (65k entries pack into ~1k leaves). try self.reserve_overflow(gpa, self.staging.items); try self.pack_tree(gpa); return duplicate; } /// Throw the tree away and start from an empty root, so a rebuild can pack a /// fresh one. The old pages go on the free list, which withholds them for two /// generations -- the image that still references them stays intact. pub fn reset_tree(self: *Index, gpa: std.mem.Allocator) !void { for (self.node_pages.items) |p| try self.pager.free_pages(p, 1); for (self.ovf_extents.items) |e| try self.pager.free_pages(e.first, e.pages); self.node_pages.clearRetainingCapacity(); self.ovf_extents.clearRetainingCapacity(); self.ovf_tail = 0; self.ovf_end = 0; try self.node_pages.ensureUnusedCapacity(gpa, 2); try self.pager.reserve_pages(2); self.node_pages.appendAssumeCapacity(self.pager.alloc_pages_assume_reserved(1)); self.node_pages.appendAssumeCapacity(self.pager.alloc_pages_assume_reserved(1)); self.page_mut(0).* = empty_node(0); self.page_mut(1).* = empty_node(1); self.root = 1; self.first_leaf = 1; self.leaf_count = 1; self.depth = 0; self.entry_count = 0; self.multikey = false; } /// 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_off(self: *Index, off: u64) void { var leaf_id = self.first_leaf; while (leaf_id != 0) { const node = self.page(leaf_id); const next = node.next; // Slots are removed high-to-low so the indices stay valid. var i = node.count; while (i > 0) { i -= 1; if (self.off_of(leaf_id, i) == off) self.leaf_remove(leaf_id, i); } leaf_id = next; } } /// Remove the entries `doc` contributed, locating each by regeneration /// instead of scanning the tree. /// /// Entry generation is a pure function of the document, so the entries /// to remove can be regenerated and looked up directly; each is a /// descent plus a leaf-local scan of its key band (equal keys may /// legitimately span several leaves). `remove_id` walks every leaf /// comparing ids, which made a single document update cost O(index /// size) per index. /// /// 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, 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, off)) return self.remove_off(off); } } /// Reject when any of `new_entries` has a key already present under a /// different id. Entries with `exclude_id` (the replacing document's /// own old entries) are allowed. /// `exclude_id` is the document whose own existing entries do not count as /// duplicates -- a replace re-inserts its entries, so its old ones must be /// ignored. It must be **null for an insert**, where the document has no /// entries yet: passing its id there would make a colliding entry with the /// *same* id invisible, which is exactly the case the implicit `_id_` index /// has to catch. pub fn check_unique( self: *const Index, new_entries: []const Entry, 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 != null and have.off == exclude.?; if (!is_self) return error.DuplicateKeyIndex; } } } /// The id stored under exactly `key`, or null. Exact byte equality rather /// 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) ?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.off; } // -- search ------------------------------------------------------------- /// All ids whose key equals `key` (component-wise). For a partial key /// (fewer components than the index has) this is a prefix search. pub fn lookup_eq( self: *const Index, gpa: std.mem.Allocator, key: []const bson.Value, out: *std.ArrayListUnmanaged(u64), ) !void { var enc: std.ArrayListUnmanaged(u8) = .empty; defer enc.deinit(gpa); for (key) |v| try bson.encode_key(v, gpa, &enc); var it = self.seek(enc.items); while (it.next()) |e| { if (cmp_prefix(enc.items, e.key) != .eq) break; try out.append(gpa, e.off); } } /// All ids whose key starts with `prefix` and whose component at /// `prefix.len` falls within [lo, hi]. Range bounds apply to the next /// component after the equality prefix. pub fn lookup_range( self: *const Index, gpa: std.mem.Allocator, prefix: []const bson.Value, lo: ?bson.Value, lo_incl: bool, hi: ?bson.Value, hi_incl: bool, 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 // extended key orders within the prefix's own band). The scan // starts at the lower bound and stops at the first key beyond the // band or the upper bound — no slice ends to compute. var enc_a: std.ArrayListUnmanaged(u8) = .empty; var enc_b: std.ArrayListUnmanaged(u8) = .empty; defer enc_a.deinit(gpa); defer enc_b.deinit(gpa); for (prefix) |v| try bson.encode_key(v, gpa, &enc_a); const prefix_len = enc_a.items.len; if (lo) |l| try bson.encode_key(l, gpa, &enc_a); if (hi) |h| { for (prefix) |v| try bson.encode_key(v, gpa, &enc_b); try bson.encode_key(h, gpa, &enc_b); } const prefix_key = enc_a.items[0..prefix_len]; const lo_key: ?[]const u8 = if (lo != null) enc_a.items else null; const hi_key: ?[]const u8 = if (hi != null) enc_b.items else null; var it = self.seek(if (lo_key) |lk| lk else prefix_key); while (it.next()) |e| { if (lo_key) |lk| { // Exclusive lower bound: skip entries still at or below lo // (an entry whose component equals lo is exactly lo). if (!lo_incl and cmp_prefix(lk, e.key) != .lt) continue; } if (hi_key) |hk| { const c = cmp_prefix(hk, e.key); if (hi_incl) { if (c == .lt) break; // key > hi } else { if (c != .gt) break; // key >= hi } } else if (cmp_prefix(prefix_key, e.key) != .eq) { break; // band ended } try out.append(gpa, e.off); } } // -- iteration ---------------------------------------------------------- /// Ordered iteration over the whole index. Entries come out in key /// order; equal keys in the order the leaves happen to hold them. pub const Iter = struct { ix: *const Index, leaf: u32, slot: u32, pub fn next(self: *Iter) ?EntryRef { const ix = self.ix; while (self.leaf != 0) { const node = ix.page(self.leaf); if (self.slot < node.count) { const key = ix.key_of(self.leaf, self.slot); const off = ix.off_of(self.leaf, self.slot); self.slot += 1; return .{ .key = key, .off = off }; } self.leaf = node.next; self.slot = 0; } return null; } }; pub fn iter(self: *const Index) Iter { return .{ .ix = self, .leaf = self.first_leaf, .slot = 0 }; } /// Reverse ordered iteration. Leaves are doubly linked and `prev` has /// always been maintained -- nothing walked it until now, so a descending /// scan had to materialize every candidate and reverse the list. This turns /// `find({}).sort({_id: -1}).limit(20)` from O(collection) into O(20). pub const RevIter = struct { ix: *const Index, leaf: u32, /// One past the slot to yield next, so 0 means this leaf is done. slot: u32, pub fn next(self: *RevIter) ?EntryRef { const ix = self.ix; while (self.leaf != 0) { if (self.slot > 0) { self.slot -= 1; return .{ .key = ix.key_of(self.leaf, self.slot), .off = ix.off_of(self.leaf, self.slot), }; } const prev = ix.page(self.leaf).prev; self.leaf = prev; if (prev != 0) self.slot = ix.page(prev).count; } return null; } }; pub fn iter_reverse(self: *const Index) RevIter { const last = self.descend_last(); return .{ .ix = self, .leaf = last, .slot = self.page(last).count }; } /// The rightmost leaf. An internal node's children are `first_child` /// followed by one per separator, so the last child is the last slot's /// `extra` (or `first_child` when the node holds no separators -- which /// removal can leave behind, since it never rebalances). fn descend_last(self: *const Index) u32 { var node_id = self.root; while (self.page(node_id).is_leaf == 0) { const node = self.page(node_id); node_id = if (node.count == 0) node.first_child else get_slot(node, node.count - 1).extra; } return node_id; } /// Ordered iteration starting at the first entry whose key is not less /// than `prefix` (prefix semantics). Used by the equality/range /// searches and the TTL sweep's datetime band. pub fn seek(self: *const Index, prefix: []const u8) Iter { const b = self.lower_bound(prefix); return .{ .ix = self, .leaf = b.leaf, .slot = b.slot }; } // -- serialization ------------------------------------------------------ /// The canonical spec document bytes /// ({v, key, name, unique?, sparse?, expireAfterSeconds?}) stored in the /// log and used to rebuild the index on replay. pub fn write_spec( self: *const Index, gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), ) !void { var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); var pairs: std.ArrayListUnmanaged(bson.Pair) = .empty; try self.spec_pairs(arena.allocator(), &pairs); try bson.write_doc(pairs.items, gpa, out); } /// The spec as pairs in `arena` (values alias this index's own storage, /// which outlives any reply). Used by listIndexes. pub fn spec_pairs( self: *const Index, arena: std.mem.Allocator, out: *std.ArrayListUnmanaged(bson.Pair), ) !void { try out.append(arena, .{ .key = "v", .value = .{ .int32 = 2 } }); const key_pairs = try arena.alloc(bson.Pair, self.keys.len); for (self.keys, 0..) |k, i| { key_pairs[i] = .{ .key = k.path, .value = if (k.descending) .{ .int32 = -1 } else .{ .int32 = 1 } }; } try out.append(arena, .{ .key = "key", .value = .{ .doc = key_pairs } }); try out.append(arena, .{ .key = "name", .value = .{ .string = self.name } }); if (self.unique) try out.append(arena, .{ .key = "unique", .value = .{ .bool = true } }); if (self.sparse) try out.append(arena, .{ .key = "sparse", .value = .{ .bool = true } }); // int32 like MongoDB: parse_spec caps the value at // max_expire_after_seconds, so the cast always fits. if (self.ttl) |secs| try out.append(arena, .{ .key = "expireAfterSeconds", .value = .{ .int32 = @intCast(secs) } }); } pub fn spec_equal(a: *const Index, b: *const Index) bool { if (!std.mem.eql(u8, a.name, b.name)) return false; if (a.unique != b.unique or a.sparse != b.sparse) return false; if (!std.meta.eql(a.ttl, b.ttl)) return false; if (a.keys.len != b.keys.len) return false; for (a.keys, b.keys) |ka, kb| { if (!std.mem.eql(u8, ka.path, kb.path)) return false; if (ka.descending != kb.descending) return false; } return true; } // -- tree internals ----------------------------------------------------- const Record = struct { key: []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 /// records, promoted separators). Optional rather than a 0 /// sentinel: offset 0 is a real slab position, held by the first /// record that ever spilled -- which would otherwise be copied into /// the slab again on every move, past the reserved capacity. spill_off: ?u64 = null, }; const StableKey = struct { key: []const u8, spill_off: ?u64, }; const Split = struct { /// The separator to promote, stable across node-array growth (a /// slice into the overflow slab, or a copy in the promo buffer). key: []const u8, spill_off: ?u64, right: u32, }; fn empty_node(is_leaf: u32) Node { return .{ .is_leaf = is_leaf, .count = 0, .parent = 0, .next = 0, .prev = 0, .first_child = 0, .data_start = page_data, ._rsv = 0, .buf = undefined, }; } /// Allocate a node id. Infallible: insert paths reserve capacity first; /// the pack path reserves via reserve_for before packing. /// /// The assert is the tripwire for `reserve_for`'s bound. Overrunning the /// reservation is not a graceful failure: `appendAssumeCapacity` writes /// past the buffer, and in ReleaseFast (this project's default) nothing /// else checks. It also cannot be reported to the caller -- this runs /// after the log append, on the path whose whole point is that a /// document can never be live but unindexed -- so panicking is the only /// honest response. fn alloc_node(self: *Index) u32 { assert_msg(self.node_pages.items.len < self.node_pages.capacity, "node allocation overran reserve_for's bound"); const p = self.pager.alloc_pages_assume_reserved(1); self.node_pages.appendAssumeCapacity(p); const id: u32 = @intCast(self.node_pages.items.len - 1); self.page_mut(id).* = empty_node(0); return id; } /// Allocate a node id, growing the array. For the pack path, which is /// fallible anyway and would otherwise have to reserve one node per /// entry when it needs one per leaf. fn alloc_node_grow(self: *Index, gpa: std.mem.Allocator) !u32 { const p = try self.pager.alloc_pages(1); try self.node_pages.append(gpa, p); const id: u32 = @intCast(self.node_pages.items.len - 1); self.page_mut(id).* = empty_node(0); return id; } // -- arena access ------------------------------------------------------- // // Every read of a node page goes through `page`, every write through // `page_mut`, and every overflow-slab read through `ovf`. Nothing else // touches `nodes.items` or `overflow.items`. That is not style: M0 moves // both stores into an mmap'd data file, where a write to a page belonging // to the last durable checkpoint has to copy the page first (PLAN // Amendment A1). Funnelling writes through one function is what makes // that a change of three bodies instead of sixty call sites, and what // lets a debug build enforce "no store below the stable mark". // // The rule that copy-on-write will impose, worth honouring already: do // not hold a `*Node` across a `page_mut` of *the same* id. Under COW the // second call can move that id to a fresh page, leaving the first // pointer aimed at a page nothing will ever read again. Holding pointers // to two *different* ids at once stays fine. // // The helpers that take a node id rather than a `*Node` -- store_record, // remove_record, repack_keep_prefix -- each re-acquire the page, so they // are where a caller could break the rule. Every caller currently // complies, and where it is not obvious it is because a value was read // out first: `insert_rec` passes `node.count` to repack_keep_prefix and // never touches `node` again afterwards. Audited as part of introducing // these accessors; re-audit when COW lands, and consider passing the // `*Node` down so the copy happens once at the top of the call. /// The page holding node `id`, for reading. inline fn page(self: *const Index, id: u32) *const Node { return @ptrCast(self.pager.page(self.node_pages.items[id])); } /// The page holding node `id`, for writing. /// Writable, via copy-on-write: a node inside the published image is copied /// to a fresh page and its table slot updated, so the durable bytes are /// never disturbed. Infallible in practice because `reserve_for` reserves /// the pages a batch can need -- the `catch` here would mean the reservation /// was short, which its own assert reports first and more precisely. inline fn page_mut(self: *Index, id: u32) *Node { const p = self.pager.page_mut_cow(&self.node_pages.items[id]) catch @panic("multiforadb: out of pages while writing an index node"); return @ptrCast(p); } /// Overflow-slab bytes in `[from, to)`. Slot offsets are u64 because a /// spilled record can sit anywhere in the slab; the casts live here so /// the callers read as plain slicing. inline fn ovf(self: *const Index, from: u64, to: u64) []const u8 { return self.pager.bytes(from, @intCast(to - from)); } fn get_slot(node_page: *const Node, i: u32) Slot { return std.mem.bytesToValue(Slot, node_page.buf[i * slot_size ..][0..slot_size]); } fn set_slot(node_page: *Node, i: u32, s: Slot) void { std.mem.bytesAsValue(Slot, node_page.buf[i * slot_size ..][0..slot_size]).* = s; } /// The key bytes of slot `i`, either in the node's page or the slab. fn key_of(self: *const Index, node_id: u32, i: u32) []const u8 { const node = self.page(node_id); const s = get_slot(node, i); if (s.spill) return self.ovf(s.off, s.off + s.key_len); return node.buf[@intCast(s.off)..@intCast(s.off + s.key_len)]; } /// The id bytes of leaf slot `i`. /// 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; 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 /// it stays inline, its bytes. Oversized records spill (slot only). fn fits(self: *const Index, node_id: u32, rec_len: u64) bool { const node = self.page(node_id); const inline_bytes: u64 = if (rec_len > inline_limit) 0 else rec_len; return (@as(u64, node.count) + 1) * slot_size + inline_bytes <= node.data_start; } /// Write `rec` into `node` at slot position `pos` (append when pos == /// 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); // 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.off != null) off_len else rec.child, .spill = false, ._pad = 0, }; if (rec.spill_off) |off| { s.off = off; s.spill = true; } else if (rec_len > inline_limit) { // Tripwire for reserve_overflow, for the same reason as // alloc_node's: this append runs after the log append and cannot // fail back to the caller. assert_msg(self.ovf_tail + rec_len <= self.ovf_end, "spilled record overran reserve_overflow's bound"); s.off = self.ovf_tail; @memcpy(self.pager.bytes_mut(self.ovf_tail, rec.key.len), rec.key); self.ovf_tail += rec.key.len; if (rec.off) |o| { std.mem.writeInt(u64, self.pager.bytes_mut(self.ovf_tail, off_len)[0..off_len], o, .little); self.ovf_tail += off_len; } 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.off) |o| std.mem.writeInt(u64, dst[rec.key.len..][0..off_len], o, .little); s.off = node.data_start; } if (pos < node.count) { const src = node.buf[pos * slot_size .. node.count * slot_size]; const dst = node.buf[(pos + 1) * slot_size .. (node.count + 1) * slot_size]; std.mem.copyBackwards(u8, dst, src); } set_slot(node, pos, s); node.count += 1; } /// Remove slot `i`, closing the hole its inline bytes leave and /// adjusting surviving inline offsets. Infallible. fn remove_record(self: *Index, node_id: u32, i: u32) void { const node = self.page_mut(node_id); const s = get_slot(node, i); const is_leaf = node.is_leaf == 1; const rec_len: u64 = s.key_len + (if (is_leaf) s.extra else 0); if (!s.spill and rec_len > 0) { const src = node.buf[@intCast(s.off + rec_len)..page_data]; const dst = node.buf[@intCast(s.off) .. page_data - @as(usize, @intCast(rec_len))]; std.mem.copyForwards(u8, dst, src); var j: u32 = 0; while (j < node.count) : (j += 1) { if (j == i) continue; var sj = get_slot(node, j); if (!sj.spill and sj.off > s.off) sj.off -= rec_len; set_slot(node, j, sj); } // data_start is the low boundary of the live region and is // unchanged: the freed bytes sit dead at the top of the page, // below the accounting, until the next split repacks it. } const src_slots = node.buf[(i + 1) * slot_size .. node.count * slot_size]; const dst_slots = node.buf[i * slot_size .. (node.count - 1) * slot_size]; std.mem.copyForwards(u8, dst_slots, src_slots); node.count -= 1; if (node.count == 0) node.data_start = page_data; } /// Repack `node` keeping only slots [0, k). Record bytes are copied to /// the scratch and written back packed from the top of the page; slots /// are rewritten in place with their new offsets. fn repack_keep_prefix(self: *Index, node_id: u32, k: u32) void { const node = self.page_mut(node_id); const is_leaf = node.is_leaf == 1; // Pass 1: surviving inline records to the scratch, in slot order. var scratch_len: usize = 0; var j: u32 = 0; while (j < k) : (j += 1) { const s = get_slot(node, j); if (s.spill) continue; const rec_len: usize = @intCast(s.key_len + (if (is_leaf) s.extra else 0)); const src = node.buf[@intCast(s.off)..@intCast(s.off + rec_len)]; @memcpy(self.scratch[scratch_len .. scratch_len + rec_len], src); scratch_len += rec_len; } // Pass 2: rewrite slots and data, back to front so the scratch is // consumed LIFO. var cursor: usize = page_data; j = k; while (j > 0) { j -= 1; var s = get_slot(node, j); if (s.spill) continue; const rec_len: usize = @intCast(s.key_len + (if (is_leaf) s.extra else 0)); cursor -= rec_len; scratch_len -= rec_len; @memcpy(node.buf[cursor .. cursor + rec_len], self.scratch[scratch_len .. scratch_len + rec_len]); s.off = cursor; set_slot(node, j, s); } node.data_start = @intCast(cursor); node.count = k; } /// The key at (node, i) in a form stable across node-array growth. fn stable_key(self: *Index, node_id: u32, i: u32) StableKey { const node = self.page(node_id); const s = get_slot(node, i); if (s.spill) return .{ .key = self.ovf(s.off, s.off + s.key_len), .spill_off = s.off }; const k = self.key_of(node_id, i); @memcpy(self.promo[0..k.len], k); return .{ .key = self.promo[0..k.len], .spill_off = null }; } /// What one record costs a page: its slot plus, when it stays inline, /// its bytes. The unit both `fits` and the split point are measured in. fn record_cost(rec_len: u64) u32 { return slot_size + @as(u32, if (rec_len > inline_limit) 0 else @intCast(rec_len)); } /// `record_cost` of the record already in slot `i`. fn slot_cost(self: *const Index, node_id: u32, i: u32) u32 { const node = self.page(node_id); const s = get_slot(node, i); if (s.spill) return slot_size; return slot_size + s.key_len + (if (node.is_leaf == 1) s.extra else 0); } /// Where to cut a merged run of records so that neither half exceeds /// half the total cost by more than one record: the first cut whose /// left half would pass half the total. A page's live records plus one /// new record cost at most page_data plus one record, so both halves /// are then guaranteed to fit a page. Cutting by slot count is not: /// every large record can sit on one side of the count midpoint. fn balanced_cut(costs: []const u32) u32 { var total: u64 = 0; for (costs) |c| total += c; var acc: u64 = 0; var mid: u32 = 0; while (mid < costs.len) : (mid += 1) { acc += costs[mid]; if (acc * 2 > total) break; } return @min(mid, @as(u32, @intCast(costs.len - 1))); } /// Entry position in a leaf, by (key, id). 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 self.off_of(leaf_id, mid) < off); if (less) lo = mid + 1 else hi = mid; } return lo; } /// Separator position in an internal node: after any equal keys, so the /// "last separator <= key" descent lands on the newest right child. fn separator_pos(self: *const Index, node_id: u32, key: []const u8) u32 { const node = self.page(node_id); var lo: u32 = 0; var hi: u32 = node.count; while (lo < hi) { const mid = lo + (hi - lo) / 2; if (std.mem.order(u8, self.key_of(node_id, mid), key) != .gt) lo = mid + 1 else hi = mid; } return lo; } /// The child holding the range `key` sorts into: right of the last /// separator that is <= key (equal keys live right of equal separators). fn descend_insert(self: *const Index, node_id: u32, key: []const u8) u32 { const node = self.page(node_id); var lo: u32 = 0; var hi: u32 = node.count; while (lo < hi) { const mid = lo + (hi - lo) / 2; if (std.mem.order(u8, self.key_of(node_id, mid), key) != .gt) lo = mid + 1 else hi = mid; } if (lo == 0) return node.first_child; if (lo == node.count) { if (node.count == 0) return node.first_child; return get_slot(node, node.count - 1).extra; } return get_slot(node, lo - 1).extra; } /// The child containing the lower bound of `prefix`: the child left of /// the first separator not less than the prefix (prefix semantics), or /// the rightmost child when every separator is less. fn descend_lower(self: *const Index, node_id: u32, prefix: []const u8) u32 { const node = self.page(node_id); var lo: u32 = 0; var hi: u32 = node.count; while (lo < hi) { const mid = lo + (hi - lo) / 2; if (cmp_prefix(prefix, self.key_of(node_id, mid)) == .gt) lo = mid + 1 else hi = mid; } if (lo == node.count) { // A one-child internal node (deletion does not rebalance) has // no separators; its only child is first_child. if (node.count == 0) return node.first_child; return get_slot(node, node.count - 1).extra; } if (lo == 0) return node.first_child; return get_slot(node, lo - 1).extra; } /// Position in a leaf of the first slot whose key is not less than /// `prefix` (prefix semantics). fn leaf_lower(self: *const Index, leaf_id: u32, prefix: []const u8) 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; if (cmp_prefix(prefix, self.key_of(leaf_id, mid)) == .gt) lo = mid + 1 else hi = mid; } return lo; } /// The (leaf, slot) of the first entry whose key is not less than /// `prefix`. fn lower_bound(self: *const Index, prefix: []const u8) struct { leaf: u32, slot: u32 } { var node_id = self.root; while (self.page(node_id).is_leaf == 0) node_id = self.descend_lower(node_id, prefix); return .{ .leaf = node_id, .slot = self.leaf_lower(node_id, prefix) }; } /// Insert one entry, splitting upward as needed. Infallible. 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; self.page_mut(self.root).parent = new_root; self.store_record(new_root, 0, .{ .key = up.key, .child = up.right, .spill_off = up.spill_off }); self.page_mut(up.right).parent = new_root; self.root = new_root; self.depth += 1; } } /// 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, off: u64) ?Split { const node = self.page(node_id); if (node.is_leaf == 1) { 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; } // remove_record leaves freed bytes dead at the top of the page, // so a churned leaf can run out of room with only a few live // records. Reclaim the dead bytes first; a leaf with a handful // 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 + 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, off); } const child = self.descend_insert(node_id, key); const res = self.insert_rec(child, key, off) orelse return null; return self.insert_separator(node_id, res); } /// Split a full leaf around the record being inserted. The new record /// takes its place in the leaf's key order and the merged run is cut /// where the two halves come closest to equal cost, so the half the new /// record lands in is guaranteed to have room for it. Records equal to /// 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, 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, 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 + off_len) else self.slot_cost(leaf_id, @intCast(if (m < pos) m else m - 1)); } const mid = @max(1, @min(balanced_cut(costs[0..n]), n - 1)); const right_id = self.alloc_node(); { const node = self.page_mut(leaf_id); const right = self.page_mut(right_id); right.is_leaf = 1; right.next = node.next; right.prev = leaf_id; right.parent = node.parent; } // Move the merged tail; spilled records keep their slab reference. var m: u32 = mid; while (m < n) : (m += 1) { const at: u32 = self.page(right_id).count; if (m == pos) { 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), .off = self.off_of(leaf_id, src), .spill_off = if (slot.spill) slot.off else null, }); } // Keep the merged head in the left page: its own records, plus the // 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, .off = off }); } else { self.repack_keep_prefix(leaf_id, mid); } // Link the chain. const left = self.page_mut(leaf_id); if (left.next != 0) self.page_mut(left.next).prev = right_id; left.next = right_id; self.leaf_count += 1; self.entry_count += 1; const sep = self.stable_key(right_id, 0); return .{ .key = sep.key, .spill_off = sep.spill_off, .right = right_id }; } /// Insert a promoted separator into an internal node, splitting it when /// full. Returns the next promotion, or null. fn insert_separator(self: *Index, node_id: u32, split: Split) ?Split { // The incoming key may live in the promo buffer, which a nested // split_internal (below) would overwrite with its own promoted key; // spilled keys already live in the immutable slab. Copy inline keys // to a stack buffer so they survive. var local: [inline_limit]u8 = undefined; const key: []const u8 = if (split.spill_off != null) split.key else blk: { @memcpy(local[0..split.key.len], split.key); break :blk local[0..split.key.len]; }; // As in a leaf: a dropped child leaves its separator's bytes dead // in the page, so reclaim before believing the page is full. This // is also what guarantees at least two separators at the split. if (!self.fits(node_id, key.len)) { self.repack_keep_prefix(node_id, self.page(node_id).count); } if (self.fits(node_id, key.len)) { self.store_record(node_id, self.separator_pos(node_id, key), .{ .key = key, .child = split.right, .spill_off = split.spill_off, }); self.page_mut(split.right).parent = node_id; return null; } return self.split_internal(node_id, key, split.spill_off, split.right); } /// Split a full internal node around the separator being inserted: the /// new separator joins the node's order, the merged run is cut where /// the halves come closest to equal cost (see split_leaf), and the /// separator at the cut is promoted -- its child becoming the right /// node's first child. fn split_internal( self: *Index, node_id: u32, key: []const u8, spill_off: ?u64, child: u32, ) Split { const old_count = self.page(node_id).count; std.debug.assert(old_count >= 2); const pos = self.separator_pos(node_id, key); 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) else self.slot_cost(node_id, @intCast(if (m < pos) m else m - 1)); } // The cut is promoted rather than kept, so only the right half // needs a separator left over. const mid = @min(balanced_cut(costs[0..n]), n - 1); // The promoted key has to survive the repack below. A spilled key // already lives in the immutable slab (and can be far larger than // the promo buffer); an inline one is copied into promo. const promoted: StableKey = if (mid != pos) self.stable_key(node_id, if (mid < pos) mid else mid - 1) else if (spill_off != null) .{ .key = key, .spill_off = spill_off } else blk: { @memcpy(self.promo[0..key.len], key); break :blk .{ .key = self.promo[0..key.len], .spill_off = null }; }; const right_id = self.alloc_node(); self.page_mut(right_id).is_leaf = 0; self.page_mut(right_id).parent = self.page(node_id).parent; // The promoted separator's child heads the right node. const mid_child: u32 = if (mid == pos) child else get_slot(self.page(node_id), if (mid < pos) mid else mid - 1).extra; self.page_mut(right_id).first_child = mid_child; self.page_mut(mid_child).parent = right_id; var m: u32 = mid + 1; while (m < n) : (m += 1) { const at: u32 = self.page(right_id).count; if (m == pos) { self.store_record(right_id, at, .{ .key = key, .child = child, .spill_off = spill_off }); self.page_mut(child).parent = right_id; continue; } const src: u32 = if (m < pos) m else m - 1; const slot = get_slot(self.page(node_id), src); self.store_record(right_id, at, .{ .key = self.key_of(node_id, src), .child = slot.extra, .spill_off = if (slot.spill) slot.off else null, }); self.page_mut(slot.extra).parent = right_id; } if (pos < mid) { self.repack_keep_prefix(node_id, mid - 1); self.store_record(node_id, pos, .{ .key = key, .child = child, .spill_off = spill_off }); self.page_mut(child).parent = node_id; } else { self.repack_keep_prefix(node_id, mid); } return .{ .key = promoted.key, .spill_off = promoted.spill_off, .right = right_id }; } /// 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, 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 (e.off == off) { self.leaf_remove(it.leaf, it.slot - 1); return true; } } return false; } /// Remove leaf slot and, when the leaf empties, unlink it and drop it /// from its parent, cascading up while internal nodes empty. fn leaf_remove(self: *Index, leaf_id: u32, slot_idx: u32) void { self.remove_record(leaf_id, slot_idx); self.entry_count -= 1; const node = self.page(leaf_id); if (node.count > 0) return; // Empty leaf: unlink and drop from the parent (unless it is the // root, which stays as the empty root leaf). if (leaf_id == self.root) return; if (node.prev != 0) self.page_mut(node.prev).next = node.next; if (node.next != 0) self.page_mut(node.next).prev = node.prev; if (leaf_id == self.first_leaf) self.first_leaf = node.next; self.leaf_count -= 1; var child = leaf_id; var parent = node.parent; while (parent != 0) { self.drop_child(parent, child); const pnode = self.page(parent); if (pnode.count == 0 and pnode.first_child == 0) { if (parent == self.root) { self.replace_root_with_leaf(); return; } child = parent; parent = pnode.parent; } else { return; } } } /// Remove `child` from `parent`'s child list. Infallible; `child`'s /// node is abandoned in place (its page is simply never referenced /// again — node ids are append-only, so this is a leak of at most the /// peak tree size, exactly what the old entry array's capacity was). fn drop_child(self: *Index, parent_id: u32, child_id: u32) void { const pnode = self.page_mut(parent_id); if (pnode.first_child == child_id) { if (pnode.count > 0) { const s0 = get_slot(pnode, 0); pnode.first_child = s0.extra; self.remove_record(parent_id, 0); } else { pnode.first_child = 0; } return; } var j: u32 = 0; while (j < pnode.count) : (j += 1) { if (get_slot(pnode, j).extra == child_id) { self.remove_record(parent_id, j); return; } } } /// The internal root emptied: swap in a fresh empty leaf as the root. fn replace_root_with_leaf(self: *Index) void { // Removal reserves no capacity, so this must not allocate a node: // re-use the emptied root page as the empty root leaf. self.page_mut(self.root).* = empty_node(1); self.first_leaf = self.root; self.leaf_count = 1; self.depth = 0; } /// Pack the sorted staging array into a fresh tree: leaves filled in /// key order, interior levels built bottom-up. Frees the staging keys /// and clears the staging array. fn pack_tree(self: *Index, gpa: std.mem.Allocator) !void { defer { for (self.staging.items) |e| gpa.free(e.key); self.staging.clearRetainingCapacity(); } if (self.staging.items.len == 0) return; // A fresh tree replaces whatever was here; the old pages are // abandoned in place, like any other dropped node. self.leaf_count = 0; self.depth = 0; const Level = struct { id: u32, first_key: []const u8, // aliases a leaf's page or the slab }; var level: std.ArrayListUnmanaged(Level) = .empty; defer level.deinit(gpa); // Leaves. var prev: u32 = 0; var lit = self.staging.items; while (lit.len > 0) { const leaf = try self.alloc_node_grow(gpa); self.page_mut(leaf).is_leaf = 1; const first_key = lit[0].key; // Fill until the next record would not fit. var used: usize = 0; var slots: usize = 0; var n: usize = 0; while (n < lit.len) : (n += 1) { 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, .off = lit[n].off }); slots += 1; used += inline_bytes; } self.page_mut(leaf).prev = prev; if (prev != 0) self.page_mut(prev).next = leaf; prev = leaf; self.leaf_count += 1; try level.append(gpa, .{ .id = leaf, .first_key = first_key }); lit = lit[n..]; } self.page_mut(prev).next = 0; self.first_leaf = level.items[0].id; // Interior levels: group the level below into internal nodes whose // separators are the first keys of its members. while (level.items.len > 1) { var next: std.ArrayListUnmanaged(Level) = .empty; defer next.deinit(gpa); var i: usize = 0; while (i < level.items.len) { const node = try self.alloc_node_grow(gpa); self.page_mut(node).first_child = level.items[i].id; self.page_mut(level.items[i].id).parent = node; const first_key = level.items[i].first_key; var used: usize = 0; var slots: usize = 0; var j = i + 1; while (j < level.items.len) : (j += 1) { const rec_len: u64 = level.items[j].first_key.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(node, @intCast(slots), .{ .key = level.items[j].first_key, .child = level.items[j].id, }); self.page_mut(level.items[j].id).parent = node; slots += 1; used += inline_bytes; } try next.append(gpa, .{ .id = node, .first_key = first_key }); i = j; } level.deinit(gpa); level = next; next = .empty; // ownership moved to `level`; the defer frees nothing self.depth += 1; } self.root = level.items[0].id; self.page_mut(self.root).parent = 0; self.entry_count = self.staging.items.len; } }; /// The index whose key pattern is exactly `key_pairs` (same paths, same /// order, same directions), or null. Keeps the IndexKey layout — and what /// counts as a match — inside this module. pub fn find_by_key_pattern(indexes: []const *Index, key_pairs: []const bson.Pair) ?*const Index { for (indexes) |ix| { if (ix.keys.len != key_pairs.len) continue; var match = true; for (ix.keys, key_pairs) |k, kp| { if (!std.mem.eql(u8, k.path, kp.key) or k.descending != descending(kp.value)) { match = false; break; } } if (match) return ix; } return null; } pub const SpecError = error{ InvalidIndexSpec, TtlOnCompoundIndex, InvalidExpireAfterSeconds, OutOfMemory }; /// Parse {key: {...}, name?, unique?, sparse?, expireAfterSeconds?} from a /// spec document — the form drivers send and the form the log stores. /// The error set is inferred rather than `SpecError`, because building an index /// now allocates pages in the data file and so can fail on file growth too. pub fn parse_spec(gpa: std.mem.Allocator, pager: *pgr.Pager, spec: *const bson.Document) !Index { const key_value = bson.get_pair(spec.pairs, "key") orelse return error.InvalidIndexSpec; const key_pairs = switch (key_value) { .doc => |p| p, else => return error.InvalidIndexSpec, }; if (key_pairs.len == 0 or key_pairs.len > max_index_keys) return error.InvalidIndexSpec; var keys: [max_index_keys]IndexKey = undefined; for (key_pairs, 0..) |p, i| { const ok_mag = switch (p.value) { .int32 => |n| n == 1 or n == -1, .int64 => |n| n == 1 or n == -1, .double => |n| n == 1.0 or n == -1.0, else => false, }; if (!ok_mag) return error.InvalidIndexSpec; keys[i] = .{ .path = p.key, .descending = descending(p.value) }; } var unique = false; var sparse = false; if (bson.get_pair(spec.pairs, "unique")) |v| unique = query.truthy(v); if (bson.get_pair(spec.pairs, "sparse")) |v| sparse = query.truthy(v); var ttl: ?i64 = null; if (bson.get_pair(spec.pairs, "expireAfterSeconds")) |v| { ttl = try expire_after_seconds(v); // MongoDB's rule: TTL is a single-field index option. A compound key // would leave it ambiguous which component dates the document. if (key_pairs.len != 1) return error.TtlOnCompoundIndex; } const name_value = bson.get_pair(spec.pairs, "name") orelse { const nm = try default_name(gpa, key_pairs); defer gpa.free(nm); return Index.init(gpa, pager, nm, keys[0..key_pairs.len], unique, sparse, ttl); }; const name = switch (name_value) { .string => |s| s, else => return error.InvalidIndexSpec, }; return Index.init(gpa, pager, name, keys[0..key_pairs.len], unique, sparse, ttl); } /// MongoDB's bound on expireAfterSeconds. Keeping it means a TTL always /// round-trips as an int32, exactly as a real server reports it. pub const max_expire_after_seconds: i64 = 2147483647; /// The seconds in an `expireAfterSeconds` option: integral and within /// [0, max_expire_after_seconds]. 0 is legal (expire at the stored instant); /// a negative, fractional, out-of-range or non-numeric value is not. A /// double is accepted because that is what a JavaScript driver sends for a /// plain number — and the range check runs before the conversion, so /// @intFromFloat is always defined. fn expire_after_seconds(v: bson.Value) error{InvalidExpireAfterSeconds}!i64 { const secs: i64 = switch (v) { .int32 => |n| n, .int64 => |n| n, .double => |d| blk: { if (!std.math.isFinite(d) or @trunc(d) != d) return error.InvalidExpireAfterSeconds; if (d < 0 or d > @as(f64, @floatFromInt(max_expire_after_seconds))) return error.InvalidExpireAfterSeconds; break :blk @intFromFloat(d); }, else => return error.InvalidExpireAfterSeconds, }; if (secs < 0 or secs > max_expire_after_seconds) return error.InvalidExpireAfterSeconds; return secs; } /// Whether a key-pattern direction value means descending. The single /// definition of what -1 means in a key pattern, shared with dropIndexes' /// key-pattern matching. pub fn descending(v: bson.Value) bool { return switch (v) { .int32 => |n| n < 0, .int64 => |n| n < 0, .double => |n| n < 0, else => false, }; } /// MongoDB's default index name: a_1_b_-1. fn default_name(gpa: std.mem.Allocator, key_pairs: []const bson.Pair) ![]u8 { var out: std.ArrayListUnmanaged(u8) = .empty; errdefer out.deinit(gpa); for (key_pairs, 0..) |p, i| { if (i > 0) try out.append(gpa, '_'); try out.appendSlice(gpa, p.key); try out.append(gpa, '_'); if (descending(p.value)) { try out.append(gpa, '-'); } try out.append(gpa, '1'); } return out.toOwnedSlice(gpa); } // --------------------------------------------------------------------------- // Comparison // --------------------------------------------------------------------------- /// Encoded-key byte order, tie-broken by the id bytes. This is the total /// order batches are sorted into before packing. /// /// bson.encode_key guarantees this reproduces component-wise bson.compare /// 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. /// A stream of candidate slab offsets, however they were produced: a plan's /// materialized lookups, or the index read end to end. The point is that the /// consumer is one loop, so the governing invariant of this file -- an index /// only generates candidates, the full filter is re-applied to every one -- /// lives in exactly one place regardless of which shape produced them. /// /// A `.scan`/`.scan_rev` holds an iterator positioned in the tree, so it must /// be drained (or dropped) before this collection is written to. The offsets it /// yields are values and are unaffected by any later mutation. pub const Candidates = union(enum) { scan: Index.Iter, scan_rev: Index.RevIter, list: struct { items: []const u64, i: usize = 0 }, pub fn next(self: *Candidates) ?u64 { switch (self.*) { .scan => |*it| return if (it.next()) |e| e.off else null, .scan_rev => |*it| return if (it.next()) |e| e.off else null, .list => |*l| { if (l.i >= l.items.len) return null; defer l.i += 1; return l.items[l.i]; }, } } }; pub fn compare_entries(a: Entry, b: Entry) std.math.Order { 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 /// before the prefix; `.gt` when after. /// /// Comparing raw bytes is sound because every column encoding is /// self-delimiting, so a prefix of the encoded key is exactly the encoding /// of its leading columns. fn cmp_prefix(prefix: []const u8, key: []const u8) std.math.Order { const n = @min(prefix.len, key.len); const o = std.mem.order(u8, prefix[0..n], key[0..n]); if (o != .eq) return o; // The key ran out first, so it sorts below the prefix. if (prefix.len > key.len) return .gt; return .eq; } /// Advance an odometer of positions, each bounded by the matching `limits` /// entry. Returns false once it wraps, i.e. the product is exhausted. fn advance_choice(choice: []usize, limits: []const usize) bool { var i = choice.len; while (i > 0) { i -= 1; choice[i] += 1; if (choice[i] < limits[i]) return true; choice[i] = 0; } return false; } // --------------------------------------------------------------------------- // Query planning // --------------------------------------------------------------------------- const Clause = struct { path: []const u8, value: bson.Value, }; /// Flatten top-level pairs and $and members into AND-ed predicates. Every /// other top-level operator ($or, $nor, ...) is skipped: the full filter is /// re-applied later, so a usable sibling still yields a valid superset. fn flatten_clauses( gpa: std.mem.Allocator, pairs: []const bson.Pair, out: *std.ArrayListUnmanaged(Clause), ) !void { for (pairs) |p| { if (p.key.len > 0 and p.key[0] == '$') { if (std.mem.eql(u8, p.key, "$and")) { const members = switch (p.value) { .array => |a| a, else => continue, }; for (members) |m| { const mp = switch (m) { .doc => |d| d, else => continue, }; try flatten_clauses(gpa, mp, out); } } continue; } try out.append(gpa, .{ .path = p.key, .value = p.value }); } } /// Per-index-component info extracted from the filter clauses. const CompInfo = struct { eq: ?bson.Value = null, in_values: ?[]const bson.Value = null, lo: ?bson.Value = null, lo_incl: bool = false, hi: ?bson.Value = null, hi_incl: bool = false, }; /// Extract the usable constraint from one clause value: a bare non-regex /// equality, or an operator doc whose operators are all in /// {$eq, $in, $gt, $gte, $lt, $lte}. Anything else leaves the info /// untouched (unusable — the full filter is re-applied anyway). fn analyze_clause(value: bson.Value, info: *CompInfo) void { if (value == .doc) { const pairs = value.doc; if (pairs.len > 0 and !query.all_operator_keys(pairs)) { // Bare document equality: {a: {n: 1}} compares the whole doc. info.eq = value; return; } for (pairs) |p| { if (std.mem.eql(u8, p.key, "$eq")) { info.eq = p.value; } else if (std.mem.eql(u8, p.key, "$in")) { info.in_values = switch (p.value) { .array => |a| a, else => return, }; } else if (std.mem.eql(u8, p.key, "$gt")) { info.lo = p.value; info.lo_incl = false; } else if (std.mem.eql(u8, p.key, "$gte")) { info.lo = p.value; info.lo_incl = true; } else if (std.mem.eql(u8, p.key, "$lt")) { info.hi = p.value; info.hi_incl = false; } else if (std.mem.eql(u8, p.key, "$lte")) { info.hi = p.value; info.hi_incl = true; } else { info.eq = null; info.in_values = null; info.lo = null; info.hi = null; return; } } return; } if (value == .regex) return; info.eq = value; } /// A candidate-generation plan for one index: `lookup_keys` is the /// cartesian product of the leading equality/$in components (each key has /// `key_len` Values aliasing the filter), plus an optional range on the /// next component. pub const Plan = struct { index: *const Index, lookup_keys: std.ArrayListUnmanaged([]const bson.Value), lo: ?bson.Value, lo_incl: bool, hi: ?bson.Value, hi_incl: bool, /// The candidates come out already in the requested sort order, so the /// caller can skip sorting and stop as soon as the page is full. provides_sort: bool = false, /// That order is the reverse of the index's. backward: bool = false, /// Whether this plan reads the whole index with no narrowing, which is /// the case a streaming leaf walk can answer without materializing /// anything. At the M0 scale that matters: a `countDocuments({})` over /// 20 million documents would otherwise build a 160 MB list of offsets /// before the caller sees the first one. /// /// Multikey indexes are excluded. One document contributes several entries /// there, so a full walk yields it more than once -- `search` dedupes, a /// stream cannot. /// /// That check is currently redundant: `index_provides_sort` refuses /// multikey, and a run-0 plan with no range is only formed when it supplies /// the sort, so no multikey plan reaches here today. Kept deliberately -- /// the two guards protect different things, and if the planner ever learns /// to order a multikey index, streaming must not silently start returning /// duplicates. Verified by removing both: the commands.zig test /// "a sorted full scan over a multikey index returns each document once" /// goes red only then, which is the honest statement of what pins this. pub fn full_scan(self: *const Plan) bool { if (self.index.multikey) return false; if (self.lo != null or self.hi != null) return false; return self.lookup_keys.items.len == 1 and self.lookup_keys.items[0].len == 0; } pub fn deinit(self: *Plan, gpa: std.mem.Allocator) void { for (self.lookup_keys.items) |k| gpa.free(k); self.lookup_keys.deinit(gpa); } /// How many leading index components the lookup keys pin down. Every key /// is built with the same component count, and there is always at least /// one (a pure range plan appends a single empty key). pub fn key_len(self: *const Plan) usize { return self.lookup_keys.items[0].len; } /// Collect the candidate ids, sorted and deduplicated. Range scans can /// return the same id non-adjacently (a doc with {tags: ["a","b"]} /// contributes two entries inside one range), so adjacent-dup skipping /// would be wrong. /// /// Duplicates are only possible from a multikey index (one document, /// several entries) or from several lookup keys (whose ranges can be the /// same key repeated, as in {$in: [1, 1]}); the common single-key lookup /// on a non-multikey index skips the pass entirely. pub fn search( self: *const Plan, gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged(u64), ) !void { for (self.lookup_keys.items) |key| { if (self.lo == null and self.hi == null) { try self.index.lookup_eq(gpa, key, out); } else { try self.index.lookup_range(gpa, key, self.lo, self.lo_incl, self.hi, self.hi_incl, out); } } // 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 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(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(u64, out.items, {}, std.sort.asc(u64)); var w: usize = 1; for (out.items[1..]) |id| { if (id != out.items[w - 1]) { out.items[w] = id; w += 1; } } out.items.len = w; } } }; /// Pick the index (if any) that can generate a superset of the matching /// documents: the one covering the longest leading run of equality/$in /// predicates, optionally with a range on the next key. Returns null when /// nothing usable remains — the caller scans. /// /// `id_ix` is the collection's implicit `_id_` index (kept separate from /// the secondary `indexes` list so the listing/drop commands stay /// unchanged). Every document has an `_id` and the index is not sparse, so /// a full scan of it cannot miss a document — which is what the sort /// planner's full-scan plan relies on. The encoded keys are canonical, so /// `_id` equality on compare-equal values (int32 1, int64 1, double 1.0) /// finds the same entries. pub fn plan( gpa: std.mem.Allocator, id_ix: ?*const Index, indexes: []const *Index, filter: []const bson.Pair, sort: []const query.SortKey, ) !?Plan { if (id_ix == null and indexes.len == 0) return null; var clauses: std.ArrayListUnmanaged(Clause) = .empty; defer clauses.deinit(gpa); try flatten_clauses(gpa, filter, &clauses); var best: ?Plan = null; if (id_ix) |idx| { if (try evaluate_index(gpa, idx, clauses.items, sort)) |cand| { best = cand; } } for (indexes) |ix| { var cand = (try evaluate_index(gpa, ix, clauses.items, sort)) orelse continue; if (best) |b| { if (plan_better(&cand, &b)) { best.?.deinit(gpa); best = cand; } else { cand.deinit(gpa); } } else { best = cand; } } return best; } fn plan_better(a: *const Plan, b: *const Plan) bool { if (a.key_len() != b.key_len()) return a.key_len() > b.key_len(); // Same selectivity: providing the sort saves ordering the whole result. if (a.provides_sort != b.provides_sort) return a.provides_sort; const a_range = a.lo != null or a.hi != null; const b_range = b.lo != null or b.hi != null; if (a_range != b_range) return a_range; return a.lookup_keys.items.len < b.lookup_keys.items.len; } /// Whether scanning `ix` in order satisfies `sort`, and if so whether that /// means reading the tree backwards. /// /// The sort keys must line up with the index components that follow the /// equality-pinned prefix: those components are fixed to one value each, so /// they do not affect the order of what remains. Directions must agree /// uniformly — every key the same way round, or every key opposite — since /// the leaves can only be read forwards or backwards. /// /// A multikey index is excluded: it emits a document once per indexed /// value, so its order is not an order on documents. fn index_provides_sort(ix: *const Index, run: usize, sort: []const query.SortKey) ?bool { if (sort.len == 0 or ix.multikey) return null; if (run + sort.len > ix.keys.len) return null; const backward = sort[0].descending != ix.keys[run].descending; for (sort, 0..) |sk, i| { const k = ix.keys[run + i]; if (!std.mem.eql(u8, sk.path, k.path)) return null; if ((sk.descending != k.descending) != backward) return null; } return backward; } fn evaluate_index( gpa: std.mem.Allocator, ix: *const Index, clauses: []const Clause, sort: []const query.SortKey, ) !?Plan { const n = ix.keys.len; var infos: [max_index_keys]CompInfo = undefined; for (0..n) |i| { var info = CompInfo{}; for (clauses) |cl| { if (std.mem.eql(u8, cl.path, ix.keys[i].path)) analyze_clause(cl.value, &info); } infos[i] = info; } // Longest leading run of equality/$in components. var run: usize = 0; while (run < n and (infos[run].eq != null or infos[run].in_values != null)) run += 1; var lo: ?bson.Value = null; var lo_incl = false; var hi: ?bson.Value = null; var hi_incl = false; if (run < n) { lo = infos[run].lo; lo_incl = infos[run].lo_incl; hi = infos[run].hi; hi_incl = infos[run].hi_incl; } const sort_dir = index_provides_sort(ix, run, sort); // With no filter to narrow anything down, a full index scan is only // worth it when it is what produces the ordering — otherwise scanning // the docs map directly is strictly cheaper. if (run == 0 and lo == null and hi == null and sort_dir == null) return null; // A two-sided range on a multikey index can under-approximate: a doc // like {a: [1, 2]} satisfies {a: {$gt: 5, $lt: 25}} with the array for // the lower bound (rank 5 > 5) and an element for the upper (1 < 25), // so no single entry lies inside (5, 25) — the range scan would miss // it. One-sided ranges are safe: whichever candidate satisfies the // bound is itself an entry inside it. Equality/$in are unaffected. if (ix.multikey and lo != null and hi != null) return null; // A sparse index skips documents missing a field; {a: null} would // otherwise miss them. Never use a sparse index for a null component. if (ix.sparse) { for (0..run) |i| { if (infos[i].eq) |v| { if (v == .null) return null; } if (infos[i].in_values) |list| { for (list) |m| if (m == .null) return null; } } if (lo) |v| if (v == .null) return null; if (hi) |v| if (v == .null) return null; } var counts: [max_index_keys]usize = undefined; for (0..run) |i| { counts[i] = if (infos[i].eq != null) 1 else infos[i].in_values.?.len; } var combos: u64 = 1; for (0..run) |i| { // A single huge $in list (or a product over 100) falls back to a // scan; checking the factor first keeps the product from wrapping. if (counts[i] > max_combos) return null; combos *= counts[i]; if (combos > max_combos) return null; } var pl = Plan{ .index = ix, .lookup_keys = .empty, .lo = lo, .lo_incl = lo_incl, .hi = hi, .hi_incl = hi_incl, }; // Several lookup keys ($in) concatenate disjoint ranges, whose // concatenation is not ordered. pl.provides_sort = sort_dir != null and combos == 1; pl.backward = sort_dir orelse false; errdefer pl.deinit(gpa); if (run == 0) { const empty = try gpa.alloc(bson.Value, 0); errdefer gpa.free(empty); try pl.lookup_keys.append(gpa, empty); } else { var choice: [max_index_keys]usize = undefined; @memset(choice[0..run], 0); while (true) { const key = try gpa.alloc(bson.Value, run); errdefer gpa.free(key); // An eq component contributes its single value; an $in // component the choice-th member. (Never materialize the // single-value option as a temporary array: it would dangle.) for (0..run) |i| { key[i] = if (infos[i].eq) |v| v else infos[i].in_values.?[choice[i]]; } try pl.lookup_keys.append(gpa, key); if (!advance_choice(choice[0..run], counts[0..run])) break; } } return pl; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- const testing = std.testing; /// Serialize a fabricated doc's pairs to canonical bytes (owned by the /// caller), since entry generation now reads stored documents as bytes. fn bytes_of(gpa: std.mem.Allocator, pairs: []const bson.Pair) ![]u8 { var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); try bson.write_doc(pairs, gpa, &out); return out.toOwnedSlice(gpa); } /// A fabricated tree document for functions that still parse specs from /// pairs (parse_spec). Never deinit'd — mirrors the old doc_of. fn doc_of(pairs: []const bson.Pair) bson.Document { return .{ .arena = undefined, .pairs = pairs }; } /// One data file shared by every test in this file, created on first use. /// /// Shared rather than per-test because a pager needs an `io` and a temp path, /// and threading both through twenty tests would bury what each is about. The /// tests are independent regardless: each Index owns its own pages, and nothing /// here frees any, so they cannot interfere. Allocated from the page allocator /// so it is not reported as a leak by whichever test happens to create it. var test_pager_state: ?struct { threaded: *std.Io.Threaded, pager: *pgr.Pager, } = null; fn test_pager() *pgr.Pager { if (test_pager_state) |st| return st.pager; const a = std.heap.page_allocator; const threaded = a.create(std.Io.Threaded) catch @panic("test pager"); threaded.* = .init_single_threaded; const pg = a.create(pgr.Pager) catch @panic("test pager"); std.Io.Dir.cwd().deleteFile(threaded.io(), ".zig-cache/index-test.data") catch {}; pg.* = pgr.Pager.open(a, threaded.io(), ".zig-cache/index-test.data", .{}) catch @panic("test pager"); test_pager_state = .{ .threaded = threaded, .pager = pg }; return pg; } fn simple_index( gpa: std.mem.Allocator, pager: *pgr.Pager, paths: []const []const u8, unique: bool, sparse: bool, ) !Index { var keys: [max_index_keys]IndexKey = undefined; for (paths, 0..) |p, i| keys[i] = .{ .path = p, .descending = false }; return Index.init(gpa, pager, "test", keys[0..paths.len], unique, sparse, null); } /// 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 u64, ) !void { var out: std.ArrayListUnmanaged(u64) = .empty; defer out.deinit(gpa); try ix.lookup_eq(gpa, key, &out); 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. fn refs_of(gpa: std.mem.Allocator, ix: *const Index, out: *std.ArrayListUnmanaged(EntryRef)) !void { var it = ix.iter(); while (it.next()) |e| try out.append(gpa, e); } /// 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 a.off < b.off; } test "entries sort across numeric types and string/null/objectid" { const gpa = testing.allocator; var ix = try simple_index(gpa, test_pager(), &.{"a"}, false, false); defer ix.deinit(gpa); const d_int = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "a", .value = .{ .int32 = 5 } } }); defer gpa.free(d_int); const d_dbl = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "a", .value = .{ .double = 5.0 } } }); defer gpa.free(d_dbl); const d_str = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = 3 } }, .{ .key = "a", .value = .{ .string = "b" } } }); defer gpa.free(d_str); const d_nul = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = 4 } }, .{ .key = "a", .value = .null } }); 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, 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_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). var refs: std.ArrayListUnmanaged(EntryRef) = .empty; defer refs.deinit(gpa); try refs_of(gpa, &ix, &refs); try testing.expectEqual(@as(usize, 5), refs.items.len); try testing.expect(ref_lt(refs.items[0], refs.items[1])); try testing.expect(ref_lt(refs.items[1], refs.items[2])); try testing.expect(ref_lt(refs.items[2], refs.items[3])); } test "missing field is indexed as null; sparse skips the document" { const gpa = testing.allocator; var ix = try simple_index(gpa, test_pager(), &.{"a"}, false, false); 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, 1, true); try testing.expectEqual(@as(usize, 1), ix.count()); try expect_offs(gpa, &ix, &.{.null}, &.{1}); var sp = try simple_index(gpa, test_pager(), &.{"a"}, false, true); defer sp.deinit(gpa); _ = try sp.add_doc(gpa, d, 2, true); try testing.expectEqual(@as(usize, 0), sp.count()); } test "multikey expansion indexes the array and its elements" { const gpa = testing.allocator; var ix = try simple_index(gpa, test_pager(), &.{"tags"}, false, false); defer ix.deinit(gpa); 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, 1, true); // 3 entries: the array itself, "a", "b". try testing.expectEqual(@as(usize, 3), ix.count()); // Element query. 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_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" { const gpa = testing.allocator; var ix = try simple_index(gpa, test_pager(), &.{"a"}, true, false); 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, 1, true); // Entries after dedup: the array itself and one element. try testing.expectEqual(@as(usize, 2), ix.count()); } test "parallel arrays are rejected" { const gpa = testing.allocator; var ix = try simple_index(gpa, test_pager(), &.{ "a", "b" }, false, false); defer ix.deinit(gpa); const d = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "a", .value = .{ .array = &.{ .{ .int32 = 1 }, .{ .int32 = 2 } } } }, .{ .key = "b", .value = .{ .array = &.{ .{ .int32 = 3 }, .{ .int32 = 4 } } } }, }); defer gpa.free(d); try testing.expectError(error.ParallelArrays, ix.add_doc(gpa, d, 1, true)); // One array path is fine. const ok = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "a", .value = .{ .array = &.{ .{ .int32 = 1 }, .{ .int32 = 2 } } } }, .{ .key = "b", .value = .{ .int32 = 3 } }, }); defer gpa.free(ok); _ = try ix.add_doc(gpa, ok, 2, true); try testing.expectEqual(@as(usize, 3), ix.count()); } test "unique conflict across documents, replace of own entries allowed" { const gpa = testing.allocator; var ix = try simple_index(gpa, test_pager(), &.{"a"}, true, false); defer ix.deinit(gpa); 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, 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, 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_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 "iter_reverse yields every entry in exact reverse order" { // Node.prev has always been maintained and nothing walked it, so a // descending scan materialized the whole index and reversed the list. The // interesting cases are structural, not arithmetic: enough entries to build // several leaves and an interior level, so descend_last has to follow the // last separator's child rather than first_child, and a run of removals // afterwards because removal never rebalances -- it can leave an internal // node with no separators at all, which is the branch descend_last would // otherwise get wrong. // // Mutation checks: start iter_reverse at `first_leaf` and the order is // wrong from the first entry; make descend_last follow `first_child` // unconditionally and it silently misses everything to the right. const gpa = testing.allocator; var ix = try simple_index(gpa, test_pager(), &.{"a"}, false, false); defer ix.deinit(gpa); const n = 400; for (0..n) |i| { const d = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = @intCast(i + 1) } }, .{ .key = "a", .value = .{ .int32 = @intCast(i + 1) } }, }); defer gpa.free(d); _ = try ix.add_doc(gpa, d, @intCast(i + 1), false); } try testing.expect(ix.depth >= 1); // an interior level exists var fwd: std.ArrayListUnmanaged(u64) = .empty; defer fwd.deinit(gpa); var it = ix.iter(); while (it.next()) |e| try fwd.append(gpa, e.off); try testing.expectEqual(@as(usize, n), fwd.items.len); var rev: std.ArrayListUnmanaged(u64) = .empty; defer rev.deinit(gpa); var rit = ix.iter_reverse(); while (rit.next()) |e| try rev.append(gpa, e.off); try testing.expectEqual(fwd.items.len, rev.items.len); for (fwd.items, 0..) |off, i| { try testing.expectEqual(off, rev.items[rev.items.len - 1 - i]); } // Now churn: delete most of it and re-check, since removal reshapes the // interior without rebalancing. for (0..n) |i| { if (i % 4 == 0) continue; ix.remove_off(@intCast(i + 1)); } fwd.clearRetainingCapacity(); rev.clearRetainingCapacity(); var it2 = ix.iter(); while (it2.next()) |e| try fwd.append(gpa, e.off); var rit2 = ix.iter_reverse(); while (rit2.next()) |e| try rev.append(gpa, e.off); try testing.expectEqual(fwd.items.len, rev.items.len); for (fwd.items, 0..) |off, i| { try testing.expectEqual(off, rev.items[rev.items.len - 1 - i]); } } test "Candidates streams the same offsets a materialized plan would" { // The streaming path and the materializing path must be interchangeable, // because scan_sorted picks between them on a property of the plan. Compare // them directly rather than trusting that. // // The multikey hazard full_scan() guards against lives in commands.zig's // "a sorted full scan over a multikey index returns each document once", // not here -- this index is not multikey. See Plan.full_scan for why that // test only reddens when *both* multikey guards are removed. const gpa = testing.allocator; var ix = try simple_index(gpa, test_pager(), &.{"a"}, false, false); defer ix.deinit(gpa); for (0..50) |i| { const d = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = @intCast(i + 1) } }, .{ .key = "a", .value = .{ .int32 = @intCast(i % 7) } }, }); defer gpa.free(d); _ = try ix.add_doc(gpa, d, @intCast(i + 1), false); } var streamed: std.ArrayListUnmanaged(u64) = .empty; defer streamed.deinit(gpa); var c: Candidates = .{ .scan = ix.iter() }; while (c.next()) |off| try streamed.append(gpa, off); var listed: std.ArrayListUnmanaged(u64) = .empty; defer listed.deinit(gpa); try ix.lookup_eq(gpa, &.{}, &listed); var c2: Candidates = .{ .list = .{ .items = listed.items } }; var from_list: std.ArrayListUnmanaged(u64) = .empty; defer from_list.deinit(gpa); while (c2.next()) |off| try from_list.append(gpa, off); try testing.expectEqualSlices(u64, listed.items, streamed.items); try testing.expectEqualSlices(u64, listed.items, from_list.items); } test "lookup_exact matches whole keys only" { // The point of exact byte equality rather than cmp_prefix: this answers // "is this document present", which is what the engine will ask once the // docs hashmap is gone, and a prefix match would answer yes for a longer // key that merely starts the same way. Compound keys make that concrete -- // the encoding of {a: 1} is a prefix of the encoding of {a: 1, b: 2}. const gpa = testing.allocator; var ix = try simple_index(gpa, test_pager(), &.{ "a", "b" }, false, false); defer ix.deinit(gpa); const d = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "a", .value = .{ .int32 = 1 } }, .{ .key = "b", .value = .{ .int32 = 2 } }, }); defer gpa.free(d); _ = try ix.add_doc(gpa, d, 1, false); var enc_full: std.ArrayListUnmanaged(u8) = .empty; defer enc_full.deinit(gpa); try bson.encode_key(.{ .int32 = 1 }, gpa, &enc_full); var enc_prefix: std.ArrayListUnmanaged(u8) = .empty; defer enc_prefix.deinit(gpa); try enc_prefix.appendSlice(gpa, enc_full.items); try bson.encode_key(.{ .int32 = 2 }, gpa, &enc_full); // The full two-column key hits. try testing.expect(ix.lookup_exact(enc_full.items) != null); 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". try testing.expect(ix.lookup_exact(enc_prefix.items) == null); // And an absent key misses. var enc_other: std.ArrayListUnmanaged(u8) = .empty; defer enc_other.deinit(gpa); try bson.encode_key(.{ .int32 = 9 }, gpa, &enc_other); try testing.expect(ix.lookup_exact(enc_other.items) == null); } test "range bounds inclusive and exclusive" { const gpa = testing.allocator; var ix = try simple_index(gpa, test_pager(), &.{"a"}, false, false); defer ix.deinit(gpa); 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.off, true); } 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(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.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.expectEqual(@as(u64, 5), out.items[0]); } test "empty index and remove_off" { const gpa = testing.allocator; var ix = try simple_index(gpa, test_pager(), &.{"a"}, false, false); defer ix.deinit(gpa); 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, 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); } test "compound index prefix search and range on the next key" { const gpa = testing.allocator; var ix = try simple_index(gpa, test_pager(), &.{ "a", "b" }, false, false); defer ix.deinit(gpa); 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, &.{ .{ .key = "_id", .value = .{ .int32 = s.a } }, .{ .key = "a", .value = .{ .int32 = s.a } }, .{ .key = "b", .value = .{ .int32 = s.b } }, }); defer gpa.free(d); _ = try ix.add_doc(gpa, d, s.off, true); } // Prefix on a only. try expect_offs(gpa, &ix, &.{.{ .int32 = 1 }}, &.{ 1, 2 }); // Full key. 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(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(out.items[0] == 1 or out.items[0] == 2); } test "remove_doc leaves the index identical to a full scan removal" { // remove_doc finds entries by regenerating them from the document // instead of scanning for the id. If regeneration ever disagreed with // what was inserted, entries would be left behind and the index would // silently return stale ids -- so check it against the scan directly, // over documents that exercise multikey arrays, missing fields and // duplicate values. const gpa = testing.allocator; var prng = std.Random.DefaultPrng.init(0xDEADBEEF); const rand = prng.random(); for ([_]bool{ false, true }) |sparse| { var by_doc = try simple_index(gpa, test_pager(), &.{ "a", "b" }, false, sparse); defer by_doc.deinit(gpa); var by_scan = try simple_index(gpa, test_pager(), &.{ "a", "b" }, false, sparse); defer by_scan.deinit(gpa); const n = 120; 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: u64 = @intCast(i + 1); try ids.append(gpa, id); const shape = rand.intRangeAtMost(u8, 0, 3); const av = rand.intRangeAtMost(i32, 0, 3); var np: usize = 0; switch (shape) { // A plain value. 0 => { pairs[i][0] = .{ .key = "a", .value = .{ .int32 = av } }; pairs[i][1] = .{ .key = "b", .value = .{ .int32 = rand.intRangeAtMost(i32, 0, 3) } }; np = 2; }, // Multikey: an array, sometimes with repeats. 1 => { arrays[i] = .{ .{ .int32 = av }, .{ .int32 = av }, .{ .int32 = av + 1 } }; pairs[i][0] = .{ .key = "a", .value = .{ .array = arrays[i][0..3] } }; pairs[i][1] = .{ .key = "b", .value = .{ .int32 = rand.intRangeAtMost(i32, 0, 3) } }; np = 2; }, // Missing "b": indexed as null, or skipped when sparse. 2 => { pairs[i][0] = .{ .key = "a", .value = .{ .int32 = av } }; np = 1; }, // Missing both. else => np = 0, } docs[i] = try bytes_of(gpa, pairs[i][0..np]); try by_doc.append_doc_entries(gpa, docs[i], id); try by_scan.append_doc_entries(gpa, docs[i], id); } _ = try by_doc.finish_bulk(gpa, false); _ = try by_scan.finish_bulk(gpa, false); try testing.expectEqual(by_scan.count(), by_doc.count()); // Remove in a shuffled order, so removals interleave rather than // peeling the leaves from one end. var order: [n]usize = undefined; for (0..n) |i| order[i] = i; rand.shuffle(usize, &order); var doc_refs: std.ArrayListUnmanaged(EntryRef) = .empty; var scan_refs: std.ArrayListUnmanaged(EntryRef) = .empty; defer doc_refs.deinit(gpa); defer scan_refs.deinit(gpa); for (order) |i| { by_doc.remove_doc(gpa, docs[i], ids.items[i]); by_scan.remove_off(ids.items[i]); doc_refs.clearRetainingCapacity(); scan_refs.clearRetainingCapacity(); try refs_of(gpa, &by_doc, &doc_refs); try refs_of(gpa, &by_scan, &scan_refs); testing.expectEqual(scan_refs.items.len, doc_refs.items.len) catch |err| { std.debug.print("sparse={} after removing doc {d}: scan left {d}, remove_doc left {d}\n", .{ sparse, i, scan_refs.items.len, doc_refs.items.len, }); return err; }; for (doc_refs.items, scan_refs.items) |x, y| { try testing.expect(std.mem.eql(u8, x.key, y.key)); try testing.expectEqual(x.off, y.off); } } for (docs) |b| gpa.free(b); try testing.expectEqual(@as(usize, 0), by_doc.count()); } } test "incremental inserts and removals stay identical to a brute-force model" { // The bulk-path differential covers packing; this one covers the // incremental path: one insert at a time (leaf splits), interleaved // with removals (empty-leaf unlinks, one-child internal nodes), all // checked against a reference model at every step. const gpa = testing.allocator; var prng = std.Random.DefaultPrng.init(0x0dd_ba11); const rand = prng.random(); var ix = try simple_index(gpa, test_pager(), &.{ "a", "b" }, false, false); defer ix.deinit(gpa); var model: std.ArrayListUnmanaged(ModelFact) = .empty; defer model.deinit(gpa); var live: std.ArrayListUnmanaged(bool) = .empty; defer live.deinit(gpa); const n = 600; for (0..n) |i| { // Insert. const a = rand.intRangeAtMost(i32, 0, 30); const b = rand.intRangeAtMost(i32, 0, 30); const id: u64 = @intCast(i + 1); const d = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = @intCast(i) } }, .{ .key = "a", .value = .{ .int32 = a } }, .{ .key = "b", .value = .{ .int32 = b } }, }); defer gpa.free(d); _ = try ix.add_doc(gpa, d, id, false); try model.append(gpa, .{ .a = a, .b = b, .off = id }); try live.append(gpa, true); // Verify equality and one-sided range against the model. try verify_model(gpa, &ix, model.items, live.items, rand); } // Remove a random half in random order. var order: std.ArrayListUnmanaged(usize) = .empty; defer order.deinit(gpa); for (0..n) |i| if (rand.boolean()) try order.append(gpa, i); rand.shuffle(usize, order.items); for (order.items) |i| { const m = model.items[i]; const d = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = @intCast(i) } }, .{ .key = "a", .value = .{ .int32 = m.a } }, .{ .key = "b", .value = .{ .int32 = m.b } }, }); defer gpa.free(d); 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, off: u64 }; fn verify_model( gpa: std.mem.Allocator, ix: *const Index, model: []const ModelFact, live: []const bool, rand: std.Random, ) !void { // lookup_eq over a random value. const a = rand.intRangeAtMost(i32, 0, 30); var out: std.ArrayListUnmanaged(u64) = .empty; defer out.deinit(gpa); try ix.lookup_eq(gpa, &.{.{ .int32 = a }}, &out); var expected: usize = 0; for (model, 0..) |m, mi| { if (live[mi] and m.a == a) expected += 1; } try testing.expectEqual(expected, out.items.len); // lookup_range over a random one-sided range. out.clearRetainingCapacity(); const lo_v = rand.intRangeAtMost(i32, -5, 35); const lo_incl = rand.boolean(); try ix.lookup_range(gpa, &.{.{ .int32 = a }}, .{ .int32 = lo_v }, lo_incl, null, false, &out); expected = 0; for (model, 0..) |m, mi| { if (!live[mi]) continue; if (m.a != a) continue; if (m.b < lo_v) continue; if (m.b == lo_v and !lo_incl) continue; expected += 1; } try testing.expectEqual(expected, out.items.len); } test "lookup_range matches a brute-force filter over random data" { const gpa = testing.allocator; // lookup_range seeks both ends of the range inside the tree instead of // scanning the whole equality band. Bounds like that are easy to get // subtly wrong at the inclusive/exclusive edges and where the band // ends, so check the whole result set against the definition rather // than spot-checking. var prng = std.Random.DefaultPrng.init(0x5eed_1234); const rand = prng.random(); var ids: std.ArrayListUnmanaged(u64) = .empty; defer ids.deinit(gpa); var ix = try simple_index(gpa, test_pager(), &.{ "a", "b" }, false, false); defer ix.deinit(gpa); // Deliberately few distinct values so equal keys, and therefore the // boundaries between them, come up constantly. const n = 400; var facts: [n]struct { a: i32, b: i32 } = undefined; for (0..n) |i| { const a = rand.intRangeAtMost(i32, 0, 4); const b = rand.intRangeAtMost(i32, 0, 9); facts[i] = .{ .a = a, .b = b }; const id: u64 = @intCast(i + 1); try ids.append(gpa, id); const d = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = @intCast(i) } }, .{ .key = "a", .value = .{ .int32 = a } }, .{ .key = "b", .value = .{ .int32 = b } }, }); defer gpa.free(d); try ix.append_doc_entries(gpa, d, id); } _ = try ix.finish_bulk(gpa, false); var out: std.ArrayListUnmanaged(u64) = .empty; defer out.deinit(gpa); for (0..600) |case| { const a = rand.intRangeAtMost(i32, 0, 4); const lo_v = rand.intRangeAtMost(i32, -1, 10); const hi_v = rand.intRangeAtMost(i32, -1, 10); const lo_incl = rand.boolean(); const hi_incl = rand.boolean(); const use_lo = rand.boolean(); const use_hi = rand.boolean(); out.clearRetainingCapacity(); try ix.lookup_range( gpa, &.{.{ .int32 = a }}, if (use_lo) .{ .int32 = lo_v } else null, lo_incl, if (use_hi) .{ .int32 = hi_v } else null, hi_incl, &out, ); var expected: usize = 0; for (facts, 0..) |f, i| { _ = i; if (f.a != a) continue; if (use_lo) { if (f.b < lo_v) continue; if (f.b == lo_v and !lo_incl) continue; } if (use_hi) { if (f.b > hi_v) continue; if (f.b == hi_v and !hi_incl) continue; } expected += 1; } testing.expectEqual(expected, out.items.len) catch |err| { std.debug.print( "case {d}: a={d} lo={?d} incl={} hi={?d} incl={}\n", .{ case, a, if (use_lo) lo_v else null, lo_incl, if (use_hi) hi_v else null, hi_incl }, ); return err; }; } } test "TTL spec round-trips through write_spec and compares in spec_equal" { const gpa = testing.allocator; const spec = doc_of(&.{ .{ .key = "key", .value = .{ .doc = &.{.{ .key = "expireAt", .value = .{ .int32 = 1 } }} } }, .{ .key = "name", .value = .{ .string = "expireAt_1" } }, // A driver sends a plain JS number as a double. .{ .key = "expireAfterSeconds", .value = .{ .double = 60.0 } }, }); var ix = try parse_spec(gpa, test_pager(), &spec); defer ix.deinit(gpa); try testing.expectEqual(@as(?i64, 60), ix.ttl); // Serialize and reparse: the log/compaction path. var bytes: std.ArrayListUnmanaged(u8) = .empty; defer bytes.deinit(gpa); try ix.write_spec(gpa, &bytes); var reparsed_doc = try bson.Document.parse(gpa, bytes.items); defer reparsed_doc.deinit(); try testing.expectEqual(@as(i32, 60), reparsed_doc.get("expireAfterSeconds").?.int32); var ix2 = try parse_spec(gpa, test_pager(), &reparsed_doc); defer ix2.deinit(gpa); try testing.expect(Index.spec_equal(&ix, &ix2)); // Same name and key, different expiry: not the same spec (the command // layer turns this into IndexOptionsConflict). const other = doc_of(&.{ .{ .key = "key", .value = .{ .doc = &.{.{ .key = "expireAt", .value = .{ .int32 = 1 } }} } }, .{ .key = "name", .value = .{ .string = "expireAt_1" } }, .{ .key = "expireAfterSeconds", .value = .{ .int32 = 90 } }, }); var ix3 = try parse_spec(gpa, test_pager(), &other); defer ix3.deinit(gpa); try testing.expect(!Index.spec_equal(&ix, &ix3)); // The largest legal expiry, arriving as an int64, still round-trips as // an int32 — the only type this ever emits. const big = doc_of(&.{ .{ .key = "key", .value = .{ .doc = &.{.{ .key = "expireAt", .value = .{ .int32 = 1 } }} } }, .{ .key = "expireAfterSeconds", .value = .{ .int64 = max_expire_after_seconds } }, }); var ix_big = try parse_spec(gpa, test_pager(), &big); defer ix_big.deinit(gpa); bytes.clearRetainingCapacity(); try ix_big.write_spec(gpa, &bytes); var big_doc = try bson.Document.parse(gpa, bytes.items); defer big_doc.deinit(); try testing.expectEqual(@as(i32, 2147483647), big_doc.get("expireAfterSeconds").?.int32); var ix_big2 = try parse_spec(gpa, test_pager(), &big_doc); defer ix_big2.deinit(gpa); try testing.expectEqual(@as(?i64, max_expire_after_seconds), ix_big2.ttl); // No option at all: ttl null, and nothing emitted (old logs reparse // unchanged). const plain = doc_of(&.{ .{ .key = "key", .value = .{ .doc = &.{.{ .key = "expireAt", .value = .{ .int32 = 1 } }} } }, }); var ix4 = try parse_spec(gpa, test_pager(), &plain); defer ix4.deinit(gpa); try testing.expect(ix4.ttl == null); bytes.clearRetainingCapacity(); try ix4.write_spec(gpa, &bytes); var plain_doc = try bson.Document.parse(gpa, bytes.items); defer plain_doc.deinit(); try testing.expect(plain_doc.get("expireAfterSeconds") == null); } test "TTL spec rejects compound keys and bad expireAfterSeconds" { const gpa = testing.allocator; const compound = doc_of(&.{ .{ .key = "key", .value = .{ .doc = &.{ .{ .key = "a", .value = .{ .int32 = 1 } }, .{ .key = "b", .value = .{ .int32 = 1 } }, } } }, .{ .key = "expireAfterSeconds", .value = .{ .int32 = 60 } }, }); try testing.expectError(error.TtlOnCompoundIndex, parse_spec(gpa, test_pager(), &compound)); const bad = [_]bson.Value{ .{ .int32 = -1 }, .{ .int64 = -1 }, .{ .double = -0.5 }, .{ .double = 1.5 }, // Past MongoDB's bound, as an int and as a double (1e19 would also // overflow the conversion, which the range check runs before). .{ .int64 = max_expire_after_seconds + 1 }, .{ .double = 3e9 }, .{ .double = 1e19 }, .{ .double = std.math.inf(f64) }, .{ .double = std.math.nan(f64) }, .{ .string = "60" }, .{ .bool = true }, .null, }; for (bad) |v| { const spec = doc_of(&.{ .{ .key = "key", .value = .{ .doc = &.{.{ .key = "expireAt", .value = .{ .int32 = 1 } }} } }, .{ .key = "expireAfterSeconds", .value = v }, }); try testing.expectError(error.InvalidExpireAfterSeconds, parse_spec(gpa, test_pager(), &spec)); } // 0 is legal: expire at exactly the stored instant. const zero = doc_of(&.{ .{ .key = "key", .value = .{ .doc = &.{.{ .key = "expireAt", .value = .{ .int32 = 1 } }} } }, .{ .key = "expireAfterSeconds", .value = .{ .int32 = 0 } }, }); var ix = try parse_spec(gpa, test_pager(), &zero); defer ix.deinit(gpa); try testing.expectEqual(@as(?i64, 0), ix.ttl); // The default name still comes from the key pattern. try testing.expectEqualStrings("expireAt_1", ix.name); } fn str_doc(gpa: std.mem.Allocator, i: usize, fill: u8, len: usize) ![]u8 { const s = try gpa.alloc(u8, len); defer gpa.free(s); @memset(s, fill); return bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = @intCast(i) } }, .{ .key = "s", .value = .{ .string = s } }, }); } test "a churned leaf of large keys splits without promoting from an empty half" { // remove_record leaves the freed bytes dead at the top of the page, so // a leaf holding one or two ~1 KiB keys runs out of room after a few // insert/remove cycles while still holding almost nothing. Splitting // then would hand the right half zero records and promote whatever the // uninitialised slot 0 happened to hold. const gpa = testing.allocator; var ix = try simple_index(gpa, test_pager(), &.{"s"}, false, false); defer ix.deinit(gpa); const n = 12; var docs: [n][]u8 = undefined; var ids: [n]u64 = undefined; var made: usize = 0; defer for (0..made) |i| { gpa.free(docs[i]); }; for (0..n) |i| { docs[i] = try str_doc(gpa, i, @intCast('a' + i), 1000); ids[i] = @intCast(i + 1); made += 1; } // Keep one entry live while the page fills with dead bytes, then leave // two live: a bad split promotes an empty page's slot 0 as the // separator, and every later descent then misses the left leaf. The // leaf chain would still hold both, so this has to be checked through a // lookup, which descends. for (0..n - 1) |i| { _ = try ix.add_doc(gpa, docs[i], ids[i], false); if (i >= 1) ix.remove_doc(gpa, docs[i - 1], ids[i - 1]); } _ = try ix.add_doc(gpa, docs[n - 1], ids[n - 1], false); try testing.expectEqual(@as(usize, 2), ix.count()); for ([_]usize{ n - 2, n - 1 }) |i| { var s: [1000]u8 = undefined; @memset(&s, @intCast('a' + i)); try expect_offs(gpa, &ix, &.{.{ .string = &s }}, &.{ids[i]}); } } test "a split with lopsided record sizes keeps the new record inside its page" { // The halves are split by slot count, so all the large records can end // up on one side; the record that caused the split is then stored into // that half with no room left for it. const gpa = testing.allocator; var ix = try simple_index(gpa, test_pager(), &.{"s"}, false, false); defer ix.deinit(gpa); var docs: std.ArrayListUnmanaged([]u8) = .empty; var ids: std.ArrayListUnmanaged(u64) = .empty; defer { for (docs.items) |d| gpa.free(d); docs.deinit(gpa); ids.deinit(gpa); } var i: usize = 0; // 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, @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, @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, @intCast(i + 1)); for (docs.items, ids.items) |d, id| _ = try ix.add_doc(gpa, d, id, false); try testing.expectEqual(docs.items.len, ix.count()); // Every key comes back, in order, exactly once. var prev: []const u8 = ""; var seen: usize = 0; var it = ix.iter(); while (it.next()) |e| : (seen += 1) { try testing.expect(std.mem.order(u8, prev, e.key) != .gt); prev = e.key; } try testing.expectEqual(docs.items.len, seen); } test "the _id index plan covers equality, ranges and _id sort order" { // The implicit _id_ index is a normal Index (keys = [_id: 1]) passed to // plan separately from the secondaries. Its encoded keys are canonical, // so compare-equal numbers (int32/int64/double) find the same entries, // and a full scan of it is the sort planner's order supply for // sort({_id: ...}). const gpa = testing.allocator; var ix = try simple_index(gpa, test_pager(), &.{"_id"}, false, false); defer ix.deinit(gpa); for (0..5) |i| { const d = try bytes_of(gpa, &.{ .{ .key = "_id", .value = .{ .int32 = @intCast(i + 1) } }, .{ .key = "v", .value = .{ .int32 = @intCast(i) } }, }); defer gpa.free(d); const id: u64 = @intCast(i + 1); _ = try ix.add_doc(gpa, d, id, false); } // {_id: 3} → an equality plan whose candidates are just that doc. { const f = [_]bson.Pair{.{ .key = "_id", .value = .{ .int32 = 3 } }}; var p = (try plan(gpa, &ix, &.{}, &f, &.{})).?; defer p.deinit(gpa); try testing.expectEqual(@as(usize, 1), p.key_len()); 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.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(u64) = .empty; defer ids.deinit(gpa); try p.search(gpa, &ids); try testing.expectEqual(@as(usize, 1), ids.items.len); 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(u64) = .empty; defer ids.deinit(gpa); try p.search(gpa, &ids); try testing.expectEqual(@as(usize, 3), ids.items.len); 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. { const sort = [_]query.SortKey{.{ .path = "_id", .descending = false }}; var p = (try plan(gpa, &ix, &.{}, &.{}, &sort)).?; defer p.deinit(gpa); try testing.expect(p.provides_sort and !p.backward); 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.expectEqual(@as(u64, 1), ids.items[0]); try testing.expectEqual(@as(u64, 5), ids.items[4]); } // sort({_id: -1}): backward. { const sort = [_]query.SortKey{.{ .path = "_id", .descending = true }}; var p = (try plan(gpa, &ix, &.{}, &.{}, &sort)).?; defer p.deinit(gpa); try testing.expect(p.provides_sort and p.backward); var ids: std.ArrayListUnmanaged(u64) = .empty; defer ids.deinit(gpa); try p.search(gpa, &ids); 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. { const f = [_]bson.Pair{.{ .key = "v", .value = .{ .int32 = 1 } }}; try testing.expect((try plan(gpa, &ix, &.{}, &f, &.{})) == null); } } test "planner picks eq run, ranges, and bails on sparse null" { const gpa = testing.allocator; var ix = try simple_index(gpa, test_pager(), &.{ "a", "b" }, false, false); defer ix.deinit(gpa); var sp = try simple_index(gpa, test_pager(), &.{ "a", "b" }, false, true); defer sp.deinit(gpa); // {a: 1, b: 2} → full-key equality. { const f = [_]bson.Pair{ .{ .key = "a", .value = .{ .int32 = 1 } }, .{ .key = "b", .value = .{ .int32 = 2 } }, }; var p = (try plan(gpa, null, &.{&ix}, &f, &.{})).?; defer p.deinit(gpa); try testing.expectEqual(@as(usize, 2), p.key_len()); try testing.expect(p.lo == null and p.hi == null); } // {a: 1, b: {$gt: 2}} → equality run of 1 + range on the next key. { const f = [_]bson.Pair{ .{ .key = "a", .value = .{ .int32 = 1 } }, .{ .key = "b", .value = .{ .doc = &.{.{ .key = "$gt", .value = .{ .int32 = 2 } }} } }, }; var p = (try plan(gpa, null, &.{&ix}, &f, &.{})).?; defer p.deinit(gpa); try testing.expectEqual(@as(usize, 1), p.key_len()); try testing.expect(p.hi == null and p.lo != null and !p.lo_incl); } // {a: 1} only → prefix run of 1. { const f = [_]bson.Pair{.{ .key = "a", .value = .{ .int32 = 1 } }}; var p = (try plan(gpa, null, &.{&ix}, &f, &.{})).?; defer p.deinit(gpa); try testing.expectEqual(@as(usize, 1), p.key_len()); } // Pure range on the first key → key_len 0 with a bound. { const f = [_]bson.Pair{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$gte", .value = .{ .int32 = 1 } }} } }}; var p = (try plan(gpa, null, &.{&ix}, &f, &.{})).?; defer p.deinit(gpa); try testing.expectEqual(@as(usize, 0), p.key_len()); try testing.expect(p.lo != null and p.lo_incl); } // Unusable filter → no plan. { const f = [_]bson.Pair{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$regex", .value = .{ .string = "^x" } }} } }}; try testing.expect((try plan(gpa, null, &.{&ix}, &f, &.{})) == null); const or_f = [_]bson.Pair{.{ .key = "$or", .value = .{ .array = &.{ .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} }, } } }}; try testing.expect((try plan(gpa, null, &.{&ix}, &or_f, &.{})) == null); } // Sparse index bails on a null component. { const f = [_]bson.Pair{.{ .key = "a", .value = .null }}; try testing.expect((try plan(gpa, null, &.{&sp}, &f, &.{})) == null); // Non-sparse is fine with null. var p = (try plan(gpa, null, &.{&ix}, &f, &.{})).?; defer p.deinit(gpa); try testing.expect(p.key_len() == 1); // A null inside $in bails too. const fin = [_]bson.Pair{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$in", .value = .{ .array = &.{ .{ .int32 = 1 }, .null } } }} } }}; try testing.expect((try plan(gpa, null, &.{&sp}, &fin, &.{})) == null); } // $in cartesian product is capped. { var members: [20]bson.Value = undefined; for (0..20) |j| members[j] = .{ .int32 = @intCast(j) }; const f = [_]bson.Pair{ .{ .key = "a", .value = .{ .doc = &.{.{ .key = "$in", .value = .{ .array = &members } }} } }, .{ .key = "b", .value = .{ .doc = &.{.{ .key = "$in", .value = .{ .array = &members } }} } }, }; // 20 * 20 = 400 > 100 → fall back to a scan. try testing.expect((try plan(gpa, null, &.{&ix}, &f, &.{})) == null); } }