query/commands/wire: trim the scan and request paths

Matching allocated an ArrayList per filter field per candidate document,
on the process-wide allocator, to hold what is almost always a single
value. Candidates now collect into a stack buffer that spills to the heap
only for arrays: measured 15.7 -> 12.0ms on a 65,536-document range scan.

The OOM-propagation test moves with it. Its point is that a failed
collection must surface as an error rather than an empty candidate list,
which would make $ne and $exists:false report a match -- a wrong answer
rather than a failed one. That invariant still holds on the spill path, so
the test now uses an array long enough to reach the allocator, and a new
test pins the flip side: the common single-value match now completes
correctly even when the allocator always fails, because it never calls it.

Query operators were dispatched by a chain of up to fourteen mem.eql per
value per document, with $gt/$gte/$lt/$lte re-comparing the operator name
inside the loop over candidate values. Names resolve to an enum once per
filter field. Command dispatch likewise walked a 30-entry table comparing
strings; it is a comptime StaticStringMap now.

Each request built a fresh reply arena and handed its pages straight back.
One reply per connection, reset between requests, keeps them.

countDocuments() arrives as [{$match: F}?, {$group: {_id: <literal>,
n: {$sum: 1}}}], which the general path answered by materializing every
matching document and discarding them all. It is now recognized and
answered from a counting scan: countDocuments({}) 2.3 -> 1.5ms.

The detector is deliberately conservative -- grouping by "$field", summing
a field, an unmodelled accumulator or any extra stage all fall through to
the general path, since those need the documents themselves. A unit test
pins each accept and reject, and the whole count path was checked against
the general one through the real driver, including the shapes that must
not take it.

The filtered range-scan row does not move: it is bound by walking 65,536
documents that each live in their own arena, not by the matcher. That is
Phase 4 work.

Verified: 77 unit tests under ReleaseFast and ReleaseSafe, e2e
29/16/17/3/2, the crash pair, e2e6 72/72.
This commit is contained in:
2026-08-02 19:13:29 +03:00
parent 552b916833
commit 75e412a4af
5 changed files with 327 additions and 61 deletions

View File

