//! MongoDB command dispatch. Each command fills `reply` with its result; //! unknown commands and failures produce error replies with real codes. const std = @import("std"); const builtin = @import("builtin"); const bson = @import("bson.zig"); const wire = @import("wire.zig"); const db = @import("db.zig"); const Collection = db.Collection; const query = @import("query.zig"); const update = @import("update.zig"); const index = @import("index.zig"); const cursor = @import("cursor.zig"); // Always active, including in the default ReleaseFast build -- see assert.zig. const assert = @import("assert.zig").assert; const assert_msg = @import("assert.zig").assert_msg; pub const Context = struct { gpa: std.mem.Allocator, io: std.Io, oid_gen: *bson.ObjectIdGen, connection_id: u32, client_desc: []const u8, engine: *db.Engine, server_start: std.Io.Timestamp, /// What an aggregation's last stage asked to be written, and where. /// /// `$out` and `$merge` write to a collection the pipeline is not reading, /// and three things in this file stand against doing that inside the /// handler: `aggregate` is a `.read` command, dispatch takes locks from a /// static table before the handler runs, and `Collection.lock` allows only /// one collection lock at a time. So the handler computes the documents /// under the locks it has and leaves them here; the epilogue applies them /// once every lock is released, next to the commit and the checkpoint that /// already live there. /// /// Cleared at the top of every dispatch, so a request can never inherit the /// one before it. pending_write: ?PendingWrite = null, }; /// A write an aggregation pipeline asked the epilogue to perform. pub const PendingWrite = struct { db: []const u8, coll: []const u8, /// Documents in the reply's arena, which outlives the epilogue. docs: []const *const bson.Document, mode: enum { /// `$out`: the target holds the pipeline's output and nothing else. replace, /// `$merge`: each document replaces the one with its `_id`, or is /// inserted. The default `whenMatched`/`whenNotMatched` pair, which is /// the only one this server implements. merge, }, }; pub const ErrorCode = enum(i32) { command_not_found = 59, bad_value = 2, /// 72 is MongoDB's `InvalidOptions`. There is no `InvalidArgument` in its /// table at all, so that is the name this used to send. invalid_options = 72, namespace_not_found = 26, index_not_found = 27, duplicate_key = 11000, namespace_exists = 48, failed_to_parse = 9, /// `ConflictingUpdateOperators`, measured on mongod 8.3.7: two paths in /// one update where either is a prefix of the other, so which of them /// decides the result would depend on the order the operators ran in. conflicting_update_operators = 40, internal_error = 1, /// "Unrecognized pipeline stage name". A `Location` code, so mongod names it /// `Location40324` rather than after any symbol. location_unrecognized_stage = 40324, /// `Location40323`, measured on mongod 8.3.7: an array element that packs /// two stages into one document, where a pipeline stage is one field. location_stage_needs_one_field = 40323, /// `ImmutableField`, measured: what a pipeline-style update answers when a /// stage leaves the document with a different `_id` than it started with. immutable_field = 66, index_options_conflict = 85, /// `IndexKeySpecsConflict`, measured on mongod 8.3.7: the same index name /// over a different *set of documents*, which is what a differing /// `partialFilterExpression` is -- as against a differing option, which is /// 85 next door. index_key_specs_conflict = 86, cannot_create_index = 67, invalid_index_specification_option = 197, /// The three hashed-index codes, measured on mongod 8.3.7. They are bare /// location numbers with no name in `error_codes.yml`, so the reply's /// `codeName` is the generic one -- which is what mongod itself sends. hashed_unique = 16764, hashed_array_value = 16766, hashed_two_components = 31303, // Cursor codes. Taken from MongoDB's own error_codes.js rather than // recalled -- CursorInUse in particular is 143, not the 12051 that turns up // in older notes. cursor_not_found = 43, cursor_in_use = 143, query_plan_killed = 175, unauthorized = 13, type_mismatch = 14, /// `PathNotViable`, measured on mongod 8.3.7: what an update answers when /// a path segment names a field inside something that cannot hold one -- /// in practice, a non-numeric segment applied to an array. path_not_viable = 28, operation_failed = 96, // Session and transaction codes, measured against mongod 8.3.7 with a raw // OP_MSG probe -- the driver rewrites `lsid` with its own session, so a // malformed one cannot be sent through it and none of this could have been // checked the usual way. The two five-digit ones are IDL parser codes: they // are what mongod's generated command parsers answer, not hand-written // checks, which is why they look unlike the rest of the table. illegal_operation = 20, invalid_uuid = 207, idl_failed_to_parse = 40414, idl_unknown_field = 40415, // Aggregation codes, measured against mongod 8.3.7 rather than recalled -- // the four-and-five-digit ones are `Location` codes, which mongod names // after the number rather than after a symbol. // // These are what an unimplemented construct answers with, which is the same // choice `location_unrecognized_stage` already made for `$addFields`: this // server reports what it does not implement using MongoDB's own code for // "no such thing", because a code MongoDB never emits would break the // error-code parity every milestone is held to. The message names the // construct, so the answer is a bug report rather than a wrong number. invalid_pipeline_operator = 168, location_project_empty = 51272, location_project_mixed = 31254, location_project_unknown_expression = 31325, location_write_stage_not_last = 40601, // Expression codes, measured with the corpus recorder against mongod // 8.3.7. Note 15983 rather than $group's 40238 for "two operators in one // document": mongod distinguishes an expression from an accumulator there, // and a client switching on the code would notice if we did not. location_two_expression_operators = 15983, location_wrong_operand_count = 16020, location_switch_no_default = 40069, location_divide_by_zero = 4848401, location_non_numeric_arithmetic = 7157723, location_replace_root_not_document = 40228, location_unwind_bad_path = 28818, location_unknown_group_operator = 15952, location_group_needs_id = 15955, location_accumulator_not_object = 40234, location_one_accumulator = 40238, }; /// Which lock (if any) a command needs on the engine. Contract: only /// `.write` commands may call engine mutation functions (insert, replace, /// remove, drop*, get_or_create_collection, compact); `.read` commands may /// only read (`get_collection`, `database_names`, `collection_names`); /// `.none` commands must not touch the engine at all. /// /// This describes engine *data* only. The cursor store is a separate resource /// with its own leaf mutex, and any kind may mutate it: `getMore` is `.read` /// because it only reads documents, even though it advances cursor state, and /// `killCursors` is `.none` because the store is all it touches. const CommandKind = enum { none, read, write }; /// Lock shape for one command, acquired by dispatch: the catalog lock mode /// and whether the command's target collection is locked (shared for reads, /// exclusive for writes/DDL). The target collection is the message field /// named after the command (find/count/insert/...), which every /// collection-targeting command uses except `getMore` -- see `Command.coll_field`. const LockShape = struct { catalog: enum { none, shared, exclusive } = .none, coll: enum { none, shared, exclusive } = .none, }; const Command = struct { name: []const u8, kind: CommandKind, locks: LockShape = .{}, /// Body field naming the target collection, when it is not the command's /// own field. Only `getMore` needs it: its own value is an int64 cursor id. coll_field: ?[]const u8 = null, handler: *const fn (*Context, *wire.Message, *wire.Reply) anyerror!void, }; /// The one place a command exists: name, lock class, and handler declared /// together so a new command cannot be given a handler but no lock. const command_table = [_]Command{ // Handshake, admin info, no-ops: never touch the engine. .{ .name = "hello", .kind = .none, .handler = cmd_hello }, .{ .name = "isMaster", .kind = .none, .handler = cmd_is_master }, .{ .name = "ismaster", .kind = .none, .handler = cmd_is_master }, .{ .name = "ping", .kind = .none, .handler = cmd_ping }, .{ .name = "buildInfo", .kind = .none, .handler = cmd_build_info }, .{ .name = "getParameter", .kind = .none, .handler = cmd_get_parameter }, .{ .name = "whatsmyuri", .kind = .none, .handler = cmd_whatsmyuri }, .{ .name = "hostInfo", .kind = .none, .handler = cmd_host_info }, .{ .name = "getCmdLineOpts", .kind = .none, .handler = cmd_get_cmd_line_opts }, .{ .name = "serverStatus", .kind = .none, .handler = cmd_server_status }, .{ .name = "endSessions", .kind = .none, .handler = cmd_end_sessions }, .{ .name = "connectionStatus", .kind = .none, .handler = cmd_connection_status }, // killCursors touches only the cursor store, so it needs no lock -- and its // own field really is the collection name, unlike getMore's. .{ .name = "killCursors", .kind = .none, .handler = cmd_kill_cursors }, // Read-only: scan the engine without mutating it. .{ .name = "find", .kind = .read, .locks = .{ .catalog = .shared, .coll = .shared }, .handler = cmd_find }, // getMore is the one command whose collection is not its own field: its // value is an int64 cursor id, so dispatch reads `collection` instead. .{ .name = "getMore", .kind = .read, .locks = .{ .catalog = .shared, .coll = .shared }, .coll_field = "collection", .handler = cmd_get_more, }, .{ .name = "count", .kind = .read, .locks = .{ .catalog = .shared, .coll = .shared }, .handler = cmd_count }, .{ .name = "distinct", .kind = .read, .locks = .{ .catalog = .shared, .coll = .shared }, .handler = cmd_distinct }, .{ .name = "aggregate", .kind = .read, .locks = .{ .catalog = .shared, .coll = .shared }, .handler = cmd_aggregate }, .{ .name = "listDatabases", .kind = .read, .locks = .{ .catalog = .shared }, .handler = cmd_list_databases }, .{ .name = "listCollections", .kind = .read, .locks = .{ .catalog = .shared }, .handler = cmd_list_collections }, // Writes: the target collection exclusively; create/drop take the // catalog exclusively (they mutate the maps). .{ .name = "create", .kind = .write, .locks = .{ .catalog = .exclusive, .coll = .exclusive }, .handler = cmd_create }, // `drop` takes the catalog exclusively and **no collection lock**: it frees // the very Collection a lock would live in, and dispatch then unlocked the // freed memory. That was a use-after-free on an `Io.RwLock`, and under // testing.allocator it is a hard SIGSEGV on the first insert-then-drop. // // Nothing is lost by dropping the lock, because the catalog lock is what // actually excludes here: every collection lock in this engine -- dispatch, // the TTL sweep, `compact`'s rebuild, `write_catalog`, `slab_stats`, // reclamation -- is taken while holding the catalog at least shared, so // holding it exclusively already keeps every one of them out. The // collection lock was buying exclusion that was already there, and paying // for it by locking an object about to cease existing. .{ .name = "drop", .kind = .write, .locks = .{ .catalog = .exclusive }, .handler = cmd_drop }, .{ .name = "dropDatabase", .kind = .write, .locks = .{ .catalog = .exclusive }, .handler = cmd_drop_database }, .{ .name = "createIndexes", .kind = .write, .locks = .{ .catalog = .shared, .coll = .exclusive }, .handler = cmd_create_indexes }, .{ .name = "dropIndexes", .kind = .write, .locks = .{ .catalog = .shared, .coll = .exclusive }, .handler = cmd_drop_indexes }, .{ .name = "insert", .kind = .write, .locks = .{ .catalog = .shared, .coll = .exclusive }, .handler = cmd_insert }, .{ .name = "update", .kind = .write, .locks = .{ .catalog = .shared, .coll = .exclusive }, .handler = cmd_update }, .{ .name = "delete", .kind = .write, .locks = .{ .catalog = .shared, .coll = .exclusive }, .handler = cmd_delete }, .{ .name = "findAndModify", .kind = .write, .locks = .{ .catalog = .shared, .coll = .exclusive }, .handler = cmd_find_and_modify }, .{ .name = "listIndexes", .kind = .read, .locks = .{ .catalog = .shared, .coll = .shared }, .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 = 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); }; // Session and transaction fields are checked here: after the command is // known -- mongod answers CommandNotFound to an unknown command carrying a // malformed lsid, measured, not assumed -- and before any lock is taken, // for the reason the comment below records at length. if (try reject_bad_session_fields(msg, reply, name)) return; // Lock the catalog (shared for most commands, exclusive for DDL), then // the target collection, then run the handler. The collection lock is // taken while the catalog lock is held, so a concurrent drop can never // free the collection out from under us. // Resolve the namespace *before* taking any lock. It used to happen after // the catalog lock, with `orelse return` on both parts -- and a plain // `return` is not an error return, so it ran neither the errdefer below nor // the explicit unlocks after the handler. The catalog lock was simply // leaked, shared, forever. // // A database-level command reaches it: `db.aggregate(...)` sends // `{aggregate: 1}`, whose value is not a string, so str_arg returns null. // The symptom was baffling because a leaked *shared* lock is invisible to // readers -- ping and listDatabases kept answering in microseconds -- while // the next write that needs the catalog exclusive to create a collection // blocks forever. It presented as an unrelated client-side timeout one // command later. // Nothing carries over: a handler that errors before it sets one must not // leave the previous command's write to be applied below. ctx.pending_write = null; var ns: ?struct { db: []const u8, coll: []const u8 } = null; if (cmd.locks.coll != .none) { const db_name = msg.db_name() orelse return bad_value(reply, "command requires a $db"); const field = cmd.coll_field orelse name; const coll_name = str_arg(msg.body.get(field)) orelse return bad_value(reply, "command requires a collection name"); ns = .{ .db = db_name, .coll = coll_name }; } switch (cmd.locks.catalog) { .none => {}, .shared => try ctx.engine.lock_catalog(false), .exclusive => try ctx.engine.lock_catalog(true), } var catalog_held = cmd.locks.catalog != .none; var coll: ?*Collection = null; errdefer { if (coll) |c| ctx.engine.unlock_collection(c, cmd.locks.coll == .exclusive); if (catalog_held) ctx.engine.unlock_catalog(cmd.locks.catalog == .exclusive); } if (ns) |n| { // Write commands may create the collection on first use; the catalog // lock is upgraded to exclusive for that, then restored to shared. const create = cmd.kind == .write and cmd.locks.catalog == .shared; if (try ctx.engine.lock_collection(n.db, n.coll, cmd.locks.coll == .exclusive, create)) |c| { coll = c; } } const result = cmd.handler(ctx, msg, reply); // Release the collection and catalog locks before the commit: the // commit may block on other writers' appends, and must never do so // while holding a collection lock. if (coll) |c| ctx.engine.unlock_collection(c, cmd.locks.coll == .exclusive); coll = null; if (catalog_held) ctx.engine.unlock_catalog(cmd.locks.catalog == .exclusive); catalog_held = false; // Here, and only here: no lock is held, so taking the target's is not a // second one. See `Context.pending_write`. if (ctx.pending_write) |pending| { ctx.pending_write = null; apply_pending_write(ctx, pending) catch |err| { // The pipeline's own answer is already in `reply`; replace it with // the failure, because a client told `ok: 1` would believe the // collection had been written. The pairs go, the arena stays -- // resetting it would free the very strings this message is built // from. const detail = try std.fmt.allocPrint( reply.arena_alloc(), "the pipeline's output could not be written to {s}.{s}: {s}", .{ pending.db, pending.coll, @errorName(err) }, ); reply.pairs.clearRetainingCapacity(); try reply.put_error(@intFromEnum(ErrorCode.operation_failed), "OperationFailed", detail); }; try ctx.engine.commit(); } // Keyed on what the command *is*, not on which lock it happened to take. // These two agreed for every command until `drop` gave up its collection // lock, at which point the old `locks.coll == .exclusive` would have // silently stopped committing and checkpointing after a drop. It was // already wrong for `dropDatabase`, the one write that never held a // collection lock: it has been skipping this epilogue all along, so a // dropped database waited for some later write to trigger a checkpoint // before the catalog recording it was written. if (cmd.kind == .write) { // Durability (seal + fsync) coalesces across concurrent writers. A // commit error deliberately wins over the handler's captured `result`: // whether the write reached disk matters more to the client than why // the write itself was unhappy. // commit() asserts its own postcondition (committed_seq >= this // command's seq) internally. Re-checking it here is not possible // without the log lock, and taking it just to assert would add a real // race in exchange for a weaker check than the one already made. try ctx.engine.commit(); // The write is durable by now, so a compaction failure is a maintenance // problem and not the client's. Report it and hand the request back // rather than turning an applied write into an error the client retries. // A checkpoint reclaims the log, so an open stops paying for every write // ever made. Runs here, with no lock held, for the same reason // compaction does: it takes the log lock and must not do that while // holding a collection lock. if (ctx.engine.take_checkpoint()) { ctx.engine.checkpoint() catch |err| { // Durability is unaffected -- the log still holds everything. // The cost is a slower next open, which is not the client's // problem, so report and carry on. std.debug.print("multiforadb: checkpoint failed: {s}\n", .{@errorName(err)}); }; } if (ctx.engine.take_compact()) { ctx.engine.compact() catch |err| { std.debug.print("multiforadb: compaction failed: {s}\n", .{@errorName(err)}); ctx.engine.request_compact(); }; } } return result; } // --------------------------------------------------------------------------- // Handshake / administration // --------------------------------------------------------------------------- fn add_server_info(ctx: *Context, reply: *wire.Reply) !void { try reply.put("isWritablePrimary", .{ .bool = true }); try reply.put("maxBsonObjectSize", .{ .int32 = wire.max_bson_object_size }); try reply.put("maxMessageSizeBytes", .{ .int32 = 48000000 }); try reply.put("maxWriteBatchSize", .{ .int32 = 100000 }); const now = std.Io.Timestamp.now(ctx.io, .real); try reply.put("localTime", .{ .datetime = now.toMilliseconds() }); try reply.put("logicalSessionTimeoutMinutes", .{ .int32 = 30 }); try reply.put("connectionId", .{ .int32 = @intCast(ctx.connection_id) }); try reply.put("minWireVersion", .{ .int32 = 0 }); // 9, because this server calls itself 4.4.0 in `buildInfo` and 4.4 is // wire 9. Reporting 8 was reporting 4.2, and a driver believes the wire // version over the string: it refused client-side to send `hint` on an // unacknowledged delete or findAndModify (ten spec cases), and withheld // `comment` from getMore, listCollections and listDatabases. Both are // things this engine handles. try reply.put("maxWireVersion", .{ .int32 = 9 }); try reply.put("readOnly", .{ .bool = false }); // Deliberately no `topologyVersion`. A driver treats its presence as // "this server supports the streaming (awaitable) hello protocol" and // switches monitoring to an exhaust hello: it sends one hello with // maxAwaitTimeMS and the OP_MSG exhaustAllowed flag, then expects a // stream of unsolicited replies each carrying moreToCome. We answer // once with moreToCome clear and go back to reading, so the driver // fails the heartbeat ("Server ended moreToCome unexpectedly"), drops // the connection and resets its pool — a connect/disconnect loop once // per heartbeat, which is what MongoDB Compass showed. Omitting the // field keeps monitoring on the polling path, which we do implement. // // This is the whole of the mechanism, and it does not depend on the wire // version: `useStreamingProtocol` (driver lib/sdam/monitor.js:154) polls // whenever `topologyVersion` is absent, whatever else the handshake said. // Checked when maxWireVersion went to 9 above, since the old comment here // leaned on 8 as a second line of defence that never existed. } fn cmd_hello(ctx: *Context, _: *wire.Message, reply: *wire.Reply) !void { try add_server_info(ctx, reply); try reply.put_ok(); } fn cmd_is_master(ctx: *Context, _: *wire.Message, reply: *wire.Reply) !void { try add_server_info(ctx, reply); try reply.put("ismaster", .{ .bool = true }); try reply.put("helloOk", .{ .bool = true }); try reply.put_ok(); } fn cmd_ping(_: *Context, _: *wire.Message, reply: *wire.Reply) !void { try reply.put_ok(); } fn cmd_build_info(_: *Context, _: *wire.Message, reply: *wire.Reply) !void { try reply.put("version", .{ .string = "4.4.0" }); try reply.put("gitVersion", .{ .string = "multiforadb" }); try reply.put("versionArray", .{ .array = try int_array(reply, &.{ 4, 4, 0, 0 }) }); try reply.put("openssl", .{ .doc = &.{} }); try reply.put("loaderFlags", .{ .string = "" }); try reply.put("compilerInfo", .{ .string = "zig 0.16.0" }); try reply.put("allocator", .{ .string = "system" }); try reply.put("javascriptEngine", .{ .string = "none" }); try reply.put("bits", .{ .int32 = 64 }); try reply.put("debug", .{ .bool = false }); try reply.put("maxBsonObjectSize", .{ .int32 = wire.max_bson_object_size }); try reply.put("storageEngines", .{ .array = try str_array(reply, &.{"wiredTiger"}) }); try reply.put_ok(); } fn cmd_get_parameter(_: *Context, msg: *wire.Message, reply: *wire.Reply) !void { // mongosh probes featureCompatibilityVersion; respond per requested key. const params = doc_arg(msg.body.get("getParameter")) orelse return invalid_arg(reply, "getParameter requires a document"); if (bson.get_pair(params, "featureCompatibilityVersion") == null) { return invalid_arg(reply, "no option found to get"); } const fcv = try reply.arena_alloc().alloc(bson.Pair, 1); fcv[0] = .{ .key = "version", .value = .{ .string = "4.4" } }; try reply.put("featureCompatibilityVersion", .{ .doc = fcv }); try reply.put_ok(); } fn cmd_whatsmyuri(ctx: *Context, _: *wire.Message, reply: *wire.Reply) !void { try reply.put("you", .{ .string = ctx.client_desc }); try reply.put_ok(); } fn cmd_host_info(ctx: *Context, _: *wire.Message, reply: *wire.Reply) !void { const now = std.Io.Timestamp.now(ctx.io, .real); const cpu_count: usize = std.Thread.getCpuCount() catch 1; const system = try reply.arena_alloc().alloc(bson.Pair, 6); system[0] = .{ .key = "currentTime", .value = .{ .datetime = now.toMilliseconds() } }; system[1] = .{ .key = "hostname", .value = .{ .string = "localhost" } }; system[2] = .{ .key = "cpuAddrSize", .value = .{ .int32 = 64 } }; system[3] = .{ .key = "memSizeMB", .value = .{ .int32 = 0 } }; system[4] = .{ .key = "numCores", .value = .{ .int32 = @intCast(cpu_count) } }; system[5] = .{ .key = "cpuArch", .value = .{ .string = @tagName(builtin.cpu.arch) } }; try reply.put("system", .{ .doc = system }); const os = try reply.arena_alloc().alloc(bson.Pair, 3); os[0] = .{ .key = "type", .value = .{ .string = @tagName(builtin.os.tag) } }; os[1] = .{ .key = "name", .value = .{ .string = @tagName(builtin.os.tag) } }; os[2] = .{ .key = "version", .value = .{ .string = "unknown" } }; try reply.put("os", .{ .doc = os }); try reply.put("extra", .{ .doc = &.{} }); try reply.put_ok(); } fn cmd_get_cmd_line_opts(_: *Context, _: *wire.Message, reply: *wire.Reply) !void { const argv = try reply.arena_alloc().alloc(bson.Pair, 2); argv[0] = .{ .key = "dbpath", .value = .{ .string = "multiforadb.log" } }; argv[1] = .{ .key = "port", .value = .{ .int32 = 27017 } }; try reply.put("argv", .{ .array = &.{} }); try reply.put("parsed", .{ .doc = argv }); try reply.put_ok(); } fn cmd_server_status(ctx: *Context, _: *wire.Message, reply: *wire.Reply) !void { const now = std.Io.Timestamp.now(ctx.io, .real); const uptime: i64 = std.Io.Timestamp.durationTo(ctx.server_start, now).toSeconds(); try reply.put("host", .{ .string = "localhost" }); try reply.put("version", .{ .string = "4.4.0" }); try reply.put("process", .{ .string = "mongod" }); try reply.put("uptime", .{ .double = @floatFromInt(uptime) }); try reply.put("localTime", .{ .datetime = now.toMilliseconds() }); const connections = try reply.arena_alloc().alloc(bson.Pair, 1); connections[0] = .{ .key = "current", .value = .{ .int32 = @intCast(ctx.connection_id) } }; try reply.put("connections", .{ .doc = connections }); // Not a MongoDB section, and named so nobody mistakes it for one. It is // what the churn gate reads: a steady-state size ratio can look healthy // while reclamation does nothing at all -- the file grows, a rebuild // periodically halves it, and the average comes out respectable. // `reclaimedBytes` rising while `allocTail` stays put is the shape that // says the free list is carrying the workload, and no ratio shows that. const s = ctx.engine.slab_stats(); const mf = try reply.arena_alloc().alloc(bson.Pair, 8); mf[0] = .{ .key = "liveBytes", .value = .{ .int64 = @intCast(s.live_bytes) } }; mf[1] = .{ .key = "deadBytes", .value = .{ .int64 = @intCast(s.dead_bytes) } }; mf[2] = .{ .key = "slabBytes", .value = .{ .int64 = @intCast(s.slab_bytes) } }; mf[3] = .{ .key = "reclaimedBytes", .value = .{ .int64 = @intCast(s.reclaimed_bytes) } }; mf[4] = .{ .key = "slabRuns", .value = .{ .int64 = @intCast(s.slab_runs) } }; mf[5] = .{ .key = "freeReadyBytes", .value = .{ .int64 = @intCast(s.free_ready_bytes) } }; mf[6] = .{ .key = "allocTailBytes", .value = .{ .int64 = @intCast(s.alloc_tail_bytes) } }; mf[7] = .{ .key = "compactions", .value = .{ .int64 = @intCast(s.compactions) } }; try reply.put("multifora", .{ .doc = mf }); try reply.put_ok(); } // --------------------------------------------------------------------------- // Sessions // // This server keeps no session registry, and that is a decision rather than an // omission: a session here would own nothing. There are no transactions to // scope, no cursors that outlive their connection differently because of one, // and no retryable writes -- a driver disables those for a standalone. A // registry would be a mutex on the dispatch path guarding state nothing reads. // M4 gets to design it, when the transaction state machine says what it needs. // // What is *not* optional is telling the truth about the fields a driver sends // anyway. `lsid` rides on every acknowledged command already, because // `add_server_info` advertises `logicalSessionTimeoutMinutes`. Accepting it and // doing nothing is honest -- there is nothing to do. Accepting `txnNumber` and // doing nothing is not: it would run a transactional write non-transactionally // and answer ok, and the client would only find out at `commitTransaction`, // long after the data was on disk. // --------------------------------------------------------------------------- /// mongod's name for a binary subtype, for the one message that quotes it. fn subtype_name(subtype: u8) []const u8 { return switch (subtype) { 0x00 => "general", 0x01 => "function", 0x02 => "binary", 0x03 => "uuid_old", 0x04 => "UUID", 0x05 => "MD5", 0x06 => "encrypt", else => "unknown", }; } /// The first `lsid` field this server does not know, for the message that has /// to name it. Walked again on the error path only, so that `Message.lsid` /// stays a yes-or-no answer. fn unknown_lsid_field(msg: *wire.Message) []const u8 { const doc = switch (msg.body.get("lsid") orelse return "?") { .doc => |d| d, else => return "?", }; for (doc) |pair| { const known = std.mem.eql(u8, pair.key, "id") or std.mem.eql(u8, pair.key, "uid") or std.mem.eql(u8, pair.key, "txnNumber") or std.mem.eql(u8, pair.key, "txnUUID"); if (!known) return pair.key; } return "?"; } /// Writes the reply for a malformed `lsid` and answers whether it did. fn reject_bad_lsid(msg: *wire.Message, reply: *wire.Reply, cmd: []const u8) !bool { _ = msg.lsid() catch |err| { const arena = reply.arena_alloc(); const text = switch (err) { error.LsidNotDocument => try std.fmt.allocPrint( arena, "BSON field '{s}.lsid' is the wrong type '{s}', expected type 'object'", .{ cmd, msg.body.get("lsid").?.type_name() }, ), error.LsidIdMissing => try std.fmt.allocPrint( arena, "BSON field '{s}.lsid.id' is missing but a required field", .{cmd}, ), error.LsidIdNotBinary => try std.fmt.allocPrint( arena, "BSON field '{s}.lsid.id' is the wrong type '{s}', expected type 'binData'", .{ cmd, bson.get_pair(msg.body.get("lsid").?.doc, "id").?.type_name() }, ), error.LsidIdNotUuid => try std.fmt.allocPrint( arena, "BSON field '{s}.lsid.id' is the wrong binData type '{s}', expected type 'UUID'", .{ cmd, subtype_name(bson.get_pair(msg.body.get("lsid").?.doc, "id").?.binary.subtype) }, ), error.LsidUnknownField => try std.fmt.allocPrint( arena, "BSON field '{s}.lsid.{s}' is an unknown field.", .{ cmd, unknown_lsid_field(msg) }, ), else => "", }; switch (err) { error.LsidNotDocument, error.LsidIdNotBinary, error.LsidIdNotUuid => try reply.put_error( @intFromEnum(ErrorCode.type_mismatch), "TypeMismatch", text, ), error.LsidIdMissing => try reply.put_error( @intFromEnum(ErrorCode.idl_failed_to_parse), "IDLFailedToParse", text, ), error.LsidUnknownField => try reply.put_error( @intFromEnum(ErrorCode.idl_unknown_field), "IDLUnknownField", text, ), error.LsidIdWrongLength => try reply.put_error( @intFromEnum(ErrorCode.invalid_uuid), "InvalidUUID", "uuid must be a 16-byte binary field with UUID (4) subtype", ), error.LsidTxnNumberWithoutTxnUuid => try invalid_arg( reply, "Cannot specify txnNumber in lsid without specifying txnUUID", ), error.LsidInternalSession => try invalid_arg( reply, "Internal sessions are not supported outside of transactions", ), } return true; }; return false; } /// Writes the reply for a transaction field this server cannot honour, and /// answers whether it did. /// /// Refusing rather than ignoring is the whole point. A driver that is told /// `ok` for a write carrying a `txnNumber` has been told the write is part of /// a transaction; it is not, it is already durable, and the first the client /// hears of it is a failing `commitTransaction`. The order of the checks and /// every message below are mongod 8.3.7's, measured. fn reject_txn_fields(msg: *wire.Message, reply: *wire.Reply, cmd: []const u8) !bool { const txn_number = msg.body.get("txnNumber"); if (msg.body.get("startTransaction") != null and msg.body.get("autocommit") == null) { try invalid_arg(reply, "'startTransaction' field requires 'autocommit' field to also be specified"); return true; } if (msg.body.get("autocommit") != null and txn_number == null) { try invalid_arg(reply, "'autocommit' field requires a transaction number to also be specified"); return true; } const n = txn_number orelse return false; if (n != .int64 and n != .int32) { const text = try std.fmt.allocPrint( reply.arena_alloc(), "BSON field '{s}.txnNumber' is the wrong type '{s}', expected type 'long'", .{ cmd, n.type_name() }, ); try reply.put_error(@intFromEnum(ErrorCode.type_mismatch), "TypeMismatch", text); return true; } if (msg.body.get("lsid") == null) { try invalid_arg(reply, "Transaction number requires a session ID to also be specified"); return true; } try reply.put_error( @intFromEnum(ErrorCode.illegal_operation), "IllegalOperation", "Transaction numbers are only allowed on a replica set member or mongos", ); return true; } /// Answers whether an error reply was written, in which case dispatch is done. fn reject_bad_session_fields(msg: *wire.Message, reply: *wire.Reply, cmd: []const u8) !bool { if (try reject_bad_lsid(msg, reply, cmd)) return true; return reject_txn_fields(msg, reply, cmd); } /// Still a no-op -- there is nothing to end -- but no longer a blind `ok`. /// /// A driver sends this on close for every session it handed out, so it is the /// one session command that arrives in normal operation. Validating an array /// we then discard looks like ceremony; it is not. `ok: 1` to a malformed /// `endSessions` is the same class of answer as `ok: 1` to a transactional /// write: the client is told the server understood, and it did not. mongod's /// field path is `endSessions.endSessionsFromClient`, an IDL artefact -- the /// command's own field is `endSessions` and the parsed argument has a /// different name -- and it is reproduced rather than tidied, because a name /// that differs from the real server's is worse than an odd one. fn cmd_end_sessions(_: *Context, msg: *wire.Message, reply: *wire.Reply) !void { const arena = reply.arena_alloc(); const sessions = switch (msg.body.get("endSessions").?) { .array => |a| a, else => |v| { const text = try std.fmt.allocPrint( arena, "BSON field 'endSessions.endSessions' is the wrong type '{s}', expected type 'array'", .{v.type_name()}, ); return reply.put_error(@intFromEnum(ErrorCode.type_mismatch), "TypeMismatch", text); }, }; for (sessions, 0..) |entry, i| { if (try reject_bad_session_entry(entry, i, reply)) return; } try reply.put_ok(); } /// One element of `endSessions`. Answers whether an error reply was written. fn reject_bad_session_entry(entry: bson.Value, i: usize, reply: *wire.Reply) !bool { const arena = reply.arena_alloc(); const prefix = "BSON field 'endSessions.endSessionsFromClient"; const doc = switch (entry) { .doc => |d| d, else => { const text = try std.fmt.allocPrint( arena, "{s}.{d}' is the wrong type '{s}', expected type 'object'", .{ prefix, i, entry.type_name() }, ); try reply.put_error(@intFromEnum(ErrorCode.type_mismatch), "TypeMismatch", text); return true; }, }; var seen_id = false; for (doc) |pair| { if (std.mem.eql(u8, pair.key, "uid")) continue; if (!std.mem.eql(u8, pair.key, "id")) { const text = try std.fmt.allocPrint( arena, "{s}.{s}' is an unknown field.", .{ prefix, pair.key }, ); try reply.put_error(@intFromEnum(ErrorCode.idl_unknown_field), "IDLUnknownField", text); return true; } seen_id = true; const bin = switch (pair.value) { .binary => |b| b, else => { const text = try std.fmt.allocPrint( arena, "{s}.id' is the wrong type '{s}', expected type 'binData'", .{ prefix, pair.value.type_name() }, ); try reply.put_error(@intFromEnum(ErrorCode.type_mismatch), "TypeMismatch", text); return true; }, }; if (bin.subtype != 4) { const text = try std.fmt.allocPrint( arena, "{s}.id' is the wrong binData type '{s}', expected type 'UUID'", .{ prefix, subtype_name(bin.subtype) }, ); try reply.put_error(@intFromEnum(ErrorCode.type_mismatch), "TypeMismatch", text); return true; } if (bin.data.len != 16) { try reply.put_error( @intFromEnum(ErrorCode.invalid_uuid), "InvalidUUID", "uuid must be a 16-byte binary field with UUID (4) subtype", ); return true; } } if (!seen_id) { try reply.put_error( @intFromEnum(ErrorCode.idl_failed_to_parse), "IDLFailedToParse", "BSON field 'endSessions.endSessionsFromClient.id' is missing but a required field", ); return true; } return false; } fn cmd_connection_status(_: *Context, _: *wire.Message, reply: *wire.Reply) !void { const auth_info = try reply.arena_alloc().alloc(bson.Pair, 2); auth_info[0] = .{ .key = "authenticatedUsers", .value = .{ .array = &.{} } }; auth_info[1] = .{ .key = "authenticatedUserRoles", .value = .{ .array = &.{} } }; try reply.put("authInfo", .{ .doc = auth_info }); try reply.put_ok(); } fn cmd_list_databases(ctx: *Context, _: *wire.Message, reply: *wire.Reply) !void { var names: std.ArrayListUnmanaged([]const u8) = .empty; defer names.deinit(ctx.gpa); try ctx.engine.database_names(&names); const values = try reply.arena_alloc().alloc(bson.Value, names.items.len); for (names.items, 0..) |n, i| { const entry = try reply.arena_alloc().alloc(bson.Pair, 3); entry[0] = .{ .key = "name", .value = .{ .string = try reply.arena_alloc().dupe(u8, n) } }; entry[1] = .{ .key = "sizeOnDisk", .value = .{ .double = 0 } }; entry[2] = .{ .key = "empty", .value = .{ .bool = true } }; values[i] = .{ .doc = entry }; } try reply.put("databases", .{ .array = values }); try reply.put("totalSize", .{ .int32 = 0 }); try reply.put("totalSizeMb", .{ .int32 = 0 }); try reply.put_ok(); } /// The collection part of the namespace a `listCollections` cursor reports. /// mongod uses this pseudo-collection, and the exact string matters: the previous /// `"."` had an *empty* collection part, and the driver throws client-side /// when it tries to build a getMore or killCursors from a namespace like that -- /// so the moment such a cursor stopped being id 0 it would have broken. const list_collections_ns: []const u8 = "$cmd.listCollections"; fn cmd_list_collections(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { const db_name = msg.db_name() orelse return invalid_arg(reply, "listCollections requires $db"); const batch_size = (try aggregate_batch_size(reply, msg) orelse return).value; var names: std.ArrayListUnmanaged([]const u8) = .empty; defer names.deinit(ctx.gpa); try ctx.engine.collection_names(db_name, &names); const arena = reply.arena_alloc(); const docs = try arena.alloc(*const bson.Document, names.items.len); for (names.items, 0..) |n, i| { const entry = try arena.alloc(bson.Pair, 3); entry[0] = .{ .key = "name", .value = .{ .string = try arena.dupe(u8, n) } }; entry[1] = .{ .key = "type", .value = .{ .string = "collection" } }; entry[2] = .{ .key = "options", .value = .{ .doc = &.{} } }; docs[i] = try doc_from_pairs(arena, entry); } try emit_first_batch(ctx, reply, db_name, list_collections_ns, null, docs, batch_size); try reply.put_ok(); } fn cmd_create(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { const db_name = msg.db_name() orelse return invalid_arg(reply, "create requires $db"); const coll_name = str_arg(msg.body.get("create")) orelse return bad_value(reply, "create requires a collection name"); _ = try ctx.engine.get_or_create_collection(db_name, coll_name); try reply.put_ok(); } fn cmd_drop(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { const db_name = msg.db_name() orelse return invalid_arg(reply, "drop requires $db"); const coll_name = str_arg(msg.body.get("drop")) orelse return bad_value(reply, "drop requires a collection name"); if (!try ctx.engine.drop_collection(db_name, coll_name)) { return reply.put_error(@intFromEnum(ErrorCode.namespace_not_found), "NamespaceNotFound", "ns not found"); } try reply.put_ok(); } fn cmd_drop_database(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { const db_name = msg.db_name() orelse return invalid_arg(reply, "dropDatabase requires $db"); _ = try ctx.engine.drop_database(db_name); try reply.put("dropped", .{ .string = try reply.arena_alloc().dupe(u8, db_name) }); try reply.put_ok(); } // --------------------------------------------------------------------------- // Indexes // --------------------------------------------------------------------------- fn cmd_create_indexes(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { const db_name = msg.db_name() orelse return invalid_arg(reply, "createIndexes requires $db"); const coll_name = str_arg(msg.body.get("createIndexes")) orelse return bad_value(reply, "createIndexes requires a collection name"); const indexes = switch (msg.body.get("indexes") orelse return bad_value(reply, "createIndexes requires indexes")) { .array => |a| a, else => return bad_value(reply, "indexes must be an array"), }; const existed = ctx.engine.get_collection(db_name, coll_name) != null; const coll = try ctx.engine.get_or_create_collection(db_name, coll_name); const num_before: i32 = @intCast(coll.indexes.items.len + 1); // + the _id_ index for (indexes) |spec_v| { const spec = switch (spec_v) { .doc => |p| p, else => return bad_value(reply, "indexes must be documents"), }; const key_value = bson.get_pair(spec, "key") orelse return bad_value(reply, "index spec requires key"); const key_pairs = switch (key_value) { .doc => |p| p, else => return bad_value(reply, "key must be a document"), }; if (key_pairs.len == 0) return bad_value(reply, "cannot create index with an empty key"); // {_id: 1} is the implicit index: an idempotent no-op. Any other // secondary index touching _id is rejected. var has_id = false; for (key_pairs) |p| { if (std.mem.eql(u8, p.key, "_id")) has_id = true; } if (has_id) { const only = key_pairs[0].value; const is_id_index = key_pairs.len == 1 and ((only == .int32 and only.int32 == 1) or (only == .int64 and only.int64 == 1) or (only == .double and only.double == 1.0)); if (is_id_index) { // The no-op must not swallow options that would change what // the index does — MongoDB rejects a TTL _id index rather // than quietly ignoring the expiry. if (bson.get_pair(spec, "expireAfterSeconds") != null) { return reply.put_error( @intFromEnum(ErrorCode.invalid_index_specification_option), "InvalidIndexSpecificationOption", "the field 'expireAfterSeconds' is not valid for an _id " ++ "index specification", ); } continue; } return bad_value(reply, "cannot create a secondary index on the _id field"); } const name = bson.get_pair(spec, "name") orelse bson.Value.null; if (name == .string and std.mem.eql(u8, name.string, "_id_")) { return bad_value(reply, "cannot create index with name '_id_'"); } const spec_doc = bson.Document{ .arena = undefined, .pairs = spec }; _ = ctx.engine.create_index(db_name, coll_name, &spec_doc) catch |err| switch (err) { error.InvalidIndexSpec => return bad_value(reply, "invalid index spec"), // A filter mongod would not accept either. It restricts the // operators because every one of them narrows a set in a way // another predicate can be checked against -- which is what a // future implication test needs. `$ne` and `$regex` do not. error.PartialFilterUnsupported => return reply.put_error( @intFromEnum(ErrorCode.cannot_create_index), "CannotCreateIndex", "unsupported expression in partialFilterExpression", ), error.UnknownIndexPlugin => return reply.put_error( @intFromEnum(ErrorCode.cannot_create_index), "CannotCreateIndex", "Unknown index plugin", ), error.TwoHashedComponents => return reply.put_error( @intFromEnum(ErrorCode.hashed_two_components), "Location31303", "A maximum of one index field is allowed to be hashed", ), error.UniqueHashed => return reply.put_error( @intFromEnum(ErrorCode.hashed_unique), "Location16764", "Currently hashed indexes cannot guarantee uniqueness. Use a regular index.", ), error.PartialFilterNotDocument => return reply.put_error( @intFromEnum(ErrorCode.type_mismatch), "TypeMismatch", "partialFilterExpression must be a document", ), error.PartialAndSparse => return reply.put_error( @intFromEnum(ErrorCode.cannot_create_index), "CannotCreateIndex", "cannot mix sparse and partialFilterExpression: a sparse index is a partial " ++ "one whose filter is {: {$exists: true}}, and a document satisfying " ++ "one and not the other has no defined answer", ), error.TtlOnCompoundIndex => return reply.put_error( @intFromEnum(ErrorCode.cannot_create_index), "CannotCreateIndex", "TTL indexes are single-field indexes, compound indexes do not support TTL", ), error.InvalidExpireAfterSeconds => return reply.put_error( @intFromEnum(ErrorCode.cannot_create_index), "CannotCreateIndex", "TTL index 'expireAfterSeconds' option must be a whole number " ++ "between 0 and 2147483647", ), error.IndexOptionsConflict => return reply.put_error(@intFromEnum(ErrorCode.index_options_conflict), "IndexOptionsConflict", "index already exists with a different specification"), error.IndexKeySpecsConflict => return reply.put_error( @intFromEnum(ErrorCode.index_key_specs_conflict), "IndexKeySpecsConflict", "an index with the same name exists over a different set of documents", ), error.DuplicateKeyIndex => { const ix_name = if (name == .string) name.string else "index"; const msg_text = try e11000_message(reply, db_name, coll_name, ix_name, try render_spec_key(reply, key_pairs)); return reply.put_error(@intFromEnum(ErrorCode.duplicate_key), "DuplicateKey", msg_text); }, error.ParallelArrays => return bad_value(reply, "cannot index parallel arrays"), // Reachable here as well as on insert: a collection can already // hold the array the new index cannot hash. error.HashedArray => return hashed_array_error(reply), else => return err, }; } try reply.put("createdCollectionAutomatically", .{ .bool = !existed }); try reply.put("numIndexesBefore", .{ .int32 = num_before }); try reply.put("numIndexesAfter", .{ .int32 = @intCast(coll.indexes.items.len + 1) }); try reply.put_ok(); } fn cmd_list_indexes(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { const db_name = msg.db_name() orelse return invalid_arg(reply, "listIndexes requires $db"); const coll_name = str_arg(msg.body.get("listIndexes")) orelse return bad_value(reply, "listIndexes requires a collection name"); const coll = ctx.engine.get_collection(db_name, coll_name) orelse return reply.put_error(@intFromEnum(ErrorCode.namespace_not_found), "NamespaceNotFound", "ns not found"); const batch_size = (try aggregate_batch_size(reply, msg) orelse return).value; // The _id_ index first, then the secondaries. const arena = reply.arena_alloc(); const values = try arena.alloc(*const bson.Document, coll.indexes.items.len + 1); const id_pairs = try arena.alloc(bson.Pair, 2); id_pairs[0] = .{ .key = "v", .value = .{ .int32 = 2 } }; id_pairs[1] = .{ .key = "key", .value = .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 1 } }} } }; values[0] = try doc_from_pairs(arena, try index_pairs_append(reply, id_pairs, "_id_")); for (coll.indexes.items, 0..) |ix, i| { // The pairs live in the reply arena (freed with it); the docs array // below references them. var pairs: std.ArrayListUnmanaged(bson.Pair) = .empty; try ix.spec_pairs(arena, &pairs); values[1 + i] = try doc_from_pairs(arena, pairs.items); } try emit_first_batch(ctx, reply, db_name, coll_name, null, values, batch_size); try reply.put_ok(); } /// The _id_ index entry: {v, key: {_id: 1}, name: "_id_"}. fn index_pairs_append( reply: *wire.Reply, pairs: []const bson.Pair, name: []const u8, ) ![]const bson.Pair { const arena = reply.arena_alloc(); const with_name = try arena.alloc(bson.Pair, pairs.len + 1); @memcpy(with_name[0..pairs.len], pairs); with_name[pairs.len] = .{ .key = "name", .value = .{ .string = try arena.dupe(u8, name) } }; return with_name; } fn cmd_drop_indexes(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { const db_name = msg.db_name() orelse return invalid_arg(reply, "dropIndexes requires $db"); const coll_name = str_arg(msg.body.get("dropIndexes")) orelse return bad_value(reply, "dropIndexes requires a collection name"); const coll = ctx.engine.get_collection(db_name, coll_name) orelse return reply.put_error(@intFromEnum(ErrorCode.namespace_not_found), "NamespaceNotFound", "ns not found"); const n_indexes_was: i32 = @intCast(coll.indexes.items.len + 1); const arg = msg.body.get("index") orelse return bad_value(reply, "dropIndexes requires index"); if (arg == .string and std.mem.eql(u8, arg.string, "*")) { // Drop every secondary index. Copy the names first: each drop // mutates the collection's index list. var names: std.ArrayListUnmanaged([]const u8) = .empty; defer names.deinit(ctx.gpa); for (coll.indexes.items) |ix| try names.append(ctx.gpa, ix.name); for (names.items) |nm| _ = try ctx.engine.drop_index(db_name, coll_name, nm); } else if (arg == .string) { if (std.mem.eql(u8, arg.string, "_id_")) { return invalid_arg(reply, "cannot drop the _id index"); } if (!try ctx.engine.drop_index(db_name, coll_name, arg.string)) { return reply.put_error(@intFromEnum(ErrorCode.index_not_found), "IndexNotFound", "index not found with name"); } } else if (arg == .doc) { // A key document: drop the index with a matching key pattern. const key_value = bson.get_pair(arg.doc, "key") orelse bson.Value{ .doc = arg.doc }; const key_pairs = switch (key_value) { .doc => |p| p, else => return bad_value(reply, "dropIndexes index must be a name, key document, or '*'"), }; const target = index.find_by_key_pattern(coll.indexes.items, key_pairs) orelse return reply.put_error(@intFromEnum(ErrorCode.index_not_found), "IndexNotFound", "index not found with key pattern"); _ = try ctx.engine.drop_index(db_name, coll_name, target.name); } else { return bad_value(reply, "dropIndexes index must be a name, key document, or '*'"); } try reply.put("nIndexesWas", .{ .int32 = n_indexes_was }); try reply.put_ok(); } /// The E11000 text drivers parse. One definition for both create-time and /// write-time conflicts; they differ only in how the dup key is rendered. fn e11000_message( reply: *wire.Reply, db_name: []const u8, coll_name: []const u8, index_name: []const u8, key_text: []const u8, ) ![]const u8 { return std.fmt.allocPrint( reply.arena_alloc(), "E11000 duplicate key error collection: {s}.{s} index: {s} dup key: {s}", .{ db_name, coll_name, index_name, key_text }, ); } /// Render the key pattern with placeholder values for a createIndexes /// duplicate-key error (no specific document is involved). fn render_spec_key(reply: *wire.Reply, key_pairs: []const bson.Pair) ![]const u8 { const arena = reply.arena_alloc(); var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(arena); try out.append(arena, '{'); for (key_pairs, 0..) |p, i| { if (i > 0) try out.appendSlice(arena, ", "); try out.appendSlice(arena, p.key); try out.appendSlice(arena, ": ?"); } try out.append(arena, '}'); return out.toOwnedSlice(arena); } // --------------------------------------------------------------------------- // CRUD // --------------------------------------------------------------------------- fn cmd_insert(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { const db_name = msg.db_name() orelse return invalid_arg(reply, "insert requires $db"); const coll_name = str_arg(msg.body.get("insert")) orelse return bad_value(reply, "insert requires a collection name"); const docs = try batch_arg(msg, reply, "insert", "documents") orelse return; var inserted: i64 = 0; var write_errors: std.ArrayListUnmanaged(bson.Value) = .empty; defer write_errors.deinit(reply.arena_alloc()); // Group commit: one fsync for the whole batch instead of one per document. // Nothing to open or close here -- appends never sync, and the dispatch // epilogue is the single commit point. It runs on every return path, so a // failed doc (writeErrors) or a hard error still syncs what was appended, // and does it after the collection lock is released. for (docs, 0..) |*doc, i| { if (ctx.engine.insert(db_name, coll_name, doc, ctx.oid_gen)) |_| { inserted += 1; } else |err| { switch (err) { error.DuplicateKey, error.DuplicateKeyIndex => { const e = try reply.arena_alloc().alloc(bson.Pair, 3); e[0] = .{ .key = "index", .value = .{ .int32 = @intCast(i) } }; e[1] = .{ .key = "code", .value = .{ .int32 = @intFromEnum(ErrorCode.duplicate_key) } }; e[2] = .{ .key = "errmsg", .value = .{ .string = try duplicate_key_message(ctx, reply, db_name, coll_name, doc) } }; try write_errors.append(reply.arena_alloc(), .{ .doc = e }); }, // Measured: a per-document writeError with `ok: 1`, not a // command error -- the rest of the batch still goes in. error.HashedArray => try write_errors.append( reply.arena_alloc(), try hashed_array_write_error(reply, i), ), else => return err, } } } try reply.put("n", .{ .int32 = @intCast(inserted) }); if (write_errors.items.len > 0) { // Copy into the reply arena: write_errors is freed when this command // returns, before the reply is serialized. const arr = try reply.arena_alloc().alloc(bson.Value, write_errors.items.len); @memcpy(arr, write_errors.items); try reply.put("writeErrors", .{ .array = arr }); } try reply.put_ok(); } /// Parse a `batchSize`-shaped option. Null means the error reply is already /// written. `zero_is_default` distinguishes `find`, where 0 is a real request for /// an empty batch, from `getMore`, where mongod reads it as "no document target" /// -- which is also what an absent field means. fn batch_size_arg( reply: *wire.Reply, v: bson.Value, label: []const u8, zero_is_default: bool, ) !??u32 { const n = int_value(v) orelse { const text = try std.fmt.allocPrint(reply.arena_alloc(), "{s} must be a number", .{label}); try bad_value(reply, text); return null; }; if (n < 0) { const text = try std.fmt.allocPrint( reply.arena_alloc(), "{s} value must be non-negative", .{label}, ); try bad_value(reply, text); return null; } if (n == 0 and zero_is_default) return @as(?u32, null); return @as(?u32, std.math.cast(u32, n) orelse std.math.maxInt(u32)); } /// The `find` options that shape the cursor rather than the query. const CursorOpts = struct { batch_size: ?u32 = null, /// One batch and no cursor. Set explicitly, and also by a negative `limit`. single_batch: bool = false, no_timeout: bool = false, }; /// Parse the cursor-shaping options, or write an error reply and return null. fn parse_cursor_opts(reply: *wire.Reply, msg: *wire.Message) !?CursorOpts { // Every tailable form is refused, and that is parity rather than a gap: // mongod rejects a tailable cursor on a non-capped collection, and this // engine has no capped collections at all. Silently ignoring the flag would // be worse than erroring -- the cursor would report EOF and a driver's tail // loop would exit, which reads to the application as data loss. if (bool_arg(msg.body.get("tailable")) orelse false) { try bad_value(reply, "tailable cursor requested on non capped collection"); return null; } if (bool_arg(msg.body.get("awaitData")) orelse false) { try bad_value(reply, "Cannot set 'awaitData' without also setting 'tailable'"); return null; } var opts = CursorOpts{}; if (msg.body.get("batchSize")) |v| { opts.batch_size = try batch_size_arg(reply, v, "batchSize", false) orelse return null; } opts.single_batch = bool_arg(msg.body.get("singleBatch")) orelse false; opts.no_timeout = bool_arg(msg.body.get("noCursorTimeout")) orelse false; return opts; } /// Whether a source may outlive the request that built it. /// /// Only a snapshot can fail this: it is the one arm that pins bytes proportional /// to the result set, and holding a large one for the idle timeout times the /// number of live cursors is exactly what `cursor_buffer_max` exists to prevent. /// Over the bound the caller emits everything in one batch instead -- which is /// what this server did before cursors existed. fn source_keepable(source: cursor.Source) bool { const buffered = switch (source) { .buffered => |b| b, else => return true, }; var total: u64 = 0; for (buffered.docs) |d| total += d.len; return total <= cursor_buffer_max; } fn cmd_find(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { const db_name = msg.db_name() orelse return invalid_arg(reply, "find requires $db"); const coll_name = str_arg(msg.body.get("find")) orelse return bad_value(reply, "find requires a collection name"); const filter = doc_arg(msg.body.get("filter")) orelse return bad_value(reply, "filter must be a document"); const sort_keys = try parse_sort_keys(reply, msg.body.get("sort")); const proj_pairs = doc_arg(msg.body.get("projection")); const skip: u64 = int_arg(msg.body.get("skip")) orelse 0; const limit_raw = int_value(msg.body.get("limit")) orelse 0; const opts = try parse_cursor_opts(reply, msg) orelse return; // A negative limit is the historical `ntoreturn` shape: this many documents // in exactly one batch. Drivers translate it before sending, but the wire // form is still legal. const single_batch = opts.single_batch or limit_raw < 0; const limit: u64 = @abs(limit_raw); const target = cursor.batch_target(opts.batch_size, true); // A find on a namespace that does not exist is an empty cursor, not an // error, and the ordinary path below says exactly that. const coll = ctx.engine.get_collection(db_name, coll_name); var arena = std.heap.ArenaAllocator.init(ctx.gpa); var arena_owned = false; defer if (!arena_owned) arena.deinit(); // Whether this reply is the whole answer, which is what makes the top-k // sort shortcut legal -- it leaves everything past k unspecified, and an // open cursor would later need those documents. const closes_here = single_batch or (limit > 0 and limit <= (target orelse 0)); var feed = try find_feed(ctx, reply, &arena, coll, .{ .db_name = db_name, .coll_name = coll_name, .filter = filter, .sort_keys = sort_keys, .skip = skip, .limit = limit, .closes_here = closes_here, }); var values: std.ArrayListUnmanaged(bson.Value) = .empty; const exhausted = try fill_batch(ctx, reply, coll, &feed, filter, proj_pairs, target, &values); var cursor_id: i64 = 0; if (!exhausted and !single_batch and source_keepable(feed.source)) { cursor_id = keep_find_cursor(ctx, &arena, coll, &feed, .{ .db = db_name, .coll = coll_name, }, filter, proj_pairs, opts); arena_owned = cursor_id != 0; } // No cursor, but documents still to come: one batch holding the rest. if (cursor_id == 0 and !exhausted and !single_batch) { _ = try fill_batch(ctx, reply, coll, &feed, filter, proj_pairs, null, &values); } const ns = try format_namespace(reply, db_name, coll_name); const batch = try cursor_doc(reply, cursor_id, ns, "firstBatch", values.items); try reply.put("cursor", .{ .doc = batch }); try reply.put_ok(); } /// Register the remainder of a `find` as a cursor, returning its id or 0. fn keep_find_cursor( ctx: *Context, arena: *std.heap.ArenaAllocator, coll: ?*Collection, feed: *const Feed, ns: cursor.Ns, filter: []const bson.Pair, proj_pairs: ?[]const bson.Pair, opts: CursorOpts, ) i64 { // Serialized before the arena is handed over: `open_cursor` takes it by // value, so anything allocated after that call would be invisible to the copy // the cursor keeps. const a = arena.allocator(); const filter_bytes = serialize_pairs(a, filter) catch return 0; const proj_bytes = if (proj_pairs) |pp| (serialize_pairs(a, pp) catch return 0) else ""; return open_cursor(ctx, arena.*, .{ .ns = ns, .layout_epoch = if (coll) |c| c.layout_epoch else 0, .filter_bytes = filter_bytes, .proj_bytes = proj_bytes, .remaining_limit = feed.remaining_limit, .batch_size = opts.batch_size, .no_timeout = opts.no_timeout, // feed.source, not the source it started as: the first batch advanced it, // and for a stream that advance *is* the resume point. .source = feed.source, }); } /// Everything `find_feed` needs that is not a lock or an arena. const FindRequest = struct { db_name: []const u8, coll_name: []const u8, filter: []const bson.Pair, sort_keys: []const query.SortKey, skip: u64, limit: u64, closes_here: bool, }; /// Choose the source for a `find` and open the feed that fills its first batch. /// /// A whole-index walk is served without materializing anything: the cursor /// remembers a key and an offset, so this is what makes `find({})` over a /// collection larger than memory possible at all. Every other shape collects its /// matches first, exactly as before cursors existed. fn find_feed( ctx: *Context, reply: *wire.Reply, arena: *std.heap.ArenaAllocator, coll: ?*Collection, req: FindRequest, ) !Feed { const remaining: ?u64 = if (req.limit == 0) null else req.limit; // One plan for the whole request: `stream_shape` reads it to decide whether a // resumable walk is possible, and the fallback scan reuses it instead of // planning the same query a second time. var plan_opt = if (coll) |c| try index.plan(ctx.gpa, &c.id_index, c.indexes.items, req.filter, req.sort_keys) else null; defer if (plan_opt) |*p| p.deinit(ctx.gpa); const plan: ?*const index.Plan = if (plan_opt) |*p| p else null; if (coll) |c| { if (stream_shape(c, plan, req.sort_keys)) |st| { var feed = Feed{ .source = .{ .stream = st }, .remaining_limit = remaining }; feed.scan = open_scan(c, &feed.source.stream); // `skip` is consumed once, here, through the same anchor the batch // uses -- which is what lets a cursor whose entire first batch falls // inside the skipped prefix still resume from the right place. try stream_skip(ctx, c, &feed, req.filter, req.skip); return feed; } } // Documents needed to fill the page, counting the skipped prefix; 0 means // unbounded. Only an index-ordered scan may stop there. const page_end: usize = if (req.limit == 0) 0 else blk: { const skip_usize = std.math.cast(usize, req.skip) orelse break :blk 0; break :blk skip_usize +| req.limit; }; // Lives only until `find_source` copies what it needs into `arena`. var matched: std.ArrayListUnmanaged(u64) = .empty; defer matched.deinit(ctx.gpa); var index_sorted = false; _ = try scan_planned( ctx, req.db_name, req.coll_name, req.filter, page_end, &matched, req.sort_keys, &index_sorted, plan, ); if (coll == null) { assert_msg(matched.items.len == 0, "find matched in a collection that does not exist"); } const source = try find_source(reply, arena, coll, matched.items, .{ .sort_keys = req.sort_keys, .index_sorted = index_sorted, .skip = req.skip, .page_end = page_end, .closes_here = req.closes_here, }); return .{ .source = source, .remaining_limit = remaining }; } /// How `find_source` should turn a match list into a source. const SourceShape = struct { sort_keys: []const query.SortKey, index_sorted: bool, skip: u64, page_end: usize, closes_here: bool, }; /// Build the source `find` pulls from, with `skip` already consumed. /// /// The query shape decides which source is possible. An index-ordered scan /// yields offsets -- 8 bytes apiece, and valid until the collection is rebuilt. /// A sort no index provides had to materialize and order every match, so there /// is no ordered offset list to point at and the remainder is snapshotted. fn find_source( reply: *wire.Reply, arena: *std.heap.ArenaAllocator, coll: ?*Collection, matched: []const u64, shape: SourceShape, ) !cursor.Source { const c = coll orelse return .{ .offsets = .{ .items = &.{} } }; if (shape.sort_keys.len == 0 or shape.index_sorted) { // Already in the order the client asked for, so skip is a slice. const rest = if (shape.skip < matched.len) matched[shape.skip..] else &.{}; return .{ .offsets = .{ .items = try arena.allocator().dupe(u64, rest) } }; } // Ordering needs the values: materialize into the reply arena and sort. const reply_arena = reply.arena_alloc(); var tree_docs: std.ArrayListUnmanaged(*const bson.Document) = .empty; for (matched) |off| try tree_docs.append(reply_arena, try doc_tree(reply_arena, c, off)); if (shape.closes_here and shape.page_end > 0 and shape.page_end *| 4 <= tree_docs.items.len) { try query.sort_docs_top_k(reply_arena, tree_docs.items, shape.sort_keys, shape.page_end); } else { try query.sort_docs(reply_arena, tree_docs.items, shape.sort_keys); } const rest = if (shape.skip < tree_docs.items.len) tree_docs.items[shape.skip..] else &.{}; return buffered_source(arena.allocator(), rest); } /// Serialize `pairs` into `arena`. A cursor cannot keep the parsed form: those /// pairs point into the per-request message arena. fn serialize_pairs(arena: std.mem.Allocator, pairs: []const bson.Pair) ![]const u8 { var buf: std.ArrayListUnmanaged(u8) = .empty; try bson.write_doc(pairs, arena, &buf); return buf.items; } /// Collect the documents in `db_name.coll_name` matching `filter`, stopping /// after `limit` matches (0 = unlimited). Returns the number matched; `out` /// may be null when only the count is wanted. Every command that scans a /// collection goes through here, so an index only needs this one call site. /// /// Candidate generation order: the _id_ fast path (docs map lookup), then a /// secondary-index plan, then a plain scan. Every candidate is re-checked /// with the unchanged filter, so an index that over-approximates is merely /// slow — never wrong. With no index created and no usable _id clause, the /// plain-scan path is the only one reached. fn scan_matching( ctx: *Context, db_name: []const u8, coll_name: []const u8, filter: []const bson.Pair, limit: usize, out: ?*std.ArrayListUnmanaged(u64), ) !usize { return scan_sorted(ctx, db_name, coll_name, filter, limit, out, &.{}, null); } /// `scan_matching` plus the option of having an index produce the ordering. /// When `sorted` is given it reports whether the candidates came out in /// `sort` order, in which case the caller must not sort them again — and /// `limit` is then a genuine early stop rather than an arbitrary subset. fn scan_sorted( ctx: *Context, db_name: []const u8, coll_name: []const u8, filter: []const bson.Pair, limit: usize, out: ?*std.ArrayListUnmanaged(u64), sort: []const query.SortKey, sorted: ?*bool, ) !usize { return scan_planned(ctx, db_name, coll_name, filter, limit, out, sort, sorted, null); } /// `scan_sorted` with the plan supplied. `find` decides between a resumable walk /// and a materializing scan by looking at the plan, so without this it would plan /// once to choose and `scan_sorted` would plan the identical query again -- /// `index.plan` flattens the filter's clauses and evaluates every index, both /// allocating, on the hot read path. /// /// `prebuilt` is borrowed: the caller keeps ownership, including its `deinit`. fn scan_planned( ctx: *Context, db_name: []const u8, coll_name: []const u8, filter: []const bson.Pair, limit: usize, out: ?*std.ArrayListUnmanaged(u64), sort: []const query.SortKey, sorted: ?*bool, prebuilt: ?*const index.Plan, ) !usize { if (sorted) |flag| flag.* = false; // Stopping early is only meaningful when the candidates come out in the // order the caller asked for. Without a sort any subset of that size is // a valid page; with one, the limit is honoured only if an index turns // out to supply the ordering. var lim: usize = if (sort.len == 0) limit else 0; const coll = ctx.engine.get_collection(db_name, coll_name) orelse return 0; var n: usize = 0; // Index plan (the implicit _id_ index first, then the secondaries): // candidates in index order, re-filtered. A candidate *is* a slab offset // now, so the map lookup that used to translate an id into one is gone -- // and so is the accidental safety net it provided: a stale entry used to be // dropped silently by `orelse continue`, where now it resolves to // superseded-but-parseable bytes that the re-applied filter might accept. // Loud beats silent: a wrong answer a test can see beats a missing // candidate nothing can. // Candidates arrive as a stream so that a whole-index read never // materializes: at the tens-of-GB target a `countDocuments({})` would // otherwise build a list of every offset in the collection before the // first one is examined. A narrowed plan still materializes, because its // multikey/$in dedupe genuinely needs the whole set, and it is bounded by // selectivity. var offs: std.ArrayListUnmanaged(u64) = .empty; defer offs.deinit(ctx.gpa); var cands: index.Candidates = undefined; // Only plan here when the caller did not; theirs is borrowed, so only ours // is freed. var owned_plan = if (prebuilt != null) null else try index.plan(ctx.gpa, &coll.id_index, coll.indexes.items, filter, sort); defer if (owned_plan) |*p| p.deinit(ctx.gpa); const plan_opt: ?*const index.Plan = prebuilt orelse if (owned_plan) |*p| p else null; if (plan_opt) |plan| { if (sorted) |flag| flag.* = plan.provides_sort; if (plan.provides_sort) lim = limit; if (plan.full_scan()) { cands = if (plan.backward) .{ .scan_rev = plan.index.iter_reverse() } else .{ .scan = plan.index.iter() }; } else { try plan.search(ctx.gpa, &offs); cands = .{ .list = .{ .items = offs.items } }; } } else { // No usable predicate: every document, in _id order. The docs map was // the fallback here, and its iteration order was the hash's; walking // the _id_ index instead is ordered, streams, and does not depend on a // structure that is going away. cands = .{ .scan = coll.id_index.iter() }; } while (cands.next()) |off| { if (!try query.matches_bytes(ctx.gpa, filter, coll.doc_bytes(off))) continue; if (out) |list| try list.append(ctx.gpa, off); n += 1; if (lim != 0 and n >= lim) break; } return n; } /// A stored document (a slab offset) materialized as a borrowed spine in /// `arena`: keys and leaf values point into the slab's stable bytes, only /// the pair/value skeleton is allocated. The arena owns the skeleton, so /// the result is never deinit'd — the reply arena frees it with the reply. fn doc_tree(arena: std.mem.Allocator, coll: *const Collection, off: u64) !*const bson.Document { return doc_tree_bytes(arena, coll.doc_bytes(off)); } /// The same borrowed spine over bytes that are already in hand -- the slab for a /// live scan, or a cursor's own snapshot for a buffered one. fn doc_tree_bytes(arena: std.mem.Allocator, bytes: []const u8) !*const bson.Document { const doc = try arena.create(bson.Document); doc.* = bson.Document{ .arena = undefined, .pairs = try bson.spine(arena, bytes) }; return doc; } // --------------------------------------------------------------------------- // Cursors: filling a batch, and the sources a batch pulls from // --------------------------------------------------------------------------- /// Largest snapshot a cursor will copy into its own arena. Mirrors MongoDB's /// 32 MiB in-memory sort limit, and only the shapes that had to materialize /// anyway can reach it. /// /// Over the bound the cursor is *declined* and the whole result goes out in one /// batch -- exactly what this server did before cursors existed. Declining is /// the right direction to fail: the alternative, holding the snapshot anyway, /// pins it for the idle timeout times the number of live cursors. const cursor_buffer_max: u64 = 32 * 1024 * 1024; /// The part of a cursor that a batch consumes. Kept separate from /// `cursor.Cursor` so `find` can fill its first batch and only then decide /// whether a cursor needs to exist at all. const Feed = struct { source: cursor.Source, /// Documents still owed across every remaining batch; null is unbounded. remaining_limit: ?u64, /// A `.stream` source's live position in the tree, valid for **this request /// only**. It is deliberately not part of `cursor.Source`: a tree position /// must never outlive the collection lock that made it safe to hold, which is /// the whole reason the stored form is a value-typed anchor instead. scan: ?Scan = null, fn limit_exhausted(self: *const Feed) bool { const rem = self.remaining_limit orelse return false; return rem == 0; } }; /// A walk over one index, in one direction, for the duration of one request. const Scan = struct { walk: union(enum) { fwd: index.Index.Iter, rev: index.Index.RevIter, }, /// The entry `peek_bytes` has produced but the batch has not yet accepted. /// Held because a batch that turns out to be full must not consume it. pending: ?index.Index.Positioned = null, fn next(self: *Scan) ?index.Index.Positioned { return switch (self.walk) { .fwd => |*it| it.positioned(), .rev => |*it| it.positioned(), }; } }; /// The next document the source will yield, *without* consuming it -- a batch /// that turns out to be full must not swallow a document it cannot carry. /// /// Candidates whose document no longer matches are consumed and skipped here. /// That re-check is the index invariant (`src/index.zig`) applied per batch, and /// it is also what makes a saved offset safe once documents start being /// recycled: a reused offset either fails the filter or resolves to a document /// that genuinely matches it. fn peek_bytes( ctx: *Context, coll: ?*Collection, filter: []const bson.Pair, feed: *Feed, ) !?[]const u8 { switch (feed.source) { // A snapshot of documents that already matched, and that nothing can // mutate underneath us -- so it is not re-filtered. .buffered => |*b| { if (b.next >= b.docs.len) return null; return b.docs[b.next]; }, .offsets => |*o| { const c = coll orelse return null; while (o.next < o.items.len) { const bytes = c.doc_bytes(o.items[o.next]); if (try query.matches_bytes(ctx.gpa, filter, bytes)) return bytes; o.next += 1; } return null; }, .stream => { const c = coll orelse return null; const sc = &(feed.scan orelse return null); if (sc.pending) |p| return c.doc_bytes(p.off); while (sc.next()) |p| { const bytes = c.doc_bytes(p.off); if (!try query.matches_bytes(ctx.gpa, filter, bytes)) { // A candidate the filter rejects is still progress: the // anchor must move past it, or a resume would walk it again. feed.source.stream.advance(p.key, p.off, p.leaf, p.slot); continue; } sc.pending = p; return bytes; } return null; }, } } fn consume_one(feed: *Feed) void { switch (feed.source) { .buffered => |*b| b.next += 1, .offsets => |*o| o.next += 1, .stream => |*st| { const sc = &(feed.scan orelse return); const p = sc.pending orelse return; st.advance(p.key, p.off, p.leaf, p.slot); sc.pending = null; }, } } /// Resolve the index a `.stream` cursor names. Empty is the implicit `_id_`, /// which is deliberately kept out of `Collection.indexes` and so is not findable /// by name. fn stream_index(coll: *Collection, name: []const u8) ?*const index.Index { if (name.len == 0) return &coll.id_index; return coll.find_index(name); } /// Open this request's walk over the index a `.stream` cursor is following. /// /// Null means the cursor can never produce another document: the index it was /// following is gone. The caller turns that into `QueryPlanKilled` rather than /// an empty batch, because an empty batch would claim the result set ended. fn open_scan(coll: *Collection, st: *cursor.Stream) ?Scan { const ix = stream_index(coll, st.index_name()) orelse return null; // A hint is a node id plus a slot, and `reset_tree` recycles ids 0 and 1 as // different nodes -- so the hint is only meaningful at the epoch it was // taken. The *anchor* is unaffected: it is key bytes and an offset, both // values, so an epoch change costs a band walk rather than correctness. const trusted = st.index_epoch == ix.epoch; st.index_epoch = ix.epoch; if (!st.started()) { // `batchSize: 0` leaves a cursor with no anchor yet, so a first getMore // starts the walk from the beginning rather than resuming. return .{ .walk = if (st.backward) .{ .rev = ix.iter_reverse() } else .{ .fwd = ix.iter() } }; } if (st.backward) { const r = ix.resume_reverse( st.anchor_key(), st.anchor_off, st.hint_leaf, st.hint_slot, trusted, ); if (r.capped) return null; return .{ .walk = .{ .rev = r.it } }; } const r = ix.resume_forward( st.anchor_key(), st.anchor_off, st.band_index, st.hint_leaf, st.hint_slot, trusted, ); if (r.capped) return null; return .{ .walk = .{ .fwd = r.it } }; } /// Whether this query can be served by walking one index end to end, and if so /// which index and in which direction. /// /// This is the shape that lets a cursor outlive its result set: it holds a key /// and an offset instead of a list, so `find({})` over a collection larger than /// memory costs O(key) of cursor state rather than 8 bytes per matching /// document. Everything else keeps the materialized sources. /// /// Two shapes qualify. A query the planner declines outright (`{}` with no sort) /// walks the `_id_` index forward -- every document has an `_id` and the index is /// not sparse, so a full walk cannot miss one. And a plan whose `full_scan()` /// holds is by construction a whole-index walk in the requested order; note that /// `full_scan()` implies `provides_sort`, since a plan with no run, no range and /// no sort direction is declined before it is built. /// /// A narrowed plan is excluded on purpose: it dedupes `$in` and multikey /// candidates across the whole set, which a stream cannot do without remembering /// what it has already emitted. fn stream_shape( coll: *Collection, plan_opt: ?*const index.Plan, sort: []const query.SortKey, ) ?cursor.Stream { var st = cursor.Stream{}; if (plan_opt) |plan| { if (!plan.full_scan()) return null; st.backward = plan.backward; if (plan.index != &coll.id_index) { if (plan.index.name.len > cursor.index_name_max) return null; @memcpy(st.index_name_buf[0..plan.index.name.len], plan.index.name); st.index_name_len = @intCast(plan.index.name.len); } st.index_epoch = plan.index.epoch; return st; } // No usable predicate and no ordering to honour: every document in _id // order, which is what the old materializing fallback did too. if (sort.len != 0) return null; st.index_epoch = coll.id_index.epoch; return st; } /// Consume `skip` matching documents without emitting them, advancing the anchor /// as it goes so a resume does not walk them again. fn stream_skip( ctx: *Context, coll: *Collection, feed: *Feed, filter: []const bson.Pair, skip: u64, ) !void { var left = skip; while (left > 0) { _ = try peek_bytes(ctx, coll, filter, feed) orelse return; consume_one(feed); left -= 1; } } /// Fill one batch from `feed`, projecting into the reply arena. Returns whether /// the source is **exhausted**. /// /// Exhaustion is observed, never predicted: a batch that reached its document /// target returns false even when the source happens to have nothing left, so /// the cursor stays open and the client gets one more (possibly empty) batch. /// Predicting it here would close the cursor a round trip early and break the /// command-count assertions in the pinned spec suites. fn fill_batch( ctx: *Context, reply: *wire.Reply, coll: ?*Collection, feed: *Feed, filter: []const bson.Pair, proj_pairs: ?[]const bson.Pair, target: ?u32, out: *std.ArrayListUnmanaged(bson.Value), ) !bool { var builder = cursor.BatchBuilder.init(target); const arena = reply.arena_alloc(); while (true) { // Checked before the target so the batch that takes the last document // the limit allows is itself the one that closes the cursor. This is // what lets `batchSize == limit` finish in a single round trip. if (feed.limit_exhausted()) return true; if (builder.full()) return false; const bytes = try peek_bytes(ctx, coll, filter, feed) orelse return true; // The stored length is exact with no projection and an upper bound with // one, since `query.project` only ever drops fields. if (builder.offer(bytes.len) == .batch_full) return false; const doc = try doc_tree_bytes(arena, bytes); try out.append(arena, try project_doc(reply, doc, proj_pairs)); consume_one(feed); if (feed.remaining_limit) |rem| feed.remaining_limit = rem - 1; } } /// Register `feed`'s remainder as a cursor and return its id, or 0 when no /// cursor is needed or one could not be had. /// /// Every "could not" path degrades to `id: 0` rather than to an error: the /// client then has the batch it was given and no cursor, which is precisely how /// this server behaved before cursors existed. fn open_cursor(ctx: *Context, arena: std.heap.ArenaAllocator, spec: cursor.OpenSpec) i64 { var owned = arena; return ctx.engine.cursors.open(ctx.io, now_ms(ctx), owned, spec) catch |err| switch (err) { // A namespace too long for the fixed buffers, a store whose every slot // is pinned, or an allocation failure. None is worth failing a query // whose documents are already in the reply. error.NameTooLong, error.TooManyCursors, error.OutOfMemory, error.Canceled => { owned.deinit(); return 0; }, }; } fn now_ms(ctx: *Context) i64 { return std.Io.Timestamp.now(ctx.io, .real).toMilliseconds(); } /// Copy `docs` into `arena` as canonical BSON, for a result with no stable /// backing store to point at. fn buffered_source( arena: std.mem.Allocator, docs: []const *const bson.Document, ) !cursor.Source { const out = try arena.alloc([]const u8, docs.len); for (docs, 0..) |d, i| out[i] = try serialize_pairs(arena, d.pairs); return .{ .buffered = .{ .docs = out } }; } /// Wrap pairs already living in an arena as a borrowed document, so generated /// results can go through the same batch path as stored ones. fn doc_from_pairs(arena: std.mem.Allocator, pairs: []const bson.Pair) !*const bson.Document { const doc = try arena.create(bson.Document); doc.* = bson.Document{ .arena = undefined, .pairs = pairs }; return doc; } /// Emit a first batch from documents already materialized in the reply arena, /// registering a cursor for whatever does not fit in it. /// /// The snapshot source is the only one available here: these documents are either /// generated (`$group`, a listing) or the output of a pipeline that has already /// materialized its window, so there is no stable structure to point back into. /// That also makes the resulting cursor independent of its collection, which is /// what lets a listing hold a cursor over a `$cmd.*` namespace. /// /// `ns_coll` is the collection part of the reported namespace and the one a /// `getMore` must name -- for a listing that is `$cmd.listCollections`, not the /// empty string that used to be reported. An empty collection part makes the /// driver throw client-side before it even sends the getMore. fn emit_first_batch( ctx: *Context, reply: *wire.Reply, ns_db: []const u8, ns_coll: []const u8, proj_pairs: ?[]const bson.Pair, docs: []const *const bson.Document, batch_size: ?u32, ) !void { var arena = std.heap.ArenaAllocator.init(ctx.gpa); var arena_owned = false; defer if (!arena_owned) arena.deinit(); const source = try buffered_source(arena.allocator(), docs); var feed = Feed{ .source = source, .remaining_limit = null }; var values: std.ArrayListUnmanaged(bson.Value) = .empty; const target = cursor.batch_target(batch_size, true); const exhausted = try fill_batch(ctx, reply, null, &feed, &.{}, proj_pairs, target, &values); var cursor_id: i64 = 0; if (!exhausted and source_keepable(feed.source)) { cursor_id = open_cursor(ctx, arena, .{ .ns = .{ .db = ns_db, .coll = ns_coll }, // Nothing about this cursor depends on the collection's layout. .layout_epoch = 0, .batch_size = batch_size, .source = feed.source, }); arena_owned = cursor_id != 0; } if (cursor_id == 0 and !exhausted) { _ = try fill_batch(ctx, reply, null, &feed, &.{}, proj_pairs, null, &values); } const ns = try format_namespace(reply, ns_db, ns_coll); const batch = try cursor_doc(reply, cursor_id, ns, "firstBatch", values.items); try reply.put("cursor", .{ .doc = batch }); } /// `batchSize` out of an `aggregate`'s `cursor` option. A bare `cursor: {}` means /// the default; a missing `cursor` is accepted as the same thing, which is looser /// than mongod (it requires the field) but cannot surprise any driver. fn aggregate_batch_size(reply: *wire.Reply, msg: *wire.Message) !?struct { value: ?u32 } { const spec = doc_arg(msg.body.get("cursor")) orelse return .{ .value = null }; const v = bson.get_pair(spec, "batchSize") orelse return .{ .value = null }; const parsed = try batch_size_arg(reply, v, "cursor.batchSize", false) orelse return null; return .{ .value = parsed }; } fn project_doc( reply: *wire.Reply, doc: *const bson.Document, proj_pairs: ?[]const bson.Pair, ) !bson.Value { if (proj_pairs) |pp| { var out: std.ArrayListUnmanaged(bson.Pair) = .empty; errdefer out.deinit(reply.arena_alloc()); try query.project(reply.arena_alloc(), doc, &.{ .arena = undefined, .pairs = pp }, &out); return .{ .doc = out.items }; } return .{ .doc = try bson.copy_pairs(reply.arena_alloc(), doc.pairs) }; } fn cmd_update(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { const db_name = msg.db_name() orelse return invalid_arg(reply, "update requires $db"); const coll_name = str_arg(msg.body.get("update")) orelse return bad_value(reply, "update requires a collection name"); const specs = try batch_arg(msg, reply, "update", "updates") orelse return; var n_matched: i64 = 0; var n_modified: i64 = 0; var upserted: std.ArrayListUnmanaged(bson.Pair) = .empty; defer upserted.deinit(reply.arena_alloc()); var write_errors: std.ArrayListUnmanaged(bson.Value) = .empty; defer write_errors.deinit(reply.arena_alloc()); // Group commit for multi-document updates: one fsync per command, issued // by the dispatch epilogue once the collection lock is released. for (specs, 0..) |*spec, si| { const q = doc_arg(spec.get("q")) orelse return bad_value(reply, "update spec requires q"); // `u` is a document of operators, a replacement document, or an array // of aggregation stages. The third is a different machine from the // first two and stays separate all the way down. const u_value = spec.get("u") orelse return bad_value(reply, "update spec requires u"); const u_pipeline: ?[]const bson.Value = switch (u_value) { .array => |a| a, else => null, }; const u_doc = if (u_pipeline == null) doc_arg(u_value) orelse return bad_value(reply, "update spec requires u") else &.{}; const multi = bool_arg(spec.get("multi")) orelse false; const upsert = bool_arg(spec.get("upsert")) orelse false; // `sort` on an update spec picks *which* match to write when the filter // matches several -- a MongoDB 8.0 addition. This server advertises 4.4, // and ignoring the field would be the worst of the three possible // answers: the client asked for a specific document and would silently // get a different one. So refuse it, which is also what a real 4.4 does // with an unknown update-spec field. if (spec.get("sort") != null) { return failed_to_parse(reply, "Unknown option to update: sort"); } // A replacement describes one document, so there is no coherent meaning // for applying it to many: every match would end up identical apart from // its `_id`. MongoDB rejects the combination rather than doing that. if (u_pipeline == null and multi and update.is_replacement(u_doc)) { return failed_to_parse(reply, "multi update is not supported for replacement-style update"); } var diag: update.Diagnostic = .{}; const opts = update.Options{ .array_filters = try parse_array_filters( reply, spec.get("arrayFilters"), "update.updates.arrayFilters", ) orelse return, .query = q, .now_ms = std.Io.Timestamp.now(ctx.io, .real).toMilliseconds(), .diag = &diag, }; // Before the scan, not after: an update naming an identifier nothing // binds is refused whether or not it would have matched anything, and // an array filter the update never uses is refused even when the whole // command was a no-op. Both measured. if (u_pipeline == null) { update.validate(u_doc, opts) catch |err| return update_refusal(reply, err, diag); } else if (opts.array_filters.len > 0) { // Not "ignored": an identifier a pipeline cannot spell would be a // silently different update from the one the client wrote. return failed_to_parse(reply, "arrayFilters may not be specified for pipeline-style updates"); } var matched: std.ArrayListUnmanaged(u64) = .empty; defer matched.deinit(ctx.gpa); _ = try scan_matching(ctx, db_name, coll_name, q, if (multi) 0 else 1, &matched); if (matched.items.len == 0) { if (upsert) { const new_doc = (try build_upsert_doc(reply, ctx, q, u_doc, u_pipeline, opts, &diag)) orelse return; ctx.engine.insert(db_name, coll_name, new_doc, ctx.oid_gen) catch |err| switch (err) { error.DuplicateKey, error.DuplicateKeyIndex => return duplicate_key_error(ctx, reply, db_name, coll_name, new_doc), error.HashedArray => return hashed_array_error(reply), else => return err, }; const id = new_doc.get("_id") orelse bson.Value.null; const u = try reply.arena_alloc().alloc(bson.Pair, 2); u[0] = .{ .key = "index", .value = .{ .int32 = @intCast(si) } }; u[1] = .{ .key = "_id", .value = try bson.copy_value(reply.arena_alloc(), id) }; try upserted.append(reply.arena_alloc(), .{ .key = "u", .value = .{ .doc = u } }); n_matched += 1; } continue; } n_matched += @intCast(matched.items.len); const coll = ctx.engine.get_collection(db_name, coll_name) orelse return; for (matched.items) |off| { // Work on a copy: the log write must precede any visible change, // and a rejected update must not corrupt the stored document. const doc = try doc_tree(reply.arena_alloc(), coll, off); const copy = if (u_pipeline) |stages| (try apply_update_pipeline(ctx, reply, stages, doc, coll)) orelse return else blk: { const c = try clone_doc(reply, doc); update.apply(c, &.{ .arena = undefined, .pairs = u_doc }, opts) catch |err| return update_refusal(reply, err, diag); break :blk c; }; const written = ctx.engine.replace(db_name, coll_name, copy, ctx.oid_gen) catch |err| switch (err) { error.DuplicateKey, error.DuplicateKeyIndex => { const e = try reply.arena_alloc().alloc(bson.Pair, 3); e[0] = .{ .key = "index", .value = .{ .int32 = @intCast(si) } }; e[1] = .{ .key = "code", .value = .{ .int32 = @intFromEnum(ErrorCode.duplicate_key) } }; e[2] = .{ .key = "errmsg", .value = .{ .string = try duplicate_key_message(ctx, reply, db_name, coll_name, copy) } }; try write_errors.append(reply.arena_alloc(), .{ .doc = e }); continue; }, error.HashedArray => { try write_errors.append( reply.arena_alloc(), try hashed_array_write_error(reply, si), ); continue; }, else => return err, }; // `n` counts matches, `nModified` counts documents the update // actually altered. A write that would store the same bytes is // neither logged nor counted here. if (written == .modified) n_modified += 1; } } try reply.put("n", .{ .int32 = @intCast(n_matched) }); try reply.put("nModified", .{ .int32 = @intCast(n_modified) }); if (write_errors.items.len > 0) { const arr = try reply.arena_alloc().alloc(bson.Value, write_errors.items.len); @memcpy(arr, write_errors.items); try reply.put("writeErrors", .{ .array = arr }); } if (upserted.items.len > 0) { const arr = try reply.arena_alloc().alloc(bson.Value, upserted.items.len); for (upserted.items, 0..) |u, i| arr[i] = u.value; try reply.put("upserted", .{ .array = arr }); } try reply.put_ok(); } fn cmd_delete(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { const db_name = msg.db_name() orelse return invalid_arg(reply, "delete requires $db"); const coll_name = str_arg(msg.body.get("delete")) orelse return bad_value(reply, "delete requires a collection name"); const specs = try batch_arg(msg, reply, "delete", "deletes") orelse return; var n_deleted: i64 = 0; // Group commit for multi-document deletes: one fsync per command, issued // by the dispatch epilogue once the collection lock is released. for (specs) |*spec| { const q = doc_arg(spec.get("q")) orelse return bad_value(reply, "delete spec requires q"); const limit = int_value(spec.get("limit")) orelse 1; var matched: std.ArrayListUnmanaged(u64) = .empty; defer matched.deinit(ctx.gpa); _ = try scan_matching(ctx, db_name, coll_name, q, if (limit == 1) 1 else 0, &matched); if (ctx.engine.get_collection(db_name, coll_name)) |coll| { var id_arena = std.heap.ArenaAllocator.init(ctx.gpa); defer id_arena.deinit(); for (matched.items) |off| { const id = (try bson.get_at(id_arena.allocator(), coll.doc_bytes(off), "_id")) orelse continue; if (try ctx.engine.remove_by_id(db_name, coll_name, id)) n_deleted += 1; } } } try reply.put("n", .{ .int32 = @intCast(n_deleted) }); try reply.put_ok(); } fn cmd_find_and_modify(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { const db_name = msg.db_name() orelse return invalid_arg(reply, "findAndModify requires $db"); const coll_name = str_arg(msg.body.get("findAndModify")) orelse return bad_value(reply, "findAndModify requires a collection name"); const q = doc_arg(msg.body.get("query")) orelse &.{}; const sort_keys = try parse_sort_keys(reply, msg.body.get("sort")); const remove = bool_arg(msg.body.get("remove")) orelse false; const do_update = msg.body.get("update") != null; const upsert = bool_arg(msg.body.get("upsert")) orelse false; const ret_new = bool_arg(msg.body.get("new")) orelse false; const proj_pairs = doc_arg(msg.body.get("fields")); if (remove and do_update) return bad_value(reply, "remove and update are mutually exclusive"); if (!remove and !do_update) return bad_value(reply, "must specify update or remove"); var diag: update.Diagnostic = .{}; const opts = update.Options{ .array_filters = try parse_array_filters( reply, msg.body.get("arrayFilters"), "findAndModify.arrayFilters", ) orelse return, .query = q, .now_ms = std.Io.Timestamp.now(ctx.io, .real).toMilliseconds(), .diag = &diag, }; // The same three shapes `update`'s `u` takes, read once here. const u_pipeline: ?[]const bson.Value = switch (msg.body.get("update") orelse bson.Value.null) { .array => |a| a, else => null, }; const u_doc: []const bson.Pair = if (u_pipeline != null) &.{} else doc_arg(msg.body.get("update")) orelse &.{}; if (u_pipeline == null) { if (do_update and doc_arg(msg.body.get("update")) == null) { return bad_value(reply, "update must be a document"); } update.validate(u_doc, opts) catch |err| return update_refusal(reply, err, diag); } else if (opts.array_filters.len > 0) { return failed_to_parse(reply, "arrayFilters may not be specified for pipeline-style updates"); } var matched: std.ArrayListUnmanaged(u64) = .empty; defer matched.deinit(ctx.gpa); // Without a sort, only the first match is ever used. _ = try scan_matching(ctx, db_name, coll_name, q, if (sort_keys.len > 0) 0 else 1, &matched); const arena = reply.arena_alloc(); const coll = ctx.engine.get_collection(db_name, coll_name) orelse return; // findAndModify reads and rewrites the document, so materialize the // (usually tiny) match set as trees in the reply arena. var matched_docs: std.ArrayListUnmanaged(*const bson.Document) = .empty; for (matched.items) |off| try matched_docs.append(arena, try doc_tree(arena, coll, off)); if (sort_keys.len > 0) { try query.sort_docs(arena, matched_docs.items, sort_keys); } const target = if (matched_docs.items.len > 0) matched_docs.items[0] else null; // Each branch decides what the reply says; the tail below emits it once. var n: i32 = 0; var updated_existing = false; var upserted_id: ?bson.Value = null; var value: bson.Value = .null; if (target == null and do_update and upsert) { const new_doc = (try build_upsert_doc(reply, ctx, q, u_doc, u_pipeline, opts, &diag)) orelse return; ctx.engine.insert(db_name, coll_name, new_doc, ctx.oid_gen) catch |err| switch (err) { error.DuplicateKey, error.DuplicateKeyIndex => return duplicate_key_error(ctx, reply, db_name, coll_name, new_doc), error.HashedArray => return hashed_array_error(reply), else => return err, }; n = 1; upserted_id = try bson.copy_value(arena, new_doc.get("_id") orelse bson.Value.null); if (ret_new) value = try project_doc(reply, new_doc, proj_pairs); } else if (target != null and remove) { n = 1; // Project before removing: this reads the stored document. value = try project_doc(reply, target.?, proj_pairs); _ = try ctx.engine.remove_by_id(db_name, coll_name, target.?.get("_id") orelse unreachable); } else if (target != null and do_update) { const before = try bson.copy_pairs(arena, target.?.pairs); const copy = if (u_pipeline) |stages| (try apply_update_pipeline(ctx, reply, stages, target.?, coll)) orelse return else blk: { const c = try clone_doc(reply, target.?); update.apply(c, &.{ .arena = undefined, .pairs = u_doc }, opts) catch |err| return update_refusal(reply, err, diag); break :blk c; }; // findAndModify reports `n` (matched) and `updatedExisting`, neither of // which distinguishes a no-op, so whether it wrote is not needed here. // It has no writeErrors array either, so a document a hashed index // cannot take is a command error here rather than a per-write one. _ = ctx.engine.replace(db_name, coll_name, copy, ctx.oid_gen) catch |err| switch (err) { error.HashedArray => return hashed_array_error(reply), else => return err, }; n = 1; updated_existing = true; value = if (ret_new) try project_doc(reply, copy, proj_pairs) else .{ .doc = before }; } // else: no match and no upsert — an empty result var leo: std.ArrayListUnmanaged(bson.Pair) = .empty; defer leo.deinit(arena); try leo.append(arena, .{ .key = "n", .value = .{ .int32 = n } }); try leo.append(arena, .{ .key = "updatedExisting", .value = .{ .bool = updated_existing } }); if (upserted_id) |id| try leo.append(arena, .{ .key = "upserted", .value = id }); try reply.put("value", value); try reply.put("lastErrorObject", .{ .doc = try leo.toOwnedSlice(arena) }); try reply.put_ok(); } fn cmd_count(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { const db_name = msg.db_name() orelse return invalid_arg(reply, "count requires $db"); const coll_name = str_arg(msg.body.get("count")) orelse return bad_value(reply, "count requires a collection name"); const q = doc_arg(msg.body.get("query")) orelse &.{}; // `count` takes skip and limit like `find` does, and ignoring them was a // silent wrong answer for `countDocuments(f, {limit})`. const skip: u64 = int_arg(msg.body.get("skip")) orelse 0; const limit: u64 = @abs(int_value(msg.body.get("limit")) orelse 0); // Counting only needs to know whether the matches reach skip + limit, so the // scan may stop there. Unlike `find` this needs no index to be an early stop: // the *count* of a window does not depend on which documents fall in it. const ceiling: usize = if (limit == 0) 0 else blk: { const s = std.math.cast(usize, skip) orelse break :blk 0; const l = std.math.cast(usize, limit) orelse break :blk 0; break :blk s +| l; }; const matched = try scan_matching(ctx, db_name, coll_name, q, ceiling, null); const after_skip = matched -| (std.math.cast(usize, skip) orelse matched); const n = if (limit == 0) after_skip else @min(after_skip, limit); try reply.put("n", .{ .int32 = @intCast(n) }); try reply.put_ok(); } /// `distinct` is the one read command whose answer is a *set*, and both halves /// of what that means were measured against mongod 8.3.7 rather than recalled: /// /// - the values come back **sorted in canonical BSON order**, not in the /// order they were met. `{s: "a"}, {s: "b"}, {s: null}` answers /// `[null, "a", "b"]` -- null ahead of the strings, because that is where /// its type ranks. /// - deduping uses the same comparator, so an int32 `1` and a double `1.0` /// collapse into one value while `null` and `"1"` stay distinct. /// /// Both fall out of `bson.compare`, the comparator `$sort` and `$min` already /// use, and that is not a coincidence: mongod accumulates into a /// `BSONElementSet` ordered by the same `woCompare`. Sorting was the part /// worth measuring -- insertion order is the obvious guess and it is wrong. /// /// Unbounded in memory, like `$group` and `$sort`: every value at the key is /// held before the answer is deduped. mongod caps the reply at 16 MB instead; /// recorded in PLAN §6 with the other two rather than solved here. fn cmd_distinct(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { const db_name = msg.db_name() orelse return invalid_arg(reply, "distinct requires $db"); // A non-string collection name never reaches here: dispatch refuses it // with BadValue while resolving the lock target, where mongod answers // InvalidNamespace (73). Left alone deliberately -- that answer is // dispatch's for every command, and changing it is its own measurement. const coll_name = str_arg(msg.body.get("distinct")) orelse return bad_value(reply, "distinct requires a collection name"); const key_value = msg.body.get("key") orelse return reply.put_error( @intFromEnum(ErrorCode.idl_failed_to_parse), "IDLFailedToParse", "BSON field 'distinctCommandRequest.key' is missing but a required field", ); const key = str_arg(key_value) orelse return distinct_wrong_type(reply, "key", key_value, "string"); // `query` absent, and `query: null`, are both an empty filter -- measured, // and the second is not guessable from the first. Anything else that is // not a document is a TypeMismatch, which is stricter than `count` is // about its own `query`, because mongod parses this one through its IDL. const query_value: bson.Value = msg.body.get("query") orelse .null; const filter: []const bson.Pair = switch (query_value) { .doc => |pairs| pairs, .null => &.{}, else => return distinct_wrong_type(reply, "query", query_value, "object"), }; // An absent collection -- or database -- is an empty set, not an error. // Measured, and the same answer `aggregate` gives. const coll = ctx.engine.get_collection(db_name, coll_name) orelse { try reply.put("values", .{ .array = &.{} }); return reply.put_ok(); }; var offs: std.ArrayListUnmanaged(u64) = .empty; defer offs.deinit(ctx.gpa); _ = try scan_matching(ctx, db_name, coll_name, filter, 0, &offs); // The values outlive this frame inside the reply, so they are built in its // arena; the collection lock dispatch is holding is what keeps the slab // bytes they were read from mapped for the duration. const arena = reply.arena_alloc(); var values: std.ArrayListUnmanaged(bson.Value) = .empty; var at_key: std.ArrayListUnmanaged(bson.Value) = .empty; for (offs.items) |off| { at_key.clearRetainingCapacity(); // Byte-walked, and the same traversal the matcher and the index use: // a path through an array of subdocuments collects from each of them. try query.collect_values_bytes(arena, coll.doc_bytes(off), key, &at_key, 0); for (at_key.items) |v| switch (v) { // A terminal array contributes its elements rather than itself, // and exactly one level deep: `[[7, 8], 9]` answers `[7, 8]` and // `9`, never 7 and 8. The interior of the path is already unwound // by `collect_values_bytes`, so this only ever sees the last // segment's value. .array => |items| try values.appendSlice(arena, items), else => try values.append(arena, v), }; } // Sort, then collapse equal neighbours -- see the note above for why both // steps use `bson.compare`. `std.mem.sort` is stable, so when several // documents spell one value differently (`1` and `1.0`) the representation // that survives is the one the scan met first. std.mem.sort(bson.Value, values.items, {}, value_less); var n: usize = 0; for (values.items) |v| { if (n > 0 and bson.compare(values.items[n - 1], v) == .eq) continue; values.items[n] = v; n += 1; } try reply.put("values", .{ .array = values.items[0..n] }); try reply.put_ok(); } fn value_less(_: void, a: bson.Value, b: bson.Value) bool { return bson.compare(a, b) == .lt; } /// The shape mongod's IDL parser reports a wrong-typed command field in. A /// driver that matches on the text is matching on this, so it is reproduced /// rather than paraphrased. fn distinct_wrong_type( reply: *wire.Reply, field: []const u8, got: bson.Value, want: []const u8, ) !void { const text = try std.fmt.allocPrint( reply.arena_alloc(), "BSON field 'distinctCommandRequest.{s}' is the wrong type '{s}', expected type '{s}'", .{ field, got.type_name(), want }, ); return reply.put_error(@intFromEnum(ErrorCode.type_mismatch), "TypeMismatch", text); } fn cmd_aggregate(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { const db_name = msg.db_name() orelse return invalid_arg(reply, "aggregate requires $db"); const coll_name = str_arg(msg.body.get("aggregate")) orelse return bad_value(reply, "aggregate requires a collection name"); const batch_size = (try aggregate_batch_size(reply, msg) orelse return).value; const pipeline_value = msg.body.get("pipeline") orelse return bad_value(reply, "aggregate requires pipeline"); var stages = switch (pipeline_value) { .array => |a| a, 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 doc_from_pairs(arena, pairs); const one = try arena.alloc(*const bson.Document, 1); one[0] = doc; docs = one; } try emit_first_batch(ctx, reply, db_name, coll_name, null, docs, batch_size); return reply.put_ok(); } // The pipeline operates on a stream of documents; each stage transforms // the current window [start, end). Before $group the stream holds slab // offsets (matched in place, never materialized); $group replaces it // with generated group documents, so the stream flips to tree form. var offs: std.ArrayListUnmanaged(u64) = .empty; defer offs.deinit(ctx.gpa); var trees: std.ArrayListUnmanaged(*const bson.Document) = .empty; defer trees.deinit(ctx.gpa); var in_trees = false; // No such collection is an empty result, not an absent one. A bare // `return` here sent a reply with no `ok` field at all, which the driver // reports as the uninformative `MongoServerError: n/a` -- and it is what // `db.aggregate(...)` hits, because a database-level aggregate names no // collection. MongoDB answers an aggregate over a missing collection with // an empty cursor. const coll = ctx.engine.get_collection(db_name, coll_name) orelse { try emit_first_batch(ctx, reply, db_name, coll_name, null, &.{}, batch_size); return reply.put_ok(); }; // A leading $match is pushed down into an indexed candidate scan; the // stage is then dropped from the pipeline so it is not applied twice. if (stages.len > 0 and stages[0] == .doc and stages[0].doc.len > 0 and std.mem.eql(u8, stages[0].doc[0].key, "$match")) { const filter = doc_arg(stages[0].doc[0].value) orelse return bad_value(reply, "$match requires a document"); _ = try scan_matching(ctx, db_name, coll_name, filter, 0, &offs); stages = stages[1..]; } else { // Every document, in _id order. A pipeline materializes its stream // anyway (stages need random access to the window), so this one stays a // list -- but it comes from the _id_ index rather than the docs map, // which is ordered and does not depend on a structure that is going // away. Streaming the whole pipeline is M1's cursor work. var it = coll.id_index.iter(); while (it.next()) |e| try offs.append(ctx.gpa, e.off); } var start: usize = 0; var end: usize = offs.items.len; var count_stage: ?[]const u8 = null; for (stages) |stage_v| { const stage = switch (stage_v) { .doc => |pairs| pairs, else => return bad_value(reply, "pipeline stages must be documents"), }; if (stage.len == 0) continue; const stage_name = stage[0].key; if (std.mem.eql(u8, stage_name, "$match")) { const filter = doc_arg(stage[0].value) orelse return bad_value(reply, "$match requires a document"); if (!in_trees) { var kept: std.ArrayListUnmanaged(u64) = .empty; defer kept.deinit(ctx.gpa); for (offs.items[start..end]) |off| { if (try query.matches_bytes(ctx.gpa, filter, coll.doc_bytes(off))) { try kept.append(ctx.gpa, off); } } offs.deinit(ctx.gpa); offs = kept; kept = .empty; } else { var kept: std.ArrayListUnmanaged(*const bson.Document) = .empty; defer kept.deinit(ctx.gpa); for (trees.items[start..end]) |d| { if (try query.matches(ctx.gpa, &.{ .arena = undefined, .pairs = filter }, d)) { try kept.append(ctx.gpa, d); } } trees.deinit(ctx.gpa); trees = kept; kept = .empty; } start = 0; end = (if (in_trees) trees.items.len else offs.items.len); } else if (std.mem.eql(u8, stage_name, "$sort")) { const keys = try parse_sort_keys(reply, stage[0].value); if (keys.len > 0) { const arena = reply.arena_alloc(); if (!in_trees) { // Sorting needs the values; materialize and switch the // stream to tree form for the rest of the pipeline. // // The list buffer must come from `ctx.gpa`, because that is // what frees it: ownership moves to `trees`, and `trees` is // released by this function's `defer trees.deinit(ctx.gpa)` // and by the $match branch above. Building it from the // reply arena instead handed a gpa-free an arena-owned // pointer -- a remote, client-triggerable invalid free that // macOS malloc turns into SIGTRAP with no panic text, so it // read as "the connection closed". Any pipeline with $sort // and no preceding $group reached it. // // The *documents* stay in the arena on purpose: it outlives // the command, and only the ArrayList's own allocator has // to match its deinit. var all: std.ArrayListUnmanaged(*const bson.Document) = .empty; errdefer all.deinit(ctx.gpa); for (offs.items) |off| try all.append(ctx.gpa, try doc_tree(arena, coll, off)); trees.deinit(ctx.gpa); trees = all; all = .empty; in_trees = true; } try query.sort_docs(arena, trees.items[start..end], keys); } } else if (std.mem.eql(u8, stage_name, "$skip")) { const n = try stage_count(reply, stage[0].value, "$skip") orelse return; start = @min(start + n, end); } else if (std.mem.eql(u8, stage_name, "$limit")) { const n = try stage_count(reply, stage[0].value, "$limit") orelse return; end = @min(end, start + n); } else if (std.mem.eql(u8, stage_name, "$group")) { const gp = doc_arg(stage[0].value) orelse return bad_value(reply, "$group requires a document"); const src: Stream = if (in_trees) .{ .docs = trees.items[start..end] } else .{ .offsets = offs.items[start..end] }; const grouped_opt = try run_group(ctx, reply, coll, gp, src); var grouped = grouped_opt orelse return; // Group results replace the stream: later stages see groups. offs.deinit(ctx.gpa); offs = .empty; trees.deinit(ctx.gpa); trees = grouped; grouped = .empty; in_trees = true; start = 0; end = trees.items.len; } else if (std.mem.eql(u8, stage_name, "$addFields") or std.mem.eql(u8, stage_name, "$set") or std.mem.eql(u8, stage_name, "$unset") or std.mem.eql(u8, stage_name, "$replaceRoot") or std.mem.eql(u8, stage_name, "$replaceWith") or std.mem.eql(u8, stage_name, "$unwind") or std.mem.eql(u8, stage_name, "$project")) { // The stages that rewrite a document, all one shape: read the // window, build a new list, replace the stream. The design review // expected these to need a per-stage iterator because `$unwind` is // 1->N -- true of the window as it stood, and no longer true once a // stage rebuilds the list rather than moving bounds over it. const arena = reply.arena_alloc(); var built: std.ArrayListUnmanaged(*const bson.Document) = .empty; errdefer built.deinit(ctx.gpa); const rewrite = try compile_rewrite(ctx, reply, arena, stage_name, stage[0].value) orelse return; var w: usize = start; while (w < end) : (w += 1) { const doc = if (in_trees) trees.items[w] else try doc_tree(arena, coll, offs.items[w]); const ec: EvalCtx = .{ .arena = arena, .coll = coll, .src = .{ .docs = (&doc)[0..1] }, .i = 0, }; apply_rewrite(ec, rewrite, doc, &built, ctx.gpa) catch |err| { try report_eval_error(reply, err); return; }; } offs.deinit(ctx.gpa); offs = .empty; trees.deinit(ctx.gpa); trees = built; built = .empty; in_trees = true; start = 0; end = trees.items.len; } else if (std.mem.eql(u8, stage_name, "$out") or std.mem.eql(u8, stage_name, "$merge")) { const is_out = std.mem.eql(u8, stage_name, "$out"); // MongoDB requires either to be last, and this server needs it too: // the stage does not produce a stream for a later one to read. if (!std.mem.eql(u8, stage_name, stages[stages.len - 1].doc[0].key)) { const detail = try std.fmt.allocPrint( reply.arena_alloc(), "{s} can only be the final stage in the pipeline", .{stage_name}, ); return reply.put_error(@intFromEnum(ErrorCode.location_write_stage_not_last), "Location40601", detail); } const target = (try write_stage_target(reply, db_name, stage[0].value, is_out)) orelse return; const arena = reply.arena_alloc(); var out_docs: std.ArrayListUnmanaged(*const bson.Document) = .empty; if (in_trees) { for (trees.items[start..end]) |d| try out_docs.append(arena, d); } else { for (offs.items[start..end]) |off| try out_docs.append(arena, try doc_tree(arena, coll, off)); } // Handed to the epilogue rather than written here: see // `Context.pending_write`. The documents live in the reply's arena, // which outlives it. ctx.pending_write = .{ .db = target.db, .coll = target.coll, .docs = out_docs.items, .mode = if (is_out) .replace else .merge, }; // Both stages answer an empty cursor, as mongod does: the output // went to a collection, not to the client. try emit_first_batch(ctx, reply, db_name, coll_name, null, &.{}, batch_size); return reply.put_ok(); } else if (std.mem.eql(u8, stage_name, "$count")) { count_stage = switch (stage[0].value) { .string => |s| s, else => return bad_value(reply, "$count requires a string"), }; } else { const msg_text = try std.fmt.allocPrint(reply.arena_alloc(), "Unrecognized pipeline stage name: '{s}'", .{stage_name}); // 40324 is right for "unrecognized stage" but its name is not // `InvalidPipelineOperator` (that is 168). mongod reports numeric // Location codes under a `Location` name -- verified by asking a // real mongod for an unknown stage. const code = @intFromEnum(ErrorCode.location_unrecognized_stage); return reply.put_error(code, "Location40324", msg_text); } } if (count_stage) |name| { const len = if (in_trees) trees.items[start..end].len else offs.items[start..end].len; const c = try reply.arena_alloc().alloc(bson.Pair, 1); c[0] = .{ .key = try reply.arena_alloc().dupe(u8, name), .value = .{ .int32 = @intCast(len) } }; const one = try reply.arena_alloc().alloc(*const bson.Document, 1); one[0] = try doc_from_pairs(reply.arena_alloc(), c); try emit_first_batch(ctx, reply, db_name, coll_name, null, one, batch_size); } else { const arena = reply.arena_alloc(); if (in_trees) { const window = trees.items[start..end]; try emit_first_batch(ctx, reply, db_name, coll_name, null, window, batch_size); } else { var page: std.ArrayListUnmanaged(*const bson.Document) = .empty; for (offs.items[start..end]) |off| try page.append(arena, try doc_tree(arena, coll, off)); const window = page.items; try emit_first_batch(ctx, reply, db_name, coll_name, null, window, batch_size); } } 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 }; } /// Refuse a `$project` that mixes inclusion with exclusion, which mongod /// refuses too -- so this is parity rather than a limitation of this server. /// Judged on the *flattened* flags, so a nested spec is treated the same way a /// dotted one is. /// /// The rest of what this used to refuse -- computed fields and nested specs -- /// is implemented now. They were refused because both read as falsy, which put /// the whole projection into its exclusion branch and returned the entire /// document minus that field. fn refuse_mixed_projection(reply: *wire.Reply, flags: []const bson.Pair) !bool { var include: ?bool = null; for (flags) |p| { // `_id` is the one field that may be excluded from an inclusion // projection, so it never decides which kind this is. if (std.mem.eql(u8, p.key, "_id")) continue; const flag = query.truthy(p.value); if (include) |want| { if (want != flag) { const detail = try std.fmt.allocPrint( reply.arena_alloc(), "Invalid $project :: caused by :: Cannot do {s} on field {s} in {s} projection", .{ if (flag) "inclusion" else "exclusion", p.key, if (want) "inclusion" else "exclusion", }, ); try reply.put_error(@intFromEnum(ErrorCode.location_project_mixed), "Location31254", detail); return true; } } else include = flag; } return false; } /// One document put through a projection, as a tree the rest of the pipeline /// can read. This is what makes `$project` a stage rather than a note about how /// to print the answer. fn projected_tree( arena: std.mem.Allocator, doc: *const bson.Document, pp: []const bson.Pair, ) !*const bson.Document { var out: std.ArrayListUnmanaged(bson.Pair) = .empty; try query.project(arena, doc, &.{ .arena = undefined, .pairs = pp }, &out); const projected = try arena.create(bson.Document); projected.* = .{ .arena = undefined, .pairs = out.items }; return projected; } /// Where a `$out` or `$merge` writes, or null once the client has been told /// why not. /// /// `$out` takes a collection name or `{db, coll}`; `$merge` takes `into` in /// either of those shapes. Everything past that -- `whenMatched`, /// `whenNotMatched`, `on`, `let` -- selects behaviour this server does not /// have, so it is refused rather than ignored: a `whenMatched: "fail"` that /// silently merged would be the same lie Tier 0 spent three commits removing. fn write_stage_target( reply: *wire.Reply, db_name: []const u8, v: bson.Value, is_out: bool, ) !?struct { db: []const u8, coll: []const u8 } { var spec = v; if (!is_out) { const d = doc_arg(v) orelse { // mongod's IDL parser answers for the whole stage document. try reply.put_error( @intFromEnum(ErrorCode.idl_failed_to_parse), "IDLFailedToParse", "BSON field '$merge.into' is missing but a required field", ); return null; }; for (d) |p| { if (std.mem.eql(u8, p.key, "into")) continue; const detail = try std.fmt.allocPrint( reply.arena_alloc(), "$merge does not support '{s}' on this server: only the default " ++ "whenMatched/whenNotMatched behaviour is implemented", .{p.key}, ); try reply.put_error(@intFromEnum(ErrorCode.idl_unknown_field), "IDLUnknownField", detail); return null; } spec = bson.get_pair(d, "into") orelse { try reply.put_error( @intFromEnum(ErrorCode.idl_failed_to_parse), "IDLFailedToParse", "BSON field '$merge.into' is missing but a required field", ); return null; }; } switch (spec) { .string => |name| return .{ .db = db_name, .coll = name }, .doc => |d| { const coll = str_arg(bson.get_pair(d, "coll")) orelse { try bad_value(reply, "the target of a write stage needs a coll"); return null; }; return .{ .db = str_arg(bson.get_pair(d, "db")) orelse db_name, .coll = coll }; }, else => { try bad_value(reply, "the target of a write stage must be a string or a document"); return null; }, } } /// Apply what an aggregation's last stage asked for. The caller holds no lock. /// /// Not atomic, and that has to be said out loud: mongod replaces an `$out` /// target atomically, and this engine has no cross-collection atomicity and no /// rename to build one out of. A crash between the drop and the last insert /// leaves the target holding part of the new output where MongoDB would leave /// the whole of the old. Recorded in `docs/M2_DESIGN_REVIEW.md` as the open /// half of this decision rather than papered over: the shape that fixes it is /// write-to-temp-and-rename, and rename is a command this server does not have. fn apply_pending_write(ctx: *Context, pending: PendingWrite) !void { try ctx.engine.lock(); defer ctx.engine.unlock(); if (pending.mode == .replace) { // `$out` means "the target holds this and nothing else". _ = ctx.engine.drop_collection(pending.db, pending.coll) catch |err| switch (err) { error.NamespaceNotFound => {}, else => return err, }; } for (pending.docs) |d| { // The pairs belong to the reply's arena; this Document is only a // carrier, so its own arena is empty and frees nothing that matters. var doc: bson.Document = .{ .arena = std.heap.ArenaAllocator.init(ctx.gpa), .pairs = d.pairs }; defer doc.arena.deinit(); // `$out` writes into a collection it has just emptied, so every write // is an insert; `$merge`'s default pair is "replace the document with // this `_id`, or insert it", which is what `replace` already means. _ = try ctx.engine.replace(pending.db, pending.coll, &doc, ctx.oid_gen); } } /// Where a pipeline stage reads its input. /// /// A pipeline starts as slab offsets -- matched and reordered in place, never /// materialized -- and flips to generated documents the moment a stage /// produces something that is not in the slab. Both forms are real and a stage /// that reads only one of them reads the wrong list. /// /// `$group` used to take `[]const u64` and be handed `offs.items[start..end]` /// unconditionally, with `start`/`end` set from whichever list was live. After /// a stage that materializes, `offs` is empty and the bounds are the tree /// count, so a second `$group` sliced an empty list with a non-zero end and /// panicked the server. Any client could send it. const Stream = union(enum) { offsets: []const u64, docs: []const *const bson.Document, fn len(self: Stream) usize { return switch (self) { .offsets => |o| o.len, .docs => |d| d.len, }; } }; /// A dotted path resolved against a document tree, the counterpart of /// `query_path_value_bytes` for the materialized half of a stream. fn path_in_pairs(pairs: []const bson.Pair, path: []const u8) ?bson.Value { var it = std.mem.splitScalar(u8, path, '.'); var cur = bson.get_pair(pairs, it.next() orelse return null) orelse return null; while (it.next()) |seg| { cur = switch (cur) { .doc => |p| bson.get_pair(p, seg) orelse return null, else => return null, }; } return cur; } /// One item of a stream, resolved along `path`, whichever form the stream is /// in. The slab side stays byte-walked: `$group` over a million documents does /// not build a million trees to read one field. fn stream_path( gpa: std.mem.Allocator, /// Null when the stream is materialized. An `.offsets` stream is the only /// form that needs one, because only it reads the slab -- which is what /// lets a pipeline-style update evaluate expressions against a document /// held in memory, before the collection it will be inserted into exists. coll: ?*const Collection, src: Stream, i: usize, path: []const u8, ) !?bson.Value { return switch (src) { .offsets => |o| try query_path_value_bytes(gpa, coll.?.doc_bytes(o[i]), path), .docs => |d| path_in_pairs(d[i].pairs, path), }; } /// A compiled aggregation expression. /// /// Compiled once per pipeline and evaluated per document, and that split is /// what preserves the property M2's refusals bought: a pipeline that cannot be /// answered is refused before a single document is read, rather than half way /// through with part of the work already reported. const Expr = union(enum) { /// A `$`-prefixed string, holding the path without its sigil. path: []const u8, constant: bson.Value, /// `{a: , b: }`: a document whose values are expressions. This /// is what a compound `$group` `_id` is, and it used to collapse every /// document into one group keyed by the unevaluated document. fields: []const Field, array: []const Expr, op: Operator, const Field = struct { key: []const u8, value: Expr }; }; const OpKind = enum { literal, add, subtract, multiply, divide, mod, eq, ne, lt, lte, gt, gte, cmp, all_of, any_of, not, cond, if_null, switch_, }; const Operator = struct { kind: OpKind, args: []const Expr, /// `$switch` only, and only when it had one. Kept apart from `args`, /// because "no default" and "a default of null" are different answers -- /// the first is an error, measured as 40069. fallback: ?*const Expr = null, }; fn op_kind(name: []const u8) ?OpKind { const table = .{ .{ "$literal", OpKind.literal }, .{ "$add", OpKind.add }, .{ "$subtract", OpKind.subtract }, .{ "$multiply", OpKind.multiply }, .{ "$divide", OpKind.divide }, .{ "$mod", OpKind.mod }, .{ "$eq", OpKind.eq }, .{ "$ne", OpKind.ne }, .{ "$lt", OpKind.lt }, .{ "$lte", OpKind.lte }, .{ "$gt", OpKind.gt }, .{ "$gte", OpKind.gte }, .{ "$cmp", OpKind.cmp }, .{ "$and", OpKind.all_of }, .{ "$or", OpKind.any_of }, .{ "$not", OpKind.not }, .{ "$cond", OpKind.cond }, .{ "$ifNull", OpKind.if_null }, .{ "$switch", OpKind.switch_ }, }; inline for (table) |e| { if (std.mem.eql(u8, name, e[0])) return e[1]; } return null; } /// Operands an operator takes, or null when it is variadic. mongod checks this /// at parse time and answers 16020, which is why this is a compile-time table /// rather than a runtime length test. fn op_arity(kind: OpKind) ?usize { return switch (kind) { .literal, .not => 1, .subtract, .divide, .mod, .cmp => 2, .cond => 3, else => null, }; } /// Compilation allocates and reports; it reads no documents, so nothing else /// can go wrong. Written out rather than inferred because `compile_expr` and /// `compile_operator` call each other, and Zig cannot infer a cycle. const CompileError = std.mem.Allocator.Error; /// Compile `v` into an expression, or answer the client and return null. /// /// `what` names the position, because a refusal that does not say what it /// refused is only half an improvement on a silent zero. fn compile_expr(reply: *wire.Reply, arena: std.mem.Allocator, v: bson.Value, what: []const u8) CompileError!?Expr { switch (v) { .string => |str| { // "$x" is a path; "x" is the string itself. The one place that // distinction is made, where it used to be open-coded per use. if (str.len > 0 and str[0] == '$') return Expr{ .path = str[1..] }; return Expr{ .constant = v }; }, .array => |items| { const out = try arena.alloc(Expr, items.len); for (items, 0..) |item, i| { out[i] = (try compile_expr(reply, arena, item, what)) orelse return null; } return Expr{ .array = out }; }, .doc => |d| { const leads_with_op = d.len > 0 and d[0].key.len > 0 and d[0].key[0] == '$'; if (!leads_with_op) { // A compound expression: every value is itself an expression. const out = try arena.alloc(Expr.Field, d.len); for (d, 0..) |p, i| { const sub = (try compile_expr(reply, arena, p.value, what)) orelse return null; out[i] = .{ .key = p.key, .value = sub }; } return Expr{ .fields = out }; } if (d.len != 1) { // 15983, and deliberately not `$group`'s 40238: mongod uses a // different code for an expression than for an accumulator, and // a client that switches on the code would notice. const detail = try std.fmt.allocPrint( reply.arena_alloc(), "an expression specification must contain exactly one field, the name of the expression", .{}, ); try reply.put_error(@intFromEnum(ErrorCode.location_two_expression_operators), "Location15983", detail); return null; } const kind = op_kind(d[0].key) orelse { const detail = try std.fmt.allocPrint( reply.arena_alloc(), "Unrecognized expression '{s}'", .{d[0].key}, ); try reply.put_error(@intFromEnum(ErrorCode.invalid_pipeline_operator), "InvalidPipelineOperator", detail); return null; }; return compile_operator(reply, arena, kind, d[0].key, d[0].value, what); }, else => return Expr{ .constant = v }, } } fn compile_operator( reply: *wire.Reply, arena: std.mem.Allocator, kind: OpKind, name: []const u8, spec: bson.Value, what: []const u8, ) CompileError!?Expr { // `$literal` is the one operator whose argument is *not* an expression -- // that is the whole of what it does. if (kind == .literal) { const one = try arena.alloc(Expr, 1); one[0] = .{ .constant = spec }; return Expr{ .op = .{ .kind = kind, .args = one } }; } if (kind == .switch_) return compile_switch(reply, arena, spec, what); // `$cond` has a document form as well as its three-element array. if (kind == .cond and spec == .doc and spec.doc.len > 0 and spec.doc[0].key[0] != '$') { const args = try arena.alloc(Expr, 3); const names = [_][]const u8{ "if", "then", "else" }; for (names, 0..) |field, i| { const sub = bson.get_pair(spec.doc, field) orelse { const detail = try std.fmt.allocPrint(reply.arena_alloc(), "Missing '{s}' parameter to $cond", .{field}); try reply.put_error(@intFromEnum(ErrorCode.location_wrong_operand_count), "Location16020", detail); return null; }; args[i] = (try compile_expr(reply, arena, sub, what)) orelse return null; } return Expr{ .op = .{ .kind = kind, .args = args } }; } // Everything else takes its operands as an array, or as a bare value when // there is one of them -- `{$not: "$f"}` is as legal as `{$not: ["$f"]}`. var args: []const Expr = undefined; if (spec == .array) { const out = try arena.alloc(Expr, spec.array.len); for (spec.array, 0..) |item, i| { out[i] = (try compile_expr(reply, arena, item, what)) orelse return null; } args = out; } else { const one = try arena.alloc(Expr, 1); one[0] = (try compile_expr(reply, arena, spec, what)) orelse return null; args = one; } if (op_arity(kind)) |want| { if (args.len != want) { const detail = try std.fmt.allocPrint( reply.arena_alloc(), "Expression {s} takes exactly {d} arguments. {d} were passed in.", .{ name, want, args.len }, ); try reply.put_error(@intFromEnum(ErrorCode.location_wrong_operand_count), "Location16020", detail); return null; } } return Expr{ .op = .{ .kind = kind, .args = args } }; } /// `{$switch: {branches: [{case, then}, ...], default: }}`. The branches /// flatten into `args` as case, then, case, then; the default stays apart, so /// that having none is distinguishable from having one that is null. fn compile_switch(reply: *wire.Reply, arena: std.mem.Allocator, spec: bson.Value, what: []const u8) CompileError!?Expr { const d = doc_arg(spec) orelse { try bad_value(reply, "$switch requires an object"); return null; }; const branches = switch (bson.get_pair(d, "branches") orelse bson.Value.null) { .array => |a| a, else => { try bad_value(reply, "$switch requires an array of branches"); return null; }, }; const args = try arena.alloc(Expr, branches.len * 2); for (branches, 0..) |b, i| { const bd = doc_arg(b) orelse { try bad_value(reply, "$switch branches must be objects"); return null; }; const case_v = bson.get_pair(bd, "case") orelse { try bad_value(reply, "$switch branch requires a case"); return null; }; const then_v = bson.get_pair(bd, "then") orelse { try bad_value(reply, "$switch branch requires a then"); return null; }; args[i * 2] = (try compile_expr(reply, arena, case_v, what)) orelse return null; args[i * 2 + 1] = (try compile_expr(reply, arena, then_v, what)) orelse return null; } var fallback: ?*const Expr = null; if (bson.get_pair(d, "default")) |dv| { const boxed = try arena.create(Expr); boxed.* = (try compile_expr(reply, arena, dv, what)) orelse return null; fallback = boxed; } return Expr{ .op = .{ .kind = .switch_, .args = args, .fallback = fallback } }; } /// A failure only a document can produce, so it cannot be caught at compile /// time. Both codes measured against mongod. const EvalError = error{ DivideByZero, SwitchNoDefault, NonNumericArithmetic, ReplaceRootNotDocument } || std.mem.Allocator.Error || error{ EndOfStream, Overflow, InvalidBson }; /// A `$project` taken apart: the inclusion/exclusion flags flattened to dotted /// paths, and the computed fields as expressions. /// /// Flattening is what lets a nested spec work without touching /// `query.project`, which `find` shares: `{n: {x: 1}}` *is* `{"n.x": 1}`, and a /// dotted path is something the projection already narrows correctly. The /// nested form used to read as *falsy*, which flipped the whole projection into /// its exclusion branch and returned every document minus that field. const ProjectSpec = struct { flags: []const bson.Pair, computed: []const Expr.Field, /// A projection that only computes -- `{$project: {b: }}` -- keeps /// `_id` and nothing else. `query.project` cannot say that: with no /// non-`_id` flag it reads the spec as an *exclusion* and returns the whole /// document, so this case is built directly instead. id_only: bool = false, }; fn compile_project( reply: *wire.Reply, arena: std.mem.Allocator, pp: []const bson.Pair, ) !?ProjectSpec { var flags: std.ArrayListUnmanaged(bson.Pair) = .empty; var computed: std.ArrayListUnmanaged(Expr.Field) = .empty; if (try flatten_project(reply, arena, pp, "", &flags, &computed)) return null; if (flags.items.len == 0 and computed.items.len == 0) { try reply.put_error( @intFromEnum(ErrorCode.location_project_empty), "Location51272", "Invalid $project :: caused by :: projection specification must have at least one field", ); return null; } return ProjectSpec{ .flags = flags.items, .computed = computed.items, .id_only = flags.items.len == 0, }; } /// Walk a projection spec into flags and computed fields. Returns true when it /// has already answered the client. fn flatten_project( reply: *wire.Reply, arena: std.mem.Allocator, pp: []const bson.Pair, prefix: []const u8, flags: *std.ArrayListUnmanaged(bson.Pair), computed: *std.ArrayListUnmanaged(Expr.Field), ) CompileError!bool { for (pp) |p| { const key = if (prefix.len == 0) p.key else try std.fmt.allocPrint(arena, "{s}.{s}", .{ prefix, p.key }); switch (p.value) { .bool, .int32, .int64, .double => try flags.append(arena, .{ .key = key, .value = p.value }), .doc => |d| { // `{a: {$op: ...}}` computes; `{a: {b: 1}}` narrows. if (d.len > 0 and d[0].key.len > 0 and d[0].key[0] == '$') { const e = (try compile_expr(reply, arena, p.value, "a $project value")) orelse return true; try computed.append(arena, .{ .key = key, .value = e }); } else if (try flatten_project(reply, arena, d, key, flags, computed)) { return true; } }, // A bare path is a rename, which is a computed field like any other. else => { const e = (try compile_expr(reply, arena, p.value, "a $project value")) orelse return true; try computed.append(arena, .{ .key = key, .value = e }); }, } } return false; } /// A compiled document-rewriting stage. const Rewrite = union(enum) { /// `$addFields` and `$set`, which are the same stage under two names. add_fields: []const Expr.Field, unset: []const []const u8, replace_root: Expr, unwind: UnwindSpec, project: ProjectSpec, }; fn compile_rewrite( ctx: *Context, reply: *wire.Reply, arena: std.mem.Allocator, name: []const u8, spec: bson.Value, ) !?Rewrite { _ = ctx; if (std.mem.eql(u8, name, "$project")) { const pp = doc_arg(spec) orelse { try bad_value(reply, "$project requires a document"); return null; }; const parsed = (try compile_project(reply, arena, pp)) orelse return null; // Mixed inclusion and exclusion is judged on the *flattened* flags, so // a nested spec is treated the same way a dotted one is. if (try refuse_mixed_projection(reply, parsed.flags)) return null; return Rewrite{ .project = parsed }; } if (std.mem.eql(u8, name, "$unwind")) { return Rewrite{ .unwind = (try unwind_spec(reply, spec)) orelse return null }; } if (std.mem.eql(u8, name, "$unset")) { return Rewrite{ .unset = (try unset_paths(reply, arena, spec)) orelse return null }; } if (std.mem.eql(u8, name, "$replaceRoot")) { const d = doc_arg(spec) orelse { try bad_value(reply, "$replaceRoot requires a document"); return null; }; const new_root = bson.get_pair(d, "newRoot") orelse { try bad_value(reply, "$replaceRoot requires newRoot"); return null; }; return Rewrite{ .replace_root = (try compile_expr(reply, arena, new_root, "$replaceRoot's newRoot")) orelse return null }; } // `$replaceWith` is `$replaceRoot` with the expression in place of the // `{newRoot: ...}` wrapper -- one stage under two spellings, like // `$addFields` and `$set`. Reached from `aggregate` as well as from a // pipeline-style update, since the compiler is shared. if (std.mem.eql(u8, name, "$replaceWith")) { return Rewrite{ .replace_root = (try compile_expr(reply, arena, spec, "$replaceWith's expression")) orelse return null }; } const d = doc_arg(spec) orelse { try bad_value(reply, "$addFields requires a document"); return null; }; const fields = try arena.alloc(Expr.Field, d.len); for (d, 0..) |p, i| { const sub = (try compile_expr(reply, arena, p.value, "an $addFields value")) orelse return null; fields[i] = .{ .key = p.key, .value = sub }; } return Rewrite{ .add_fields = fields }; } /// Put one document through a rewrite, appending whatever it produces. A stage /// may emit no documents (`$unwind` of an empty array) or several. fn apply_rewrite( ec: EvalCtx, rewrite: Rewrite, doc: *const bson.Document, out: *std.ArrayListUnmanaged(*const bson.Document), gpa: std.mem.Allocator, ) EvalError!void { switch (rewrite) { .add_fields => |fields| { var pairs: []const bson.Pair = doc.pairs; for (fields) |f| { // A value that resolves to nothing leaves the field out // entirely, rather than setting it to null. const v = (try eval_expr(ec, f.value)) orelse continue; pairs = try set_path(ec.arena, pairs, f.key, v); } try out.append(gpa, try tree_of(ec.arena, pairs)); }, .unset => |paths| { var pairs: []const bson.Pair = doc.pairs; for (paths) |path| pairs = try unset_path(ec.arena, pairs, path); try out.append(gpa, try tree_of(ec.arena, pairs)); }, .replace_root => |e| { const v = (try eval_expr(ec, e)) orelse return error.ReplaceRootNotDocument; const pairs = switch (v) { .doc => |p| p, else => return error.ReplaceRootNotDocument, }; try out.append(gpa, try tree_of(ec.arena, pairs)); }, .project => |spec| { var kept: std.ArrayListUnmanaged(bson.Pair) = .empty; if (spec.id_only) { if (bson.get_pair(doc.pairs, "_id")) |id| try kept.append(ec.arena, .{ .key = "_id", .value = id }); } else { query.project(ec.arena, doc, &.{ .arena = undefined, .pairs = spec.flags }, &kept) catch return error.OutOfMemory; } var pairs: []const bson.Pair = kept.items; for (spec.computed) |f| { // Same rule as `$addFields`: an expression that resolves to // nothing leaves the field out rather than setting it null. const v = (try eval_expr(ec, f.value)) orelse continue; pairs = try set_path(ec.arena, pairs, f.key, v); } try out.append(gpa, try tree_of(ec.arena, pairs)); }, .unwind => |spec| { const found = try eval_expr(ec, .{ .path = spec.path }); const items: []const bson.Value = switch (found orelse bson.Value.null) { .array => |a| a, // A non-array is kept whole, and a missing or null field is // dropped unless the caller asked to keep it. .null => if (spec.keep_empty) &.{} else return, else => { try out.append(gpa, doc); return; }, }; if (items.len == 0) { if (!spec.keep_empty) return; var pairs = try unset_path(ec.arena, doc.pairs, spec.path); if (spec.index_field) |f| pairs = try set_path(ec.arena, pairs, f, .null); try out.append(gpa, try tree_of(ec.arena, pairs)); return; } for (items, 0..) |item, i| { var pairs = try set_path(ec.arena, doc.pairs, spec.path, item); if (spec.index_field) |f| { pairs = try set_path(ec.arena, pairs, f, .{ .int64 = @intCast(i) }); } try out.append(gpa, try tree_of(ec.arena, pairs)); } }, } } fn tree_of(arena: std.mem.Allocator, pairs: []const bson.Pair) EvalError!*const bson.Document { const d = try arena.create(bson.Document); d.* = .{ .arena = undefined, .pairs = pairs }; return d; } /// A document with `path` set to `v`, creating the intermediate documents a /// dotted path names. Siblings are kept and an existing field is replaced where /// it stands, which is what makes `$addFields` "add or overwrite" rather than /// "append". fn set_path( arena: std.mem.Allocator, pairs: []const bson.Pair, path: []const u8, v: bson.Value, ) EvalError![]const bson.Pair { const dot = std.mem.indexOfScalar(u8, path, '.'); const head = if (dot) |d| path[0..d] else path; const value: bson.Value = if (dot) |d| blk: { const existing = switch (bson.get_pair(pairs, head) orelse bson.Value.null) { .doc => |sub| sub, // A non-document in the way is replaced by one, as mongod does. else => &.{}, }; break :blk .{ .doc = try set_path(arena, existing, path[d + 1 ..], v) }; } else v; for (pairs, 0..) |p, i| { if (!std.mem.eql(u8, p.key, head)) continue; const out = try arena.alloc(bson.Pair, pairs.len); @memcpy(out, pairs); out[i] = .{ .key = head, .value = value }; return out; } const out = try arena.alloc(bson.Pair, pairs.len + 1); @memcpy(out[0..pairs.len], pairs); out[pairs.len] = .{ .key = head, .value = value }; return out; } /// The same document without `path`. A path naming nothing changes nothing. fn unset_path( arena: std.mem.Allocator, pairs: []const bson.Pair, path: []const u8, ) EvalError![]const bson.Pair { const dot = std.mem.indexOfScalar(u8, path, '.'); const head = if (dot) |d| path[0..d] else path; var out: std.ArrayListUnmanaged(bson.Pair) = .empty; try out.ensureTotalCapacity(arena, pairs.len); for (pairs) |p| { if (!std.mem.eql(u8, p.key, head)) { out.appendAssumeCapacity(p); continue; } const d = dot orelse continue; // a leaf: drop it const sub = switch (p.value) { .doc => |x| x, else => { out.appendAssumeCapacity(p); continue; }, }; out.appendAssumeCapacity(.{ .key = p.key, .value = .{ .doc = try unset_path(arena, sub, path[d + 1 ..]) }, }); } return out.items; } /// The paths a `$unset` names: one string, or an array of them. fn unset_paths(reply: *wire.Reply, arena: std.mem.Allocator, v: bson.Value) !?[]const []const u8 { switch (v) { .string => |str| { const one = try arena.alloc([]const u8, 1); one[0] = str; return one; }, .array => |items| { const out = try arena.alloc([]const u8, items.len); for (items, 0..) |item, i| { out[i] = switch (item) { .string => |str| str, else => { try bad_value(reply, "$unset takes field names"); return null; }, }; } return out; }, else => { try bad_value(reply, "$unset takes a field name or an array of them"); return null; }, } } /// What a `$unwind` was asked to do. Both spellings land here: the bare /// `"$path"` string and the document form. const UnwindSpec = struct { path: []const u8, keep_empty: bool = false, index_field: ?[]const u8 = null, }; fn unwind_spec(reply: *wire.Reply, v: bson.Value) !?UnwindSpec { const raw: bson.Value = switch (v) { .doc => |d| bson.get_pair(d, "path") orelse { try bad_value(reply, "$unwind requires a path"); return null; }, else => v, }; const str = switch (raw) { .string => |x| x, else => { try bad_value(reply, "$unwind requires a string path"); return null; }, }; if (str.len == 0 or str[0] != '$') { const detail = try std.fmt.allocPrint( reply.arena_alloc(), "path option to $unwind stage should be prefixed with a '$': {s}", .{str}, ); try reply.put_error(@intFromEnum(ErrorCode.location_unwind_bad_path), "Location28818", detail); return null; } var spec: UnwindSpec = .{ .path = str[1..] }; if (v == .doc) { if (bson.get_pair(v.doc, "preserveNullAndEmptyArrays")) |b| spec.keep_empty = query.truthy(b); if (bson.get_pair(v.doc, "includeArrayIndex")) |f| { spec.index_field = switch (f) { .string => |x| x, else => null, }; } } return spec; } /// Turn a failure only a document could produce into the reply mongod gives. fn report_eval_error(reply: *wire.Reply, err: EvalError) !void { switch (err) { error.DivideByZero => try reply.put_error( @intFromEnum(ErrorCode.location_divide_by_zero), "Location4848401", "can't $divide by zero", ), error.SwitchNoDefault => try reply.put_error( @intFromEnum(ErrorCode.location_switch_no_default), "Location40069", "$switch could not find a matching branch for an input, and no default was specified.", ), error.ReplaceRootNotDocument => try reply.put_error( @intFromEnum(ErrorCode.location_replace_root_not_document), "Location40228", "$replaceRoot requires a document as its newRoot", ), error.NonNumericArithmetic => try reply.put_error( @intFromEnum(ErrorCode.location_non_numeric_arithmetic), "Location7157723", "only numbers are allowed in an $add or $subtract expression", ), else => return err, } } /// Where an expression reads its document from. const EvalCtx = struct { arena: std.mem.Allocator, coll: ?*const Collection, src: Stream, i: usize, }; /// Evaluate `e` against one document. `null` is *absent*, which several /// operators distinguish from a present BSON null -- `$ifNull` treats them /// alike, `$push` does not. fn eval_expr(ec: EvalCtx, e: Expr) EvalError!?bson.Value { switch (e) { .path => |path| return stream_path(ec.arena, ec.coll, ec.src, ec.i, path), .constant => |v| return v, .fields => |fs| { const pairs = try ec.arena.alloc(bson.Pair, fs.len); var n: usize = 0; for (fs) |f| { // A field whose expression resolves to nothing is left out, // which is how a compound `_id` drops a missing path. const v = (try eval_expr(ec, f.value)) orelse continue; pairs[n] = .{ .key = f.key, .value = v }; n += 1; } return bson.Value{ .doc = pairs[0..n] }; }, .array => |items| { const out = try ec.arena.alloc(bson.Value, items.len); for (items, 0..) |item, i| out[i] = (try eval_expr(ec, item)) orelse .null; return bson.Value{ .array = out }; }, .op => |o| return eval_operator(ec, o), } } fn eval_operator(ec: EvalCtx, o: Operator) EvalError!?bson.Value { switch (o.kind) { .literal => return o.args[0].constant, .add, .multiply, .subtract, .divide, .mod => return eval_arithmetic(ec, o), .eq, .ne, .lt, .lte, .gt, .gte, .cmp => { // Absent compares as null, which is what makes // `{$eq: ["$missing", null]}` true. const a = (try eval_expr(ec, o.args[0])) orelse .null; const b = (try eval_expr(ec, o.args[1])) orelse .null; const ord = bson.compare(a, b); if (o.kind == .cmp) return bson.Value{ .int32 = switch (ord) { .lt => -1, .eq => 0, .gt => 1, } }; return bson.Value{ .bool = switch (o.kind) { .eq => ord == .eq, .ne => ord != .eq, .lt => ord == .lt, .lte => ord != .gt, .gt => ord == .gt, .gte => ord != .lt, else => unreachable, } }; }, .all_of, .any_of => { const want = o.kind == .any_of; for (o.args) |arg| { if (expr_truthy(try eval_expr(ec, arg)) == want) return bson.Value{ .bool = want }; } return bson.Value{ .bool = !want }; }, .not => return bson.Value{ .bool = !expr_truthy(try eval_expr(ec, o.args[0])) }, .cond => { const take: usize = if (expr_truthy(try eval_expr(ec, o.args[0]))) 1 else 2; return eval_expr(ec, o.args[take]); }, .if_null => { // Every operand but the last is a candidate; the last is the // fallback, and is returned whether or not it is itself null. for (o.args[0 .. o.args.len - 1]) |arg| { const v = try eval_expr(ec, arg); if (v) |present| { if (present != .null) return present; } } return eval_expr(ec, o.args[o.args.len - 1]); }, .switch_ => { var i: usize = 0; while (i < o.args.len) : (i += 2) { if (expr_truthy(try eval_expr(ec, o.args[i]))) return eval_expr(ec, o.args[i + 1]); } const fallback = o.fallback orelse return error.SwitchNoDefault; return eval_expr(ec, fallback.*); }, } } /// MongoDB's truthiness: false, null, absent and any numeric zero are false, /// and everything else -- including a negative number and an empty string -- /// is true. fn expr_truthy(v: ?bson.Value) bool { return switch (v orelse return false) { .bool => |b| b, .null => false, .int32 => |n| n != 0, .int64 => |n| n != 0, .double => |n| n != 0, else => true, }; } fn eval_arithmetic(ec: EvalCtx, o: Operator) EvalError!?bson.Value { var acc: f64 = if (o.kind == .multiply) 1 else 0; for (o.args, 0..) |arg, i| { const v = try eval_expr(ec, arg); // Absent or null makes the whole expression null -- not an error, and // not a zero. Measured: `{$add: ["$missing", 1]}` is null. const present = v orelse return bson.Value.null; const x: f64 = switch (present) { .int32 => |n| @floatFromInt(n), .int64 => |n| @floatFromInt(n), .double => |n| n, .null => return bson.Value.null, else => return error.NonNumericArithmetic, }; switch (o.kind) { .add => acc += x, .multiply => acc *= x, .subtract => acc = if (i == 0) x else acc - x, .divide, .mod => { if (i == 0) { acc = x; } else { if (x == 0) return error.DivideByZero; acc = if (o.kind == .divide) acc / x else @rem(acc, x); } }, else => unreachable, } } return numeric_value(acc); } /// A running total as MongoDB reports it: an integral value inside int32 range /// comes back an int32, anything else a double. The count fast path in /// `cmd_aggregate` mirrors this exactly, and a divergence between them would /// make `countDocuments` disagree with the pipeline it is a shortcut for. fn numeric_value(x: f64) bson.Value { if (x == @floor(x) and x <= 2_147_483_647 and x >= -2_147_483_648) { return .{ .int32 = @intFromFloat(x) }; } return .{ .double = x }; } /// The accumulators this server implements. Every one of them takes a single /// value per document -- a path or a constant -- so none of them needs the /// expression evaluator, which is why they land before it rather than after: /// the corpus said nine of its ten failures were reachable without one. const AccKind = enum { sum, avg, min, max, first, last, push, add_to_set, count }; fn acc_kind(name: []const u8) ?AccKind { const table = .{ .{ "$sum", AccKind.sum }, .{ "$avg", AccKind.avg }, .{ "$min", AccKind.min }, .{ "$max", AccKind.max }, .{ "$first", AccKind.first }, .{ "$last", AccKind.last }, .{ "$push", AccKind.push }, .{ "$addToSet", AccKind.add_to_set }, .{ "$count", AccKind.count }, }; inline for (table) |e| { if (std.mem.eql(u8, name, e[0])) return e[1]; } return null; } /// One output field of a `$group`, with its argument already classified. const Accumulator = struct { key: []const u8, kind: AccKind, arg: Expr, }; /// What one accumulator has seen of one group so far. /// /// One struct rather than a union: the fields are small, the branches are /// per-kind anyway, and a union would need a tag test at every site that a /// switch on `kind` already makes. const AccState = struct { /// `$sum`'s running total, and `$avg`'s numerator. total: f64 = 0, /// Documents seen for `$count`; *numeric values* seen for `$avg`, which is /// what makes `$avg` ignore the non-numbers rather than average them in as /// zeroes. n: u64 = 0, /// `$min`/`$max`/`$first`/`$last`. Null means nothing qualified, which is /// the answer mongod gives for all four. value: ?bson.Value = null, /// `$push` and `$addToSet`. items: std.ArrayListUnmanaged(bson.Value) = .empty, }; /// Minimal $group: `_id` of a constant or "$field", and `$sum` accumulators /// over the same. Everything else is refused rather than answered. fn run_group( ctx: *Context, reply: *wire.Reply, coll: *const Collection, group_pairs: []const bson.Pair, src: Stream, ) !?std.ArrayListUnmanaged(*const bson.Document) { const arena = reply.arena_alloc(); const id_expr = bson.get_pair(group_pairs, "_id") orelse { try reply.put_error( @intFromEnum(ErrorCode.location_group_needs_id), "Location15955", "a group specification must include an _id", ); return null; }; const id_class = (try compile_expr(reply, arena, id_expr, "the _id of a $group")) orelse return null; // Every accumulator is validated before a single document is read, so a // pipeline that cannot be answered is refused rather than half-answered. var accs: std.ArrayListUnmanaged(Accumulator) = .empty; defer accs.deinit(arena); for (group_pairs) |p| { if (std.mem.eql(u8, p.key, "_id")) continue; const spec = switch (p.value) { .doc => |d| d, else => { const detail = try std.fmt.allocPrint( arena, "The field '{s}' must be an accumulator object", .{p.key}, ); try reply.put_error(@intFromEnum(ErrorCode.location_accumulator_not_object), "Location40234", detail); return null; }, }; if (spec.len != 1) { const detail = try std.fmt.allocPrint( arena, "The field '{s}' must specify one accumulator", .{p.key}, ); try reply.put_error(@intFromEnum(ErrorCode.location_one_accumulator), "Location40238", detail); return null; } const kind = acc_kind(spec[0].key) orelse { // Still the honest answer for the ones that remain unimplemented -- // `$stdDevPop`, `$mergeObjects`, `$top` -- reported with MongoDB's // own code for "no such operator". See the note on the codes. const detail = try std.fmt.allocPrint( arena, "unknown group operator '{s}'", .{spec[0].key}, ); try reply.put_error(@intFromEnum(ErrorCode.location_unknown_group_operator), "Location15952", detail); return null; }; // `$count` takes `{}` and nothing else, so it never reaches the // expression classifier -- an empty document is exactly what that // refuses. const arg: Expr = if (kind == .count) .{ .constant = .null } else blk: { const what = try std.fmt.allocPrint(arena, "the argument of '{s}'", .{p.key}); break :blk (try compile_expr(reply, arena, spec[0].value, what)) orelse return null; }; try accs.append(arena, .{ .key = p.key, .kind = kind, .arg = arg }); } const Group = struct { id_value: bson.Value, states: []AccState, }; var groups: std.StringHashMapUnmanaged(Group) = .empty; defer groups.deinit(ctx.gpa); // StringHashMapUnmanaged does not copy keys; keep them alive until done. var keys_owned: std.ArrayListUnmanaged([]u8) = .empty; defer { for (keys_owned.items) |k| ctx.gpa.free(k); keys_owned.deinit(ctx.gpa); } var id_key_buf: std.ArrayListUnmanaged(u8) = .empty; defer id_key_buf.deinit(ctx.gpa); // Byte-walk materializations (nested group keys) live here. var walk_arena = std.heap.ArenaAllocator.init(ctx.gpa); defer walk_arena.deinit(); var i: usize = 0; while (i < src.len()) : (i += 1) { const ec: EvalCtx = .{ .arena = walk_arena.allocator(), .coll = coll, .src = src, .i = i }; const id_value: bson.Value = (eval_expr(ec, id_class) catch |err| { try report_eval_error(reply, err); return null; }) orelse .null; id_key_buf.clearRetainingCapacity(); try bson.write_value(id_value, ctx.gpa, &id_key_buf); const gop = try groups.getOrPut(ctx.gpa, id_key_buf.items); if (!gop.found_existing) { // Only a new group needs an owned copy of the key; the map // borrows it, so keys_owned keeps it alive until we are done. const key = try ctx.gpa.dupe(u8, id_key_buf.items); try keys_owned.append(ctx.gpa, key); gop.key_ptr.* = key; const states = try ctx.gpa.alloc(AccState, accs.items.len); @memset(states, .{}); gop.value_ptr.* = .{ .id_value = id_value, .states = states }; } for (accs.items, 0..) |acc, a| { const st = &gop.value_ptr.states[a]; if (acc.kind == .count) { st.n += 1; continue; } // A path that resolves to nothing is *absent*, which several of // these treat differently from a present null: `$push` skips it // where it would push an explicit null, and `$min` ignores it. const found: ?bson.Value = eval_expr(ec, acc.arg) catch |err| { try report_eval_error(reply, err); return null; }; switch (acc.kind) { .count => unreachable, .sum, .avg => { // A number or nothing. `{$sum: "$name"}` over strings really // is zero -- MongoDB's rule, not a stand-in for something // unimplemented -- and `$avg`'s divisor counts only what it // added, which is what makes it ignore the rest rather than // average them in as zeroes. const num: ?f64 = switch (found orelse bson.Value.null) { .int32 => |n| @as(f64, @floatFromInt(n)), .int64 => |n| @as(f64, @floatFromInt(n)), .double => |n| n, else => null, }; if (num) |x| { st.total += x; st.n += 1; } }, .min, .max => { const v = found orelse continue; // Canonical BSON order, across types: the smaller of a // number and a string is the number, which is a type rule // rather than a value one. if (st.value) |cur| { const ord = bson.compare(v, cur); const take = if (acc.kind == .min) ord == .lt else ord == .gt; if (!take) continue; } st.value = try bson.copy_value(arena, v); }, .first => { if (st.n == 0) { if (found) |v| st.value = try bson.copy_value(arena, v); st.n = 1; } }, .last => { st.value = if (found) |v| try bson.copy_value(arena, v) else null; }, .push => { const v = found orelse continue; try st.items.append(arena, try bson.copy_value(arena, v)); }, .add_to_set => { const v = found orelse continue; // Linear, because a set of BSON values has no cheap hash // that respects canonical equality, and because a group key // with thousands of distinct values in one set is not the // shape this is for. for (st.items.items) |seen| { if (bson.compare(seen, v) == .eq) break; } else try st.items.append(arena, try bson.copy_value(arena, v)); }, } } } var out: std.ArrayListUnmanaged(*const bson.Document) = .empty; errdefer out.deinit(ctx.gpa); var it = groups.iterator(); // The state arrays are the gpa's; the values inside them are the reply // arena's and outlive this function with the documents they end up in. defer { var git = groups.iterator(); while (git.next()) |e| ctx.gpa.free(e.value_ptr.states); } while (it.next()) |entry| { const npairs = 1 + accs.items.len; const pairs = try arena.alloc(bson.Pair, npairs); pairs[0] = .{ .key = "_id", .value = try bson.copy_value(arena, entry.value_ptr.id_value) }; for (accs.items, 0..) |acc, a| { const st = entry.value_ptr.states[a]; pairs[1 + a] = .{ .key = acc.key, .value = switch (acc.kind) { .sum => numeric_value(st.total), // Nothing numeric seen is `null`, not zero: an average of no // values is not an average of zero. .avg => if (st.n == 0) .null else numeric_value(st.total / @as(f64, @floatFromInt(st.n))), .count => numeric_value(@floatFromInt(st.n)), .min, .max, .first, .last => st.value orelse .null, .push, .add_to_set => .{ .array = st.items.items }, } }; } const doc = try arena.create(bson.Document); doc.* = bson.Document{ .arena = undefined, .pairs = pairs }; try out.append(ctx.gpa, doc); } return out; } /// Resolve a simple "$field" path expression inside a document. fn query_path_value_bytes( gpa: std.mem.Allocator, bytes: []const u8, path: []const u8, ) !?bson.Value { var cur: bson.Value = undefined; var it = std.mem.splitScalar(u8, path, '.'); const first = it.next() orelse return null; cur = (try bson.get_at(gpa, bytes, first)) orelse return null; while (it.next()) |seg| { cur = switch (cur) { .doc => |pairs| bson.get_pair(pairs, seg) orelse return null, else => return null, }; } return cur; } /// A cursor id from the wire. Accepts int32 as well as int64, so a hand-written /// `runCommand` is not rejected on a technicality; the driver always sends a /// long. fn cursor_id_arg(v: ?bson.Value) ?i64 { return switch (v orelse return null) { .int64 => |i| i, .int32 => |i| i, else => null, }; } fn cursor_not_found(reply: *wire.Reply, id: i64) !void { const text = try std.fmt.allocPrint(reply.arena_alloc(), "cursor id {d} not found", .{id}); return reply.put_error(@intFromEnum(ErrorCode.cursor_not_found), "CursorNotFound", text); } fn query_plan_killed(reply: *wire.Reply, why: []const u8) !void { const text = try std.fmt.allocPrint( reply.arena_alloc(), "query plan killed :: caused by :: {s}", .{why}, ); const code = @intFromEnum(ErrorCode.query_plan_killed); return reply.put_error(code, "QueryPlanKilled", text); } /// Whether this cursor can still be answered from `coll`, or why not. /// /// What a rebuild since the cursor was created costs depends entirely on what the /// cursor remembers. `.offsets` holds slab offsets and a rebuild moved every /// document, so those offsets now name unrelated bytes. `.stream` holds key /// bytes, and a repack changes no key -- only the anchor's offset and position /// hint go stale, and both are checked before they are believed, so the walk /// resumes at the right key and the offsets it yields come fresh out of the tree. /// `.buffered` holds copies of the documents and needs no collection at all, /// which is what lets a listing hold a cursor over a `$cmd.*` namespace. fn cursor_still_valid(c: *cursor.Cursor, coll: ?*Collection) ?[]const u8 { const live = coll orelse { // A snapshot needs no collection at all, which is what lets a listing // hold a cursor over a `$cmd.*` namespace nothing backs. return if (c.source == .buffered) null else "collection dropped"; }; if (live.layout_epoch == c.layout_epoch) return null; if (c.source == .offsets) return "collection rebuilt"; // Survived the rebuild: adopt the new layout so the next getMore does not // re-examine it. c.layout_epoch = live.layout_epoch; return null; } /// The stored filter and projection, reparsed for this request. They were /// serialized into the cursor's own arena because the parsed forms pointed into /// the request that created them; `spine` parses the structure and borrows the /// leaf bytes, so this costs no copy of the filter's strings. fn cursor_query(reply: *wire.Reply, c: *const cursor.Cursor) !struct { filter: []const bson.Pair, proj: ?[]const bson.Pair, } { const arena = reply.arena_alloc(); return .{ .filter = if (c.filter_bytes.len == 0) &.{} else try bson.spine(arena, c.filter_bytes), .proj = if (c.proj_bytes.len == 0) null else try bson.spine(arena, c.proj_bytes), }; } /// The shape of a `getMore` request, or null once the error reply is written. fn parse_get_more(reply: *wire.Reply, msg: *wire.Message) !?struct { id: i64, batch_size: ?u32 } { const id = cursor_id_arg(msg.body.get("getMore")) orelse { const text = "BSON field 'getMore.getMore' is the wrong type"; try reply.put_error(@intFromEnum(ErrorCode.type_mismatch), "TypeMismatch", text); return null; }; var batch_size: ?u32 = null; if (msg.body.get("batchSize")) |v| { batch_size = try batch_size_arg(reply, v, "batchSize", true) orelse return null; } return .{ .id = id, .batch_size = batch_size }; } /// Claim the cursor for this request, or write the error reply and return null. /// /// The namespace check inside `pin` is load-bearing rather than cosmetic: /// dispatch locks the collection the *message* names, so a getMore quoting one /// cursor's id and another collection's name would otherwise iterate the first /// collection's index while holding the second collection's lock. fn pin_cursor(ctx: *Context, reply: *wire.Reply, id: i64, ns: cursor.Ns) !?*cursor.Cursor { return ctx.engine.cursors.pin(ctx.io, id, ns, now_ms(ctx)) catch |err| switch (err) { error.CursorNotFound => { try cursor_not_found(reply, id); return null; }, error.CursorNamespaceMismatch => { var found: cursor.NsBuf = .{}; const arena = reply.arena_alloc(); const text = if (ctx.engine.cursors.ns_of(ctx.io, id, &found)) try std.fmt.allocPrint( arena, "Requested getMore on namespace '{s}.{s}', but cursor belongs to" ++ " a different namespace {s}.{s}", .{ ns.db, ns.coll, found.ns().db, found.ns().coll }, ) else try std.fmt.allocPrint( arena, "Requested getMore on namespace '{s}.{s}', but cursor belongs to" ++ " a different namespace", .{ ns.db, ns.coll }, ); try reply.put_error(@intFromEnum(ErrorCode.unauthorized), "Unauthorized", text); return null; }, error.CursorInUse => { const text = try std.fmt.allocPrint( reply.arena_alloc(), "cursor id {d} is already in use", .{id}, ); try reply.put_error(@intFromEnum(ErrorCode.cursor_in_use), "CursorInUse", text); return null; }, error.Canceled => return err, }; } fn cmd_get_more(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { const db_name = msg.db_name() orelse return invalid_arg(reply, "getMore requires $db"); const coll_name = str_arg(msg.body.get("collection")) orelse return bad_value(reply, "Field 'collection' must be of type string"); if (coll_name.len == 0) return bad_value(reply, "Collection names cannot be empty"); const req = try parse_get_more(reply, msg) orelse return; const id = req.id; const ns = cursor.Ns{ .db = db_name, .coll = coll_name }; const c = try pin_cursor(ctx, reply, id, ns) orelse return; // Set to true by every path that must not leave the cursor behind, including // the error paths below: a cursor whose collection is gone can never answer // again, so keeping it would only occupy a slot until the idle sweep. var done = false; defer ctx.engine.cursors.release(ctx.io, c, now_ms(ctx), done); const coll = ctx.engine.get_collection(db_name, coll_name); if (cursor_still_valid(c, coll)) |why| { done = true; return query_plan_killed(reply, why); } const q = try cursor_query(reply, c); var feed = Feed{ .source = c.source, .remaining_limit = c.remaining_limit }; // Persist on every exit, not just the success path. For a stream the advance // *is* the resume anchor, so an early return that skipped this would report a // batch and then hand the same documents out again on the next getMore. // Registered after `release`'s defer, so it runs before it. defer { c.source = feed.source; c.remaining_limit = feed.remaining_limit; } if (feed.source == .stream) { // Only a stream needs the collection here; `open_scan` walks its index. assert_msg(coll != null, "a streaming cursor reached getMore with no collection"); // Reopening the walk is where a resume actually happens. Null means the // index the cursor was following is gone, or its anchor could not be // located within the walk bound -- either way there is no honest way to // continue, and an empty batch would falsely claim the result ended. feed.scan = open_scan(coll.?, &feed.source.stream); if (feed.scan == null) { done = true; return query_plan_killed(reply, "the index this cursor was following is gone"); } } var values: std.ArrayListUnmanaged(bson.Value) = .empty; // Deliberately not `batch_size orelse c.batch_size`: mongod does not carry // the find's batchSize into a bare getMore. Measured -- find with // batchSize 2 then a bare getMore returns 4998 of 5000 documents. const target = cursor.batch_target(req.batch_size, false); done = try fill_batch(ctx, reply, coll, &feed, q.filter, q.proj, target, &values); const ns_str = try format_namespace(reply, db_name, coll_name); const live_id: i64 = if (done) 0 else id; const batch = try cursor_doc(reply, live_id, ns_str, "nextBatch", values.items); try reply.put("cursor", .{ .doc = batch }); try reply.put_ok(); } fn cmd_kill_cursors(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { const db_name = msg.db_name() orelse return invalid_arg(reply, "killCursors requires $db"); // Unlike getMore, killCursors' own field really is the collection name. const coll_name = str_arg(msg.body.get("killCursors")) orelse return bad_value(reply, "killCursors requires a collection name"); const cursors_arg = msg.body.get("cursors") orelse return bad_value(reply, "killCursors requires a cursors array"); const ids = switch (cursors_arg) { .array => |a| a, else => return bad_value(reply, "cursors must be an array"), }; const arena = reply.arena_alloc(); var killed: std.ArrayListUnmanaged(bson.Value) = .empty; var not_found: std.ArrayListUnmanaged(bson.Value) = .empty; const ns = cursor.Ns{ .db = db_name, .coll = coll_name }; for (ids) |v| { const id = cursor_id_arg(v) orelse { try not_found.append(arena, v); continue; }; // A namespace mismatch reports not-found rather than erroring: // killCursors is best-effort by design, and the driver ignores its reply // entirely. switch (ctx.engine.cursors.kill(ctx.io, id, ns)) { .killed => try killed.append(arena, .{ .int64 = id }), .not_found => try not_found.append(arena, .{ .int64 = id }), } } try reply.put("cursorsKilled", .{ .array = killed.items }); try reply.put("cursorsNotFound", .{ .array = not_found.items }); // Empty by construction: a cursor pinned by an in-flight getMore is marked // and reported killed, since the client's intent is satisfied and the // request frees it on release. `cursorsUnknown` exists for shape -- every // outcome here is classified. try reply.put("cursorsAlive", .{ .array = &.{} }); try reply.put("cursorsUnknown", .{ .array = &.{} }); try reply.put_ok(); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Deep-copy a stored document into the reply arena so updates can be /// applied off the live doc. fn clone_doc(reply: *wire.Reply, doc: *const bson.Document) !*bson.Document { const arena = reply.arena_alloc(); const owned = try arena.create(bson.Document); owned.* = .{ .arena = std.heap.ArenaAllocator.init(arena), .pairs = try bson.copy_pairs(arena, doc.pairs), }; return owned; } /// The reply an update refusal turns into. /// /// Shared by all four places that apply an update, because a refusal wired /// into three of them would be a silent divergence between `update`, /// `findAndModify` and the upsert path -- and the two new ones exist to stop /// a silent divergence in the first place. /// /// Every message here was measured on mongod 8.3.7 and is reproduced verbatim, /// except where mongod's text embeds a shell-syntax rendering of the offending /// BSON (`Cannot create field 'nope' in element {y: [ { b: 3 } ]}`): there is /// no formatter here that produces it, and a half-copy would be worse than a /// clear sentence that does not pretend. Codes are exact throughout, and codes /// are what the corpus asserts. /// /// mongod wraps the refusals it only reaches with a document in hand in /// `Plan executor error during update :: caused by :: `. That prefix is /// dropped here: it names a mongod component this server does not have. fn update_refusal(reply: *wire.Reply, err: anyerror, diag: update.Diagnostic) !void { const arena = reply.arena_alloc(); switch (err) { error.PositionalFirst => return bad_value(reply, if (std.mem.eql(u8, diag.segment, "$")) try std.fmt.allocPrint( arena, "Cannot have positional (i.e. '$') element in the first position in path '{s}'", .{diag.path}, ) else try std.fmt.allocPrint( arena, "Cannot have array filter identifier (i.e. '$[]') element in the " ++ "first position in path '{s}'", .{diag.path}, )), error.TooManyPositional => return bad_value(reply, try std.fmt.allocPrint( arena, "Too many positional (i.e. '$') elements found in path '{s}'", .{diag.path}, )), error.NoArrayFilter => return bad_value(reply, try std.fmt.allocPrint( arena, "No array filter found for identifier '{s}' in path '{s}'", .{ diag.segment, diag.path }, )), error.BadArrayFilterIdentifier => return bad_value(reply, try std.fmt.allocPrint( arena, "Error parsing array filter :: caused by :: The top-level field name must be an " ++ "alphanumeric string beginning with a lowercase letter, found '{s}'", .{diag.segment}, )), error.NoPositionalMatch => return bad_value( reply, "The positional operator did not find the match needed from the query.", ), error.ArrayPathRequired => return bad_value(reply, try std.fmt.allocPrint( arena, "The path '{s}' must exist in the document in order to apply array updates.", .{diag.path}, )), error.NotAnArrayPath => return bad_value(reply, try std.fmt.allocPrint( arena, "Cannot apply array updates to non-array element at path '{s}'", .{diag.path}, )), error.RenameDynamicSource => return bad_value(reply, try std.fmt.allocPrint( arena, "The source field for $rename may not be dynamic: {s}", .{diag.path}, )), error.RenameDynamicDestination => return bad_value(reply, try std.fmt.allocPrint( arena, "The destination field for $rename may not be dynamic: {s}", .{diag.path}, )), error.UnusedArrayFilter => return failed_to_parse(reply, try std.fmt.allocPrint( arena, "The array filter for identifier '{s}' was not used in the update", .{diag.segment}, )), error.DuplicateArrayFilter => return failed_to_parse(reply, try std.fmt.allocPrint( arena, "Found multiple array filters with the same top-level field name {s}", .{diag.segment}, )), error.EmptyArrayFilter => return failed_to_parse( reply, "Cannot use an expression without a top-level field name in arrayFilters", ), error.MultipleArrayFilterIdentifiers => return failed_to_parse(reply, try std.fmt.allocPrint( arena, "Error parsing array filter :: caused by :: Expected a single top-level field " ++ "name, found '{s}' and '{s}'", .{ diag.segment, diag.other }, )), error.NotNumericField => return reply.put_error( @intFromEnum(ErrorCode.type_mismatch), "TypeMismatch", try std.fmt.allocPrint( arena, "Cannot apply {s} to a value of non-numeric type. The field '{s}' is of " ++ "non-numeric type {s}", .{ diag.segment, diag.path, diag.other }, ), ), error.NotNumericOperand => return reply.put_error( @intFromEnum(ErrorCode.type_mismatch), "TypeMismatch", try std.fmt.allocPrint( arena, "Cannot {s} with non-numeric argument at field '{s}'", .{ if (std.mem.eql(u8, diag.segment, "$inc")) "increment" else "multiply", diag.path }, ), ), error.NotAnArrayField => return bad_value(reply, try std.fmt.allocPrint( arena, "Cannot apply {s} to non-array field. Field named '{s}' has non-array type", .{ diag.segment, diag.path }, )), error.NotAnArrayPathElement => return reply.put_error( @intFromEnum(ErrorCode.type_mismatch), "TypeMismatch", try std.fmt.allocPrint( arena, "Path '{s}' contains an element of non-array type", .{diag.path}, ), ), // mongod has two sentences here -- "$pop expects 1 or -1, found: 2" // and "Expected a number in: t: \"x\"" -- both code 9, and both about // an argument that is not one of the two values `$pop` takes. One // sentence covering both says the same thing without rendering the // operand, which no formatter here does. error.BadPopArgument => return failed_to_parse(reply, try std.fmt.allocPrint( arena, "$pop expects 1 or -1, at field '{s}'", .{diag.path}, )), error.PullAllNeedsArray => return bad_value(reply, try std.fmt.allocPrint( arena, "$pullAll requires an array argument but was given a {s}", .{diag.other}, )), // `$push` and `$addToSet` disagree about the code for the identical // mistake: 2 and 14. Measured on both, and not derivable from either. error.BadEach => if (std.mem.eql(u8, diag.segment, "$addToSet")) return reply.put_error( @intFromEnum(ErrorCode.type_mismatch), "TypeMismatch", "The argument to $each in $addToSet must be an array", ) else return bad_value(reply, "The argument to $each in $push must be an array"), error.BadPushModifier => return bad_value(reply, try std.fmt.allocPrint( arena, "Unrecognized or invalid $push modifier '{s}' at field '{s}'", .{ diag.segment, diag.path }, )), error.BadCurrentDateType => return bad_value( reply, "The '$type' string field is required to be 'date' or 'timestamp': " ++ "{$currentDate: {field : {$type: 'date'}}}", ), error.BadCurrentDateOperand => return bad_value(reply, try std.fmt.allocPrint( arena, "{s} is not valid type for $currentDate. Please use a boolean ('true') or a " ++ "$type expression ({{$type: 'timestamp/date'}}).", .{diag.other}, )), error.UnknownModifier => return failed_to_parse(reply, try std.fmt.allocPrint( arena, "Unknown modifier: {s}. Expected a valid update modifier or pipeline-style " ++ "update specified as an array", .{diag.segment}, )), error.ModifierNeedsFields => return failed_to_parse(reply, try std.fmt.allocPrint( arena, "Modifiers operate on fields but we found type {s} instead", .{diag.segment}, )), error.ConflictingUpdate => return reply.put_error( @intFromEnum(ErrorCode.conflicting_update_operators), "ConflictingUpdateOperators", try std.fmt.allocPrint( arena, "Updating the path '{s}' would create a conflict at '{s}'", .{ diag.path, diag.segment }, ), ), error.PathNotViable => return reply.put_error( @intFromEnum(ErrorCode.path_not_viable), "PathNotViable", try std.fmt.allocPrint( arena, "Cannot create field '{s}' in an array, at path '{s}'", .{ diag.segment, diag.path }, ), ), error.ImmutableId, error.InvalidUpdate => return bad_value(reply, "bad update"), else => return err, } } /// Read an `arrayFilters` argument into the bindings `$[]` /// resolves against. /// /// Only the shape is the command's business, and only the shape is checked /// here: which identifier a filter names, whether it is spelled legally and /// whether the update ever uses it all belong to `update.validate`, which is /// the half that can see the update's paths. `field` is the dotted name /// mongod puts in the message, and it differs between the two callers. /// /// Returns null having written the error reply, like the other `*_arg` /// helpers. fn parse_array_filters( reply: *wire.Reply, value: ?bson.Value, comptime field: []const u8, ) !?[]update.ArrayFilter { const v = value orelse return &.{}; const arr = switch (v) { .array => |a| a, else => { try wrong_type(reply, field, v, "array"); return null; }, }; const out = try reply.arena_alloc().alloc(update.ArrayFilter, arr.len); for (arr, 0..) |elem, i| { out[i] = .{ .pairs = switch (elem) { .doc => |d| d, else => { const arena = reply.arena_alloc(); const name = try std.fmt.allocPrint(arena, field ++ ".{d}", .{i}); try wrong_type_dynamic(reply, name, elem, "object"); return null; }, } }; } return out; } fn wrong_type( reply: *wire.Reply, comptime field: []const u8, got: bson.Value, comptime want: []const u8, ) !void { return wrong_type_dynamic(reply, field, got, want); } fn wrong_type_dynamic( reply: *wire.Reply, field: []const u8, got: bson.Value, want: []const u8, ) !void { const text = try std.fmt.allocPrint( reply.arena_alloc(), "BSON field '{s}' is the wrong type '{s}', expected type '{s}'", .{ field, got.type_name(), want }, ); return reply.put_error(@intFromEnum(ErrorCode.type_mismatch), "TypeMismatch", text); } /// The stages an update may be written out of. Every one rewrites a single /// document into a single document, which is what makes them usable here: /// `$match` could drop it, `$group` and `$unwind` could change how many there /// are, and `$sort` means nothing to one. mongod refuses those four by name /// rather than by shape, and so does this. const update_pipeline_stages = [_][]const u8{ "$addFields", "$set", "$project", "$unset", "$replaceRoot", "$replaceWith", }; /// Apply a pipeline-style update to one document, returning the new one. /// /// Returns null having written the error reply, like the other `*_arg` /// helpers. `coll` may be null: the stages are fed a materialized document, so /// nothing reads the slab, and the upsert path has no collection yet. fn apply_update_pipeline( ctx: *Context, reply: *wire.Reply, stages: []const bson.Value, doc: *const bson.Document, coll: ?*const Collection, ) !?*const bson.Document { const arena = reply.arena_alloc(); // The `_id` the document came in with. Every stage may drop it -- a // `$replaceRoot` almost always does -- and it comes back afterwards, // because a pipeline update rewrites a document rather than replacing one // document with another. Measured on all six stages. const original_id = doc.get("_id"); var current = doc; for (stages) |stage| { const spec = doc_arg(stage) orelse { try failed_to_parse(reply, "each element of a pipeline update must be a document"); return null; }; if (spec.len != 1) { try reply.put_error( @intFromEnum(ErrorCode.location_stage_needs_one_field), "Location40323", "A pipeline stage specification object must contain exactly one field.", ); return null; } const name = spec[0].key; if (!contains_name(&update_pipeline_stages, name)) { if (is_known_pipeline_stage(name)) { try reply.put_error( @intFromEnum(ErrorCode.invalid_options), "InvalidOptions", try std.fmt.allocPrint(arena, "{s} is not allowed to be used within an update", .{name}), ); } else { try reply.put_error( @intFromEnum(ErrorCode.location_unrecognized_stage), "Location40324", try std.fmt.allocPrint(arena, "Unrecognized pipeline stage name: '{s}'", .{name}), ); } return null; } const rewrite = try compile_rewrite(ctx, reply, arena, name, spec[0].value) orelse return null; var built: std.ArrayListUnmanaged(*const bson.Document) = .empty; defer built.deinit(ctx.gpa); const ec: EvalCtx = .{ .arena = arena, .coll = coll, .src = .{ .docs = (¤t)[0..1] }, .i = 0 }; apply_rewrite(ec, rewrite, current, &built, ctx.gpa) catch |err| { try report_eval_error(reply, err); return null; }; // Every stage here is 1->1, so this holds by construction; it is an // assertion rather than a branch because a stage that broke it would // silently drop or duplicate the document being updated. assert(built.items.len == 1); current = built.items[0]; } if (original_id) |id| { if (current.get("_id")) |now| { if (bson.compare(id, now) != .eq) { try reply.put_error( @intFromEnum(ErrorCode.immutable_field), "ImmutableField", "After applying the update, the (immutable) field '_id' was found to have been altered", ); return null; } } else { const with_id = try arena.alloc(bson.Pair, current.pairs.len + 1); with_id[0] = .{ .key = "_id", .value = id }; @memcpy(with_id[1..], current.pairs); const owned = try arena.create(bson.Document); owned.* = .{ .arena = std.heap.ArenaAllocator.init(arena), .pairs = with_id }; current = owned; } } return current; } fn contains_name(names: []const []const u8, name: []const u8) bool { for (names) |n| if (std.mem.eql(u8, n, name)) return true; return false; } /// Whether a name is a stage this server knows anywhere, which is the whole of /// the difference between "not here" (72) and "not at all" (40324). fn is_known_pipeline_stage(name: []const u8) bool { const known = [_][]const u8{ "$match", "$group", "$sort", "$limit", "$skip", "$unwind", "$count", "$out", "$merge", "$lookup", "$facet", "$sample", "$sortByCount", "$documents", }; return contains_name(&known, name); } /// Build the document for an upsert: equality fields from the filter, then /// the update operators applied. Owned by the reply arena. /// /// The `_id` is settled *here* rather than in the storage engine. `insert` /// generates one into the bytes it writes and leaves the caller's tree without /// it, so `updateOne(..., {upsert: true}).upsertedId` came back null and /// `findOneAndUpdate` with `returnDocument: after` returned a document missing /// its `_id`. Both are what the client is told about a document it has never /// seen, and both were wrong. Found by `tests/spec/operators/`, which is the /// first corpus here to upsert into an empty collection and then look. fn build_upsert_doc( reply: *wire.Reply, ctx: *Context, q: []const bson.Pair, u_doc: []const bson.Pair, u_pipeline: ?[]const bson.Value, opts: update.Options, diag: *update.Diagnostic, ) !?*const bson.Document { const arena = reply.arena_alloc(); var pairs: std.ArrayListUnmanaged(bson.Pair) = .empty; defer pairs.deinit(arena); for (q) |p| { const is_operator = p.key.len > 0 and p.key[0] == '$'; const is_embedded_operator = p.value == .doc and query.all_operator_keys(p.value.doc); if (!is_operator and !is_embedded_operator) { try pairs.append(arena, .{ .key = try arena.dupe(u8, p.key), .value = try bson.copy_value(arena, p.value) }); } } const owned = try arena.create(bson.Document); owned.* = bson.Document{ .arena = std.heap.ArenaAllocator.init(arena), .pairs = try pairs.toOwnedSlice(arena) }; // Apply update operators to build the final doc; _id handled by insert. // `inserting` is what `$setOnInsert` asks about, and this is the only // caller that answers yes. // A pipeline runs over the document the filter implies, exactly as it // would over a stored one; operators get the `inserting` bit, which is // what `$setOnInsert` asks about and this is the only caller that answers // yes. var built: *const bson.Document = owned; if (u_pipeline) |stages| { built = (try apply_update_pipeline(ctx, reply, stages, owned, null)) orelse return null; } else { var insert_opts = opts; insert_opts.inserting = true; update.apply(owned, &.{ .arena = undefined, .pairs = u_doc }, insert_opts) catch |err| { try update_refusal(reply, err, diag.*); return null; }; } // After the update, because `$setOnInsert` may supply the `_id` itself and // a generated one would then be the wrong answer. At the front, because // that is where MongoDB stores it and where the `_id_` index descends on // it. if (built.get("_id") == null) { const with_id = try arena.alloc(bson.Pair, built.pairs.len + 1); with_id[0] = .{ .key = "_id", .value = .{ .object_id = ctx.oid_gen.new(ctx.io) } }; @memcpy(with_id[1..], built.pairs); const owned_id = try arena.create(bson.Document); owned_id.* = .{ .arena = std.heap.ArenaAllocator.init(arena), .pairs = with_id }; built = owned_id; } return built; } fn parse_sort_keys(reply: *wire.Reply, value: ?bson.Value) ![]const query.SortKey { const pairs = doc_arg(value) orelse return &.{}; const out = try reply.arena_alloc().alloc(query.SortKey, pairs.len); for (pairs, 0..) |p, i| { const descending = switch (p.value) { .int32 => |n| n < 0, .int64 => |n| n < 0, .double => |n| n < 0, .string => |s| std.mem.eql(u8, s, "desc"), else => false, }; out[i] = .{ .path = try reply.arena_alloc().dupe(u8, p.key), .descending = descending, }; } return out; } fn doc_arg(v: ?bson.Value) ?[]const bson.Pair { return switch (v orelse return null) { .doc => |pairs| pairs, else => null, }; } fn str_arg(v: ?bson.Value) ?[]const u8 { return switch (v orelse return null) { .string => |s| s, else => null, }; } fn bool_arg(v: ?bson.Value) ?bool { return switch (v orelse return null) { .bool => |b| b, .int32 => |i| i != 0, else => null, }; } fn int_arg(v: ?bson.Value) ?u64 { const i = int_value(v) orelse return null; return if (i < 0) null else @intCast(i); } /// Numeric argument as a signed integer, with no clamping — callers that care /// about the sign (find's limit, delete's limit, $skip) apply their own rule. fn int_value(v: ?bson.Value) ?i64 { return switch (v orelse return null) { .int32 => |i| i, .int64 => |i| i, // lossyCast saturates instead of trapping on out-of-range doubles. .double => |d| std.math.lossyCast(i64, d), else => null, }; } /// A `$skip`/`$limit` stage operand: a non-negative document count. Writes /// the error reply and returns null when the operand is not a number. fn stage_count(reply: *wire.Reply, v: bson.Value, stage: []const u8) !?usize { const n = int_value(v) orelse { const text = try std.fmt.allocPrint(reply.arena_alloc(), "{s} requires a number", .{stage}); try bad_value(reply, text); return null; }; return @intCast(@max(0, n)); } /// Fetch a batch argument, writing the standard error reply and returning /// null when it is missing or malformed. fn batch_arg( msg: *wire.Message, reply: *wire.Reply, cmd: []const u8, name: []const u8, ) !?[]const bson.Document { return msg.batch(name) catch |err| { const arena = reply.arena_alloc(); const text = switch (err) { error.MissingBatch => try std.fmt.allocPrint(arena, "{s} requires {s}", .{ cmd, name }), error.BatchNotArray => try std.fmt.allocPrint(arena, "{s} must be an array", .{name}), error.BatchElementNotDoc => try std.fmt.allocPrint(arena, "{s} must be documents", .{name}), error.OutOfMemory => return error.OutOfMemory, }; try bad_value(reply, text); return null; }; } fn invalid_arg(reply: *wire.Reply, msg: []const u8) !void { return reply.put_error(@intFromEnum(ErrorCode.invalid_options), "InvalidOptions", msg); } fn bad_value(reply: *wire.Reply, msg: []const u8) !void { return reply.put_error(@intFromEnum(ErrorCode.bad_value), "BadValue", msg); } /// What a hashed index answers for a document holding an array at its path. /// Measured on mongod 8.3.7: refused when the *document* arrives rather than /// when the index is created, because there is no single hash for an array. const hashed_array_message = "hashed indexes do not currently support array values"; /// The createIndexes shape: a command error, because the index does not come /// into existence at all. Measured, `codeName` included. fn hashed_array_error(reply: *wire.Reply) !void { return reply.put_error( @intFromEnum(ErrorCode.hashed_array_value), "Location16766", hashed_array_message, ); } /// The write shape: one entry in `writeErrors` beside `ok: 1`, so the rest of /// a batch still lands. A writeError carries no `codeName`, measured. fn hashed_array_write_error(reply: *wire.Reply, i: usize) !bson.Value { const e = try reply.arena_alloc().alloc(bson.Pair, 3); e[0] = .{ .key = "index", .value = .{ .int32 = @intCast(i) } }; e[1] = .{ .key = "code", .value = .{ .int32 = @intFromEnum(ErrorCode.hashed_array_value) } }; e[2] = .{ .key = "errmsg", .value = .{ .string = hashed_array_message } }; return .{ .doc = e }; } fn failed_to_parse(reply: *wire.Reply, msg: []const u8) !void { return reply.put_error(@intFromEnum(ErrorCode.failed_to_parse), "FailedToParse", msg); } /// The E11000 text, shared by the top-level error reply and the per-document /// `writeErrors` entries of a batch insert. Uses the collection's /// dup_index (set by a rejected unique-index write) when the conflict came /// from a secondary index; otherwise it is the _id_ index. Per-collection /// so concurrent writers on other collections cannot clobber it. fn duplicate_key_message( ctx: *Context, reply: *wire.Reply, db_name: []const u8, coll_name: []const u8, doc: *const bson.Document, ) ![]const u8 { var index_name: []const u8 = "_id_"; var key_text: []const u8 = undefined; const coll = ctx.engine.get_collection(db_name, coll_name); if (coll) |c| { if (c.dup_index) |name| { index_name = name; key_text = try render_dup_key(ctx, reply, db_name, coll_name, name, doc); return e11000_message(reply, db_name, coll_name, index_name, key_text); } } key_text = try serialize_value_compact(reply, doc.get("_id") orelse bson.Value.null); return e11000_message(reply, db_name, coll_name, index_name, key_text); } /// Render the dup key of a secondary index from the offending document: the /// document's values for the index key pattern. fn render_dup_key( ctx: *Context, reply: *wire.Reply, db_name: []const u8, coll_name: []const u8, index_name: []const u8, doc: *const bson.Document, ) ![]const u8 { const arena = reply.arena_alloc(); const coll = ctx.engine.get_collection(db_name, coll_name) orelse // Index not found (defensive): fall back to the document's _id. return serialize_value_compact(reply, doc.get("_id") orelse bson.Value.null); const ix = coll.find_index(index_name) orelse return serialize_value_compact(reply, doc.get("_id") orelse bson.Value.null); var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(arena); try out.append(arena, '{'); for (ix.keys, 0..) |k, i| { if (i > 0) try out.appendSlice(arena, ", "); try out.appendSlice(arena, k.path); try out.appendSlice(arena, ": "); var values: std.ArrayListUnmanaged(bson.Value) = .empty; defer values.deinit(arena); try query.collect_values(arena, doc.pairs, k.path, &values, 0); const v: bson.Value = if (values.items.len > 0) values.items[0] else .null; try out.appendSlice(arena, try serialize_value_compact(reply, v)); } try out.append(arena, '}'); return out.toOwnedSlice(arena); } fn duplicate_key_error( ctx: *Context, reply: *wire.Reply, db_name: []const u8, coll_name: []const u8, doc: *const bson.Document, ) !void { const msg_text = try duplicate_key_message(ctx, reply, db_name, coll_name, doc); return reply.put_error(@intFromEnum(ErrorCode.duplicate_key), "DuplicateKey", msg_text); } /// Compact extended-JSON-ish rendering of a value for error messages. fn serialize_value_compact(reply: *wire.Reply, v: bson.Value) ![]const u8 { const arena = reply.arena_alloc(); return switch (v) { .int32 => |i| try std.fmt.allocPrint(arena, "{d}", .{i}), .int64 => |i| try std.fmt.allocPrint(arena, "{d}", .{i}), .double => |d| try std.fmt.allocPrint(arena, "{d}", .{d}), .string => |s| try std.fmt.allocPrint(arena, "'{s}'", .{s}), .bool => |b| try std.fmt.allocPrint(arena, "{}", .{b}), .object_id => |oid| blk: { const hex = std.fmt.bytesToHex(oid[0..], .lower); break :blk try std.fmt.allocPrint(arena, "ObjectId('{s}')", .{hex}); }, else => "{ ... }", }; } /// Build a { id: , ns: "...", : [...] } cursor document. pub fn cursor_doc( reply: *wire.Reply, cursor_id: i64, ns: []const u8, batch_key: []const u8, docs: []const bson.Value, ) ![]const bson.Pair { const c = try reply.arena_alloc().alloc(bson.Pair, 3); c[0] = .{ .key = "id", .value = .{ .int64 = cursor_id } }; c[1] = .{ .key = "ns", .value = .{ .string = ns } }; c[2] = .{ .key = batch_key, .value = .{ .array = docs } }; return c; } fn format_namespace(reply: *wire.Reply, db_name: []const u8, coll_name: []const u8) ![]const u8 { return std.fmt.allocPrint(reply.arena_alloc(), "{s}.{s}", .{ db_name, coll_name }); } fn int_array(reply: *wire.Reply, values: []const i32) ![]const bson.Value { const out = try reply.arena_alloc().alloc(bson.Value, values.len); for (values, 0..) |v, i| out[i] = .{ .int32 = v }; return out; } fn str_array(reply: *wire.Reply, values: []const []const u8) ![]const bson.Value { const out = try reply.arena_alloc().alloc(bson.Value, values.len); for (values, 0..) |v, i| out[i] = .{ .string = try reply.arena_alloc().dupe(u8, v) }; return out; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- const testing = std.testing; /// Temp-log-backed engine plus the Context a command test dispatches with. const TestDb = struct { tmp: std.testing.TmpDir, path: []u8, engine: db.Engine, gen: bson.ObjectIdGen, fn init(io: std.Io) !TestDb { const tmp = std.testing.tmpDir(.{}); const path = try std.fmt.allocPrint(testing.allocator, ".zig-cache/tmp/{s}/cmd.log", .{tmp.sub_path}); return .{ .tmp = tmp, .path = path, .engine = try db.Engine.open(testing.allocator, io, path), .gen = bson.ObjectIdGen.init(io), }; } fn deinit(self: *TestDb) void { self.engine.deinit(); self.tmp.cleanup(); testing.allocator.free(self.path); } fn ctx(self: *TestDb, io: std.Io) Context { return test_ctx(io, &self.engine, &self.gen, 1); } }; fn test_ctx(io: std.Io, engine: *db.Engine, gen: *bson.ObjectIdGen, connection_id: u32) Context { return .{ .gpa = testing.allocator, .io = io, .oid_gen = gen, .connection_id = connection_id, .client_desc = "127.0.0.1:0", .engine = engine, .server_start = std.Io.Timestamp.now(io, .real), }; } test "ping and hello replies parse" { var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); var msg = try parse_fake_msg("ping", .null, &.{}); defer msg.deinit(); try dispatch(&ctx, &msg, &reply); try testing.expectEqual(@as(f64, 1.0), reply.pairs.items[0].value.double); var reply2 = wire.Reply.init(testing.allocator); defer reply2.deinit(); var msg2 = try parse_fake_msg("hello", .null, &.{}); defer msg2.deinit(); try dispatch(&ctx, &msg2, &reply2); const ok = bson.get_pair(reply2.pairs.items, "ok").?; try testing.expectEqual(@as(f64, 1.0), ok.double); const primary = bson.get_pair(reply2.pairs.items, "isWritablePrimary").?; try testing.expect(primary.bool); try testing.expectEqual(@as(i32, 9), bson.get_pair(reply2.pairs.items, "maxWireVersion").?.int32); } test "serverStatus reports what the slab is doing" { // The churn gate reads these, and it needs them to be the collections' own // figures rather than a second opinion about them -- a counter that drifts // from what the catalog says would make the gate measure the drift. // // Mutation check: report the engine's running `live_bytes`/`dead_bytes` // instead of summing the collections. Not red here, and that is the point: // it is red in `checkpoint`, where the two are compared, which is why this // reads the same side of that comparison. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); try seed_docs(&tdb, io, "c", 60); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); var msg = try parse_fake_msg("serverStatus", .{ .int32 = 1 }, &.{}); defer msg.deinit(); try dispatch(&ctx, &msg, &reply); const mf = bson.get_pair(reply.pairs.items, "multifora").?.doc; const live = bson.get_pair(mf, "liveBytes").?.int64; const dead = bson.get_pair(mf, "deadBytes").?.int64; const slab = bson.get_pair(mf, "slabBytes").?.int64; try testing.expect(live > 0); try testing.expectEqual(slab, live + dead); // Nothing has died and nothing has been reclaimed yet. try testing.expectEqual(@as(i64, 0), dead); try testing.expectEqual(@as(i64, 0), bson.get_pair(mf, "reclaimedBytes").?.int64); try testing.expectEqual(@as(i64, 0), bson.get_pair(mf, "compactions").?.int64); try testing.expectEqual(@as(i64, 1), bson.get_pair(mf, "slabRuns").?.int64); try testing.expect(bson.get_pair(mf, "allocTailBytes").?.int64 > 0); // Present even at zero: a gate that cannot tell "nothing ready" from // "field missing" cannot be read at all. try testing.expect(bson.get_pair(mf, "freeReadyBytes") != null); } test "the wire version agrees with the version the server calls itself" { // These two are read by different parts of a driver -- the handshake picks // features off the wire version, `runOnRequirements` in the spec suites // reads the string -- and when they disagreed the driver believed the wire // version and withheld commands the version string promised. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); var hello = wire.Reply.init(testing.allocator); defer hello.deinit(); var hello_msg = try parse_fake_msg("hello", .null, &.{}); defer hello_msg.deinit(); try dispatch(&ctx, &hello_msg, &hello); var info = wire.Reply.init(testing.allocator); defer info.deinit(); var info_msg = try parse_fake_msg("buildInfo", .null, &.{}); defer info_msg.deinit(); try dispatch(&ctx, &info_msg, &info); // The mapping is the server's own: 4.2 is wire 8, 4.4 is wire 9. const wire_version = bson.get_pair(hello.pairs.items, "maxWireVersion").?.int32; const version = bson.get_pair(info.pairs.items, "version").?.string; const expected: i32 = if (std.mem.startsWith(u8, version, "4.4.")) 9 else if (std.mem.startsWith(u8, version, "4.2.")) 8 else -1; try testing.expectEqual(expected, wire_version); } test "handshake does not advertise the streaming hello protocol" { // `topologyVersion` in a hello/isMaster reply tells the driver it may // monitor with an exhaust hello and expect moreToCome replies we never // send; the driver then kills the connection every heartbeat. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); for ([_][]const u8{ "hello", "isMaster", "ismaster" }) |cmd| { var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); var msg = try parse_fake_msg(cmd, .null, &.{}); defer msg.deinit(); try dispatch(&ctx, &msg, &reply); try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double); try testing.expect(bson.get_pair(reply.pairs.items, "topologyVersion") == null); } } test "unknown command gives CommandNotFound" { var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); var msg = try parse_fake_msg("nonsenseCmd", .null, &.{}); defer msg.deinit(); try dispatch(&ctx, &msg, &reply); try testing.expectEqual(@as(f64, 0.0), bson.get_pair(reply.pairs.items, "ok").?.double); try testing.expectEqual(@as(i32, 59), bson.get_pair(reply.pairs.items, "code").?.int32); try testing.expectEqualStrings("CommandNotFound", bson.get_pair(reply.pairs.items, "codeName").?.string); } test "getParameter responds for featureCompatibilityVersion" { var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); var fcv_doc = try testing.allocator.alloc(bson.Pair, 1); defer testing.allocator.free(fcv_doc); fcv_doc[0] = .{ .key = "featureCompatibilityVersion", .value = .{ .int32 = 1 } }; var msg = try parse_fake_msg("getParameter", .{ .doc = fcv_doc }, &.{}); defer msg.deinit(); try dispatch(&ctx, &msg, &reply); const fcv = bson.get_pair(reply.pairs.items, "featureCompatibilityVersion").?; try testing.expectEqualStrings("4.4", fcv.doc[0].value.string); } test "insert counts only successful inserts" { var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); // documents: [{_id:1}, {_id:1} (dup), {_id:2}] → n: 2 + one writeError. const docs = [_]bson.Value{ .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 1 } }} }, .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 1 } }} }, .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 2 } }} }, }; var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); var msg = try parse_fake_msg("insert", .{ .string = "users" }, &.{ .{ .key = "documents", .value = .{ .array = &docs } }, }); defer msg.deinit(); try dispatch(&ctx, &msg, &reply); try testing.expectEqual(@as(i64, 2), bson.get_pair(reply.pairs.items, "n").?.int32); const errors = bson.get_pair(reply.pairs.items, "writeErrors").?; try testing.expectEqual(@as(usize, 1), errors.array.len); const first = errors.array[0].doc[0]; try testing.expectEqualStrings("index", first.key); try testing.expectEqual(@as(i64, 1), first.value.int32); } fn parse_fake_msg(name: []const u8, value: bson.Value, extra: []const bson.Pair) !wire.Message { var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(testing.allocator); const pairs = try testing.allocator.alloc(bson.Pair, 2 + extra.len); defer testing.allocator.free(pairs); pairs[0] = .{ .key = name, .value = value }; pairs[1] = .{ .key = "$db", .value = .{ .string = "test" } }; @memcpy(pairs[2..], extra); try bson.write_doc(pairs, testing.allocator, &out); var msg: std.ArrayListUnmanaged(u8) = .empty; defer msg.deinit(testing.allocator); try msg.appendSlice(testing.allocator, &[_]u8{ 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0xDD, 0x07, 0, 0, 0, 0, 0, 0 }); try msg.append(testing.allocator, 0x00); try msg.appendSlice(testing.allocator, out.items); std.mem.writeInt(u32, msg.items[0..4], @intCast(msg.items.len), .little); std.mem.writeInt(i32, msg.items[12..16], wire.op_code_msg, .little); return wire.Message.parse(testing.allocator, msg.items); } /// Runs one command and hands back its reply's `code`, or null on ok:1. fn run_for_code(ctx: *Context, name: []const u8, value: bson.Value, extra: []const bson.Pair) !?i32 { var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); var msg = try parse_fake_msg(name, value, extra); defer msg.deinit(); try dispatch(ctx, &msg, &reply); if (bson.get_pair(reply.pairs.items, "code")) |c| return c.int32; try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double); return null; } fn doc_count(ctx: *Context, coll: []const u8) !i32 { var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); var msg = try parse_fake_msg("count", .{ .string = coll }, &.{}); defer msg.deinit(); try dispatch(ctx, &msg, &reply); return bson.get_pair(reply.pairs.items, "n").?.int32; } test "a well-formed session id changes nothing and is not echoed" { var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); const uuid = [_]u8{0x5A} ** 16; const lsid = bson.Value{ .doc = &.{ .{ .key = "id", .value = .{ .binary = .{ .subtype = 4, .data = &uuid } } }, } }; var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); var msg = try parse_fake_msg("insert", .{ .string = "sess" }, &.{ .{ .key = "documents", .value = .{ .array = &.{.{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} }} } }, .{ .key = "lsid", .value = lsid }, }); defer msg.deinit(); try dispatch(&ctx, &msg, &reply); try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double); // mongod answers a well-formed lsid with exactly `{ok: 1}` and no echo, // measured; a driver reads only `$clusterTime` and `operationTime` back. try testing.expect(bson.get_pair(reply.pairs.items, "lsid") == null); try testing.expectEqual(@as(i32, 1), try doc_count(&ctx, "sess")); } test "a malformed session id is refused without leaking a lock" { // The second half is the point. `reject_bad_session_fields` runs before any // lock is taken, and the way to show it is to keep using the engine after // each refusal: a leaked *shared* catalog lock is invisible to readers and // only stops the next write that needs it exclusively. That is exactly how // the nameless-command bug hid for a milestone. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); const uuid = [_]u8{0x7C} ** 16; const good = bson.Value{ .binary = .{ .subtype = 4, .data = &uuid } }; // Every code here was measured against mongod 8.3.7, not recalled. const cases = [_]struct { code: i32, lsid: bson.Value }{ .{ .code = 14, .lsid = .{ .int32 = 5 } }, .{ .code = 40414, .lsid = .{ .doc = &.{} } }, .{ .code = 14, .lsid = .{ .doc = &.{.{ .key = "id", .value = .{ .string = "nope" } }} } }, .{ .code = 14, .lsid = .{ .doc = &.{ .{ .key = "id", .value = .{ .binary = .{ .subtype = 0, .data = &uuid } } }, } } }, .{ .code = 207, .lsid = .{ .doc = &.{ .{ .key = "id", .value = .{ .binary = .{ .subtype = 4, .data = uuid[0..15] } } }, } } }, .{ .code = 40415, .lsid = .{ .doc = &.{ .{ .key = "id", .value = good }, .{ .key = "bogus", .value = .{ .int32 = 1 } }, } } }, .{ .code = 72, .lsid = .{ .doc = &.{ .{ .key = "id", .value = good }, .{ .key = "txnUUID", .value = good }, } } }, }; for (cases, 0..) |c, i| { const insert_doc = bson.Value{ .array = &.{.{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} }} }; const code = try run_for_code(&ctx, "insert", .{ .string = "leaky" }, &.{ .{ .key = "documents", .value = insert_doc }, .{ .key = "lsid", .value = c.lsid }, }); try testing.expectEqual(c.code, code.?); // A write that needs the catalog exclusive to create a collection: the // one operation a leaked shared lock would block. var name_buf: [16]u8 = undefined; const fresh = try std.fmt.bufPrint(&name_buf, "after{d}", .{i}); try testing.expectEqual(@as(?i32, null), try run_for_code(&ctx, "insert", .{ .string = fresh }, &.{ .{ .key = "documents", .value = insert_doc }, })); } } test "a transactional write is refused rather than applied" { // The count is the assertion. Ignoring `txnNumber` would answer ok, write // the document, and leave the client to discover at commitTransaction that // its transaction was never one -- long after the data was durable. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); const uuid = [_]u8{0x3E} ** 16; const lsid = bson.Value{ .doc = &.{ .{ .key = "id", .value = .{ .binary = .{ .subtype = 4, .data = &uuid } } }, } }; const docs = bson.Value{ .array = &.{.{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} }} }; try testing.expectEqual(@as(i32, 20), (try run_for_code(&ctx, "insert", .{ .string = "txn" }, &.{ .{ .key = "documents", .value = docs }, .{ .key = "lsid", .value = lsid }, .{ .key = "txnNumber", .value = .{ .int64 = 1 } }, .{ .key = "startTransaction", .value = .{ .bool = true } }, .{ .key = "autocommit", .value = .{ .bool = false } }, })).?); try testing.expectEqual(@as(i32, 0), try doc_count(&ctx, "txn")); // The order the checks fire in is mongod's, measured: a missing session id // is reported before the standalone refusal, and a bad type before both. try testing.expectEqual(@as(i32, 72), (try run_for_code(&ctx, "insert", .{ .string = "txn" }, &.{ .{ .key = "documents", .value = docs }, .{ .key = "txnNumber", .value = .{ .int64 = 1 } }, })).?); try testing.expectEqual(@as(i32, 14), (try run_for_code(&ctx, "insert", .{ .string = "txn" }, &.{ .{ .key = "documents", .value = docs }, .{ .key = "lsid", .value = lsid }, .{ .key = "txnNumber", .value = .{ .string = "nope" } }, })).?); try testing.expectEqual(@as(i32, 72), (try run_for_code(&ctx, "insert", .{ .string = "txn" }, &.{ .{ .key = "documents", .value = docs }, .{ .key = "lsid", .value = lsid }, .{ .key = "startTransaction", .value = .{ .bool = true } }, })).?); try testing.expectEqual(@as(i32, 0), try doc_count(&ctx, "txn")); } test "endSessions judges the array it is handed" { // Still a no-op, and that is not what is being tested. The command arrives // in normal operation -- a driver sends it on close for every session it // handed out -- so a blind `ok` here tells a client the server understood // something it never looked at. Codes measured against mongod 8.3.7. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); const uuid = [_]u8{0x6B} ** 16; const good = bson.Value{ .binary = .{ .subtype = 4, .data = &uuid } }; const one = bson.Value{ .doc = &.{.{ .key = "id", .value = good }} }; try testing.expectEqual(@as(?i32, null), try run_for_code(&ctx, "endSessions", .{ .array = &.{} }, &.{})); try testing.expectEqual(@as(?i32, null), try run_for_code(&ctx, "endSessions", .{ .array = &.{one} }, &.{})); // `uid` rides along once authentication is on, exactly as it does in lsid. try testing.expectEqual(@as(?i32, null), try run_for_code(&ctx, "endSessions", .{ .array = &.{.{ .doc = &.{ .{ .key = "id", .value = good }, .{ .key = "uid", .value = .{ .binary = .{ .subtype = 0, .data = &[_]u8{0} ** 32 } } }, } }} }, &.{})); try testing.expectEqual(@as(i32, 14), (try run_for_code(&ctx, "endSessions", .{ .string = "nope" }, &.{})).?); try testing.expectEqual(@as(i32, 14), (try run_for_code(&ctx, "endSessions", .{ .array = &.{.{ .int32 = 5 }} }, &.{})).?); try testing.expectEqual(@as(i32, 14), (try run_for_code(&ctx, "endSessions", .{ .array = &.{.{ .doc = &.{ .{ .key = "id", .value = .{ .string = "nope" } }, } }} }, &.{})).?); try testing.expectEqual(@as(i32, 207), (try run_for_code(&ctx, "endSessions", .{ .array = &.{.{ .doc = &.{ .{ .key = "id", .value = .{ .binary = .{ .subtype = 4, .data = uuid[0..15] } } }, } }} }, &.{})).?); try testing.expectEqual(@as(i32, 40414), (try run_for_code(&ctx, "endSessions", .{ .array = &.{.{ .doc = &.{} }} }, &.{})).?); try testing.expectEqual(@as(i32, 40415), (try run_for_code(&ctx, "endSessions", .{ .array = &.{.{ .doc = &.{ .{ .key = "id", .value = good }, .{ .key = "bogus", .value = .{ .int32 = 1 } }, } }} }, &.{})).?); // A bad entry after a good one is still a bad entry. try testing.expectEqual(@as(i32, 14), (try run_for_code(&ctx, "endSessions", .{ .array = &.{ one, .{ .int32 = 5 } } }, &.{})).?); } test "an unknown command is reported before its session id is judged" { // Measured: mongod answers CommandNotFound to `{nosuchcmd: 1, lsid: 5}`, // so the lookup comes first and this is where the check belongs. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); try testing.expectEqual(@as(i32, 59), (try run_for_code(&ctx, "nosuchcmd", .{ .int32 = 1 }, &.{ .{ .key = "lsid", .value = .{ .int32 = 5 } }, })).?); } test "concurrent insert/find commands on a threaded Io" { // Exercises dispatch's lock classification end-to-end: writer fibers run // `insert` under the exclusive lock, reader fibers run `count`/`find` // under the shared lock. Every committed insert must be visible once all // writers finish, and no reader may observe more docs than can exist. var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var gen = bson.ObjectIdGen.init(io); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const path = try std.fmt.allocPrint(testing.allocator, ".zig-cache/tmp/{s}/conc.log", .{tmp.sub_path}); defer testing.allocator.free(path); var engine = try db.Engine.open(testing.allocator, io, path); defer engine.deinit(); const writers = 4; const readers = 4; const per_writer: i32 = 150; const total: i32 = writers * per_writer; var next_id = std.atomic.Value(i32).init(1); var remaining = std.atomic.Value(usize).init(@intCast(total)); const Worker = struct { fn writer( iow: std.Io, eng: *db.Engine, id_counter: *std.atomic.Value(i32), pending: *std.atomic.Value(usize), fiber_id: u32, total_writes: i32, ) error{Canceled}!void { var wgen = bson.ObjectIdGen.init(iow); var ctx = test_ctx(iow, eng, &wgen, fiber_id); while (true) { const id = id_counter.fetchAdd(1, .monotonic); if (id > total_writes) return; const docs = [_]bson.Value{.{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = id } }} }}; var msg = parse_fake_msg("insert", .{ .string = "users" }, &.{ .{ .key = "documents", .value = .{ .array = &docs } }, }) catch return error.Canceled; defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); dispatch(&ctx, &msg, &reply) catch return error.Canceled; _ = pending.fetchSub(1, .monotonic); } } fn reader( iow: std.Io, eng: *db.Engine, pending: *std.atomic.Value(usize), fiber_id: u32, total_writes: i32, ) error{Canceled}!void { var rgen = bson.ObjectIdGen.init(iow); var ctx = test_ctx(iow, eng, &rgen, fiber_id); while (pending.load(.acquire) > 0) { var msg = parse_fake_msg("count", .{ .string = "users" }, &.{}) catch return error.Canceled; defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); dispatch(&ctx, &msg, &reply) catch return error.Canceled; const n = bson.get_pair(reply.pairs.items, "n") orelse return error.Canceled; if (n.int32 > total_writes) return error.Canceled; } } }; var group: std.Io.Group = .init; defer group.cancel(io); for (0..readers) |i| group.async(io, Worker.reader, .{ io, &engine, &remaining, @intCast(i + 1), total }); for (0..writers) |i| group.async(io, Worker.writer, .{ io, &engine, &next_id, &remaining, @intCast(i + 1), total }); try group.await(io); var ctx = test_ctx(io, &engine, &gen, 0); var msg = try parse_fake_msg("count", .{ .string = "users" }, &.{}); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); try testing.expectEqual(total, bson.get_pair(reply.pairs.items, "n").?.int32); } // -- index command tests ---------------------------------------------------- /// Dispatch an insert of the given documents (each a bson.Value .doc). fn dispatch_insert( tdb: *TestDb, io: std.Io, coll_name: []const u8, docs: []const bson.Value, ) !void { var ctx = tdb.ctx(io); var msg = try parse_fake_msg("insert", .{ .string = coll_name }, &.{ .{ .key = "documents", .value = .{ .array = docs } }, }); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double); // `ok: 1` is not success for an insert batch: a rejected document comes // back as a writeError alongside it. Asserting only `ok` let a corpus // silently shrink -- when _id_ became a unique index, the mixed-type // corpus below lost its int64 1 document and every test over it still // passed, over nine documents instead of ten. if (bson.get_pair(reply.pairs.items, "writeErrors")) |we| { std.debug.print("dispatch_insert: unexpected writeErrors: {any}\n", .{we}); return error.TestUnexpectedResult; } try testing.expectEqual(@as(i32, @intCast(docs.len)), bson.get_pair(reply.pairs.items, "n").?.int32); } /// Dispatch createIndexes for one spec. fn dispatch_create_index(tdb: *TestDb, io: std.Io, coll_name: []const u8, spec: bson.Value) !void { var ctx = tdb.ctx(io); const specs = [_]bson.Value{spec}; var msg = try parse_fake_msg("createIndexes", .{ .string = coll_name }, &.{ .{ .key = "indexes", .value = .{ .array = &specs } }, }); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double); } /// Dispatch find and append the serialized _id of every result to `out`. fn dispatch_find_ids( tdb: *TestDb, io: std.Io, coll_name: []const u8, filter: []const bson.Pair, out: *std.ArrayListUnmanaged([]u8), ) !void { var ctx = tdb.ctx(io); var msg = try parse_fake_msg("find", .{ .string = coll_name }, &.{ .{ .key = "filter", .value = .{ .doc = filter } }, }); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double); const cur = bson.get_pair(reply.pairs.items, "cursor") orelse return error.TestUnexpectedResult; const batch = switch (bson.get_pair(cur.doc, "firstBatch") orelse return error.TestUnexpectedResult) { .array => |a| a, else => return error.TestUnexpectedResult, }; for (batch) |d| { const idv = bson.get_pair(d.doc, "_id") orelse continue; try out.append(testing.allocator, try bson.serialize_value(testing.allocator, idv)); } std.mem.sort([]u8, out.items, {}, less_u8); } fn less_u8(_: void, a: []u8, b: []u8) bool { return std.mem.order(u8, a, b) == .lt; } test "createIndexes, listIndexes, dropIndexes, and idempotent re-create" { var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); // createIndexes builds the index immediately. { var ctx = tdb.ctx(io); const specs = [_]bson.Value{.{ .doc = &.{ .{ .key = "key", .value = .{ .doc = &.{.{ .key = "email", .value = .{ .int32 = 1 } }} } }, .{ .key = "name", .value = .{ .string = "email_1" } }, } }}; var msg = try parse_fake_msg("createIndexes", .{ .string = "users" }, &.{ .{ .key = "indexes", .value = .{ .array = &specs } }, }); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double); try testing.expectEqual(@as(i64, 1), bson.get_pair(reply.pairs.items, "numIndexesBefore").?.int32); try testing.expectEqual(@as(i64, 2), bson.get_pair(reply.pairs.items, "numIndexesAfter").?.int32); } // Idempotent re-create of the same spec. try dispatch_create_index(&tdb, io, "users", .{ .doc = &.{ .{ .key = "key", .value = .{ .doc = &.{.{ .key = "email", .value = .{ .int32 = 1 } }} } }, .{ .key = "name", .value = .{ .string = "email_1" } }, } }); // listIndexes: _id_ first, then the secondary. { var ctx = tdb.ctx(io); var msg = try parse_fake_msg("listIndexes", .{ .string = "users" }, &.{}); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double); const cur = bson.get_pair(reply.pairs.items, "cursor").?; const batch = bson.get_pair(cur.doc, "firstBatch").?.array; try testing.expectEqual(@as(usize, 2), batch.len); try testing.expectEqualStrings("_id_", bson.get_pair(batch[0].doc, "name").?.string); try testing.expectEqualStrings("email_1", bson.get_pair(batch[1].doc, "name").?.string); const key_pairs = bson.get_pair(batch[1].doc, "key").?.doc; try testing.expect(bson.get_pair(key_pairs, "email") != null); } // listIndexes on a missing collection: NamespaceNotFound. { var ctx = tdb.ctx(io); var msg = try parse_fake_msg("listIndexes", .{ .string = "nope" }, &.{}); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); try testing.expectEqual(@as(i32, 26), bson.get_pair(reply.pairs.items, "code").?.int32); } // dropIndexes("*") removes the secondary but keeps _id_. { var ctx = tdb.ctx(io); var msg = try parse_fake_msg("dropIndexes", .{ .string = "users" }, &.{ .{ .key = "index", .value = .{ .string = "*" } }, }); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double); try testing.expectEqual(@as(i64, 2), bson.get_pair(reply.pairs.items, "nIndexesWas").?.int32); } try testing.expectEqual(@as(usize, 0), tdb.engine.get_collection("test", "users").?.indexes.items.len); // dropIndexes("_id_") errors. { var ctx = tdb.ctx(io); var msg = try parse_fake_msg("dropIndexes", .{ .string = "users" }, &.{ .{ .key = "index", .value = .{ .string = "_id_" } }, }); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); try testing.expectEqual(@as(i32, 72), bson.get_pair(reply.pairs.items, "code").?.int32); } } test "TTL index round-trips through createIndexes/listIndexes; bad specs give 67" { var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); // A driver sends expireAfterSeconds as a double. try dispatch_create_index(&tdb, io, "sessions", .{ .doc = &.{ .{ .key = "key", .value = .{ .doc = &.{ .{ .key = "expireAt", .value = .{ .int32 = 1 } }, } }, }, .{ .key = "name", .value = .{ .string = "expireAt_1" } }, .{ .key = "expireAfterSeconds", .value = .{ .double = 60.0 } }, } }); try testing.expectEqual(@as(?i64, 60), tdb.engine.get_collection("test", "sessions").?.indexes.items[0].ttl); // listIndexes reports it back. { var ctx = tdb.ctx(io); var msg = try parse_fake_msg("listIndexes", .{ .string = "sessions" }, &.{}); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); const cur = bson.get_pair(reply.pairs.items, "cursor").?; const batch = bson.get_pair(cur.doc, "firstBatch").?.array; try testing.expectEqual(@as(usize, 2), batch.len); try testing.expectEqual(@as(i32, 60), bson.get_pair(batch[1].doc, "expireAfterSeconds").?.int32); // The _id_ entry never carries one. try testing.expect(bson.get_pair(batch[0].doc, "expireAfterSeconds") == null); } // Same name and key, different expiry: IndexOptionsConflict, as in // MongoDB (changing it is a collMod, which this server does not have). const bad = [_]struct { spec: bson.Value, code: i32 }{ .{ .code = 85, .spec = .{ .doc = &.{ .{ .key = "key", .value = .{ .doc = &.{.{ .key = "expireAt", .value = .{ .int32 = 1 } }} } }, .{ .key = "name", .value = .{ .string = "expireAt_1" } }, .{ .key = "expireAfterSeconds", .value = .{ .int32 = 90 } }, } } }, // TTL on a compound key. .{ .code = 67, .spec = .{ .doc = &.{ .{ .key = "key", .value = .{ .doc = &.{ .{ .key = "a", .value = .{ .int32 = 1 } }, .{ .key = "b", .value = .{ .int32 = 1 } }, } } }, .{ .key = "expireAfterSeconds", .value = .{ .int32 = 60 } }, } } }, // Negative and non-numeric expiries. .{ .code = 67, .spec = .{ .doc = &.{ .{ .key = "key", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } }, .{ .key = "expireAfterSeconds", .value = .{ .int32 = -1 } }, } } }, .{ .code = 67, .spec = .{ .doc = &.{ .{ .key = "key", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } }, .{ .key = "expireAfterSeconds", .value = .{ .string = "60" } }, } } }, // Past MongoDB's 2147483647 bound. .{ .code = 67, .spec = .{ .doc = &.{ .{ .key = "key", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } }, .{ .key = "expireAfterSeconds", .value = .{ .int64 = index.max_expire_after_seconds + 1 } }, } } }, // {_id: 1} is otherwise an idempotent no-op, but an expiry on it // would be silently dropped, so it is rejected instead. .{ .code = 197, .spec = .{ .doc = &.{ .{ .key = "key", .value = .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 1 } }} } }, .{ .key = "expireAfterSeconds", .value = .{ .int32 = 60 } }, } } }, // A partial filter holding an operator that narrows nothing another // predicate could be checked against. .{ .code = 67, .spec = .{ .doc = &.{ .{ .key = "key", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } }, .{ .key = "partialFilterExpression", .value = .{ .doc = &.{ .{ .key = "t", .value = .{ .doc = &.{.{ .key = "$ne", .value = .{ .int32 = 1 } }} } }, } } }, } } }, // A filter that is not a document at all. .{ .code = 14, .spec = .{ .doc = &.{ .{ .key = "key", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } }, .{ .key = "partialFilterExpression", .value = .{ .int32 = 1 } }, } } }, // Sparse and a partial filter overlap and may not be combined. .{ .code = 67, .spec = .{ .doc = &.{ .{ .key = "key", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } }, .{ .key = "sparse", .value = .{ .bool = true } }, .{ .key = "partialFilterExpression", .value = .{ .doc = &.{ .{ .key = "t", .value = .{ .bool = true } }, } } }, } } }, // The three hashed refusals. Each code is a bare location number // measured on mongod 8.3.7, and each is a different kind of no: two // hashed components order nothing, a unique hashed index cannot tell // a duplicate from a collision, and an unrecognised string names an // index type this server does not have. .{ .code = 31303, .spec = .{ .doc = &.{ .{ .key = "key", .value = .{ .doc = &.{ .{ .key = "a", .value = .{ .string = "hashed" } }, .{ .key = "b", .value = .{ .string = "hashed" } }, } } }, } } }, .{ .code = 16764, .spec = .{ .doc = &.{ .{ .key = "key", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .string = "hashed" } }} } }, .{ .key = "unique", .value = .{ .bool = true } }, } } }, .{ .code = 67, .spec = .{ .doc = &.{ .{ .key = "key", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .string = "bogus" } }} } }, } } }, }; for (bad) |case| { var ctx = tdb.ctx(io); const specs = [_]bson.Value{case.spec}; var msg = try parse_fake_msg("createIndexes", .{ .string = "sessions" }, &.{ .{ .key = "indexes", .value = .{ .array = &specs } }, }); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); try testing.expectEqual(case.code, bson.get_pair(reply.pairs.items, "code").?.int32); } // Nothing partial was registered by the rejected specs. try testing.expectEqual(@as(usize, 1), tdb.engine.get_collection("test", "sessions").?.indexes.items.len); } test "unique index constraint returns 11000 through insert and update" { var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); try dispatch_create_index(&tdb, io, "users", .{ .doc = &.{ .{ .key = "key", .value = .{ .doc = &.{.{ .key = "email", .value = .{ .int32 = 1 } }} } }, .{ .key = "name", .value = .{ .string = "email_1" } }, .{ .key = "unique", .value = .{ .bool = true } }, } }); const docs = [_]bson.Value{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "email", .value = .{ .string = "a@x.io" } } } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "email", .value = .{ .string = "a@x.io" } } } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 3 } }, .{ .key = "email", .value = .{ .string = "b@x.io" } } } }, }; var ctx = tdb.ctx(io); var msg = try parse_fake_msg("insert", .{ .string = "users" }, &.{ .{ .key = "documents", .value = .{ .array = &docs } }, }); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); // 2 inserted, 1 writeError with code 11000 naming the index. try testing.expectEqual(@as(i64, 2), bson.get_pair(reply.pairs.items, "n").?.int32); const errors = bson.get_pair(reply.pairs.items, "writeErrors").?.array; try testing.expectEqual(@as(usize, 1), errors.len); try testing.expectEqual(@as(i64, 11000), bson.get_pair(errors[0].doc, "code").?.int32); const errmsg = bson.get_pair(errors[0].doc, "errmsg").?.string; try testing.expect(std.mem.indexOf(u8, errmsg, "email_1") != null); try testing.expect(std.mem.indexOf(u8, errmsg, "E11000") != null); // An update that collides is reported as a writeError with code 11000. const updates = [_]bson.Value{.{ .doc = &.{ .{ .key = "q", .value = .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 3 } }} } }, .{ .key = "u", .value = .{ .doc = &.{.{ .key = "$set", .value = .{ .doc = &.{.{ .key = "email", .value = .{ .string = "a@x.io" } }} } }} } }, } }}; var msg2 = try parse_fake_msg("update", .{ .string = "users" }, &.{ .{ .key = "updates", .value = .{ .array = &updates } }, }); defer msg2.deinit(); var reply2 = wire.Reply.init(testing.allocator); defer reply2.deinit(); try dispatch(&ctx, &msg2, &reply2); try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply2.pairs.items, "ok").?.double); try testing.expectEqual(@as(i64, 0), bson.get_pair(reply2.pairs.items, "nModified").?.int32); const up_errs = bson.get_pair(reply2.pairs.items, "writeErrors").?.array; try testing.expectEqual(@as(usize, 1), up_errs.len); try testing.expectEqual(@as(i64, 11000), bson.get_pair(up_errs[0].doc, "code").?.int32); } test "an array under a hashed index fails its own document and no other" { // Measured on mongod 8.3.7: `ok: 1` with one writeError, not a command // error -- so the rest of the batch still lands. Getting this wrong the // other way would turn one bad document into a whole failed insert. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); try dispatch_create_index(&tdb, io, "evt", .{ .doc = &.{ .{ .key = "key", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .string = "hashed" } }} } }, } }); const docs = [_]bson.Value{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "a", .value = .{ .int32 = 7 } } } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "a", .value = .{ .array = &.{ .{ .int32 = 1 }, .{ .int32 = 2 }, } } } } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 3 } }, .{ .key = "a", .value = .{ .int32 = 8 } } } }, }; var ctx = tdb.ctx(io); var msg = try parse_fake_msg("insert", .{ .string = "evt" }, &.{ .{ .key = "documents", .value = .{ .array = &docs } }, }); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double); try testing.expectEqual(@as(i64, 2), bson.get_pair(reply.pairs.items, "n").?.int32); const errors = bson.get_pair(reply.pairs.items, "writeErrors").?.array; try testing.expectEqual(@as(usize, 1), errors.len); try testing.expectEqual(@as(i64, 1), bson.get_pair(errors[0].doc, "index").?.int32); try testing.expectEqual(@as(i64, 16766), bson.get_pair(errors[0].doc, "code").?.int32); // The same value arriving through an update is the same answer, and the // document is left as it was. Mutation: return the error instead of // appending a writeError and `ok` goes to 0 here. const updates = [_]bson.Value{.{ .doc = &.{ .{ .key = "q", .value = .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 3 } }} } }, .{ .key = "u", .value = .{ .doc = &.{.{ .key = "$set", .value = .{ .doc = &.{ .{ .key = "a", .value = .{ .array = &.{.{ .int32 = 1 }} } }, } } }} } }, } }}; var msg2 = try parse_fake_msg("update", .{ .string = "evt" }, &.{ .{ .key = "updates", .value = .{ .array = &updates } }, }); defer msg2.deinit(); var reply2 = wire.Reply.init(testing.allocator); defer reply2.deinit(); try dispatch(&ctx, &msg2, &reply2); try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply2.pairs.items, "ok").?.double); try testing.expectEqual(@as(i64, 0), bson.get_pair(reply2.pairs.items, "nModified").?.int32); const up_errs = bson.get_pair(reply2.pairs.items, "writeErrors").?.array; try testing.expectEqual(@as(i64, 16766), bson.get_pair(up_errs[0].doc, "code").?.int32); } /// Free a list of serialized ids (each element is gpa-owned). fn free_id_list(gpa: std.mem.Allocator, list: *std.ArrayListUnmanaged([]u8)) void { for (list.items) |id| gpa.free(id); list.deinit(gpa); } /// Free the serialized ids and reset the list, keeping capacity. fn clear_id_list(gpa: std.mem.Allocator, list: *std.ArrayListUnmanaged([]u8)) void { for (list.items) |id| gpa.free(id); list.clearRetainingCapacity(); } /// Runs `distinct` and hands back its `values`. The caller owns `reply`, /// because the values are allocated in the reply's arena. fn distinct_values( tdb: *TestDb, io: std.Io, reply: *wire.Reply, coll: []const u8, extra: []const bson.Pair, ) ![]const bson.Value { var ctx = tdb.ctx(io); var msg = try parse_fake_msg("distinct", .{ .string = coll }, extra); defer msg.deinit(); try dispatch(&ctx, &msg, reply); return switch (bson.get_pair(reply.pairs.items, "values") orelse return error.TestUnexpectedResult) { .array => |a| a, else => error.TestUnexpectedResult, }; } test "distinct answers a sorted set, not the order it met the values" { // The load-bearing property, and the one that is not guessable: mongod // sorts the answer in canonical BSON order. Insertion order is the // obvious implementation and it is wrong -- so the documents here are // seeded in an order that tells the two apart, and `null` is included // because its type ranks below strings and would otherwise trail them. // // Mutation check: delete the `std.mem.sort` in cmd_distinct and this // reads ["b", "a", null]. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); try dispatch_insert(&tdb, io, "d", &.{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "s", .value = .{ .string = "b" } } } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "s", .value = .{ .string = "a" } } } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 3 } }, .{ .key = "s", .value = .null } } }, // No `s` at all: contributes nothing, where an explicit null contributes // null. Measured -- the two are not the same absence. .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 4 } }} }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 5 } }, .{ .key = "s", .value = .{ .string = "a" } } } }, }); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); const values = try distinct_values(&tdb, io, &reply, "d", &.{ .{ .key = "key", .value = .{ .string = "s" } }, }); try testing.expectEqual(@as(usize, 3), values.len); try testing.expect(values[0] == .null); try testing.expectEqualStrings("a", values[1].string); try testing.expectEqualStrings("b", values[2].string); } test "distinct unwinds a terminal array exactly one level" { // `[[7, 8], 9]` answers `[7, 8]` and `9` -- the inner array is a value, // not something to descend into. Both halves are measured, and both // mutations are visible here: no unwinding at all makes `[1, 2, 2]` a // value, and recursive unwinding turns `[7, 8]` into 7 and 8. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); const inner = [_]bson.Value{ .{ .int32 = 7 }, .{ .int32 = 8 } }; try dispatch_insert(&tdb, io, "d", &.{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "arr", .value = .{ .array = &.{ .{ .int32 = 1 }, .{ .int32 = 2 }, .{ .int32 = 2 } } } }, } }, // An empty array contributes nothing, the way a missing field does. .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "arr", .value = .{ .array = &.{} } }, } }, // A non-array at the key is itself one value. .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 3 } }, .{ .key = "arr", .value = .{ .string = "not an array" } }, } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 4 } }, .{ .key = "arr", .value = .{ .array = &.{ .{ .array = &inner }, .{ .int32 = 9 } } } }, } }, }); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); const values = try distinct_values(&tdb, io, &reply, "d", &.{ .{ .key = "key", .value = .{ .string = "arr" } }, }); // Canonical order again: the numbers, then the string, then the array. try testing.expectEqual(@as(usize, 5), values.len); try testing.expectEqual(@as(i32, 1), values[0].int32); try testing.expectEqual(@as(i32, 2), values[1].int32); try testing.expectEqual(@as(i32, 9), values[2].int32); try testing.expectEqualStrings("not an array", values[3].string); try testing.expectEqual(@as(usize, 2), values[4].array.len); try testing.expectEqual(@as(i32, 7), values[4].array[0].int32); } test "distinct dedupes by value, so an int 1 and a double 1.0 are one" { // Deduping and sorting are the same comparator, which is why this falls // out for free -- and why `null` and `"1"` survive alongside it. The // surviving spelling is the first the scan met, because the sort is // stable; a switch to an unstable sort would make this arbitrary. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); try dispatch_insert(&tdb, io, "d", &.{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "m", .value = .{ .int32 = 1 } } } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "m", .value = .{ .double = 1.0 } } } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 3 } }, .{ .key = "m", .value = .{ .string = "1" } } } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 4 } }, .{ .key = "m", .value = .null } } }, }); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); const values = try distinct_values(&tdb, io, &reply, "d", &.{ .{ .key = "key", .value = .{ .string = "m" } }, }); try testing.expectEqual(@as(usize, 3), values.len); try testing.expect(values[0] == .null); try testing.expectEqual(@as(i32, 1), values[1].int32); try testing.expectEqualStrings("1", values[2].string); } test "distinct traverses a path through an array of subdocuments" { var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); try dispatch_insert(&tdb, io, "d", &.{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "n", .value = .{ .doc = &.{.{ .key = "d", .value = .{ .int32 = 5 } }} } }, } }, // Multikey: both subdocuments contribute, which is the same traversal // the matcher and the index generator use. .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "n", .value = .{ .array = &.{ .{ .doc = &.{.{ .key = "d", .value = .{ .int32 = 1 } }} }, .{ .doc = &.{.{ .key = "d", .value = .{ .int32 = 2 } }} }, } } }, } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 3 } }, .{ .key = "n", .value = .{ .doc = &.{.{ .key = "d", .value = .{ .int32 = 5 } }} } }, } }, }); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); const values = try distinct_values(&tdb, io, &reply, "d", &.{ .{ .key = "key", .value = .{ .string = "n.d" } }, }); try testing.expectEqual(@as(usize, 3), values.len); try testing.expectEqual(@as(i32, 1), values[0].int32); try testing.expectEqual(@as(i32, 2), values[1].int32); try testing.expectEqual(@as(i32, 5), values[2].int32); } test "distinct answers the empty set where it has nothing, rather than erroring" { // Three separate ways of having nothing, all of them `ok: 1` with an empty // array rather than an error. The empty key in particular reads like a // malformed request and is not one. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); try dispatch_insert(&tdb, io, "d", &.{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "x", .value = .{ .int32 = 11 } } } }, }); const cases = [_]struct { coll: []const u8, key: []const u8 }{ .{ .coll = "no_such_collection", .key = "x" }, .{ .coll = "d", .key = "nothing_has_this" }, .{ .coll = "d", .key = "" }, }; for (cases) |c| { var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); const values = try distinct_values(&tdb, io, &reply, c.coll, &.{ .{ .key = "key", .value = .{ .string = c.key } }, }); try testing.expectEqual(@as(usize, 0), values.len); try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double); } } test "distinct refuses a malformed request with mongod's own codes" { // Measured against mongod 8.3.7, not recalled: the missing `key` is an IDL // parse failure (40414) while a wrong-typed one is a TypeMismatch (14), // and a *null* query is an empty filter rather than a wrong type. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); const key_x = bson.Pair{ .key = "key", .value = .{ .string = "x" } }; try testing.expectEqual(@as(?i32, 40414), try run_for_code(&ctx, "distinct", .{ .string = "d" }, &.{})); try testing.expectEqual(@as(?i32, 14), try run_for_code(&ctx, "distinct", .{ .string = "d" }, &.{ .{ .key = "key", .value = .{ .int32 = 7 } }, })); try testing.expectEqual(@as(?i32, 14), try run_for_code(&ctx, "distinct", .{ .string = "d" }, &.{ .{ .key = "key", .value = .{ .doc = &.{} } }, })); try testing.expectEqual(@as(?i32, 14), try run_for_code(&ctx, "distinct", .{ .string = "d" }, &.{ key_x, .{ .key = "query", .value = .{ .int32 = 7 } }, })); try testing.expectEqual(@as(?i32, null), try run_for_code(&ctx, "distinct", .{ .string = "d" }, &.{ key_x, .{ .key = "query", .value = .null }, })); // An unknown top-level field is tolerated. mongod's IDL refuses it with // 40415, but this server tolerates unknown fields on every command, and // `comment` and `rawData` -- which the CRUD corpus requires be ignored -- // arrive through exactly this door. try testing.expectEqual(@as(?i32, null), try run_for_code(&ctx, "distinct", .{ .string = "d" }, &.{ key_x, .{ .key = "comment", .value = .{ .string = "c" } }, .{ .key = "rawData", .value = .{ .bool = true } }, })); // The message text is mongod's, reproduced rather than paraphrased, // because a driver that matches on it is matching on this. var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); var msg = try parse_fake_msg("distinct", .{ .string = "d" }, &.{}); defer msg.deinit(); try dispatch(&ctx, &msg, &reply); try testing.expectEqualStrings( "BSON field 'distinctCommandRequest.key' is missing but a required field", bson.get_pair(reply.pairs.items, "errmsg").?.string, ); } test "distinct applies its filter before collecting" { var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); try dispatch_insert(&tdb, io, "d", &.{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "x", .value = .{ .int32 = 11 } } } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "x", .value = .{ .int32 = 22 } } } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 3 } }, .{ .key = "x", .value = .{ .int32 = 33 } } } }, }); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); const values = try distinct_values(&tdb, io, &reply, "d", &.{ .{ .key = "key", .value = .{ .string = "x" } }, .{ .key = "query", .value = .{ .doc = &.{ .{ .key = "_id", .value = .{ .doc = &.{.{ .key = "$gt", .value = .{ .int32 = 1 } }} } }, } } }, }); try testing.expectEqual(@as(usize, 2), values.len); try testing.expectEqual(@as(i32, 22), values[0].int32); try testing.expectEqual(@as(i32, 33), values[1].int32); } test "drop does not unlock the collection it just freed" { // Regression test for a use-after-free: dispatch held `drop`'s collection // lock across the handler, the handler freed the Collection the lock lives // in, and dispatch then ran `unlock_collection` on freed memory. One // insert and one drop was enough -- SIGSEGV inside `Io.RwLock.unlock`. // // Two things kept it hidden. `drop` had no unit test at all: before this // one, every `parse_fake_msg("drop", ...)` in the tree was inside a test // written to hunt it. And over the wire it did not fault -- 25 // insert/drop cycles against a live server pass -- because the general // allocator leaves the freed page mapped, so the atomic write lands // somewhere harmless. testing.allocator is what makes it visible, which // is exactly why this test belongs here rather than in an e2e script. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); try dispatch_insert(&tdb, io, "arr", &.{ .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 1 } }} }, }); try testing.expectEqual(@as(?i32, null), try run_for_code(&ctx, "drop", .{ .string = "arr" }, &.{})); try testing.expect(ctx.engine.get_collection("test", "arr") == null); // The namespace is reusable afterwards, and dropping it again is // NamespaceNotFound rather than a second free. try dispatch_insert(&tdb, io, "arr", &.{ .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 2 } }} }, }); try testing.expectEqual(@as(i32, 1), try doc_count(&ctx, "arr")); try testing.expectEqual(@as(?i32, null), try run_for_code(&ctx, "drop", .{ .string = "arr" }, &.{})); try testing.expectEqual( @as(?i32, @intFromEnum(ErrorCode.namespace_not_found)), try run_for_code(&ctx, "drop", .{ .string = "arr" }, &.{}), ); } test "dropDatabase frees its collections without unlocking them" { // The same shape one level up, and the reason the epilogue is now keyed on // `kind == .write`: dropDatabase is the one write that never held a // collection lock, so it never reached the commit/checkpoint epilogue. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); try dispatch_insert(&tdb, io, "one", &.{ .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 1 } }} }, }); try dispatch_insert(&tdb, io, "two", &.{ .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 2 } }} }, }); try testing.expectEqual(@as(?i32, null), try run_for_code(&ctx, "dropDatabase", .{ .int32 = 1 }, &.{})); try testing.expect(ctx.engine.get_collection("test", "one") == null); try testing.expect(ctx.engine.get_collection("test", "two") == null); } test "a positional update is refused on the wire and stores nothing" { // The end of the chain the unit tests start: the refusal has to reach the // client as a code, and the stored document -- not just the working copy // -- has to be the one that was there before. // // Every case here previously answered ok: 1 with nModified: 1, having // replaced `y` with a document keyed by the path segment's text. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); const cases = [_]struct { coll: []const u8, path: []const u8, code: i32 }{ .{ .coll = "a1", .path = "y.$[i].b", .code = 2 }, // no array filter binds `i` .{ .coll = "a2", .path = "$[]", .code = 2 }, // positional in first position .{ .coll = "a3", .path = "y.$.b", .code = 2 }, // no predicate for `$` to use .{ .coll = "a4", .path = "y.nope.b", .code = 28 }, // PathNotViable, same branch }; for (cases) |c| { try dispatch_insert(&tdb, io, c.coll, &.{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "y", .value = .{ .array = &.{ .{ .doc = &.{.{ .key = "b", .value = .{ .int32 = 3 } }} }, } } }, } }, }); const updates = [_]bson.Value{.{ .doc = &.{ .{ .key = "q", .value = .{ .doc = &.{} } }, .{ .key = "u", .value = .{ .doc = &.{ .{ .key = "$set", .value = .{ .doc = &.{ .{ .key = c.path, .value = .{ .int32 = 2 } }, } } }, } } }, } }}; try testing.expectEqual(@as(?i32, c.code), try run_for_code(&ctx, "update", .{ .string = c.coll }, &.{ .{ .key = "updates", .value = .{ .array = &updates } }, })); // Read it back through `distinct`: if the array survived it still has // an element with `b: 3`, and if it was overwritten by a document // there is nothing at `y.b` at all. var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); const values = try distinct_values(&tdb, io, &reply, c.coll, &.{ .{ .key = "key", .value = .{ .string = "y.b" } }, }); try testing.expectEqual(@as(usize, 1), values.len); try testing.expectEqual(@as(i32, 3), values[0].int32); } } test "an all-positional update writes every element on the wire" { // The other end of the same chain: `$[]` reaches the stored document, and // reaches *all* of it. `distinct` on `y.b` is the check that says so in one // number -- two values means only one element moved. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); try dispatch_insert(&tdb, io, "all", &.{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "y", .value = .{ .array = &.{ .{ .doc = &.{.{ .key = "b", .value = .{ .int32 = 3 } }} }, .{ .doc = &.{.{ .key = "b", .value = .{ .int32 = 1 } }} }, } } }, } }, }); const updates = [_]bson.Value{.{ .doc = &.{ .{ .key = "q", .value = .{ .doc = &.{} } }, .{ .key = "u", .value = .{ .doc = &.{ .{ .key = "$set", .value = .{ .doc = &.{ .{ .key = "y.$[].b", .value = .{ .int32 = 9 } }, } } }, } } }, } }}; try testing.expectEqual(@as(?i32, null), try run_for_code(&ctx, "update", .{ .string = "all" }, &.{ .{ .key = "updates", .value = .{ .array = &updates } }, })); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); const values = try distinct_values(&tdb, io, &reply, "all", &.{ .{ .key = "key", .value = .{ .string = "y.b" } }, }); try testing.expectEqual(@as(usize, 1), values.len); try testing.expectEqual(@as(i32, 9), values[0].int32); } test "arrayFilters reach the update, and are refused before the scan" { // The plumbing, end to end: an identifier in the path only means anything // if the filter beside it arrives with it. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); try dispatch_insert(&tdb, io, "af", &.{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "y", .value = .{ .array = &.{ .{ .doc = &.{.{ .key = "b", .value = .{ .int32 = 3 } }} }, .{ .doc = &.{.{ .key = "b", .value = .{ .int32 = 1 } }} }, } } }, } }, }); const filters = [_]bson.Value{.{ .doc = &.{.{ .key = "i.b", .value = .{ .int32 = 3 } }} }}; const updates = [_]bson.Value{.{ .doc = &.{ .{ .key = "q", .value = .{ .doc = &.{} } }, .{ .key = "u", .value = .{ .doc = &.{ .{ .key = "$set", .value = .{ .doc = &.{ .{ .key = "y.$[i].b", .value = .{ .int32 = 9 } }, } } }, } } }, .{ .key = "arrayFilters", .value = .{ .array = &filters } }, } }}; try testing.expectEqual(@as(?i32, null), try run_for_code(&ctx, "update", .{ .string = "af" }, &.{ .{ .key = "updates", .value = .{ .array = &updates } }, })); // Only the element the filter selected moved. Mutation check: drop the // `arrayFilters` read in `cmd_update` and this is a `NoArrayFilter` reply // instead, which is the honest failure -- but silently ignoring the field // would write both elements. var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); const values = try distinct_values(&tdb, io, &reply, "af", &.{ .{ .key = "key", .value = .{ .string = "y.b" } }, }); try testing.expectEqual(@as(usize, 2), values.len); try testing.expectEqual(@as(i32, 1), values[0].int32); try testing.expectEqual(@as(i32, 9), values[1].int32); // A filter no path uses is refused even when the query matches nothing at // all, which is what makes the check belong beside the scan rather than // inside `apply`. Mutation check: delete the standalone `update.validate` // call and this answers ok -- `apply` runs once per matched document, and // there are none. const unused = [_]bson.Value{.{ .doc = &.{ .{ .key = "q", .value = .{ .doc = &.{.{ .key = "nomatch", .value = .{ .int32 = 1 } }} } }, .{ .key = "u", .value = .{ .doc = &.{ .{ .key = "$set", .value = .{ .doc = &.{.{ .key = "y.b", .value = .{ .int32 = 2 } }} } }, } } }, .{ .key = "arrayFilters", .value = .{ .array = &filters } }, } }}; try testing.expectEqual(@as(?i32, 9), try run_for_code(&ctx, "update", .{ .string = "af" }, &.{ .{ .key = "updates", .value = .{ .array = &unused } }, })); // Shape is the command's business, and it answers TypeMismatch for it. const bad = [_]bson.Value{.{ .doc = &.{ .{ .key = "q", .value = .{ .doc = &.{} } }, .{ .key = "u", .value = .{ .doc = &.{ .{ .key = "$set", .value = .{ .doc = &.{.{ .key = "y.$[i].b", .value = .{ .int32 = 9 } }} } }, } } }, .{ .key = "arrayFilters", .value = .{ .array = &.{.{ .int32 = 3 }} } }, } }}; try testing.expectEqual(@as(?i32, 14), try run_for_code(&ctx, "update", .{ .string = "af" }, &.{ .{ .key = "updates", .value = .{ .array = &bad } }, })); const not_an_array = [_]bson.Value{.{ .doc = &.{ .{ .key = "q", .value = .{ .doc = &.{} } }, .{ .key = "u", .value = .{ .doc = &.{ .{ .key = "$set", .value = .{ .doc = &.{.{ .key = "y.$[i].b", .value = .{ .int32 = 9 } }} } }, } } }, .{ .key = "arrayFilters", .value = .{ .int32 = 1 } }, } }}; try testing.expectEqual(@as(?i32, 14), try run_for_code(&ctx, "update", .{ .string = "af" }, &.{ .{ .key = "updates", .value = .{ .array = ¬_an_array } }, })); } test "aggregate $sort without a preceding $group sorts and frees correctly" { // Regression test for a remote, client-triggerable invalid free: the // $sort stage materialized its document list from the reply arena and // handed it to `trees`, which is freed with the gpa. Two things made it // survive for so long, and this test is shaped to close both: // // - every existing aggregate test sorts *after* a $group, which leaves // the stream already materialized so the guilty branch never runs. // So this pipeline must have $sort with NO $group before it. // - the symptom was allocator-dependent (macOS malloc aborted; other // allocators may not notice). testing.allocator detects an invalid // free itself, which is what gives this teeth in every mode. // // Mutation check: change the `all.append(ctx.gpa, ...)` back to // `all.append(arena, ...)` in cmd_aggregate and this goes red. var threaded = std.Io.Threaded.init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); // Insert out of order so a missing sort is visible, not coincidental. try dispatch_insert(&tdb, io, "agg", &.{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "x", .value = .{ .int32 = 30 } } } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "x", .value = .{ .int32 = 10 } } } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 3 } }, .{ .key = "x", .value = .{ .int32 = 20 } } } }, }); const sort_stage = bson.Value{ .doc = &.{.{ .key = "$sort", .value = .{ .doc = &.{.{ .key = "x", .value = .{ .int32 = 1 } }} } }} }; const stages = [_]bson.Value{sort_stage}; var ctx = tdb.ctx(io); var msg = try parse_fake_msg("aggregate", .{ .string = "agg" }, &.{ .{ .key = "pipeline", .value = .{ .array = &stages } }, .{ .key = "cursor", .value = .{ .doc = &.{} } }, }); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double); const cur = bson.get_pair(reply.pairs.items, "cursor") orelse return error.TestUnexpectedResult; const batch = switch (bson.get_pair(cur.doc, "firstBatch") orelse return error.TestUnexpectedResult) { .array => |a| a, else => return error.TestUnexpectedResult, }; try testing.expectEqual(@as(usize, 3), batch.len); // Ascending by x means _id order 2, 3, 1. const want = [_]i32{ 2, 3, 1 }; for (batch, want) |d, id| { try testing.expectEqual(id, bson.get_pair(d.doc, "_id").?.int32); } } test "$out and $merge write through the epilogue" { // The write stages are the reason `Context.pending_write` exists. They // write to a collection the pipeline is not reading, and doing that inside // the handler would take a second collection lock while the first is held // -- which `Collection.lock`'s own comment forbids. So the handler computes // and the epilogue writes, with nothing held. // // Mutation check: apply the write inside the `$out` branch instead of // stashing it. Deadlocks rather than fails, which is the argument for the // epilogue in one line. var threaded = std.Io.Threaded.init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); try dispatch_insert(&tdb, io, "src", &.{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "x", .value = .{ .int32 = 1 } } } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "x", .value = .{ .int32 = 2 } } } }, }); try dispatch_insert(&tdb, io, "dst", &.{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 9 } }, .{ .key = "old", .value = .{ .bool = true } } } }, }); var ctx = tdb.ctx(io); const run = struct { fn go(c: *Context, stages: []const bson.Value) !wire.Reply { var reply = wire.Reply.init(testing.allocator); errdefer reply.deinit(); var msg = try parse_fake_msg("aggregate", .{ .string = "src" }, &.{ .{ .key = "pipeline", .value = .{ .array = stages } }, .{ .key = "cursor", .value = .{ .doc = &.{} } }, }); defer msg.deinit(); try dispatch(c, &msg, &reply); return reply; } }.go; // $out replaces the target outright: the pre-existing document is gone. { const stages = [_]bson.Value{.{ .doc = &.{.{ .key = "$out", .value = .{ .string = "dst" } }} }}; var reply = try run(&ctx, &stages); defer reply.deinit(); try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double); } { const coll = ctx.engine.get_collection("test", "dst").?; try testing.expectEqual(@as(u64, 2), coll.doc_count); } // $merge keeps what it does not name and replaces what it does. try dispatch_insert(&tdb, io, "dst", &.{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 9 } }, .{ .key = "old", .value = .{ .bool = true } } } }, }); { const stages = [_]bson.Value{.{ .doc = &.{.{ .key = "$merge", .value = .{ .doc = &.{ .{ .key = "into", .value = .{ .string = "dst" } }, } } }} }}; var reply = try run(&ctx, &stages); defer reply.deinit(); try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double); } { const coll = ctx.engine.get_collection("test", "dst").?; try testing.expectEqual(@as(u64, 3), coll.doc_count); } // And the three shapes that are refused rather than half-honoured. const Case = struct { name: []const u8, stages: []const bson.Value, code: i32 }; const out_then_match = [_]bson.Value{ .{ .doc = &.{.{ .key = "$out", .value = .{ .string = "dst" } }} }, .{ .doc = &.{.{ .key = "$match", .value = .{ .doc = &.{} } }} }, }; const merge_bare = [_]bson.Value{.{ .doc = &.{.{ .key = "$merge", .value = .{ .doc = &.{} } }} }}; const merge_when = [_]bson.Value{.{ .doc = &.{.{ .key = "$merge", .value = .{ .doc = &.{ .{ .key = "into", .value = .{ .string = "dst" } }, .{ .key = "whenMatched", .value = .{ .string = "fail" } }, } } }} }}; const cases = [_]Case{ .{ .name = "$out is not last", .stages = &out_then_match, .code = 40601 }, .{ .name = "$merge without into", .stages = &merge_bare, .code = 40414 }, .{ .name = "$merge with whenMatched", .stages = &merge_when, .code = 40415 }, }; for (cases) |c| { var reply = try run(&ctx, c.stages); defer reply.deinit(); testing.expectEqual(@as(f64, 0.0), bson.get_pair(reply.pairs.items, "ok").?.double) catch |err| { std.debug.print(" {s}: answered ok:1\n", .{c.name}); return err; }; try testing.expectEqual(c.code, bson.get_pair(reply.pairs.items, "code").?.int32); // And nothing was written: a refused stage leaves the target alone. try testing.expectEqual(@as(u64, 3), ctx.engine.get_collection("test", "dst").?.doc_count); } } test "$project is a stage, not a note about how to print the answer" { // It used to set a variable applied once, at the emit. Three consequences, // all measured on a live server before this changed: only the *last* // `$project` in a pipeline had any effect; a `$match` after one still saw // the field it had removed; and `{y: {$literal: 5}}` read as falsy, which // flipped the whole projection into its exclusion branch and returned every // document minus `y`. // // Every answer below is byte-identical to mongod 8.3.7 on the same input. // // Mutation check: move the projection back to the emit -- pass `pp` to // `emit_first_batch` instead of transforming the stream -- and the first // two cases go red. var threaded = std.Io.Threaded.init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); try dispatch_insert(&tdb, io, "pj", &.{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "g", .value = .{ .string = "a" } }, .{ .key = "x", .value = .{ .int32 = 10 } } } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "g", .value = .{ .string = "b" } }, .{ .key = "x", .value = .{ .int32 = 20 } } } }, }); var ctx = tdb.ctx(io); const keep_g = bson.Value{ .doc = &.{.{ .key = "$project", .value = .{ .doc = &.{ .{ .key = "g", .value = .{ .int32 = 1 } }, } } }} }; // A $match after a $project cannot see what the projection removed. { const match_x = bson.Value{ .doc = &.{.{ .key = "$match", .value = .{ .doc = &.{ .{ .key = "x", .value = .{ .int32 = 10 } }, } } }} }; const stages = [_]bson.Value{ keep_g, match_x }; var msg = try parse_fake_msg("aggregate", .{ .string = "pj" }, &.{ .{ .key = "pipeline", .value = .{ .array = &stages } }, .{ .key = "cursor", .value = .{ .doc = &.{} } }, }); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); const cur = bson.get_pair(reply.pairs.items, "cursor").?; try testing.expectEqual(@as(usize, 0), bson.get_pair(cur.doc, "firstBatch").?.array.len); } // And a $group after one reads the projected document, not the stored one. { const group_g = bson.Value{ .doc = &.{.{ .key = "$group", .value = .{ .doc = &.{ .{ .key = "_id", .value = .null }, .{ .key = "n", .value = .{ .doc = &.{.{ .key = "$sum", .value = .{ .int32 = 1 } }} } }, .{ .key = "x", .value = .{ .doc = &.{.{ .key = "$sum", .value = .{ .string = "$x" } }} } }, } } }} }; const stages = [_]bson.Value{ keep_g, group_g }; var msg = try parse_fake_msg("aggregate", .{ .string = "pj" }, &.{ .{ .key = "pipeline", .value = .{ .array = &stages } }, .{ .key = "cursor", .value = .{ .doc = &.{} } }, }); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); const cur = bson.get_pair(reply.pairs.items, "cursor").?; const batch = bson.get_pair(cur.doc, "firstBatch").?.array; try testing.expectEqual(@as(usize, 1), batch.len); try testing.expectEqual(@as(i32, 2), bson.get_pair(batch[0].doc, "n").?.int32); // `x` was projected away, so summing it is summing nothing. try testing.expectEqual(@as(i32, 0), bson.get_pair(batch[0].doc, "x").?.int32); } // The three shapes mongod refuses, refused with its codes. const Case = struct { name: []const u8, spec: []const bson.Pair, code: i32 }; const cases = [_]Case{ .{ .name = "empty", .spec = &.{}, .code = 51272 }, .{ .name = "mixed inclusion and exclusion", .spec = &.{ .{ .key = "g", .value = .{ .int32 = 1 } }, .{ .key = "x", .value = .{ .int32 = 0 } }, }, .code = 31254, }, // A computed field and a nested spec stood here until the expression // evaluator and the flattening landed; both work now. What is left is // the one shape mongod refuses too. .{ .name = "an unknown expression", .spec = &.{.{ .key = "y", .value = .{ .doc = &.{.{ .key = "$bogusExpr", .value = .{ .int32 = 5 } }} } }}, .code = 168, }, }; for (cases) |c| { const stages = [_]bson.Value{.{ .doc = &.{.{ .key = "$project", .value = .{ .doc = c.spec } }} }}; var msg = try parse_fake_msg("aggregate", .{ .string = "pj" }, &.{ .{ .key = "pipeline", .value = .{ .array = &stages } }, .{ .key = "cursor", .value = .{ .doc = &.{} } }, }); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); testing.expectEqual(@as(f64, 0.0), bson.get_pair(reply.pairs.items, "ok").?.double) catch |err| { std.debug.print(" {s}: answered ok:1\n", .{c.name}); return err; }; testing.expectEqual(c.code, bson.get_pair(reply.pairs.items, "code").?.int32) catch |err| { std.debug.print(" {s}: wrong code\n", .{c.name}); return err; }; } } test "a pipeline may group what an earlier stage generated" { // A remote crash, reachable by any client with a two-stage pipeline and no // authentication in front of it: `$group` took `[]const u64` and was handed // `offs.items[start..end]` whatever the stream was made of. After a stage // that materializes -- another `$group`, and now `$project` -- `offs` is // empty while the bounds count trees, so the slice ran off an empty list // and panicked the server thread. // // "index out of bounds: index 2, len 0", measured against the binary at the // previous commit before this was written. // // Mutation check: hand `run_group` `.{ .offsets = offs.items[start..end] }` // unconditionally, and this aborts the run rather than failing it -- which // is exactly why it was worth a test rather than a code read. var threaded = std.Io.Threaded.init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); try dispatch_insert(&tdb, io, "gg", &.{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "k", .value = .{ .string = "a" } } } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "k", .value = .{ .string = "a" } } } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 3 } }, .{ .key = "k", .value = .{ .string = "b" } } } }, }); // Group by key, then count the groups: the second $group reads what the // first one generated, which lives in no slab. const by_k = bson.Value{ .doc = &.{.{ .key = "$group", .value = .{ .doc = &.{ .{ .key = "_id", .value = .{ .string = "$k" } }, .{ .key = "n", .value = .{ .doc = &.{.{ .key = "$sum", .value = .{ .int32 = 1 } }} } }, } } }} }; const count_groups = bson.Value{ .doc = &.{.{ .key = "$group", .value = .{ .doc = &.{ .{ .key = "_id", .value = .null }, .{ .key = "groups", .value = .{ .doc = &.{.{ .key = "$sum", .value = .{ .int32 = 1 } }} } }, // And a path that only resolves against the *generated* document, // which is what makes this more than a bounds check. .{ .key = "docs", .value = .{ .doc = &.{.{ .key = "$sum", .value = .{ .string = "$n" } }} } }, } } }} }; const stages = [_]bson.Value{ by_k, count_groups }; var ctx = tdb.ctx(io); var msg = try parse_fake_msg("aggregate", .{ .string = "gg" }, &.{ .{ .key = "pipeline", .value = .{ .array = &stages } }, .{ .key = "cursor", .value = .{ .doc = &.{} } }, }); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double); const cur = bson.get_pair(reply.pairs.items, "cursor").?; const batch = bson.get_pair(cur.doc, "firstBatch").?.array; try testing.expectEqual(@as(usize, 1), batch.len); try testing.expectEqual(@as(i32, 2), bson.get_pair(batch[0].doc, "groups").?.int32); try testing.expectEqual(@as(i32, 3), bson.get_pair(batch[0].doc, "docs").?.int32); } test "$group refuses what it cannot compute instead of answering zero" { // The failure this closes was not a missing feature, it was a wrong number // reported as success. `{$avg: "$x"}` answered `0`, and so did `$max` and // `$push`; a compound `_id` collapsed every document into one group keyed // by the unevaluated expression; `{$literal: 1}` came back echoed. Six of // eight probed pipelines answered `ok: 1` with a wrong result. An // unrecognised stage is a bug report; an `$avg` that returns `0` is a // corrupted report nobody files. // // Every code and codeName below was read off mongod 8.3.7, not recalled, // and this test is where they are pinned. // // Mutation check: drop any one arm of the validation in `run_group` and // the matching row answers `ok: 1` again. var threaded = std.Io.Threaded.init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); try dispatch_insert(&tdb, io, "g", &.{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "k", .value = .{ .string = "a" } }, .{ .key = "x", .value = .{ .int32 = 10 } } } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "k", .value = .{ .string = "a" } }, .{ .key = "x", .value = .{ .int32 = 20 } } } }, }); const Case = struct { name: []const u8, group: []const bson.Pair, code: i32 }; const path_x = bson.Value{ .string = "$x" }; const sum_one = bson.Value{ .doc = &.{.{ .key = "$sum", .value = .{ .int32 = 1 } }} }; const cases = [_]Case{ // `$avg` and `$push` stood here until the accumulators landed. The // ones that remain unimplemented answer the same way, which is the // point: the refusal is a property of what is missing, not of a list. .{ .name = "$stdDevPop", .group = &.{ .{ .key = "_id", .value = .{ .string = "$k" } }, .{ .key = "v", .value = .{ .doc = &.{.{ .key = "$stdDevPop", .value = path_x }} } }, }, .code = 15952, }, .{ .name = "$mergeObjects", .group = &.{ .{ .key = "_id", .value = .{ .string = "$k" } }, .{ .key = "v", .value = .{ .doc = &.{.{ .key = "$mergeObjects", .value = path_x }} } }, }, .code = 15952, }, .{ .name = "an accumulator that is not a document", .group = &.{ .{ .key = "_id", .value = .{ .string = "$k" } }, .{ .key = "v", .value = path_x }, }, .code = 40234, }, .{ .name = "two operators in one accumulator", .group = &.{ .{ .key = "_id", .value = .{ .string = "$k" } }, .{ .key = "v", .value = .{ .doc = &.{ .{ .key = "$sum", .value = path_x }, .{ .key = "$max", .value = path_x }, } } }, }, .code = 40238, }, .{ .name = "no _id", .group = &.{.{ .key = "v", .value = sum_one }}, .code = 15955, }, // A compound `_id`, `$literal` and a `$multiply` argument stood here // until the expression evaluator landed. What is refused is still what // is missing, and these are the two that remain missing. .{ .name = "an unknown expression operator", .group = &.{ .{ .key = "_id", .value = .{ .doc = &.{.{ .key = "$bogusExpr", .value = path_x }} } }, .{ .key = "v", .value = sum_one }, }, .code = 168, }, .{ .name = "an operator given the wrong number of operands", .group = &.{ .{ .key = "_id", .value = .{ .doc = &.{.{ .key = "$subtract", .value = .{ .array = &.{path_x} } }} } }, .{ .key = "v", .value = sum_one }, }, .code = 16020, }, }; var ctx = tdb.ctx(io); for (cases) |c| { const stages = [_]bson.Value{.{ .doc = &.{.{ .key = "$group", .value = .{ .doc = c.group } }} }}; var msg = try parse_fake_msg("aggregate", .{ .string = "g" }, &.{ .{ .key = "pipeline", .value = .{ .array = &stages } }, .{ .key = "cursor", .value = .{ .doc = &.{} } }, }); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); const ok = bson.get_pair(reply.pairs.items, "ok").?.double; testing.expectEqual(@as(f64, 0.0), ok) catch |err| { std.debug.print(" {s}: answered ok:1\n", .{c.name}); return err; }; const code = bson.get_pair(reply.pairs.items, "code").?.int32; testing.expectEqual(c.code, code) catch |err| { std.debug.print(" {s}: code {d}, wanted {d}\n", .{ c.name, code, c.code }); return err; }; } // And the one shape that *is* implemented still answers, so the refusals // above are a fence and not a wall. const good = [_]bson.Value{.{ .doc = &.{.{ .key = "$group", .value = .{ .doc = &.{ .{ .key = "_id", .value = .{ .string = "$k" } }, .{ .key = "v", .value = .{ .doc = &.{.{ .key = "$sum", .value = path_x }} } }, } } }} }}; var msg = try parse_fake_msg("aggregate", .{ .string = "g" }, &.{ .{ .key = "pipeline", .value = .{ .array = &good } }, .{ .key = "cursor", .value = .{ .doc = &.{} } }, }); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double); const cur = bson.get_pair(reply.pairs.items, "cursor").?; const batch = bson.get_pair(cur.doc, "firstBatch").?.array; try testing.expectEqual(@as(usize, 1), batch.len); try testing.expectEqual(@as(i32, 30), bson.get_pair(batch[0].doc, "v").?.int32); } 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 "a sorted full scan over a multikey index returns each document once" { // scan_sorted streams a whole-index read instead of materializing it, which // is what keeps a countDocuments({}) from building a list of every offset in // the collection. But one document contributes several entries to a multikey // index, so walking that index end to end yields it once per array element. // The materializing path deduped; a stream cannot, so Plan.full_scan() // refuses multikey indexes and this shape keeps materializing. // // The shape is `find({}).sort({tags: 1})`: no filter, so the only reason to // use an index at all is that it supplies the order. // // Mutation check, and it is worth stating precisely because the obvious // version of it does nothing: two independent guards refuse this, so // removing either one alone leaves the test green. `index_provides_sort` // returns null for a multikey index, and `Plan.full_scan` refuses one // again. Remove *both* and each document comes back three times. So this // test pins the pair, not either guard -- which is the useful property, // since it is the behaviour that matters rather than which check delivers // it. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); try dispatch_insert(&tdb, io, "mk", &.{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "tags", .value = .{ .array = &.{ .{ .int32 = 1 }, .{ .int32 = 2 }, .{ .int32 = 3 } } } }, } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "tags", .value = .{ .array = &.{ .{ .int32 = 4 }, .{ .int32 = 5 }, .{ .int32 = 6 } } } }, } }, }); try dispatch_create_index(&tdb, io, "mk", .{ .doc = &.{ .{ .key = "key", .value = .{ .doc = &.{.{ .key = "tags", .value = .{ .int32 = 1 } }} } }, .{ .key = "name", .value = .{ .string = "tags_1" } }, } }); var ctx = tdb.ctx(io); var msg = try parse_fake_msg("find", .{ .string = "mk" }, &.{ .{ .key = "filter", .value = .{ .doc = &.{} } }, .{ .key = "sort", .value = .{ .doc = &.{.{ .key = "tags", .value = .{ .int32 = 1 } }} } }, }); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double); const cur = bson.get_pair(reply.pairs.items, "cursor") orelse return error.TestUnexpectedResult; const batch = switch (bson.get_pair(cur.doc, "firstBatch") orelse return error.TestUnexpectedResult) { .array => |arr| arr, else => return error.TestUnexpectedResult, }; // Two documents, not six. try testing.expectEqual(@as(usize, 2), batch.len); } test "a command with no collection name errors and holds no lock" { // Regression for a leaked catalog lock. dispatch resolved the namespace // *after* taking the catalog lock, with `orelse return` -- and a plain // return runs neither the errdefer nor the explicit unlocks, so the lock // was held shared forever. `db.aggregate(...)` sends {aggregate: 1}, whose // value is not a string, so it reached exactly that path. // // Two assertions, because the first alone would have passed before the fix // for the wrong reason: the reply must be a real error (it used to be an // empty document, which a driver reports as the useless "n/a"), and a // subsequent write that has to take the catalog exclusive to create a // collection must still complete. The second is the lock check. // // Mutation check: restore the `orelse return` pair after the lock // acquisition and this test hangs on the insert rather than failing -- the // suite times out. That is a deadlock, so it cannot be asserted more // politely from a single fiber; the e2e suite covers the same sequence // where a hang surfaces as a client timeout instead. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); { var ctx = tdb.ctx(io); // {aggregate: 1} -- a database-level aggregate, no collection named. var msg = try parse_fake_msg("aggregate", .{ .int32 = 1 }, &.{ .{ .key = "pipeline", .value = .{ .array = &.{} } }, .{ .key = "cursor", .value = .{ .doc = &.{} } }, }); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); // A proper error, not an empty reply. try testing.expectEqual(@as(f64, 0.0), bson.get_pair(reply.pairs.items, "ok").?.double); try testing.expectEqual( @as(i32, @intFromEnum(ErrorCode.bad_value)), bson.get_pair(reply.pairs.items, "code").?.int32, ); } // The lock assertion: this insert creates a collection, which upgrades the // catalog lock to exclusive. With the lock leaked it never returns. try dispatch_insert(&tdb, io, "after", &.{ .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 1 } }} }, }); } test "compare-equal _id encodings collide under the unique _id_ index" { // _id uniqueness moved from a docs-map probe keyed on serialize_value to // the _id_ B+tree, keyed on the canonical bson.encode_key (PLAN A3/A4). // That changes observable behavior, in MongoDB's direction: int32 1, // int64 1 and double 1.0 are one _id, not three. // // Mutation check: setting `unique = false` back on id_index makes these // collisions vanish (this test and "insert counts only successful inserts" // both go red). // // The other half of the change is covered elsewhere, which is worth // knowing so nobody re-checks it here: passing `id_key` instead of null as // check_unique's exclude on the insert path is caught by "insert counts // only successful inserts", not by this test. Two documents with the // *same* encoding share an id_key, so exclude-self hides the collision; // int32 1 and int64 1 have different id_keys, so this test survives that // mutation. The two tests are complementary, not redundant. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); try dispatch_insert(&tdb, io, "ids", &.{ .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 1 } }} }, }); // Each of these is the same _id as int32 1. for ([_]bson.Value{ .{ .doc = &.{.{ .key = "_id", .value = .{ .int64 = 1 } }} }, .{ .doc = &.{.{ .key = "_id", .value = .{ .double = 1.0 } }} }, }) |dup| { var ctx = tdb.ctx(io); const one = [_]bson.Value{dup}; var msg = try parse_fake_msg("insert", .{ .string = "ids" }, &.{ .{ .key = "documents", .value = .{ .array = &one } }, }); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); const we = bson.get_pair(reply.pairs.items, "writeErrors") orelse return error.TestUnexpectedResult; const first = we.array[0]; try testing.expectEqual(@as(i32, 11000), bson.get_pair(first.doc, "code").?.int32); // The index named in the message is the one MongoDB names. const errmsg = bson.get_pair(first.doc, "errmsg").?.string; try testing.expect(std.mem.indexOf(u8, errmsg, "_id_") != null); } // A different rank is a different _id: "1" is not 1. try dispatch_insert(&tdb, io, "ids", &.{ .{ .doc = &.{.{ .key = "_id", .value = .{ .string = "1" } }} }, }); // And the collision is not merely rejection: a lookup by any of the // equivalent encodings finds the one stored document. for ([_]bson.Value{ .{ .int32 = 1 }, .{ .int64 = 1 }, .{ .double = 1.0 } }) |probe| { var ids: std.ArrayListUnmanaged([]u8) = .empty; defer { for (ids.items) |id| testing.allocator.free(id); ids.deinit(testing.allocator); } try dispatch_find_ids(&tdb, io, "ids", &.{.{ .key = "_id", .value = probe }}, &ids); try testing.expectEqual(@as(usize, 1), ids.items.len); } } test "indexed queries are equivalent to scans over a mixed corpus" { var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); // Corpus deliberately mixes numeric _id encodings, arrays, nested docs, // missing fields, explicit nulls, and duplicate values. // // It used to carry int32 1 and int64 1 as two documents, to exercise the // old docs-map fast path (they compare equal but serialize differently). // _id_ is a unique index now, keyed on the canonical bson.encode_key, so // those two *are* the same _id and the second is rejected -- which is // MongoDB's behavior. The int64 encoding still appears below, on a // distinct value; the collision itself is asserted in "compare-equal _id // encodings collide under the unique _id_ index". const corpus = [_]bson.Value{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "a", .value = .{ .int32 = 10 } }, .{ .key = "b", .value = .{ .string = "x" } }, .{ .key = "tags", .value = .{ .array = &.{ .{ .string = "a" }, .{ .string = "b" } } } }, } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int64 = 2 } }, .{ .key = "a", .value = .{ .int32 = 20 } }, .{ .key = "b", .value = .{ .string = "y" } }, .{ .key = "tags", .value = .{ .array = &.{} } }, } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 3 } }, .{ .key = "a", .value = .{ .double = 30.0 } }, .{ .key = "b", .value = .null }, .{ .key = "tags", .value = .{ .array = &.{.{ .string = "a" }} } }, } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .string = "s4" } }, .{ .key = "a", .value = .{ .int32 = 10 } }, .{ .key = "b", .value = .{ .string = "z" } }, .{ .key = "tags", .value = .{ .array = &.{ .{ .int32 = 10 }, .{ .int32 = 20 } } } }, } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 5 } }, .{ .key = "a", .value = .null }, .{ .key = "b", .value = .{ .string = "x" } }, } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 6 } }, .{ .key = "a", .value = .{ .doc = &.{.{ .key = "n", .value = .{ .int32 = 1 } }} } }, .{ .key = "b", .value = .{ .string = "w" } }, } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 7 } }, .{ .key = "a", .value = .{ .array = &.{ .{ .int32 = 1 }, .{ .int32 = 2 } } } }, .{ .key = "b", .value = .{ .string = "q" } }, } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 8 } }, .{ .key = "c", .value = .{ .int32 = 99 } }, } }, .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 9 } }, .{ .key = "a", .value = .{ .int32 = 40 } }, .{ .key = "b", .value = .{ .array = &.{ .{ .int32 = 5 }, .{ .int32 = 6 } } } }, } }, }; try dispatch_insert(&tdb, io, "eq", &corpus); const filters = [_]struct { pairs: []const bson.Pair }{ .{ .pairs = &.{} }, .{ .pairs = &.{.{ .key = "a", .value = .{ .int32 = 10 } }} }, .{ .pairs = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$eq", .value = .{ .int32 = 20 } }} } }} }, .{ .pairs = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$gt", .value = .{ .int32 = 15 } }} } }} }, .{ .pairs = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$gte", .value = .{ .int32 = 20 } }} } }} }, .{ .pairs = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$lt", .value = .{ .int32 = 30 } }} } }} }, .{ .pairs = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$lte", .value = .{ .int32 = 30 } }} } }} }, .{ .pairs = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$in", .value = .{ .array = &.{ .{ .int32 = 10 }, .{ .int32 = 30 } } } }} } }} }, .{ .pairs = &.{.{ .key = "a", .value = .{ .doc = &.{ .{ .key = "$gt", .value = .{ .int32 = 5 } }, .{ .key = "$lt", .value = .{ .int32 = 25 } } } } }} }, .{ .pairs = &.{.{ .key = "b", .value = .{ .string = "x" } }} }, .{ .pairs = &.{.{ .key = "b", .value = .null }} }, .{ .pairs = &.{ .{ .key = "a", .value = .{ .int32 = 10 } }, .{ .key = "b", .value = .{ .string = "x" } } } }, .{ .pairs = &.{.{ .key = "tags", .value = .{ .string = "a" } }} }, .{ .pairs = &.{.{ .key = "tags", .value = .{ .array = &.{ .{ .string = "a" }, .{ .string = "b" } } } }} }, .{ .pairs = &.{.{ .key = "tags", .value = .{ .doc = &.{.{ .key = "$all", .value = .{ .array = &.{.{ .string = "a" }} } }} } }} }, .{ .pairs = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$regex", .value = .{ .string = "^1" } }} } }} }, .{ .pairs = &.{.{ .key = "c", .value = .{ .doc = &.{.{ .key = "$exists", .value = .{ .bool = false } }} } }} }, .{ .pairs = &.{.{ .key = "a", .value = .null }} }, .{ .pairs = &.{.{ .key = "_id", .value = .{ .int32 = 1 } }} }, .{ .pairs = &.{.{ .key = "_id", .value = .{ .string = "s4" } }} }, .{ .pairs = &.{.{ .key = "_id", .value = .{ .doc = &.{.{ .key = "$in", .value = .{ .array = &.{ .{ .int32 = 1 }, .{ .string = "s4" } } } }} } }} }, .{ .pairs = &.{.{ .key = "$and", .value = .{ .array = &.{ .{ .doc = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$gte", .value = .{ .int32 = 10 } }} } }} }, .{ .doc = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$lte", .value = .{ .int32 = 30 } }} } }} }, } } }} }, .{ .pairs = &.{.{ .key = "a", .value = .{ .array = &.{ .{ .int32 = 1 }, .{ .int32 = 2 } } } }} }, .{ .pairs = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$gt", .value = .{ .int32 = 100 } }} } }} }, .{ .pairs = &.{.{ .key = "$or", .value = .{ .array = &.{ .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 10 } }} }, .{ .doc = &.{.{ .key = "b", .value = .{ .string = "y" } }} }, } } }} }, .{ .pairs = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$gt", .value = .null }} } }} }, .{ .pairs = &.{ .{ .key = "a", .value = .{ .int32 = 10 } }, .{ .key = "tags", .value = .{ .int32 = 10 } } } }, }; // First with a compound (a, b) index, then after dropping it — and the // _id fast path is exercised by the _id filters in both runs. try dispatch_create_index(&tdb, io, "eq", .{ .doc = &.{ .{ .key = "key", .value = .{ .doc = &.{ .{ .key = "a", .value = .{ .int32 = 1 } }, .{ .key = "b", .value = .{ .int32 = 1 } }, } } }, .{ .key = "name", .value = .{ .string = "a_1_b_1" } }, } }); // Run every filter through the index (the (a, b) plan plus the _id fast // path), then drop the index and require identical results from the // scan. The corpus's int32/int64 _id pair exercises the fast-path guard // (numbers fall back to a scan in both runs). var indexed_results: std.ArrayListUnmanaged(std.ArrayListUnmanaged([]u8)) = .empty; defer { for (indexed_results.items) |*l| free_id_list(testing.allocator, l); indexed_results.deinit(testing.allocator); } for (filters) |f| { var list: std.ArrayListUnmanaged([]u8) = .empty; errdefer free_id_list(testing.allocator, &list); try dispatch_find_ids(&tdb, io, "eq", f.pairs, &list); try indexed_results.append(testing.allocator, list); } { var ctx = tdb.ctx(io); var msg = try parse_fake_msg("dropIndexes", .{ .string = "eq" }, &.{ .{ .key = "index", .value = .{ .string = "*" } }, }); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); } var scanned: std.ArrayListUnmanaged([]u8) = .empty; defer free_id_list(testing.allocator, &scanned); for (filters, 0..) |f, fi| { clear_id_list(testing.allocator, &scanned); try dispatch_find_ids(&tdb, io, "eq", f.pairs, &scanned); const indexed = indexed_results.items[fi]; try testing.expectEqual(scanned.items.len, indexed.items.len); for (scanned.items, indexed.items) |a, b| try testing.expectEqualSlices(u8, a, b); } // Same corpus with a sparse (a, b) index: the sparse/null bail keeps // {a: null} and null-range filters correct. try dispatch_create_index(&tdb, io, "eq", .{ .doc = &.{ .{ .key = "key", .value = .{ .doc = &.{ .{ .key = "a", .value = .{ .int32 = 1 } }, .{ .key = "b", .value = .{ .int32 = 1 } }, } } }, .{ .key = "name", .value = .{ .string = "a_1_b_1_sparse" } }, .{ .key = "sparse", .value = .{ .bool = true } }, } }); for (indexed_results.items) |*l| free_id_list(testing.allocator, l); indexed_results.clearRetainingCapacity(); for (filters) |f| { var list: std.ArrayListUnmanaged([]u8) = .empty; errdefer free_id_list(testing.allocator, &list); try dispatch_find_ids(&tdb, io, "eq", f.pairs, &list); try indexed_results.append(testing.allocator, list); } { var ctx = tdb.ctx(io); var msg = try parse_fake_msg("dropIndexes", .{ .string = "eq" }, &.{ .{ .key = "index", .value = .{ .string = "*" } }, }); defer msg.deinit(); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); try dispatch(&ctx, &msg, &reply); } for (filters, 0..) |f, fi| { clear_id_list(testing.allocator, &scanned); try dispatch_find_ids(&tdb, io, "eq", f.pairs, &scanned); const indexed = indexed_results.items[fi]; try testing.expectEqual(scanned.items.len, indexed.items.len); for (scanned.items, indexed.items) |a, b| try testing.expectEqualSlices(u8, a, b); } } /// Insert `n` documents `{_id: 1..n, a: i % 5, pad}` in one batch. /// /// Goes through `dispatch_insert` rather than dispatching itself, because that /// helper checks `ok`, `writeErrors` *and* `n` -- and its comment records what /// omitting those cost last time: a corpus silently lost a document and every /// test over it still passed. A cursor test whose premise is "60 documents" must /// not be able to become "0 documents" quietly. const seed_pad = "0123456789012345678901234567890123456789"; fn seed_docs(tdb: *TestDb, io: std.Io, coll: []const u8, n: i32) !void { const arena = testing.allocator; const docs = try arena.alloc(bson.Value, @intCast(n)); defer { for (docs) |d| arena.free(d.doc); arena.free(docs); } for (docs, 0..) |*d, i| { const pairs = try arena.alloc(bson.Pair, 3); pairs[0] = .{ .key = "_id", .value = .{ .int32 = @intCast(i + 1) } }; pairs[1] = .{ .key = "a", .value = .{ .int32 = @intCast(@mod(i + 1, 5)) } }; pairs[2] = .{ .key = "pad", .value = .{ .string = seed_pad } }; d.* = .{ .doc = pairs }; } try dispatch_insert(tdb, io, coll, docs); } /// Run `find` and return (cursor id, first-batch length). fn dispatch_find( ctx: *Context, coll: []const u8, filter: []const bson.Pair, batch_size: i32, ) !struct { id: i64, n: usize } { var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); var msg = try parse_fake_msg("find", .{ .string = coll }, &.{ .{ .key = "filter", .value = .{ .doc = filter } }, .{ .key = "batchSize", .value = .{ .int32 = batch_size } }, }); defer msg.deinit(); try dispatch(ctx, &msg, &reply); const cur = bson.get_pair(reply.pairs.items, "cursor") orelse return error.TestUnexpectedResult; const id = bson.get_pair(cur.doc, "id").?.int64; const batch = bson.get_pair(cur.doc, "firstBatch").?.array; return .{ .id = id, .n = batch.len }; } /// Run `getMore` and return the error code, or 0 on success. fn dispatch_get_more(ctx: *Context, coll: []const u8, id: i64) !i32 { var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); var msg = try parse_fake_msg("getMore", .{ .int64 = id }, &.{ .{ .key = "collection", .value = .{ .string = coll } }, .{ .key = "batchSize", .value = .{ .int32 = 5 } }, }); defer msg.deinit(); try dispatch(ctx, &msg, &reply); if (bson.get_pair(reply.pairs.items, "code")) |c| return c.int32; return 0; } test "a rebuild kills an offsets cursor and spares a streaming one" { // This is also the test that proves the streaming source is *selected*: a // whole-index walk and an indexed-predicate scan are given different sources, // and a rebuild is exactly what tells them apart. If `find({})` quietly fell // back to materializing offsets, both would die here. // // A rebuild is triggered directly rather than through churn, because whether // churn crosses the compaction threshold is not something a test should have // to guess at. The garbage below is still needed: `compact` now skips a // collection with nothing to reclaim, so a clean one is not rewritten at // all and there would be no rebuild to observe. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); try seed_docs(&tdb, io, "c", 60); // A whole-index walk: no predicate, no sort. const walk = try dispatch_find(&ctx, "c", &.{}, 5); try testing.expectEqual(@as(usize, 5), walk.n); try testing.expect(walk.id != 0); // A narrowed plan over the _id_ index, which materializes its candidates. const narrowed = try dispatch_find(&ctx, "c", &.{ .{ .key = "_id", .value = .{ .doc = &.{.{ .key = "$gte", .value = .{ .int32 = 1 } }} } }, }, 5); try testing.expect(narrowed.id != 0); // Both still work before the rebuild, so the difference below is the rebuild. try testing.expectEqual(@as(i32, 0), try dispatch_get_more(&ctx, "c", walk.id)); try testing.expectEqual(@as(i32, 0), try dispatch_get_more(&ctx, "c", narrowed.id)); // Rewrite every document, so the collection has as many dead bytes as live // ones and is worth rebuilding. A replace rather than a delete: the count // stays at 60, which is what the drain below checks. try ctx.engine.lock(); var again: i32 = 1; while (again <= 60) : (again += 1) { const pairs = try testing.allocator.alloc(bson.Pair, 3); defer testing.allocator.free(pairs); pairs[0] = .{ .key = "_id", .value = .{ .int32 = again } }; // A different value: an identical replace is deliberately not a write. pairs[1] = .{ .key = "a", .value = .{ .int32 = @mod(again, 5) + 100 } }; pairs[2] = .{ .key = "pad", .value = .{ .string = seed_pad } }; var doc: bson.Document = .{ .arena = std.heap.ArenaAllocator.init(testing.allocator), .pairs = pairs }; defer doc.arena.deinit(); _ = try ctx.engine.replace("test", "c", &doc, ctx.oid_gen); } try ctx.engine.commit(); ctx.engine.unlock(); try ctx.engine.compact(); // The stream remembers key bytes, which a repack does not change. try testing.expectEqual(@as(i32, 0), try dispatch_get_more(&ctx, "c", walk.id)); // The offsets name bytes that have moved, so continuing would be a wrong // answer; QueryPlanKilled says so instead. try testing.expectEqual( @intFromEnum(ErrorCode.query_plan_killed), try dispatch_get_more(&ctx, "c", narrowed.id), ); // And a second getMore on the killed cursor no longer knows it at all. try testing.expectEqual( @intFromEnum(ErrorCode.cursor_not_found), try dispatch_get_more(&ctx, "c", narrowed.id), ); // The surviving stream drains to exactly the 60 documents, once each. // Three batches of 5 are already out: the first batch, the getMore before the // rebuild, and the one just after it. var seen: u32 = 5 + 5 + 5; var id = walk.id; var rounds: u32 = 0; while (rounds < 100) : (rounds += 1) { var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); var msg = try parse_fake_msg("getMore", .{ .int64 = id }, &.{ .{ .key = "collection", .value = .{ .string = "c" } }, .{ .key = "batchSize", .value = .{ .int32 = 7 } }, }); defer msg.deinit(); try dispatch(&ctx, &msg, &reply); const cur = bson.get_pair(reply.pairs.items, "cursor").?; seen += @intCast(bson.get_pair(cur.doc, "nextBatch").?.array.len); id = bson.get_pair(cur.doc, "id").?.int64; if (id == 0) break; } try testing.expectEqual(@as(i64, 0), id); try testing.expectEqual(@as(u32, 60), seen); } test "an upsert reports the _id it generated" { // The client has never seen this document, so the `_id` in the reply is // the only way it can name it again. `Engine.insert` generates one into // the bytes it writes and leaves the caller's tree without it, so both // `upserted` here and `findAndModify`'s returned document used to come // back without an `_id` at all -- `upsertedId: null` on the driver. // // Mutation check: delete the `_id` block in `build_upsert_doc` and both // halves go red. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); const updates = [_]bson.Value{.{ .doc = &.{ .{ .key = "q", .value = .{ .doc = &.{.{ .key = "k", .value = .{ .int32 = 1 } }} } }, .{ .key = "u", .value = .{ .doc = &.{ .{ .key = "$set", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } }, } } }, .{ .key = "upsert", .value = .{ .bool = true } }, } }}; var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); var msg = try parse_fake_msg("update", .{ .string = "up" }, &.{ .{ .key = "updates", .value = .{ .array = &updates } }, }); defer msg.deinit(); try dispatch(&ctx, &msg, &reply); const upserted = bson.get_pair(reply.pairs.items, "upserted").?.array; try testing.expectEqual(@as(usize, 1), upserted.len); try testing.expect(bson.get_pair(upserted[0].doc, "_id").? == .object_id); // And an `_id` the update supplied itself is the one that is used, rather // than being generated over. const with_id = [_]bson.Value{.{ .doc = &.{ .{ .key = "q", .value = .{ .doc = &.{.{ .key = "k", .value = .{ .int32 = 2 } }} } }, .{ .key = "u", .value = .{ .doc = &.{ .{ .key = "$setOnInsert", .value = .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 9 } }} } }, } } }, .{ .key = "upsert", .value = .{ .bool = true } }, } }}; var reply2 = wire.Reply.init(testing.allocator); defer reply2.deinit(); var msg2 = try parse_fake_msg("update", .{ .string = "up" }, &.{ .{ .key = "updates", .value = .{ .array = &with_id } }, }); defer msg2.deinit(); try dispatch(&ctx, &msg2, &reply2); const upserted2 = bson.get_pair(reply2.pairs.items, "upserted").?.array; try testing.expectEqual(@as(i32, 9), bson.get_pair(upserted2[0].doc, "_id").?.int32); } test "a pipeline-style update keeps the _id its stages dropped" { // `$replaceRoot` almost always drops the `_id`, and a pipeline update // rewrites a document rather than replacing one document with another -- // so the `_id` comes back. Mutation check: delete the restore block in // `apply_update_pipeline` and the document becomes unfindable by the id // it was stored under. var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); try dispatch_insert(&tdb, io, "pl", &.{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "x", .value = .{ .int32 = 1 } }, .{ .key = "t", .value = .{ .doc = &.{.{ .key = "u", .value = .{ .int32 = 7 } }} } }, } }, }); const stages = [_]bson.Value{ .{ .doc = &.{.{ .key = "$replaceRoot", .value = .{ .doc = &.{ .{ .key = "newRoot", .value = .{ .string = "$t" } }, } } }} }, .{ .doc = &.{.{ .key = "$addFields", .value = .{ .doc = &.{ .{ .key = "foo", .value = .{ .int32 = 1 } }, } } }} }, }; const updates = [_]bson.Value{.{ .doc = &.{ .{ .key = "q", .value = .{ .doc = &.{} } }, .{ .key = "u", .value = .{ .array = &stages } }, } }}; try testing.expectEqual(@as(?i32, null), try run_for_code(&ctx, "update", .{ .string = "pl" }, &.{ .{ .key = "updates", .value = .{ .array = &updates } }, })); var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); const ids = try distinct_values(&tdb, io, &reply, "pl", &.{ .{ .key = "key", .value = .{ .string = "_id" } }, }); try testing.expectEqual(@as(usize, 1), ids.len); try testing.expectEqual(@as(i32, 1), ids[0].int32); // And the stages did run, in order: `u` came from `$t`, `foo` from the // stage after it. A second reply, because `put` appends and a reused one // would answer with the first call's `values`. var reply2 = wire.Reply.init(testing.allocator); defer reply2.deinit(); const us = try distinct_values(&tdb, io, &reply2, "pl", &.{ .{ .key = "key", .value = .{ .string = "u" } }, }); try testing.expectEqual(@as(i32, 7), us[0].int32); } test "a pipeline-style update refuses what it cannot be written out of" { var threaded: std.Io.Threaded = .init_single_threaded; defer threaded.deinit(); const io = threaded.io(); var tdb = try TestDb.init(io); defer tdb.deinit(); var ctx = tdb.ctx(io); try dispatch_insert(&tdb, io, "plr", &.{ .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "x", .value = .{ .int32 = 1 } }, } }, }); // A real stage refused here (72) reads differently from a name that is no // stage at all (40324), and mongod distinguishes them -- so this does. const cases = [_]struct { stage: bson.Pair, code: i32 }{ .{ .stage = .{ .key = "$match", .value = .{ .doc = &.{} } }, .code = 72 }, .{ .stage = .{ .key = "$group", .value = .{ .doc = &.{} } }, .code = 72 }, .{ .stage = .{ .key = "$unwind", .value = .{ .string = "$x" } }, .code = 72 }, .{ .stage = .{ .key = "$bogus", .value = .{ .doc = &.{} } }, .code = 40324 }, }; for (cases) |c| { const one = [_]bson.Pair{c.stage}; const stages = [_]bson.Value{.{ .doc = &one }}; const updates = [_]bson.Value{.{ .doc = &.{ .{ .key = "q", .value = .{ .doc = &.{} } }, .{ .key = "u", .value = .{ .array = &stages } }, } }}; try testing.expectEqual(@as(?i32, c.code), try run_for_code(&ctx, "update", .{ .string = "plr" }, &.{ .{ .key = "updates", .value = .{ .array = &updates } }, })); } // Two stages in one element, and a stage that changes the `_id`. const packed_stage = [_]bson.Value{.{ .doc = &.{ .{ .key = "$addFields", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } }, .{ .key = "$unset", .value = .{ .string = "x" } }, } }}; const packed_updates = [_]bson.Value{.{ .doc = &.{ .{ .key = "q", .value = .{ .doc = &.{} } }, .{ .key = "u", .value = .{ .array = &packed_stage } }, } }}; try testing.expectEqual(@as(?i32, 40323), try run_for_code(&ctx, "update", .{ .string = "plr" }, &.{ .{ .key = "updates", .value = .{ .array = &packed_updates } }, })); const reid = [_]bson.Value{.{ .doc = &.{.{ .key = "$addFields", .value = .{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 9 } }, } } }} }}; const reid_updates = [_]bson.Value{.{ .doc = &.{ .{ .key = "q", .value = .{ .doc = &.{} } }, .{ .key = "u", .value = .{ .array = &reid } }, } }}; try testing.expectEqual(@as(?i32, 66), try run_for_code(&ctx, "update", .{ .string = "plr" }, &.{ .{ .key = "updates", .value = .{ .array = &reid_updates } }, })); // `arrayFilters` has nothing to bind to in a pipeline, and is refused // rather than ignored. const ok_stage = [_]bson.Value{.{ .doc = &.{.{ .key = "$addFields", .value = .{ .doc = &.{ .{ .key = "a", .value = .{ .int32 = 1 } }, } } }} }}; const af_updates = [_]bson.Value{.{ .doc = &.{ .{ .key = "q", .value = .{ .doc = &.{} } }, .{ .key = "u", .value = .{ .array = &ok_stage } }, .{ .key = "arrayFilters", .value = .{ .array = &.{.{ .doc = &.{ .{ .key = "i.b", .value = .{ .int32 = 1 } }, } }} } }, } }}; try testing.expectEqual(@as(?i32, 9), try run_for_code(&ctx, "update", .{ .string = "plr" }, &.{ .{ .key = "updates", .value = .{ .array = &af_updates } }, })); // Nothing above wrote: the document is the one that was inserted. var reply = wire.Reply.init(testing.allocator); defer reply.deinit(); const xs = try distinct_values(&tdb, io, &reply, "plr", &.{ .{ .key = "key", .value = .{ .string = "x" } }, }); try testing.expectEqual(@as(usize, 1), xs.len); try testing.expectEqual(@as(i32, 1), xs[0].int32); }