//! 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", .descending = false }}, 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); }