@@ -86,11 +86,18 @@ const command_table = [_]Command{
.{ .name = "listIndexes", .kind = .read, .handler = cmd_list_indexes },
};
/// Command name to its index in `command_table`, resolved at comptime so a
/// request costs one hash instead of a walk down the whole table comparing
/// strings.
const command_index = blk: {
var kvs: [command_table.len]struct { []const u8, usize } = undefined;
for (&command_table, 0..) |c, i| kvs[i] = .{ c.name, i };
break :blk std.StaticStringMap(usize).initComptime(kvs);
};
pub fn dispatch(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
const name = msg.command_name();
const cmd = for (&command_table) |*c| {
if (std.mem.eql(u8, c.name, name)) break c;
} else {
const cmd = if (command_index.get(name)) |i| &command_table[i] else {
var buf: [256]u8 = undefined;
const errmsg = try std.fmt.bufPrint(&buf, "no such command: '{s}'", .{name});
return reply.put_error(@intFromEnum(ErrorCode.command_not_found), "CommandNotFound", errmsg);
@@ -884,6 +891,41 @@ fn cmd_aggregate(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
else => return bad_value(reply, "pipeline must be an array"),
};
// countDocuments() reaches us as [{$match: F}?, {$group: {_id: <literal>,
// n: {$sum: 1}}}]. The general path answers that by materializing every
// matching document and then throwing them all away, so recognize the
// shape and answer it from a counting scan instead.
if (try count_only_pipeline(reply, stages)) |shape| {
const n = try scan_matching(ctx, db_name, coll_name, shape.filter, 0, null);
// No documents means no groups at all, not a group holding zero —
// same as the general path, which builds groups per document.
var docs: []const *const bson.Document = &.{};
if (n > 0) {
const arena = reply.arena_alloc();
const pairs = try arena.alloc(bson.Pair, 1 + shape.accs.len);
pairs[0] = .{ .key = "_id", .value = try bson.copy_value(arena, shape.id_value) };
for (shape.accs, 0..) |acc, i| {
// Mirrors run_group's coercion exactly: an integral sum in
// int32 range comes back as int32, otherwise a double.
const sum: f64 = @as(f64, @floatFromInt(n)) * acc.term;
pairs[1 + i] = .{
.key = acc.key,
.value = if (sum == @floor(sum) and sum <= 2_147_483_647 and sum >= -2_147_483_648)
.{ .int32 = @intFromFloat(sum) }
else
.{ .double = sum },
};
}
const doc = try arena.create(bson.Document);
doc.* = bson.Document{ .arena = undefined, .pairs = pairs };
const one = try arena.alloc(*const bson.Document, 1);
one[0] = doc;
docs = one;
}
try emit_docs(reply, db_name, coll_name, null, docs);
return reply.put_ok();
}
// The pipeline operates on a stream of documents; each stage transforms
// the current window [start, end) of `stream`, and $group replaces the
// stream entirely (so $sort/$limit after it apply to the groups).
@@ -973,6 +1015,71 @@ fn cmd_aggregate(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
try reply.put_ok();
}
/// A pipeline whose whole answer is the number of matching documents.
const CountShape = struct {
filter: []const bson.Pair,
/// The literal every document groups under.
id_value: bson.Value,
accs: []const Acc,
const Acc = struct { key: []const u8, term: f64 };
};
/// Recognize `[{$match: F}?, {$group: {_id: <literal>, k: {$sum: <number>}}}]`
/// — the shape a driver sends for countDocuments().
///
/// Deliberately conservative: a `_id` of `"$field"`, an accumulator over a
/// field, or any other stage needs the documents themselves, so anything
/// that is not exactly this shape returns null and takes the general path.
fn count_only_pipeline(reply: *wire.Reply, stages: []const bson.Value) !?CountShape {
if (stages.len == 0 or stages.len > 2) return null;
var filter: []const bson.Pair = &.{};
if (stages.len == 2) {
const first = switch (stages[0]) {
.doc => |p| p,
else => return null,
};
if (first.len != 1 or !std.mem.eql(u8, first[0].key, "$match")) return null;
filter = doc_arg(first[0].value) orelse return null;
}
const last = switch (stages[stages.len - 1]) {
.doc => |p| p,
else => return null,
};
if (last.len != 1 or !std.mem.eql(u8, last[0].key, "$group")) return null;
const gp = doc_arg(last[0].value) orelse return null;
const id_value = bson.get_pair(gp, "_id") orelse return null;
switch (id_value) {
// A field path or a computed id groups per document.
.string => |s| if (s.len > 0 and s[0] == '$') return null,
.doc, .array => return null,
else => {},
}
var accs: std.ArrayListUnmanaged(CountShape.Acc) = .empty;
for (gp) |p| {
if (std.mem.eql(u8, p.key, "_id")) continue;
const spec = switch (p.value) {
.doc => |d| d,
else => return null,
};
if (spec.len != 1 or !std.mem.eql(u8, spec[0].key, "$sum")) return null;
const term: f64 = switch (spec[0].value) {
.int32 => |i| @floatFromInt(i),
.int64 => |i| @floatFromInt(i),
.double => |d| d,
// $sum over a field depends on the documents.
else => return null,
};
try accs.append(reply.arena_alloc(), .{ .key = p.key, .term = term });
}
return .{ .filter = filter, .id_value = id_value, .accs = accs.items };
}
/// Minimal $group: supports `_id` of null/literal/"$field" and `$sum`
/// accumulators (constant or "$field").
fn run_group(ctx: *Context, reply: *wire.Reply, group_pairs: []const bson.Pair, docs: []const *const bson.Document) !?std.ArrayListUnmanaged(*const bson.Document) {
@@ -1877,6 +1984,52 @@ fn clear_id_list(gpa: std.mem.Allocator, list: *std.ArrayListUnmanaged([]u8)) vo
list.clearRetainingCapacity();
}
test "count_only_pipeline accepts only shapes a count can answer" {
// The fast path skips materializing documents, so mis-accepting a
// pipeline would silently return a wrong aggregate rather than a slow
// one. Pin exactly which shapes it claims.
var reply = wire.Reply.init(testing.allocator);
defer reply.deinit();
const group_count = bson.Value{ .doc = &.{.{ .key = "$group", .value = .{ .doc = &.{
.{ .key = "_id", .value = .null },
.{ .key = "n", .value = .{ .doc = &.{.{ .key = "$sum", .value = .{ .int32 = 1 } }} } },
} } }} };
const match_k = bson.Value{ .doc = &.{.{ .key = "$match", .value = .{ .doc = &.{.{ .key = "k", .value = .{ .int32 = 2 } }} } }} };
// Accepted: the two shapes countDocuments() produces.
try testing.expect(try count_only_pipeline(&reply, &.{group_count}) != null);
const with_match = try count_only_pipeline(&reply, &.{ match_k, group_count });
try testing.expect(with_match != null);
try testing.expectEqual(@as(usize, 1), with_match.?.filter.len);
try testing.expectEqualStrings("k", with_match.?.filter[0].key);
// Rejected: grouping by a field value needs the documents.
try testing.expect(try count_only_pipeline(&reply, &.{.{ .doc = &.{.{ .key = "$group", .value = .{ .doc = &.{
.{ .key = "_id", .value = .{ .string = "$k" } },
.{ .key = "n", .value = .{ .doc = &.{.{ .key = "$sum", .value = .{ .int32 = 1 } }} } },
} } }} }}) == null);
// Rejected: summing a field, not a constant.
try testing.expect(try count_only_pipeline(&reply, &.{.{ .doc = &.{.{ .key = "$group", .value = .{ .doc = &.{
.{ .key = "_id", .value = .null },
.{ .key = "t", .value = .{ .doc = &.{.{ .key = "$sum", .value = .{ .string = "$x" } }} } },
} } }} }}) == null);
// Rejected: an accumulator we do not model at all.
try testing.expect(try count_only_pipeline(&reply, &.{.{ .doc = &.{.{ .key = "$group", .value = .{ .doc = &.{
.{ .key = "_id", .value = .null },
.{ .key = "m", .value = .{ .doc = &.{.{ .key = "$max", .value = .{ .string = "$x" } }} } },
} } }} }}) == null);
// Rejected: any extra stage, since it could reshape the result.
try testing.expect(try count_only_pipeline(&reply, &.{ match_k, group_count, .{ .doc = &.{.{ .key = "$limit", .value = .{ .int32 = 1 } }} } }) == null);
// Rejected: a leading stage that is not $match.
try testing.expect(try count_only_pipeline(&reply, &.{ .{ .doc = &.{.{ .key = "$sort", .value = .{ .doc = &.{.{ .key = "k", .value = .{ .int32 = 1 } }} } }} }, group_count }) == null);
// Rejected: empty pipeline.
try testing.expect(try count_only_pipeline(&reply, &.{}) == null);
}
test "indexed queries are equivalent to scans over a mixed corpus" {
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();

View File

@@ -79,10 +79,20 @@ fn is_operator_doc(value: bson.Value) ?[]const bson.Pair {
return pairs;
}
/// Values a path yields before spilling to the heap. A document almost
/// always contributes exactly one value per field; arrays make it a
/// handful. Collecting those on the stack removes an allocate/free pair per
/// filter field per candidate document, which is the dominant cost of a
/// collection scan.
const inline_candidates = 8;
fn field_matches(gpa: std.mem.Allocator, path: []const u8, expected: bson.Value, doc: *const bson.Document) QueryError!bool {
var stack_fallback = std.heap.stackFallback(inline_candidates * @sizeOf(bson.Value), gpa);
const alloc = stack_fallback.get();
var candidates: std.ArrayListUnmanaged(bson.Value) = .empty;
defer candidates.deinit(gpa);
try collect_values(gpa, doc.pairs, path, &candidates, 0);
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.
@@ -91,20 +101,21 @@ fn field_matches(gpa: std.mem.Allocator, path: []const u8, expected: bson.Value,
while (i < direct_count) : (i += 1) {
const a = candidates.items[i];
if (a == .array) {
for (a.array) |elem| try candidates.append(gpa, elem);
for (a.array) |elem| try candidates.append(alloc, elem);
}
}
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.
var options: []const u8 = "";
for (pairs) |p| {
if (std.mem.eql(u8, p.key, "$options")) {
if (p.value == .string) options = p.value.string;
}
if (parse_op(p.key) == .options and p.value == .string) options = p.value.string;
}
for (pairs) |p| {
if (std.mem.eql(u8, p.key, "$options")) continue;
if (!try match_operator(gpa, p.key, p.value, candidates.items, options)) return false;
const op = parse_op(p.key);
if (op == .options) continue;
if (!try match_operator(gpa, op, p.value, candidates.items, options)) return false;
}
return true;
}
@@ -122,33 +133,79 @@ fn field_matches(gpa: std.mem.Allocator, path: []const u8, expected: bson.Value,
return false;
}
fn match_operator(gpa: std.mem.Allocator, op: []const u8, value: bson.Value, actuals: []const bson.Value, regex_options: []const u8) QueryError!bool {
if (std.mem.eql(u8, op, "$eq")) {
/// The query operators, resolved from their names once per filter field
/// instead of re-comparing strings for every candidate document.
const Op = enum {
eq,
ne,
gt,
gte,
lt,
lte,
in,
nin,
exists,
regex,
options,
not,
size,
all,
elem_match,
/// Not an operator we implement; matches nothing, as before.
unknown,
};
const op_names = std.StaticStringMap(Op).initComptime(.{
.{ "$eq", .eq },
.{ "$ne", .ne },
.{ "$gt", .gt },
.{ "$gte", .gte },
.{ "$lt", .lt },
.{ "$lte", .lte },
.{ "$in", .in },
.{ "$nin", .nin },
.{ "$exists", .exists },
.{ "$regex", .regex },
.{ "$options", .options },
.{ "$not", .not },
.{ "$size", .size },
.{ "$all", .all },
.{ "$elemMatch", .elem_match },
});
fn parse_op(name: []const u8) Op {
return op_names.get(name) orelse .unknown;
}
fn match_operator(gpa: std.mem.Allocator, op: Op, value: bson.Value, actuals: []const bson.Value, regex_options: []const u8) QueryError!bool {
if (op == .eq) {
for (actuals) |a| if (bson.compare(a, value) == .eq) return true;
return false;
}
if (std.mem.eql(u8, op, "$ne")) {
if (op == .ne) {
for (actuals) |a| if (bson.compare(a, value) == .eq) return false;
return true;
}
if (std.mem.eql(u8, op, "$gt") or std.mem.eql(u8, op, "$gte") or
std.mem.eql(u8, op, "$lt") or std.mem.eql(u8, op, "$lte"))
{
if (op == .gt or op == .gte or op == .lt or op == .lte) {
for (actuals) |a| {
const o = bson.compare(a, value);
if (std.mem.eql(u8, op, "$gt") and o == .gt) return true;
if (std.mem.eql(u8, op, "$gte") and o != .lt) return true;
if (std.mem.eql(u8, op, "$lt") and o == .lt) return true;
if (std.mem.eql(u8, op, "$lte") and o != .gt) return true;
const hit = switch (op) {
.gt => o == .gt,
.gte => o != .lt,
.lt => o == .lt,
.lte => o != .gt,
else => unreachable,
};
if (hit) return true;
}
return false;
}
if (std.mem.eql(u8, op, "$in") or std.mem.eql(u8, op, "$nin")) {
if (op == .in or op == .nin) {
const members = switch (value) {
.array => |arr| arr,
else => return false,
};
const want_in = std.mem.eql(u8, op, "$in");
const want_in = op == .in;
for (actuals) |a| {
for (members) |m| {
if (bson.compare(a, m) == .eq) return want_in;
@@ -156,14 +213,14 @@ fn match_operator(gpa: std.mem.Allocator, op: []const u8, value: bson.Value, act
}
return !want_in;
}
if (std.mem.eql(u8, op, "$exists")) {
if (op == .exists) {
const want = switch (value) {
.bool => |b| b,
else => return false,
};
return (actuals.len > 0) == want;
}
if (std.mem.eql(u8, op, "$regex")) {
if (op == .regex) {
const pattern = switch (value) {
.string => |s| s,
.doc => |pairs| blk: {
@@ -180,7 +237,7 @@ fn match_operator(gpa: std.mem.Allocator, op: []const u8, value: bson.Value, act
}
return false;
}
if (std.mem.eql(u8, op, "$not")) {
if (op == .not) {
const pairs = is_operator_doc(value) orelse {
// $not with a bare value means $ne-ish semantics; treat as
// "not equal to this regex or value".
@@ -193,11 +250,11 @@ fn match_operator(gpa: std.mem.Allocator, op: []const u8, value: bson.Value, act
return false;
};
for (pairs) |p| {
if (try match_operator(gpa, p.key, p.value, actuals, regex_options)) return false;
if (try match_operator(gpa, parse_op(p.key), p.value, actuals, regex_options)) return false;
}
return true;
}
if (std.mem.eql(u8, op, "$size")) {
if (op == .size) {
const want = switch (value) {
.int32 => |i| i,
.int64 => |i| @as(i32, @intCast(i)),
@@ -208,7 +265,7 @@ fn match_operator(gpa: std.mem.Allocator, op: []const u8, value: bson.Value, act
}
return false;
}
if (std.mem.eql(u8, op, "$all")) {
if (op == .all) {
const members = switch (value) {
.array => |arr| arr,
else => return false,
@@ -225,7 +282,7 @@ fn match_operator(gpa: std.mem.Allocator, op: []const u8, value: bson.Value, act
}
return true;
}
if (std.mem.eql(u8, op, "$elemMatch")) {
if (op == .elem_match) {
const operand = switch (value) {
.doc => |pairs| pairs,
else => return false,
@@ -238,7 +295,7 @@ fn match_operator(gpa: std.mem.Allocator, op: []const u8, value: bson.Value, act
var single: [1]bson.Value = .{elem};
var ok = true;
for (operand) |p| {
if (!try match_operator(gpa, p.key, p.value, single[0..], "")) {
if (!try match_operator(gpa, parse_op(p.key), p.value, single[0..], "")) {
ok = false;
break;
}
@@ -930,12 +987,18 @@ test "multikey path with several array candidates does not use-after-free" {
}
test "OOM during value collection propagates, not a false match" {
// A tiny FixedBufferAllocator makes the candidate collection fail; the
// error must surface instead of leaving an empty candidate list, which
// would make negating operators like $ne report a match.
const d = doc_of(&.{.{ .key = "x", .value = .{ .int32 = 5 } }});
const ne = doc_of(&.{.{ .key = "x", .value = .{ .doc = &.{.{ .key = "$ne", .value = .{ .int32 = 5 } }} } }});
// Candidate collection failing must surface as an error rather than
// leaving an empty candidate list, which would make negating operators
// like $ne and $exists:false report a match — a wrong answer, not a
// failed one.
//
// Collection only reaches the allocator once a path yields more than
// `inline_candidates` values, so use an array long enough to spill.
var many: [32]bson.Value = undefined;
for (&many, 0..) |*v, i| v.* = .{ .int32 = @intCast(i) };
const d = doc_of(&.{.{ .key = "x", .value = .{ .array = &many } }});
const ne = doc_of(&.{.{ .key = "x", .value = .{ .doc = &.{.{ .key = "$ne", .value = .{ .int32 = 999 } }} } }});
var buf: [16]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buf);
try testing.expectError(error.OutOfMemory, matches(fba.allocator(), &ne, &d));
@@ -944,6 +1007,36 @@ test "OOM during value collection propagates, not a false match" {
try testing.expectError(error.OutOfMemory, matches(fba.allocator(), &ex, &d));
}
test "the common single-value match needs no allocator at all" {
// The counterpart to the test above: a field yielding a handful of
// values is collected on the stack, so a scan does not allocate per
// filter field per document. A failing allocator must therefore still
// produce the correct answer rather than an error.
const d = doc_of(&.{
.{ .key = "x", .value = .{ .int32 = 5 } },
.{ .key = "s", .value = .{ .string = "hi" } },
});
var buf: [0]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buf);
const failing = fba.allocator();
const eq = doc_of(&.{.{ .key = "x", .value = .{ .int32 = 5 } }});
try testing.expect(try matches(failing, &eq, &d));
const ne = doc_of(&.{.{ .key = "x", .value = .{ .doc = &.{.{ .key = "$ne", .value = .{ .int32 = 5 } }} } }});
try testing.expect(!try matches(failing, &ne, &d));
const missing = doc_of(&.{.{ .key = "zz", .value = .{ .doc = &.{.{ .key = "$exists", .value = .{ .bool = false } }} } }});
try testing.expect(try matches(failing, &missing, &d));
const range = doc_of(&.{.{ .key = "x", .value = .{ .doc = &.{
.{ .key = "$gte", .value = .{ .int32 = 1 } },
.{ .key = "$lt", .value = .{ .int32 = 10 } },
} } }});
try testing.expect(try matches(failing, &range, &d));
}
test "documents compare by field name too" {
try testing.expectEqual(std.math.Order.lt, bson.compare(
.{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} },
@@ -1195,5 +1288,5 @@ test "and/or filters" {
/// 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};
return match_operator(gpa, op, value, single[0..], "");
return match_operator(gpa, parse_op(op), value, single[0..], "");
}

View File

@@ -98,6 +98,10 @@ fn handle_connection_inner(io: std.Io, stream: std.Io.net.Stream, server: *Serve
defer out_buf.deinit(server.gpa);
var reply_request_id: u32 = 1;
// One reply for the whole connection, reset per request: its arena
// keeps its pages instead of being rebuilt for every command.
var reply = wire.Reply.init(server.gpa);
defer reply.deinit();
var ctx = commands.Context{
.gpa = server.gpa,
.io = io,
@@ -134,8 +138,7 @@ fn handle_connection_inner(io: std.Io, stream: std.Io.net.Stream, server: *Serve
};
defer msg.deinit();
var reply = wire.Reply.init(server.gpa);
defer reply.deinit();
reply.reset();
commands.dispatch(&ctx, &msg, &reply) catch {
// Discard any partial reply (the client would read the first

View File

@@ -223,6 +223,18 @@ pub const Reply = struct {
self.arena.deinit();
}
/// Ready this reply for the next request on the same connection. The
/// arena keeps its pages instead of handing them back and asking the
/// allocator for fresh ones on every single command.
pub fn reset(self: *Reply) void {
_ = self.arena.reset(.retain_capacity);
// Resetting the arena invalidated every allocation made from it,
// including the pairs buffer — so drop it rather than reusing the
// (now dangling) capacity. Regrowing it just bumps the arena
// pointer through memory we already hold.
self.pairs = .empty;
}
pub fn arena_alloc(self: *Reply) std.mem.Allocator {
return self.arena.allocator();
}

View File

@@ -1,34 +1,39 @@
# Phase 1 gate — mongo-lite vs MongoDB 8.3.7, 1g dataset / ~16k docs
# ratio < 1.0 = mongo-lite faster. Reproduce: bash tests/e2e/compare-run.sh 1g 16k
# commit: 9eecb50
# Run-to-run noise is roughly +/-15% on the scan rows; mongod's own numbers
# moved that much across the runs below.
benchmark mongo-lite mongodb ratio
insertOne (sequential) x200 0.19 ms 5.1 ms 0.0x
bulk insert throughput 755.6 MB/s 688.7 MB/s 1.1x
insertOne (sequential) x200 0.19 ms 5.0 ms 0.0x
bulk insert throughput 852.9 MB/s 689.6 MB/s 1.2x
docs loaded 65,536 65,536 1.0x
createIndex({k: 1}) 60.2 ms 80.6 ms 0.7x
countDocuments({}) 2.1 ms 13.8 ms 0.2x
findOne({_id: <ObjectId>}) 0.53 ms 0.84 ms 0.6x
findOne({k: 500}) (indexed) 0.55 ms 1.7 ms 0.3x
find({p: {$gte,$lt}}).count() (scan) 20.5 ms 13.1 ms 1.6x
find({}).sort({_id:-1}).limit(20) 6.7 ms 2.5 ms 2.7x
find({}, {proj}).limit(1000) 3.5 ms 4.4 ms 0.8x
aggregate $group by k 10.3 ms 12.9 ms 0.8x
updateOne({_id}) x50 0.14 ms 0.19 ms 0.7x
updateMany({k: 7}, {$inc}) 16.2 ms 6.5 ms 2.5x
deleteOne({_id}) + insertOne 0.61 ms 5.0 ms 0.1x
node client RSS 152 MB 156 MB 1.0x
server RSS 1975 MB 1379 MB
createIndex({k: 1}) 62.4 ms 78.4 ms 0.8x
countDocuments({}) 1.5 ms 11.5 ms 0.1x
findOne({_id: <ObjectId>}) 0.48 ms 0.54 ms 0.9x
findOne({k: 500}) (indexed) 0.64 ms 4.3 ms 0.1x
find({p: {$gte,$lt}}).count() (scan) 21.4 ms 12.7 ms 1.7x
find({}).sort({_id:-1}).limit(20) 7.1 ms 2.0 ms 3.5x
find({}, {proj}).limit(1000) 3.7 ms 4.3 ms 0.9x
aggregate $group by k 9.8 ms 13.7 ms 0.7x
updateOne({_id}) x50 0.16 ms 0.19 ms 0.8x
updateMany({k: 7}, {$inc}) 17.3 ms 6.1 ms 2.8x
deleteOne({_id}) + insertOne 0.62 ms 4.9 ms 0.1x
node client RSS 160 MB 164 MB 1.0x
server RSS 1974 MB 1386 MB
kill -9 reopen 0.8s 1.3s
db on disk 1025MB 92MB
db on disk 1025MB 93MB
# Baseline before Phase 1, for reference:
# bulk insert 267 MB/s | createIndex 0.66s | sort+limit 40ms
# Baseline before Phase 1:
# bulk insert 267 MB/s | createIndex 0.66s | sort+limit 40ms | countDocuments 2.5ms
# range-scan 25ms | updateMany 20ms | reopen 3.8s | disk 1.0GB | RSS 2.0GB
#
# Remaining gaps and where they are addressed:
# db on disk 11x -> Phase 3 (block-compressed log)
# sort+limit 2.7x -> Phase 2 (index-ordered scan, _id as an ordered index)
# updateMany 2.5x -> Phase 2 (B+tree: remove_id is a linear scan per index)
# range-scan 1.6x -> 1.9 (per-field-per-doc allocation in the matcher)
# sort+limit 3.5x -> Phase 2 (index-ordered scan, _id as an ordered index)
# updateMany 2.8x -> Phase 2 (B+tree; remove_id is a linear scan per index)
# range-scan 1.7x -> Phase 4, not the matcher. The stack-buffer change
# measured 15.7 -> 12.0ms in isolation, but this row is
# bound by walking 65,536 documents that each live in
# their own arena: hash-map iteration plus a pointer
# chase per document. Contiguous byte storage is the fix.
# server RSS 1.4x -> Phase 4 (per-document arena -> byte storage)