// Dev stress test for the index B+tree (not part of the build): // zig run -O ReleaseFast src/stress.zig // Bulk-builds 30k entries, inserts 20k more one at a time (splits at depth 2), // deletes 16.6k randomly (empty-leaf cascades), drains everything, and checks // lookups against a brute-force model throughout. const std = @import("std"); const index = @import("index.zig"); const pgr = @import("pager.zig"); const bson = @import("bson.zig"); fn doc_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); } const Fact = struct { a: i32, b: i32 }; fn check_range( gpa: std.mem.Allocator, ix: *const index.Index, prefix: bson.Value, lo: ?bson.Value, hi: ?bson.Value, facts: []const Fact, alive: []const bool, ) !void { var out: std.ArrayListUnmanaged(u64) = .empty; defer out.deinit(gpa); try ix.lookup_range(gpa, &.{prefix}, lo, true, hi, false, &out); var expected: usize = 0; for (facts, 0..) |f, fi| { if (!alive[fi]) continue; if (f.a != prefix.int32) continue; if (lo) |l| if (f.b < l.int32) continue; if (hi) |h| if (f.b >= h.int32) continue; expected += 1; } if (out.items.len != expected) { std.debug.print("MISMATCH: prefix={d} lo={?d} hi={?d}: got {d}, want {d}\n", .{ prefix.int32, if (lo) |l| l.int32 else null, if (hi) |h| h.int32 else null, out.items.len, expected, }); std.process.exit(1); } } /// 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. fn harness_pager(gpa: std.mem.Allocator, name: []const u8) !*pgr.Pager { const threaded = try gpa.create(std.Io.Threaded); threaded.* = .init_single_threaded; const io = threaded.io(); const path = try std.fmt.allocPrint(gpa, ".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; } pub fn main() !void { const gpa = std.heap.page_allocator; var prng = std.Random.DefaultPrng.init(0xBADCAFE); const rand = prng.random(); const N = 30_000; var ids: std.ArrayListUnmanaged(u64) = .empty; var facts: std.ArrayListUnmanaged(Fact) = .empty; var alive: std.ArrayListUnmanaged(bool) = .empty; var pairs: [2]bson.Pair = undefined; // 1. Bulk build an index over N docs. var keys = [_]index.IndexKey{ .{ .path = "a" }, .{ .path = "b" } }; var ix = try index.Index.init(gpa, try harness_pager(gpa, "stress"), "ab", &keys, false, false, null); for (0..N) |i| { const id: u64 = @intCast(i + 1); try ids.append(gpa, id); const a = rand.intRangeAtMost(i32, 0, 99); const b = rand.intRangeAtMost(i32, 0, 999); try facts.append(gpa, .{ .a = a, .b = b }); try alive.append(gpa, true); pairs[0] = .{ .key = "a", .value = .{ .int32 = a } }; pairs[1] = .{ .key = "b", .value = .{ .int32 = b } }; const d = try doc_of(gpa, &pairs); try ix.append_doc_entries(gpa, d, id); } _ = try ix.finish_bulk(gpa, false); std.debug.print("bulk: count={d} leaves={d} depth={d} nodes={d}\n", .{ ix.count(), ix.leaf_count, ix.depth, ix.node_pages.items.len, }); if (ix.count() != N) return error.BadCount; // Random range checks against brute force. for (0..500) |_| { const a = rand.intRangeAtMost(i32, 0, 99); const lo_v = rand.intRangeAtMost(i32, -10, 1009); const hi_v = rand.intRangeAtMost(i32, -10, 1009); const use_lo = rand.boolean(); const use_hi = rand.boolean(); try check_range(gpa, &ix, .{ .int32 = a }, if (use_lo) .{ .int32 = lo_v } else null, if (use_hi) .{ .int32 = hi_v } else null, facts.items, alive.items); } // 2. Incremental inserts, random order (splits + rebalancing-free path). const M = 20_000; for (0..M) |i| { const id: u64 = @intCast(100_000 + i); try ids.append(gpa, id); const a = rand.intRangeAtMost(i32, 0, 99); const b = rand.intRangeAtMost(i32, 0, 999); try facts.append(gpa, .{ .a = a, .b = b }); try alive.append(gpa, true); pairs[0] = .{ .key = "a", .value = .{ .int32 = a } }; pairs[1] = .{ .key = "b", .value = .{ .int32 = b } }; const d = try doc_of(gpa, &pairs); _ = try ix.add_doc(gpa, d, id, false); } std.debug.print("after inserts: count={d} leaves={d} depth={d} nodes={d}\n", .{ ix.count(), ix.leaf_count, ix.depth, ix.node_pages.items.len, }); if (ix.count() != N + M) return error.BadCount; for (0..500) |_| { const a = rand.intRangeAtMost(i32, 0, 99); const lo_v = rand.intRangeAtMost(i32, -10, 1009); const hi_v = rand.intRangeAtMost(i32, -10, 1009); const use_lo = rand.boolean(); const use_hi = rand.boolean(); try check_range(gpa, &ix, .{ .int32 = a }, if (use_lo) .{ .int32 = lo_v } else null, if (use_hi) .{ .int32 = hi_v } else null, facts.items, alive.items); } // 3. Delete every 3rd doc in random order (empty-leaf cascades, // one-child internals). var order: std.ArrayListUnmanaged(usize) = .empty; for (0..N + M) |i| if (i % 3 == 0) try order.append(gpa, i); rand.shuffle(usize, order.items); for (order.items) |i| { const a = facts.items[i].a; const b = facts.items[i].b; pairs[0] = .{ .key = "a", .value = .{ .int32 = a } }; pairs[1] = .{ .key = "b", .value = .{ .int32 = b } }; const d = try doc_of(gpa, &pairs); ix.remove_doc(gpa, d, ids.items[i]); alive.items[i] = false; } std.debug.print("after deletes: count={d} leaves={d} depth={d} nodes={d}\n", .{ ix.count(), ix.leaf_count, ix.depth, ix.node_pages.items.len, }); if (ix.count() != (N + M) - (N + M) / 3 - 1) return error.BadCount; for (0..500) |_| { const a = rand.intRangeAtMost(i32, 0, 99); const lo_v = rand.intRangeAtMost(i32, -10, 1009); const hi_v = rand.intRangeAtMost(i32, -10, 1009); const use_lo = rand.boolean(); const use_hi = rand.boolean(); try check_range(gpa, &ix, .{ .int32 = a }, if (use_lo) .{ .int32 = lo_v } else null, if (use_hi) .{ .int32 = hi_v } else null, facts.items, alive.items); } // 4. Equality lookups still exact. for (0..300) |_| { const a = rand.intRangeAtMost(i32, 0, 99); var out: std.ArrayListUnmanaged(u64) = .empty; defer out.deinit(gpa); try ix.lookup_eq(gpa, &.{.{ .int32 = a }}, &out); var expected: usize = 0; for (facts.items, 0..) |f, fi| { if (alive.items[fi] and f.a == a) expected += 1; } if (out.items.len != expected) { std.debug.print("EQ MISMATCH a={d}: got {d} want {d}\n", .{ a, out.items.len, expected, }); return error.BadCount; } } // 5. Delete everything (empty-leaf cascades, one-child internals), // then verify the tree still works for fresh inserts. var live: std.ArrayListUnmanaged(usize) = .empty; defer live.deinit(gpa); for (facts.items, 0..) |_, i| if (alive.items[i]) try live.append(gpa, i); rand.shuffle(usize, live.items); var remaining = live.items.len; for (live.items) |i| { const a = facts.items[i].a; const b = facts.items[i].b; pairs[0] = .{ .key = "a", .value = .{ .int32 = a } }; pairs[1] = .{ .key = "b", .value = .{ .int32 = b } }; const d = try doc_of(gpa, &pairs); ix.remove_doc(gpa, d, ids.items[i]); remaining -= 1; if (ix.count() != remaining) { std.debug.print("COUNT MISMATCH during drain: {d} != {d}\n", .{ ix.count(), remaining, }); return error.BadCount; } } std.debug.print("after full drain: count={d} leaves={d} depth={d} nodes={d}\n", .{ ix.count(), ix.leaf_count, ix.depth, ix.node_pages.items.len, }); if (ix.count() != 0) return error.BadCount; // The drained tree still accepts and finds entries. pairs[0] = .{ .key = "a", .value = .{ .int32 = 7 } }; pairs[1] = .{ .key = "b", .value = .{ .int32 = 42 } }; const d2 = try doc_of(gpa, &pairs); defer gpa.free(d2); _ = try ix.add_doc(gpa, d2, 999_999, false); var out: std.ArrayListUnmanaged(u64) = .empty; defer out.deinit(gpa); try ix.lookup_eq(gpa, &.{.{ .int32 = 7 }}, &out); if (out.items.len != 1) return error.BadCount; var it = ix.iter(); if (it.next() == null) return error.BadCount; if (it.next() != null) return error.BadCount; std.debug.print("STRESS OK\n", .{}); }