engine: decompose the global lock; cross-connection group commit (roadmap item 5)
The single engine-wide reader/writer lock is replaced by a lock hierarchy, so writes to different collections no longer serialize on one mutex: - Collections are heap-allocated, so their addresses are stable while a command holds a collection lock (the maps only store pointers). - A catalog rwlock guards the database/collection maps: shared for every command (so a concurrent DDL cannot mutate the maps underneath it), exclusive for create/drop/dropDatabase. Each collection has its own rwlock; the ordering is always catalog -> collection -> log lock, never two collection locks at once (TTL sweep and compaction take collections one at a time). - Command dispatch acquires the catalog + target collection locks for the handler's duration, resolving the collection (creating it for writes) under the catalog lock; create/drop upgrade to the exclusive catalog lock. - Appends never fsync. Each write command's epilogue releases the collection lock, then commits once (seal + fsync) with a leader/follower group commit: the leader waits for writers mid-append (a pending counter) so its seal covers them, and followers whose records the seal covered skip their own fsync. Every acknowledged write is fsynced before its reply (crash pair verified); an unacknowledged write may vanish and a reader may observe a write before its fsync — ordinary w:1 j:true semantics instead of 'the log describes >= memory'. - Compaction snapshots collections without the log lock (so a concurrent writer holding one can always finish its append) and retries when a writer appended mid-snapshot (detected via the record seq), then swaps under the log lock — no deadlock. The compaction trigger moved to the command epilogue and the TTL monitor. - Engine.dup_index moved to the collection (per-command error paths). Also lands two B-tree edge-case fixes driven by tests that were in flight: a churned leaf full of dead bytes no longer splits with an empty right half (the leaf is repacked before splitting, and an emptied node's page is fully free again), and a slot-count split with all large records on one side shifts records between the halves until the new record fits. Plus a randomised fuzz test over key sizes (src/fuzz_split.zig) and the two regression tests. Measured (tests/e2e/results/phase6.txt): no regression on the single-connection benchmark; concurrent durable-insert throughput ~5.1k -> 12.5k docs/s from 1 -> 8 clients, ~14.8k at 32. Verified: unit suite in all three modes, all e2e suites, the kill -9 crash pair.
This commit is contained in:
129
src/fuzz_split.zig
Normal file
129
src/fuzz_split.zig
Normal file
@@ -0,0 +1,129 @@
|
||||
//! 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 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 {
|
||||
id: []u8,
|
||||
s: []u8,
|
||||
bytes: []u8,
|
||||
live: bool,
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
var ix = try index.Index.init(gpa, "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.id);
|
||||
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.id);
|
||||
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 = try std.fmt.allocPrint(gpa, "id{d}", .{op});
|
||||
errdefer gpa.free(id);
|
||||
const bytes = try make_doc(gpa, op, s);
|
||||
errdefer gpa.free(bytes);
|
||||
_ = try ix.add_doc(gpa, bytes, id, false);
|
||||
try docs.append(gpa, .{ .id = id, .s = s, .bytes = bytes, .live = true });
|
||||
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([]const u8) = .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 (std.mem.eql(u8, got, d.id)) hit = true;
|
||||
}
|
||||
if (!hit) {
|
||||
std.debug.print("seed {d} op {d}: id {s} (key len {d}) not found by descent\n", .{ seed, op, d.id, 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);
|
||||
}
|
||||
Reference in New Issue
Block a user