Files
MultiforaDB/src/commands.zig
A.Shakhmatov 21494469a5 db/index: a hashed key holds a hash of its value
M3's last row, step 4 of the index review's order. `{a: "hashed"}` was
refused with "invalid index spec" -- the right answer with the wrong code,
and the half of the row the review called honestly missing.

A hashed component is stored as a tag byte plus a 64-bit hash of the
value's *ordinary* encoded bytes. Hashing the encoding rather than the
value is what makes `{a: 5}` and `{a: 5.0}` land on the same entry for
free: `bson.encode_key` already normalizes every numeric type through
f128, because two values that compare `.eq` have to encode identically for
the tree to be a memcmp. One `encode_component` does it for entry
generation and both lookup paths, so the two sides cannot disagree -- a
component hashed on the way in and not on the way out would simply never
find anything.

Collisions are harmless, because this file's governing invariant is that
an index only generates candidates and the full filter is re-applied to
every one. The single place that would not survive one is uniqueness,
which is why `unique` is refused (16764) rather than approximated.

The planner is the mirror of the partial rule and just as conservative:
equality only. A range or a sort over a hashed component would read a band
of leaves ordered by hash, which is an arbitrary set of values, so both
are declined and the query scans. That is what leaves `find({a: {$gte:
5}})` and `find({}, {sort: {a: 1}})` correct.

The catalog needs no new field. `write_index_catalog` has always written
one byte per component and that byte has only ever held 0 or 1, so a third
value costs no format change and `catalog_version` stays 1. That is a
departure from the review, which guessed at a sixth flags bit: hashed
belongs to a *component*, and a compound index may hold one beside range
ones.

Measured on mongod 8.3.7 rather than recalled, and three of the five
answers were not what the corpus source assumed:

  two hashed components    31303, codeName Location31303
  unique on a hashed index 16764, codeName Location16764
  an unknown plugin string 67,    codeName CannotCreateIndex
  an array at the path     16766 -- a *writeError* beside `ok: 1` on an
                           insert or update, and a command error from
                           createIndexes over data that already holds one
  an array through a path  refused for a *one-element* array too, which
                           is why `array_on_path` walks the path instead
                           of counting the values at it

tests/spec/indexes/hashed.json goes 0/18 -> 17/18. The one that remains
is not about hashed indexes: `find({a: null})` has to match a document
with no `a`, and this server matches only an explicit null -- with or
without an index. Next commit.
2026-08-10 23:48:44 +03:00

7674 lines
346 KiB
Zig

//! 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
/// `"<db>."` 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 {<path>: {$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: <literal>,
// n: {$sum: 1}}}]. The general path answers that by materializing every
// matching document and then throwing them all away, so recognize the
// shape and answer it from a counting scan instead.
if (try count_only_pipeline(reply, stages)) |shape| {
const n = try scan_matching(ctx, db_name, coll_name, shape.filter, 0, null);
// No documents means no groups at all, not a group holding zero —
// same as the general path, which builds groups per document.
var docs: []const *const bson.Document = &.{};
if (n > 0) {
const arena = reply.arena_alloc();
const pairs = try arena.alloc(bson.Pair, 1 + shape.accs.len);
pairs[0] = .{ .key = "_id", .value = try bson.copy_value(arena, shape.id_value) };
for (shape.accs, 0..) |acc, i| {
// Mirrors run_group's coercion exactly: an integral sum in
// int32 range comes back as int32, otherwise a double.
const sum: f64 = @as(f64, @floatFromInt(n)) * acc.term;
pairs[1 + i] = .{
.key = acc.key,
.value = if (sum == @floor(sum) and sum <= 2_147_483_647 and sum >= -2_147_483_648)
.{ .int32 = @intFromFloat(sum) }
else
.{ .double = sum },
};
}
const doc = try 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<n>` 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: <literal>, k: {$sum: <number>}}}]`
/// — the shape a driver sends for countDocuments().
///
/// Deliberately conservative: a `_id` of `"$field"`, an accumulator over a
/// field, or any other stage needs the documents themselves, so anything
/// that is not exactly this shape returns null and takes the general path.
fn count_only_pipeline(reply: *wire.Reply, stages: []const bson.Value) !?CountShape {
if (stages.len == 0 or stages.len > 2) return null;
var filter: []const bson.Pair = &.{};
if (stages.len == 2) {
const first = switch (stages[0]) {
.doc => |p| p,
else => return null,
};
if (first.len != 1 or !std.mem.eql(u8, first[0].key, "$match")) return null;
filter = doc_arg(first[0].value) orelse return null;
}
const last = switch (stages[stages.len - 1]) {
.doc => |p| p,
else => return null,
};
if (last.len != 1 or !std.mem.eql(u8, last[0].key, "$group")) return null;
const gp = doc_arg(last[0].value) orelse return null;
const id_value = bson.get_pair(gp, "_id") orelse return null;
switch (id_value) {
// A field path or a computed id groups per document.
.string => |s| if (s.len > 0 and s[0] == '$') return null,
.doc, .array => return null,
else => {},
}
var accs: std.ArrayListUnmanaged(CountShape.Acc) = .empty;
for (gp) |p| {
if (std.mem.eql(u8, p.key, "_id")) continue;
const spec = switch (p.value) {
.doc => |d| d,
else => return null,
};
if (spec.len != 1 or !std.mem.eql(u8, spec[0].key, "$sum")) return null;
const term: f64 = switch (spec[0].value) {
.int32 => |i| @floatFromInt(i),
.int64 => |i| @floatFromInt(i),
.double => |d| d,
// $sum over a field depends on the documents.
else => return null,
};
try accs.append(reply.arena_alloc(), .{ .key = p.key, .term = term });
}
return .{ .filter = filter, .id_value = id_value, .accs = accs.items };
}
/// 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: <expr>, b: <expr>}`: 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: <expr>}}`. 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: <expr>}}` -- 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. '$[<id>]') 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 `$[<identifier>]`
/// 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 = (&current)[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: <n>, ns: "...", <batch_key>: [...] } 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 = &not_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);
}