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:
418
src/index.zig
418
src/index.zig
@@ -83,6 +83,9 @@ const page_size = 4096;
|
||||
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
|
||||
@@ -300,14 +303,27 @@ pub const Index = struct {
|
||||
/// 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. A
|
||||
// batch can also deepen the tree as it goes, and every level it
|
||||
// adds costs one more node per remaining entry; a level needs a
|
||||
// full root and the smallest root holds three ~1 KiB separators, so
|
||||
// n/8 levels per batch is well clear of the worst case.
|
||||
const extra_nodes: u64 = n * (self.depth + 2 + n / 8) + 4;
|
||||
try self.nodes.ensureUnusedCapacity(gpa, @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.
|
||||
fn reserve_overflow(self: *Index, gpa: std.mem.Allocator, entries: []const Entry) !void {
|
||||
var overflow_bytes: u64 = 0;
|
||||
for (entries) |e| {
|
||||
const rec_len: u64 = e.key.len + e.id.len;
|
||||
if (rec_len > inline_limit) overflow_bytes += rec_len;
|
||||
}
|
||||
const extra_nodes: u64 = n + n / 100 + self.depth + 2;
|
||||
try self.nodes.ensureUnusedCapacity(gpa, self.nodes.items.len + @as(usize, @intCast(extra_nodes)));
|
||||
try self.overflow.ensureUnusedCapacity(gpa, self.overflow.items.len + @as(usize, @intCast(overflow_bytes)));
|
||||
try self.overflow.ensureUnusedCapacity(gpa, @intCast(overflow_bytes));
|
||||
}
|
||||
|
||||
/// Insert pre-built entries (maintaining order) and drain the batch.
|
||||
@@ -387,7 +403,10 @@ pub const Index = struct {
|
||||
duplicate = true;
|
||||
}
|
||||
}
|
||||
try self.reserve_for(gpa, self.staging.items);
|
||||
// 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;
|
||||
}
|
||||
@@ -604,22 +623,25 @@ pub const Index = struct {
|
||||
key: []const u8,
|
||||
id: []const u8 = "",
|
||||
child: u32 = 0,
|
||||
/// When set, key+id already live in the overflow slab and are
|
||||
/// referenced rather than copied (moved leaf records, promoted
|
||||
/// separators).
|
||||
spill_off: u64 = 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,
|
||||
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,
|
||||
spill_off: ?u64,
|
||||
right: u32,
|
||||
};
|
||||
|
||||
@@ -644,6 +666,14 @@ pub const Index = struct {
|
||||
return @intCast(self.nodes.items.len - 1);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
try self.nodes.append(gpa, empty_node(0));
|
||||
return @intCast(self.nodes.items.len - 1);
|
||||
}
|
||||
|
||||
fn get_slot(node: *const Node, i: u32) Slot {
|
||||
return std.mem.bytesToValue(Slot, node.buf[i * slot_size ..][0..slot_size]);
|
||||
}
|
||||
@@ -683,13 +713,14 @@ pub const Index = struct {
|
||||
const node = &self.nodes.items[node_id];
|
||||
const rec_len: u64 = rec.key.len + rec.id.len;
|
||||
var s: Slot = .{
|
||||
.off = rec.spill_off,
|
||||
.off = 0,
|
||||
.key_len = @intCast(rec.key.len),
|
||||
.extra = if (rec.id.len > 0) @intCast(rec.id.len) else rec.child,
|
||||
.spill = false,
|
||||
._pad = 0,
|
||||
};
|
||||
if (rec.spill_off != 0) {
|
||||
if (rec.spill_off) |off| {
|
||||
s.off = off;
|
||||
s.spill = true;
|
||||
} else if (rec_len > inline_limit) {
|
||||
s.off = self.overflow.items.len;
|
||||
@@ -738,6 +769,7 @@ pub const Index = struct {
|
||||
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
|
||||
@@ -783,7 +815,39 @@ pub const Index = struct {
|
||||
if (s.spill) return .{ .key = self.overflow.items[@intCast(s.off) .. @intCast(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 = 0 };
|
||||
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.nodes.items[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).
|
||||
@@ -892,6 +956,17 @@ pub const Index = struct {
|
||||
fn insert_rec(self: *Index, node_id: u32, key: []const u8, id: []const u8) ?Split {
|
||||
const node = &self.nodes.items[node_id];
|
||||
if (node.is_leaf == 1) {
|
||||
if (self.fits(node_id, key.len + id.len)) {
|
||||
self.store_record(node_id, self.leaf_pos(node_id, key, id), .{ .key = key, .id = id });
|
||||
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 + id.len)) {
|
||||
self.store_record(node_id, self.leaf_pos(node_id, key, id), .{ .key = key, .id = id });
|
||||
self.entry_count += 1;
|
||||
@@ -904,48 +979,72 @@ pub const Index = struct {
|
||||
return self.insert_separator(node_id, res);
|
||||
}
|
||||
|
||||
/// Split a full leaf: the right half (including any records equal to
|
||||
/// the boundary key — lookups scan whole key bands, so equal keys may
|
||||
/// live on both sides) moves to a new leaf, the boundary key is
|
||||
/// promoted, and the new record is stored in whichever half holds its
|
||||
/// position.
|
||||
fn split_leaf(self: *Index, leaf_id: u32, key: []const u8, id: []const u8) ?Split {
|
||||
const node = &self.nodes.items[leaf_id];
|
||||
const mid = @max(1, node.count / 2);
|
||||
/// 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, id: []const u8) 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.nodes.items[leaf_id].count;
|
||||
std.debug.assert(old_count >= 2);
|
||||
const pos = self.leaf_pos(leaf_id, key, id);
|
||||
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 + id.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.nodes.items[leaf_id];
|
||||
const right = &self.nodes.items[right_id];
|
||||
right.is_leaf = 1;
|
||||
right.next = node.next;
|
||||
right.prev = leaf_id;
|
||||
right.parent = node.parent;
|
||||
}
|
||||
// Move the right half; spilled records keep their slab reference.
|
||||
var i: u32 = mid;
|
||||
while (i < node.count) : (i += 1) {
|
||||
const s = get_slot(&self.nodes.items[leaf_id], i);
|
||||
self.store_record(right_id, @intCast(self.nodes.items[right_id].count), .{
|
||||
.key = self.key_of(leaf_id, i),
|
||||
.id = self.id_of(leaf_id, i),
|
||||
.spill_off = if (s.spill) s.off else 0,
|
||||
// Move the merged tail; spilled records keep their slab reference.
|
||||
var m: u32 = mid;
|
||||
while (m < n) : (m += 1) {
|
||||
const at: u32 = self.nodes.items[right_id].count;
|
||||
if (m == pos) {
|
||||
self.store_record(right_id, at, .{ .key = key, .id = id });
|
||||
continue;
|
||||
}
|
||||
const src: u32 = if (m < pos) m else m - 1;
|
||||
const slot = get_slot(&self.nodes.items[leaf_id], src);
|
||||
self.store_record(right_id, at, .{
|
||||
.key = self.key_of(leaf_id, src),
|
||||
.id = self.id_of(leaf_id, src),
|
||||
.spill_off = if (slot.spill) slot.off else null,
|
||||
});
|
||||
}
|
||||
// Repack the left half in place.
|
||||
self.repack_keep_prefix(leaf_id, mid);
|
||||
// Link the chain.
|
||||
const node2 = &self.nodes.items[leaf_id];
|
||||
if (node2.next != 0) self.nodes.items[node2.next].prev = right_id;
|
||||
node2.next = right_id;
|
||||
self.leaf_count += 1;
|
||||
// Promote the right leaf's first key.
|
||||
const sep = self.stable_key(right_id, 0);
|
||||
// Store the new record in the correct half.
|
||||
if (std.mem.order(u8, key, sep.key) == .lt) {
|
||||
self.store_record(leaf_id, self.leaf_pos(leaf_id, key, id), .{ .key = key, .id = id });
|
||||
// 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, .id = id });
|
||||
} else {
|
||||
self.store_record(right_id, self.leaf_pos(right_id, key, id), .{ .key = key, .id = id });
|
||||
self.repack_keep_prefix(leaf_id, mid);
|
||||
}
|
||||
// Link the chain.
|
||||
const left = &self.nodes.items[leaf_id];
|
||||
if (left.next != 0) self.nodes.items[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 };
|
||||
}
|
||||
|
||||
@@ -957,10 +1056,16 @@ pub const Index = struct {
|
||||
// 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 != 0) split.key else blk: {
|
||||
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.nodes.items[node_id].count);
|
||||
}
|
||||
if (self.fits(node_id, key.len)) {
|
||||
self.store_record(node_id, self.separator_pos(node_id, key), .{
|
||||
.key = key,
|
||||
@@ -970,55 +1075,79 @@ pub const Index = struct {
|
||||
self.nodes.items[split.right].parent = node_id;
|
||||
return null;
|
||||
}
|
||||
const up = self.split_internal(node_id);
|
||||
// The node split; the separator goes into whichever half holds its
|
||||
// position. Both halves have room (they are half-full).
|
||||
if (std.mem.order(u8, key, up.key) == .lt) {
|
||||
self.store_record(node_id, self.separator_pos(node_id, key), .{
|
||||
.key = key,
|
||||
.child = split.right,
|
||||
.spill_off = split.spill_off,
|
||||
});
|
||||
self.nodes.items[split.right].parent = node_id;
|
||||
} else {
|
||||
self.store_record(up.right, self.separator_pos(up.right, key), .{
|
||||
.key = key,
|
||||
.child = split.right,
|
||||
.spill_off = split.spill_off,
|
||||
});
|
||||
self.nodes.items[split.right].parent = up.right;
|
||||
}
|
||||
return up;
|
||||
return self.split_internal(node_id, key, split.spill_off, split.right);
|
||||
}
|
||||
|
||||
/// Split a full internal node: the middle separator is promoted, the
|
||||
/// first half stays, the rest moves to a new right node.
|
||||
fn split_internal(self: *Index, node_id: u32) Split {
|
||||
const node = &self.nodes.items[node_id];
|
||||
const s = node.count / 2;
|
||||
// Copy the promoted key before the repack rewrites the page.
|
||||
const mid_key = self.stable_key(node_id, s);
|
||||
/// 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.nodes.items[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();
|
||||
{
|
||||
const right = &self.nodes.items[right_id];
|
||||
right.is_leaf = 0;
|
||||
right.parent = node.parent;
|
||||
right.first_child = get_slot(node, s).extra;
|
||||
// The moved subtrees now live under the right node.
|
||||
self.nodes.items[right.first_child].parent = right_id;
|
||||
}
|
||||
var i: u32 = s + 1;
|
||||
while (i < node.count) : (i += 1) {
|
||||
const slot_i = get_slot(&self.nodes.items[node_id], i);
|
||||
self.nodes.items[slot_i.extra].parent = right_id;
|
||||
self.store_record(right_id, @intCast(self.nodes.items[right_id].count), .{
|
||||
.key = self.key_of(node_id, i),
|
||||
.child = slot_i.extra,
|
||||
.spill_off = if (slot_i.spill) slot_i.off else 0,
|
||||
self.nodes.items[right_id].is_leaf = 0;
|
||||
self.nodes.items[right_id].parent = self.nodes.items[node_id].parent;
|
||||
// The promoted separator's child heads the right node.
|
||||
const mid_child: u32 = if (mid == pos)
|
||||
child
|
||||
else
|
||||
get_slot(&self.nodes.items[node_id], if (mid < pos) mid else mid - 1).extra;
|
||||
self.nodes.items[right_id].first_child = mid_child;
|
||||
self.nodes.items[mid_child].parent = right_id;
|
||||
|
||||
var m: u32 = mid + 1;
|
||||
while (m < n) : (m += 1) {
|
||||
const at: u32 = self.nodes.items[right_id].count;
|
||||
if (m == pos) {
|
||||
self.store_record(right_id, at, .{ .key = key, .child = child, .spill_off = spill_off });
|
||||
self.nodes.items[child].parent = right_id;
|
||||
continue;
|
||||
}
|
||||
const src: u32 = if (m < pos) m else m - 1;
|
||||
const slot = get_slot(&self.nodes.items[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.nodes.items[slot.extra].parent = right_id;
|
||||
}
|
||||
self.repack_keep_prefix(node_id, s);
|
||||
return .{ .key = mid_key.key, .spill_off = mid_key.spill_off, .right = 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.nodes.items[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
|
||||
@@ -1097,9 +1226,9 @@ pub const Index = struct {
|
||||
|
||||
/// The internal root emptied: swap in a fresh empty leaf as the root.
|
||||
fn replace_root_with_leaf(self: *Index) void {
|
||||
self.root = self.alloc_node();
|
||||
self.nodes.items[self.root].is_leaf = 1;
|
||||
self.nodes.items[self.root].parent = 0;
|
||||
// Removal reserves no capacity, so this must not allocate a node:
|
||||
// re-use the emptied root page as the empty root leaf.
|
||||
self.nodes.items[self.root] = empty_node(1);
|
||||
self.first_leaf = self.root;
|
||||
self.leaf_count = 1;
|
||||
self.depth = 0;
|
||||
@@ -1114,6 +1243,10 @@ pub const Index = struct {
|
||||
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,
|
||||
@@ -1126,7 +1259,7 @@ pub const Index = struct {
|
||||
var prev: u32 = 0;
|
||||
var lit = self.staging.items;
|
||||
while (lit.len > 0) {
|
||||
const leaf = self.alloc_node();
|
||||
const leaf = try self.alloc_node_grow(gpa);
|
||||
self.nodes.items[leaf].is_leaf = 1;
|
||||
const first_key = lit[0].key;
|
||||
// Fill until the next record would not fit.
|
||||
@@ -1158,7 +1291,7 @@ pub const Index = struct {
|
||||
defer next.deinit(gpa);
|
||||
var i: usize = 0;
|
||||
while (i < level.items.len) {
|
||||
const node = self.alloc_node();
|
||||
const node = try self.alloc_node_grow(gpa);
|
||||
self.nodes.items[node].first_child = level.items[i].id;
|
||||
self.nodes.items[level.items[i].id].parent = node;
|
||||
const first_key = level.items[i].first_key;
|
||||
@@ -1172,7 +1305,6 @@ pub const Index = struct {
|
||||
self.store_record(node, @intCast(slots), .{
|
||||
.key = level.items[j].first_key,
|
||||
.child = level.items[j].id,
|
||||
.spill_off = 0,
|
||||
});
|
||||
self.nodes.items[level.items[j].id].parent = node;
|
||||
slots += 1;
|
||||
@@ -2360,6 +2492,106 @@ test "TTL spec rejects compound keys and bad expireAfterSeconds" {
|
||||
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, &.{"s"}, false, false);
|
||||
defer ix.deinit(gpa);
|
||||
|
||||
const n = 12;
|
||||
var docs: [n][]u8 = undefined;
|
||||
var ids: [n][]u8 = undefined;
|
||||
var made: usize = 0;
|
||||
defer for (0..made) |i| {
|
||||
gpa.free(docs[i]);
|
||||
gpa.free(ids[i]);
|
||||
};
|
||||
for (0..n) |i| {
|
||||
docs[i] = try str_doc(gpa, i, @intCast('a' + i), 1000);
|
||||
ids[i] = try std.fmt.allocPrint(gpa, "id{d}", .{i});
|
||||
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_ids(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, &.{"s"}, false, false);
|
||||
defer ix.deinit(gpa);
|
||||
|
||||
var docs: std.ArrayListUnmanaged([]u8) = .empty;
|
||||
var ids: std.ArrayListUnmanaged([]u8) = .empty;
|
||||
defer {
|
||||
for (docs.items) |d| gpa.free(d);
|
||||
for (ids.items) |x| gpa.free(x);
|
||||
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, try std.fmt.allocPrint(gpa, "id{d}", .{i}));
|
||||
}
|
||||
// ... 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, try std.fmt.allocPrint(gpa, "id{d}", .{i}));
|
||||
}
|
||||
// ... 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, try std.fmt.allocPrint(gpa, "id{d}", .{i}));
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user