Files
MultiforaDB/src/fuzz_split.zig
A.Shakhmatov 21494469a5 db/index: a hashed key holds a hash of its value
M3's last row, step 4 of the index review's order. `{a: "hashed"}` was
refused with "invalid index spec" -- the right answer with the wrong code,
and the half of the row the review called honestly missing.

A hashed component is stored as a tag byte plus a 64-bit hash of the
value's *ordinary* encoded bytes. Hashing the encoding rather than the
value is what makes `{a: 5}` and `{a: 5.0}` land on the same entry for
free: `bson.encode_key` already normalizes every numeric type through
f128, because two values that compare `.eq` have to encode identically for
the tree to be a memcmp. One `encode_component` does it for entry
generation and both lookup paths, so the two sides cannot disagree -- a
component hashed on the way in and not on the way out would simply never
find anything.

Collisions are harmless, because this file's governing invariant is that
an index only generates candidates and the full filter is re-applied to
every one. The single place that would not survive one is uniqueness,
which is why `unique` is refused (16764) rather than approximated.

The planner is the mirror of the partial rule and just as conservative:
equality only. A range or a sort over a hashed component would read a band
of leaves ordered by hash, which is an arbitrary set of values, so both
are declined and the query scans. That is what leaves `find({a: {$gte:
5}})` and `find({}, {sort: {a: 1}})` correct.

The catalog needs no new field. `write_index_catalog` has always written
one byte per component and that byte has only ever held 0 or 1, so a third
value costs no format change and `catalog_version` stays 1. That is a
departure from the review, which guessed at a sixth flags bit: hashed
belongs to a *component*, and a compound index may hold one beside range
ones.

Measured on mongod 8.3.7 rather than recalled, and three of the five
answers were not what the corpus source assumed:

  two hashed components    31303, codeName Location31303
  unique on a hashed index 16764, codeName Location16764
  an unknown plugin string 67,    codeName CannotCreateIndex
  an array at the path     16766 -- a *writeError* beside `ok: 1` on an
                           insert or update, and a command error from
                           createIndexes over data that already holds one
  an array through a path  refused for a *one-element* array too, which
                           is why `array_on_path` walks the path instead
                           of counting the values at it

tests/spec/indexes/hashed.json goes 0/18 -> 17/18. The one that remains
is not about hashed indexes: `find({a: null})` has to match a document
with no `a`, and this server matches only an explicit null -- with or
without an index. Next commit.
2026-08-10 23:48:44 +03:00

155 lines
5.5 KiB
Zig

