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:
277
src/query.zig
277
src/query.zig
@@ -9,7 +9,10 @@ const bson = @import("bson.zig");
|
||||
// Filter matching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub const QueryError = error{OutOfMemory};
|
||||
/// The byte matcher operates on stored (canonical, validated) bytes, so an
|
||||
/// InvalidBson from the walker means a storage bug rather than hostile
|
||||
/// input — but it must still be a possible error, not a panic.
|
||||
pub const QueryError = error{ OutOfMemory, InvalidBson };
|
||||
|
||||
pub fn matches(gpa: std.mem.Allocator, filter: *const bson.Document, doc: *const bson.Document) QueryError!bool {
|
||||
for (filter.pairs) |p| {
|
||||
@@ -93,9 +96,31 @@ fn field_matches(gpa: std.mem.Allocator, path: []const u8, expected: bson.Value,
|
||||
var candidates: std.ArrayListUnmanaged(bson.Value) = .empty;
|
||||
defer candidates.deinit(alloc);
|
||||
try collect_values(alloc, doc.pairs, path, &candidates, 0);
|
||||
// MongoDB applies queries to array elements as well as the array itself.
|
||||
// Index the snapshot length, re-reading items each iteration: appending
|
||||
// may reallocate the buffer, which would invalidate a captured slice.
|
||||
try expand_arrays(alloc, &candidates);
|
||||
return apply_expected(gpa, expected, candidates.items);
|
||||
}
|
||||
|
||||
/// The byte counterpart of field_matches: collects values by walking the
|
||||
/// canonical BSON element stream of a stored document, skipping by length
|
||||
/// any field the filter does not name.
|
||||
fn field_matches_bytes(gpa: std.mem.Allocator, path: []const u8, expected: bson.Value, bytes: []const u8) QueryError!bool {
|
||||
// An arena, not a stack fallback: the byte walker materializes nested
|
||||
// doc/array values (whole-array equality, embedded docs) into the
|
||||
// allocator it is given, and those must be freed with it.
|
||||
var arena = std.heap.ArenaAllocator.init(gpa);
|
||||
defer arena.deinit();
|
||||
const alloc = arena.allocator();
|
||||
|
||||
var candidates: std.ArrayListUnmanaged(bson.Value) = .empty;
|
||||
try collect_values_bytes(alloc, bytes, path, &candidates, 0);
|
||||
try expand_arrays(alloc, &candidates);
|
||||
return apply_expected(gpa, expected, candidates.items);
|
||||
}
|
||||
|
||||
/// MongoDB applies queries to array elements as well as the array itself.
|
||||
/// Index the snapshot length, re-reading items each iteration: appending
|
||||
/// may reallocate the buffer, which would invalidate a captured slice.
|
||||
fn expand_arrays(alloc: std.mem.Allocator, candidates: *std.ArrayListUnmanaged(bson.Value)) QueryError!void {
|
||||
const direct_count = candidates.items.len;
|
||||
var i: usize = 0;
|
||||
while (i < direct_count) : (i += 1) {
|
||||
@@ -104,7 +129,11 @@ fn field_matches(gpa: std.mem.Allocator, path: []const u8, expected: bson.Value,
|
||||
for (a.array) |elem| try candidates.append(alloc, elem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The operator/equality half of field matching, shared by the tree and
|
||||
/// byte collectors.
|
||||
fn apply_expected(gpa: std.mem.Allocator, expected: bson.Value, candidates: []const bson.Value) QueryError!bool {
|
||||
if (is_operator_doc(expected)) |pairs| {
|
||||
// $options modifies $regex wherever it appears in the document, so
|
||||
// it has to be known before any operator runs.
|
||||
@@ -115,24 +144,163 @@ fn field_matches(gpa: std.mem.Allocator, path: []const u8, expected: bson.Value,
|
||||
for (pairs) |p| {
|
||||
const op = parse_op(p.key);
|
||||
if (op == .options) continue;
|
||||
if (!try match_operator(gpa, op, p.value, candidates.items, options)) return false;
|
||||
if (!try match_operator(gpa, op, p.value, candidates, options)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Bare BSON regex value: {field: /re/} behaves like {$regex: "re"}.
|
||||
if (expected == .regex) {
|
||||
for (candidates.items) |a| {
|
||||
for (candidates) |a| {
|
||||
if (a == .string and regex_match(expected.regex.pattern, expected.regex.options, a.string)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Bare equality — matches if any candidate equals the expected value.
|
||||
for (candidates.items) |actual| {
|
||||
for (candidates) |actual| {
|
||||
if (bson.compare(actual, expected) == .eq) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Whether a stored document (canonical BSON bytes) matches `filter` — the
|
||||
/// byte-matcher counterpart of `matches`, used by scans. Same semantics,
|
||||
/// different collection: fields the filter does not name are skipped by
|
||||
/// length instead of materialized.
|
||||
pub fn matches_bytes(gpa: std.mem.Allocator, filter: []const bson.Pair, bytes: []const u8) QueryError!bool {
|
||||
for (filter) |p| {
|
||||
if (p.key.len > 0 and p.key[0] == '$') {
|
||||
if (!try match_top_level_bytes(gpa, p.key, p.value, bytes)) return false;
|
||||
} else {
|
||||
if (!try field_matches_bytes(gpa, p.key, p.value, bytes)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
fn match_top_level_bytes(gpa: std.mem.Allocator, op: []const u8, value: bson.Value, bytes: []const u8) QueryError!bool {
|
||||
if (std.mem.eql(u8, op, "$and") or std.mem.eql(u8, op, "$or")) {
|
||||
const want_and = std.mem.eql(u8, op, "$and");
|
||||
const filters = switch (value) {
|
||||
.array => |arr| arr,
|
||||
else => return false,
|
||||
};
|
||||
for (filters) |item| {
|
||||
const f = switch (item) {
|
||||
.doc => |pairs| pairs,
|
||||
else => return false,
|
||||
};
|
||||
const matched = try matches_bytes(gpa, f, bytes);
|
||||
if (want_and and !matched) return false;
|
||||
if (!want_and and matched) return true;
|
||||
}
|
||||
return want_and;
|
||||
}
|
||||
if (std.mem.eql(u8, op, "$nor")) {
|
||||
const filters = switch (value) {
|
||||
.array => |arr| arr,
|
||||
else => return false,
|
||||
};
|
||||
for (filters) |item| {
|
||||
const f = switch (item) {
|
||||
.doc => |pairs| pairs,
|
||||
else => return false,
|
||||
};
|
||||
if (try matches_bytes(gpa, f, bytes)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Collect values reachable at `path` from a document's canonical bytes —
|
||||
/// the byte counterpart of `collect_values`, with the same traversal, the
|
||||
/// same order and the same multikey semantics. Appends into `out`.
|
||||
pub fn collect_values_bytes(gpa: std.mem.Allocator, bytes: []const u8, path: []const u8, out: *std.ArrayListUnmanaged(bson.Value), depth: usize) QueryError!void {
|
||||
var it = std.mem.splitScalar(u8, path, '.');
|
||||
const first = it.next() orelse return;
|
||||
const rest = it.rest();
|
||||
|
||||
var idx: usize = 4; // skip the document length prefix
|
||||
while (idx + 1 < bytes.len and bytes[idx] != 0) {
|
||||
const tag = bytes[idx];
|
||||
idx += 1;
|
||||
const key = bson.element_key(bytes, &idx) orelse return;
|
||||
if (std.mem.eql(u8, key, first)) {
|
||||
if (rest.len == 0) {
|
||||
if (depth < 8) {
|
||||
try out.append(gpa, try bson.read_value(gpa, bytes, &idx, tag));
|
||||
} else {
|
||||
try bson.skip_value(bytes, &idx, tag);
|
||||
}
|
||||
} else {
|
||||
try collect_from_value_bytes(gpa, bytes, &idx, tag, rest, out, depth + 1);
|
||||
}
|
||||
} else {
|
||||
try bson.skip_value(bytes, &idx, tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_from_value_bytes(gpa: std.mem.Allocator, bytes: []const u8, idx: *usize, tag: u8, path: []const u8, out: *std.ArrayListUnmanaged(bson.Value), depth: usize) QueryError!void {
|
||||
if (depth > 8) {
|
||||
try bson.skip_value(bytes, idx, tag);
|
||||
return;
|
||||
}
|
||||
switch (tag) {
|
||||
0x03 => {
|
||||
const start = idx.*;
|
||||
try collect_values_bytes(gpa, bytes[start..], path, out, depth);
|
||||
idx.* = start + std.mem.readInt(u32, bytes[start..][0..4], .little);
|
||||
},
|
||||
0x04 => {
|
||||
const start = idx.*;
|
||||
const total: u32 = std.mem.readInt(u32, bytes[start..][0..4], .little);
|
||||
const array_end = start + total;
|
||||
var pit = std.mem.splitScalar(u8, path, '.');
|
||||
const seg = pit.next() orelse return;
|
||||
if (std.fmt.parseInt(usize, seg, 10)) |aidx| {
|
||||
var e: usize = 0;
|
||||
var a = start + 4;
|
||||
while (a < array_end - 1 and bytes[a] != 0) {
|
||||
const atag = bytes[a];
|
||||
a += 1;
|
||||
_ = bson.element_key(bytes, &a) orelse return;
|
||||
if (e == aidx) {
|
||||
const rest = pit.rest();
|
||||
if (rest.len == 0) {
|
||||
if (depth < 8) {
|
||||
try out.append(gpa, try bson.read_value(gpa, bytes, &a, atag));
|
||||
} else {
|
||||
try bson.skip_value(bytes, &a, atag);
|
||||
}
|
||||
} else {
|
||||
try collect_from_value_bytes(gpa, bytes, &a, atag, rest, out, depth + 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
try bson.skip_value(bytes, &a, atag);
|
||||
e += 1;
|
||||
}
|
||||
} else |_| {
|
||||
// Multikey semantics: descend into embedded documents.
|
||||
var a = start + 4;
|
||||
while (a < array_end - 1 and bytes[a] != 0) {
|
||||
const atag = bytes[a];
|
||||
a += 1;
|
||||
_ = bson.element_key(bytes, &a) orelse return;
|
||||
if (atag == 0x03) {
|
||||
const dstart = a;
|
||||
try collect_values_bytes(gpa, bytes[dstart..], path, out, depth);
|
||||
}
|
||||
try bson.skip_value(bytes, &a, atag);
|
||||
}
|
||||
}
|
||||
idx.* = array_end;
|
||||
},
|
||||
else => try bson.skip_value(bytes, idx, tag),
|
||||
}
|
||||
}
|
||||
|
||||
/// The query operators, resolved from their names once per filter field
|
||||
/// instead of re-comparing strings for every candidate document.
|
||||
const Op = enum {
|
||||
@@ -1285,6 +1453,101 @@ test "and/or filters" {
|
||||
} } }}), &d));
|
||||
}
|
||||
|
||||
test "byte matcher agrees with the tree matcher on a corpus" {
|
||||
// The scan path matches stored documents as canonical BSON bytes,
|
||||
// skipping fields by length; the tree path walks materialized pairs.
|
||||
// They must agree exactly, so the byte matcher is checked against the
|
||||
// existing matcher over a corpus that exercises scalars, operators,
|
||||
// dot paths, multikey arrays, nested docs, $and/$or and missing fields.
|
||||
const gpa = testing.allocator;
|
||||
var prng = std.Random.DefaultPrng.init(0xB17E_7E);
|
||||
const rand = prng.random();
|
||||
|
||||
const n = 200;
|
||||
var bytes_list: std.ArrayListUnmanaged([]u8) = .empty;
|
||||
defer {
|
||||
for (bytes_list.items) |b| gpa.free(b);
|
||||
bytes_list.deinit(gpa);
|
||||
}
|
||||
for (0..n) |_| {
|
||||
const a = rand.intRangeAtMost(i32, 0, 10);
|
||||
var pairs: [4]bson.Pair = undefined;
|
||||
var np: usize = 0;
|
||||
pairs[np] = .{ .key = "a", .value = .{ .int32 = a } };
|
||||
np += 1;
|
||||
pairs[np] = .{ .key = "b", .value = .{ .string = if (rand.boolean()) "x" else "y" } };
|
||||
np += 1;
|
||||
if (rand.boolean()) {
|
||||
pairs[np] = .{ .key = "tags", .value = .{ .array = &.{ .{ .string = "m" }, .{ .string = "n" } } } };
|
||||
np += 1;
|
||||
}
|
||||
if (rand.boolean()) {
|
||||
pairs[np] = .{ .key = "d", .value = .{ .doc = &.{ .{ .key = "e", .value = .{ .int32 = a } } } } };
|
||||
np += 1;
|
||||
}
|
||||
var out: std.ArrayListUnmanaged(u8) = .empty;
|
||||
defer out.deinit(gpa);
|
||||
try bson.write_doc(pairs[0..np], gpa, &out);
|
||||
try bytes_list.append(gpa, try gpa.dupe(u8, out.items));
|
||||
}
|
||||
|
||||
for (0..500) |case| {
|
||||
const a = rand.intRangeAtMost(i32, 0, 12);
|
||||
const s = if (rand.boolean()) "x" else "m";
|
||||
// Values are copied into a per-iteration arena so nested literals
|
||||
// cannot dangle.
|
||||
var farena = std.heap.ArenaAllocator.init(gpa);
|
||||
defer farena.deinit();
|
||||
const fa = farena.allocator();
|
||||
var f_pairs: std.ArrayListUnmanaged(bson.Pair) = .empty;
|
||||
defer f_pairs.deinit(fa);
|
||||
switch (rand.intRangeAtMost(u8, 0, 9)) {
|
||||
0 => try f_pairs.append(fa, .{ .key = "a", .value = .{ .int32 = a } }),
|
||||
1 => {
|
||||
const v = try bson.copy_value(fa, .{ .doc = &.{.{ .key = "$gt", .value = .{ .int32 = a } }} });
|
||||
try f_pairs.append(fa, .{ .key = "a", .value = v });
|
||||
},
|
||||
2 => try f_pairs.append(fa, .{ .key = "b", .value = .{ .string = s } }),
|
||||
3 => try f_pairs.append(fa, .{ .key = "tags", .value = .{ .string = s } }),
|
||||
4 => try f_pairs.append(fa, .{ .key = "d.e", .value = .{ .int32 = a } }),
|
||||
5 => {
|
||||
const v = try bson.copy_value(fa, .{ .doc = &.{.{ .key = "$in", .value = .{ .array = &.{ .{ .int32 = a }, .{ .int32 = a + 1 } } } }} });
|
||||
try f_pairs.append(fa, .{ .key = "a", .value = v });
|
||||
},
|
||||
6 => {
|
||||
const v = try bson.copy_value(fa, .{ .array = &.{
|
||||
.{ .doc = &.{.{ .key = "a", .value = .{ .int32 = a } }} },
|
||||
.{ .doc = &.{.{ .key = "b", .value = .{ .string = s } }} },
|
||||
} });
|
||||
try f_pairs.append(fa, .{ .key = "$or", .value = v });
|
||||
},
|
||||
7 => {
|
||||
const v = try bson.copy_value(fa, .{ .doc = &.{.{ .key = "$exists", .value = .{ .bool = rand.boolean() } }} });
|
||||
try f_pairs.append(fa, .{ .key = "tags", .value = v });
|
||||
},
|
||||
8 => {
|
||||
const v = try bson.copy_value(fa, .{ .doc = &.{
|
||||
.{ .key = "$gte", .value = .{ .int32 = a } },
|
||||
.{ .key = "$lt", .value = .{ .int32 = a + 3 } },
|
||||
} });
|
||||
try f_pairs.append(fa, .{ .key = "a", .value = v });
|
||||
},
|
||||
else => try f_pairs.append(fa, .{ .key = "missing", .value = .{ .int32 = a } }),
|
||||
}
|
||||
const filter_doc = bson.Document{ .arena = undefined, .pairs = f_pairs.items };
|
||||
for (bytes_list.items) |bytes| {
|
||||
var doc = try bson.Document.parse(gpa, bytes);
|
||||
defer doc.deinit();
|
||||
const tree = try matches(gpa, &filter_doc, &doc);
|
||||
const byt = try matches_bytes(gpa, f_pairs.items, bytes);
|
||||
if (tree != byt) {
|
||||
std.debug.print("case {d}: filter mismatch: tree={} bytes={}\n", .{ case, tree, byt });
|
||||
return error.ByteMatcherMismatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Public single-value operator matcher, used by $pull and $elemMatch.
|
||||
pub fn value_matches_operator(gpa: std.mem.Allocator, op: []const u8, value: bson.Value, actual: bson.Value) QueryError!bool {
|
||||
var single: [1]bson.Value = .{actual};
|
||||
|
||||
Reference in New Issue
Block a user