storage: byte documents in a per-collection slab (roadmap item 4)

Documents live as canonical BSON bytes in a segmented per-collection slab
(fixed 8 MiB segments keep capacity slack under one segment); the docs map
holds flat offsets that stay valid across segment growth, and removed
documents leave garbage bytes until compaction rewrites. The per-document
ArenaAllocator and its second full Pair-tree copy are gone.

The matcher walks the stored bytes directly, skipping by length any field
the filter does not name (a new bson byte-walker: element_key, skip_value,
read_value with borrowed leaves, get_at, and a borrowed spine parse). The
byte matcher is differential-tested against the tree matcher on a corpus
and shares its operator logic. Stored documents are never materialized on
the scan path or in aggregate $match; $group reads group keys and sums
straight off the bytes. Sort, projection, findAndModify, updates and
index entry generation use a borrowed spine into the slab (or the byte
collector, which also replaced collect_values in build_entries). The
compaction threshold now counts uncompressed data volume, since a
compressed log would otherwise never trigger.

Measured (tests/e2e/results/phase5.txt): server RSS 1979 -> 539 MB (2.4x
smaller than MongoDB; phase1 baseline 2.0 GB), range-scan 22.5 -> ~12 ms
(parity, best run faster than MongoDB), proj 4.1 -> 3.4 ms, createIndex
parity. Verified: unit suite in all three modes with zero leaks, the
crash pair, e2e6, and the stress/spill programs.
This commit is contained in:
2026-08-02 22:15:07 +03:00
parent b4585106f1
commit 570900a6ef
12 changed files with 985 additions and 253 deletions

View File