//! Randomised differential over Index with wildly varying key sizes.
//!
//! The tree's split logic is where record sizes and slot counts interact:
//! a page can be full of bytes or full of slots, and the record that caused
//! the split has to fit the half it lands in. Fixed-shape tests never mix
//! those, so this hammers the same index with keys from 4 bytes to past the
//! inline limit, interleaves removals (which leave dead bytes behind), and
//! checks the whole tree against a model.
//!
//! Run: zig test src/fuzz_split.zig
const std = @import("std");
const bson = @import("bson.zig");
const index = @import("index.zig");
const pgr = @import("pager.zig");
const testing = std.testing;
fn make_doc(gpa: std.mem.Allocator, i: usize, s: []const u8) ![]u8 {
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
try bson.write_doc(&.{
.{ .key = "_id", .value = .{ .int32 = @intCast(i) } },
.{ .key = "s", .value = .{ .string = s } },
}, gpa, &out);
return out.toOwnedSlice(gpa);
}
const Doc = struct {
s: []u8,
bytes: []u8,
off: u64,
live: bool,
};
/// A throwaway data file for this harness. The B+tree's pages live in the data
/// file now, so a standalone harness has to provide one.
/// The Threaded is intentionally leaked: it must outlive the pager's io, and
/// these harnesses are one-shot processes. fuzz_split runs under the testing
/// allocator, which checks for leaks, so it uses the page allocator here.
fn harness_pager(gpa: std.mem.Allocator, name: []const u8) !*pgr.Pager {
const threaded = try std.heap.page_allocator.create(std.Io.Threaded);
threaded.* = .init_single_threaded;
const io = threaded.io();
const path = try std.fmt.allocPrint(std.heap.page_allocator, ".zig-cache/{s}.data", .{name});
std.Io.Dir.cwd().deleteFile(io, path) catch {};
const pg = try gpa.create(pgr.Pager);
pg.* = try pgr.Pager.open(gpa, io, path, .{});
return pg;
}
fn run(seed: u64, ops: usize, max_len: usize) !void {
const gpa = testing.allocator;
var prng = std.Random.DefaultPrng.init(seed);
const rand = prng.random();
const pg = try harness_pager(gpa, "fuzz_split");
defer {
pg.deinit();
gpa.destroy(pg);
}
var ix = try index.Index.init(gpa, pg, "s_1", &.{.{ .path = "s" }}, false, false, null);
defer ix.deinit(gpa);
var docs: std.ArrayListUnmanaged(Doc) = .empty;
defer {
for (docs.items) |d| {
gpa.free(d.s);
gpa.free(d.bytes);
}
docs.deinit(gpa);
}
var live: usize = 0;
for (0..ops) |op| {
if (live > 0 and rand.uintLessThan(u32, 100) < 35) {
// Remove a random live document.
var pick = rand.uintLessThan(usize, live);
for (docs.items) |*d| {
if (!d.live) continue;
if (pick == 0) {
ix.remove_doc(gpa, d.bytes, d.off);
d.live = false;
live -= 1;
break;
}
pick -= 1;
}
} else {
// Insert a document whose key length is drawn from a mix of
// tiny, around the inline limit, and past it (spilled).
const len = switch (rand.uintLessThan(u32, 10)) {
0...4 => rand.intRangeAtMost(usize, 1, 16),
5...7 => rand.intRangeAtMost(usize, 900, 1100),
else => rand.intRangeAtMost(usize, 1100, max_len),
};
const s = try gpa.alloc(u8, len);
errdefer gpa.free(s);
// A small alphabet so keys collide and share prefixes.
for (s) |*c| c.* = 'a' + rand.uintLessThan(u8, 4);
const id: u64 = @intCast(op + 1);
const bytes = try make_doc(gpa, op, s);
errdefer gpa.free(bytes);
_ = try ix.add_doc(gpa, bytes, id, false);
try docs.append(gpa, .{ .off = id, .s = s, .bytes = bytes, .live = true });
live += 1;
}
if (op % 25 != 0 and op != ops - 1) continue;
// The tree holds exactly the live entries, in key order.
try testing.expectEqual(live, ix.count());
var seen: usize = 0;
var prev: []const u8 = "";
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(live, seen);
// Every live document is reachable by a descent, not just by
// walking the leaf chain: a bad separator breaks only the descent.
var found: std.ArrayListUnmanaged(u64) = .empty;
defer found.deinit(gpa);
for (docs.items) |d| {
if (!d.live) continue;
if (rand.uintLessThan(u32, 100) >= 10) continue; // sample
found.clearRetainingCapacity();
try ix.lookup_eq(gpa, &.{.{ .string = d.s }}, &found);
var hit = false;
for (found.items) |got| {
if (got == d.off) hit = true;
}
if (!hit) {
std.debug.print("seed {d} op {d}: off {d} (key len {d}) not found by descent\n", .{
seed,
op,
d.off,
d.s.len,
});
return error.EntryUnreachable;
}
}
}
}
test "mixed key sizes with removals" {
for (0..6) |k| try run(@intCast(k + 1), 1500, 8000);
}
test "keys clustered around the inline limit" {
for (0..4) |k| try run(@intCast(k + 100), 1200, 1200);
}