diff --git a/src/commands.zig b/src/commands.zig index 44793e1..cb9c67d 100644 --- a/src/commands.zig +++ b/src/commands.zig @@ -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: , + // 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: , k: {$sum: }}}]` +/// — 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(); diff --git a/src/query.zig b/src/query.zig index faea98d..961f5be 100644 --- a/src/query.zig +++ b/src/query.zig @@ -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..], ""); } diff --git a/src/server.zig b/src/server.zig index 3985803..753b6b2 100644 --- a/src/server.zig +++ b/src/server.zig @@ -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 diff --git a/src/wire.zig b/src/wire.zig index f4ed4bc..ad8da0f 100644 --- a/src/wire.zig +++ b/src/wire.zig @@ -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(); } diff --git a/tests/e2e/results/phase1.txt b/tests/e2e/results/phase1.txt index ad6a4f4..20f4e01 100644 --- a/tests/e2e/results/phase1.txt +++ b/tests/e2e/results/phase1.txt @@ -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: }) 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: }) 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)