@@ -102,7 +102,7 @@ pub const Document = struct {
var arena = std.heap.ArenaAllocator.init(allocator);
errdefer arena.deinit();
var idx: usize = 0;
const pairs = try parse_doc_into(&arena, bytes, &idx);
const pairs = try parse_doc_into(arena.allocator(), bytes, &idx, false);
return .{ .arena = arena, .pairs = pairs };
}
@@ -146,14 +146,19 @@ pub fn get_pair_index(pairs: []const Pair, key: []const u8) ?usize {
// ---------------------------------------------------------------------------
const Parser = struct {
arena: *std.heap.ArenaAllocator,
allocator: std.mem.Allocator,
bytes: []const u8,
/// When set, strings and keys point into `bytes` instead of being
/// copied, so the parsed spine is only valid while `bytes` is. Used for
/// the borrowed spine of slab-resident documents; the full `parse`
/// keeps the owned, self-contained behavior.
borrow: bool,
};
const ParseError = error{ InvalidBson, OutOfMemory };
fn parse_doc_into(arena: *std.heap.ArenaAllocator, bytes: []const u8, idx: *usize) ParseError![]const Pair {
const p = Parser{ .arena = arena, .bytes = bytes };
fn parse_doc_into(allocator: std.mem.Allocator, bytes: []const u8, idx: *usize, borrow: bool) ParseError![]const Pair {
const p = Parser{ .allocator = allocator, .bytes = bytes, .borrow = borrow };
return parse_doc_inner(p, idx);
}
@@ -177,7 +182,7 @@ fn parse_doc_inner(p: Parser, idx: *usize) ParseError![]const Pair {
const start = idx.*;
const end = try doc_extent(p, start);
const gpa = p.arena.allocator();
const gpa = p.allocator;
var pairs: std.ArrayListUnmanaged(Pair) = .empty;
errdefer pairs.deinit(gpa);
@@ -205,9 +210,12 @@ fn parse_cstring(p: Parser, idx: *usize) ParseError![]const u8 {
while (idx.* < p.bytes.len and p.bytes[idx.*] != 0) idx.* += 1;
if (idx.* >= p.bytes.len) return error.InvalidBson;
idx.* += 1;
// Strings are copied into the arena so documents are self-contained and
// outlive the input buffer (wire messages and log records are transient).
return p.arena.allocator().dupe(u8, p.bytes[start .. idx.* - 1]);
// Keys and strings are copied into the arena so owned documents are
// self-contained and outlive the input buffer (wire messages and log
// records are transient); a borrowed spine leaves them pointing at the
// source bytes instead.
if (p.borrow) return p.bytes[start .. idx.* - 1];
return p.allocator.dupe(u8, p.bytes[start .. idx.* - 1]);
}
fn parse_string(p: Parser, idx: *usize) ParseError![]const u8 {
@@ -217,7 +225,8 @@ fn parse_string(p: Parser, idx: *usize) ParseError![]const u8 {
const str = p.bytes[idx.* + 4 .. idx.* + 4 + len];
if (str[len - 1] != 0) return error.InvalidBson;
idx.* += 4 + len;
return p.arena.allocator().dupe(u8, str[0 .. len - 1]);
if (p.borrow) return str[0 .. len - 1];
return p.allocator.dupe(u8, str[0 .. len - 1]);
}
fn parse_value(p: Parser, tag: u8, idx: *usize) ParseError!Value {
@@ -238,7 +247,7 @@ fn parse_value(p: Parser, tag: u8, idx: *usize) ParseError!Value {
try ensure_available(p.bytes, idx.* + 5, len);
const data = p.bytes[idx.* + 5 .. idx.* + 5 + len];
idx.* += 5 + len;
break :blk .{ .binary = .{ .subtype = subtype, .data = try p.arena.allocator().dupe(u8, data) } };
break :blk .{ .binary = .{ .subtype = subtype, .data = try p.allocator.dupe(u8, data) } };
},
0x06 => .{ .opaque_val = .{ .kind = 0x06, .data = &.{} } },
0x07 => blk: {
@@ -272,7 +281,7 @@ fn parse_value(p: Parser, tag: u8, idx: *usize) ParseError!Value {
idx.* += 12;
// Like all other types, the payload is copied into the arena so
// documents stay valid after the input buffer is reused.
break :blk .{ .opaque_val = .{ .kind = 0x0C, .data = try p.arena.allocator().dupe(u8, p.bytes[start..idx.*]) } };
break :blk .{ .opaque_val = .{ .kind = 0x0C, .data = try p.allocator.dupe(u8, p.bytes[start..idx.*]) } };
},
0x0D => .{ .code = try parse_string(p, idx) },
0x0E => .{ .symbol = try parse_string(p, idx) },
@@ -282,7 +291,7 @@ fn parse_value(p: Parser, tag: u8, idx: *usize) ParseError!Value {
if (total < 4 or total > p.bytes.len - idx.*) return error.InvalidBson;
const data = p.bytes[idx.* .. idx.* + total];
idx.* += total;
break :blk .{ .opaque_val = .{ .kind = 0x0F, .data = try p.arena.allocator().dupe(u8, data) } };
break :blk .{ .opaque_val = .{ .kind = 0x0F, .data = try p.allocator.dupe(u8, data) } };
},
0x10 => blk: {
try ensure_available(p.bytes, idx.*, 4);
@@ -316,7 +325,7 @@ fn parse_array(p: Parser, idx: *usize) ParseError![]const Value {
const start = idx.*;
const end = try doc_extent(p, start);
const gpa = p.arena.allocator();
const gpa = p.allocator;
var values: std.ArrayListUnmanaged(Value) = .empty;
errdefer values.deinit(gpa);
@@ -333,6 +342,218 @@ fn parse_array(p: Parser, idx: *usize) ParseError![]const Value {
return values.toOwnedSlice(gpa);
}
// ---------------------------------------------------------------------------
// Borrowed spine and byte walking
// ---------------------------------------------------------------------------
//
// Slab-resident documents are stored as canonical BSON bytes; the tree
// machinery needs a `[]const Pair` spine over them. `spine` parses one with
// keys and leaf values pointing at the source bytes (valid while the bytes
// live — the collection slab outlives any query). The byte-walking
// primitives below let a matcher skip over fields it does not name instead
// of materializing the whole document.
/// A borrowed parse of `bytes` into an arena: keys, strings, binary and
/// regex payloads point into `bytes`; only the pair/value skeleton is
/// allocated. The result is valid while both `bytes` and `arena` live.
pub fn spine(allocator: std.mem.Allocator, bytes: []const u8) error{ InvalidBson, OutOfMemory }![]const Pair {
var idx: usize = 0;
return parse_doc_into(allocator, bytes, &idx, true);
}
/// The first element's key, or null when the remaining bytes are not an
/// element. Advances `idx` past the key.
pub fn element_key(bytes: []const u8, idx: *usize) ?[]const u8 {
if (idx.* >= bytes.len) return null;
const start = idx.*;
while (idx.* < bytes.len and bytes[idx.*] != 0) idx.* += 1;
if (idx.* >= bytes.len) return null;
idx.* += 1;
return bytes[start .. idx.* - 1];
}
/// Advance past one value of `tag` (the key is already consumed). The input
/// is canonical (validated when it was stored), so skips are cheap; the
/// length-prefixed types still bounds-check their prefix.
pub fn skip_value(bytes: []const u8, idx: *usize, tag: u8) error{InvalidBson}!void {
switch (tag) {
0x01, 0x09, 0x11, 0x12 => idx.* += 8,
0x10 => idx.* += 4,
0x13 => idx.* += 16,
0x07 => idx.* += 12,
0x08 => idx.* += 1,
0x0A, 0x06 => {},
0x02, 0x0D, 0x0E => {
try ensure_available(bytes, idx.*, 4);
idx.* += 4 + std.mem.readInt(u32, bytes[idx.*..][0..4], .little);
},
0x03, 0x04 => {
try ensure_available(bytes, idx.*, 4);
idx.* += std.mem.readInt(u32, bytes[idx.*..][0..4], .little);
},
0x05 => {
try ensure_available(bytes, idx.*, 5);
idx.* += 5 + std.mem.readInt(u32, bytes[idx.*..][0..4], .little);
},
0x0B => {
_ = element_key(bytes, idx) orelse return error.InvalidBson;
_ = element_key(bytes, idx) orelse return error.InvalidBson;
},
0x0C => {
try ensure_available(bytes, idx.*, 4);
idx.* += 4 + std.mem.readInt(u32, bytes[idx.*..][0..4], .little) + 12;
},
0x0F => {
try ensure_available(bytes, idx.*, 4);
idx.* += std.mem.readInt(u32, bytes[idx.*..][0..4], .little);
},
else => return error.InvalidBson,
}
}
/// Read one value of `tag` into a Value whose leaves borrow `bytes`; nested
/// documents and arrays materialize their spines into `arena`. Advances
/// `idx` past the value.
pub fn read_value(allocator: std.mem.Allocator, bytes: []const u8, idx: *usize, tag: u8) error{ InvalidBson, OutOfMemory }!Value {
switch (tag) {
0x01 => {
try ensure_available(bytes, idx.*, 8);
const v: f64 = @bitCast(std.mem.readInt(u64, bytes[idx.*..][0..8], .little));
idx.* += 8;
return .{ .double = v };
},
0x02 => {
try ensure_available(bytes, idx.*, 4);
const len: u32 = std.mem.readInt(u32, bytes[idx.*..][0..4], .little);
const str = bytes[idx.* + 4 .. idx.* + 4 + len];
idx.* += 4 + len;
return .{ .string = str[0 .. len - 1] };
},
0x03 => {
const start = idx.*;
const total: u32 = std.mem.readInt(u32, bytes[start..][0..4], .little);
var sub: usize = 0;
const pairs = try parse_doc_into(allocator, bytes[start..], &sub, true);
idx.* = start + total;
return .{ .doc = pairs };
},
0x04 => {
const start = idx.*;
const total: u32 = std.mem.readInt(u32, bytes[start..][0..4], .little);
var sub: usize = start + 4;
var values: std.ArrayListUnmanaged(Value) = .empty;
errdefer values.deinit(allocator);
while (sub < start + total - 1 and bytes[sub] != 0) {
const atag = bytes[sub];
sub += 1;
_ = element_key(bytes, &sub) orelse return error.InvalidBson;
try values.append(allocator, try read_value(allocator, bytes, &sub, atag));
}
idx.* = start + total;
return .{ .array = try values.toOwnedSlice(allocator) };
},
0x05 => {
try ensure_available(bytes, idx.*, 5);
const len: u32 = std.mem.readInt(u32, bytes[idx.*..][0..4], .little);
const subtype = bytes[idx.* + 4];
const data = bytes[idx.* + 5 .. idx.* + 5 + len];
idx.* += 5 + len;
return .{ .binary = .{ .subtype = subtype, .data = data } };
},
0x07 => {
try ensure_available(bytes, idx.*, 12);
const oid: ObjectId = bytes[idx.*..][0..12].*;
idx.* += 12;
return .{ .object_id = oid };
},
0x08 => {
try ensure_available(bytes, idx.*, 1);
const v = bytes[idx.*] != 0;
idx.* += 1;
return .{ .bool = v };
},
0x09 => {
try ensure_available(bytes, idx.*, 8);
const v: i64 = std.mem.readInt(i64, bytes[idx.*..][0..8], .little);
idx.* += 8;
return .{ .datetime = v };
},
0x0A => return .null,
0x0B => {
const pattern = element_key(bytes, idx) orelse return error.InvalidBson;
const options = element_key(bytes, idx) orelse return error.InvalidBson;
return .{ .regex = .{ .pattern = pattern, .options = options } };
},
0x0C => {
try ensure_available(bytes, idx.*, 4);
const len: u32 = std.mem.readInt(u32, bytes[idx.*..][0..4], .little);
const start = idx.*;
idx.* += 4 + len + 12;
return .{ .opaque_val = .{ .kind = 0x0C, .data = bytes[start..idx.*] } };
},
0x0D => {
try ensure_available(bytes, idx.*, 4);
const len: u32 = std.mem.readInt(u32, bytes[idx.*..][0..4], .little);
const str = bytes[idx.* + 4 .. idx.* + 4 + len];
idx.* += 4 + len;
return .{ .code = str[0 .. len - 1] };
},
0x0E => {
try ensure_available(bytes, idx.*, 4);
const len: u32 = std.mem.readInt(u32, bytes[idx.*..][0..4], .little);
const str = bytes[idx.* + 4 .. idx.* + 4 + len];
idx.* += 4 + len;
return .{ .symbol = str[0 .. len - 1] };
},
0x0F => {
try ensure_available(bytes, idx.*, 4);
const total: u32 = std.mem.readInt(u32, bytes[idx.*..][0..4], .little);
const start = idx.*;
idx.* += total;
return .{ .opaque_val = .{ .kind = 0x0F, .data = bytes[start..idx.*] } };
},
0x10 => {
try ensure_available(bytes, idx.*, 4);
const v: i32 = std.mem.readInt(i32, bytes[idx.*..][0..4], .little);
idx.* += 4;
return .{ .int32 = v };
},
0x11 => {
try ensure_available(bytes, idx.*, 8);
const v: u64 = std.mem.readInt(u64, bytes[idx.*..][0..8], .little);
idx.* += 8;
return .{ .timestamp = v };
},
0x12 => {
try ensure_available(bytes, idx.*, 8);
const v: i64 = std.mem.readInt(i64, bytes[idx.*..][0..8], .little);
idx.* += 8;
return .{ .int64 = v };
},
0x13 => {
try ensure_available(bytes, idx.*, 16);
const v: [16]u8 = bytes[idx.*..][0..16].*;
idx.* += 16;
return .{ .decimal128 = v };
},
else => return error.InvalidBson,
}
}
/// The value stored under `key` in a document's bytes, or null when absent.
/// Nested documents and arrays materialize their spines into `arena`.
pub fn get_at(arena: std.mem.Allocator, bytes: []const u8, key: []const u8) error{ InvalidBson, OutOfMemory }!?Value {
var idx: usize = 4;
while (idx + 1 < bytes.len and bytes[idx] != 0) {
const tag = bytes[idx];
idx += 1;
const k = element_key(bytes, &idx) orelse return error.InvalidBson;
if (std.mem.eql(u8, k, key)) return try read_value(arena, bytes, &idx, tag);
try skip_value(bytes, &idx, tag);
}
return null;
}
// ---------------------------------------------------------------------------
// Serialization
// ---------------------------------------------------------------------------