commands/e2e: drop topologyVersion from the handshake; rename to mongo-lite

Advertising topologyVersion in the hello reply is what tells a driver the
server speaks the streaming (awaitable) hello protocol — in the Node driver
it is the only condition checked. From the second heartbeat on, the driver
then monitored with an exhaust hello (exhaustAllowed + maxAwaitTimeMS) and
waited for a stream of replies carrying moreToCome. We answered once with
the flag clear and went back to reading, so every heartbeat failed with
"Server ended moreToCome unexpectedly", destroying the connection and
clearing the pool. MongoDB Compass showed this as a connect/disconnect loop
once per heartbeat.

We do not implement streaming hello, so we must not claim to. Omitting the
field keeps monitoring on the polling path, and agrees with the
maxWireVersion 8 we report: streaming hello arrived in wire version 9.

The existing e2e files all passed against the broken server — they issue
their commands and exit before the second heartbeat — so e2e5 watches SDAM
heartbeats on an idle connection instead.

Also renames mongo-light to mongo-lite throughout (binary, log messages,
docs, gitVersion). Unrelated to the fix above, but squashed in at request
rather than left as a commit whose message described only the fix.
This commit is contained in:
2026-08-02 15:09:25 +03:00
parent dc92064b95
commit d90cde394c
14 changed files with 148 additions and 45 deletions

View File

@@ -131,11 +131,17 @@ fn add_server_info(ctx: *Context, reply: *wire.Reply) !void {
try reply.put("maxWireVersion", .{ .int32 = 8 });
try reply.put("readOnly", .{ .bool = false });
const oid = ctx.oid_gen.new(ctx.io);
const tv = try reply.arena_alloc().alloc(bson.Pair, 2);
tv[0] = .{ .key = "processId", .value = .{ .object_id = oid } };
tv[1] = .{ .key = "counter", .value = .{ .int64 = 0 } };
try reply.put("topologyVersion", .{ .doc = tv });
// 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,
// and matches maxWireVersion 8: streaming hello arrived in wire 9.
}
fn cmd_hello(ctx: *Context, _: *wire.Message, reply: *wire.Reply) !void {
@@ -156,7 +162,7 @@ fn cmd_ping(_: *Context, _: *wire.Message, reply: *wire.Reply) !void {
fn cmd_build_info(_: *Context, _: *wire.Message, reply: *wire.Reply) !void {
try reply.put("version", .{ .string = "4.4.0" });
try reply.put("gitVersion", .{ .string = "mongo-light" });
try reply.put("gitVersion", .{ .string = "mongo-lite" });
try reply.put("versionArray", .{ .array = try int_array(reply, &.{ 4, 4, 0, 0 }) });
try reply.put("openssl", .{ .doc = &.{} });
try reply.put("loaderFlags", .{ .string = "" });
@@ -213,7 +219,7 @@ fn cmd_host_info(ctx: *Context, _: *wire.Message, reply: *wire.Reply) !void {
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 = "mongo-light.log" } };
argv[0] = .{ .key = "dbpath", .value = .{ .string = "mongo-lite.log" } };
argv[1] = .{ .key = "port", .value = .{ .int32 = 27017 } };
try reply.put("argv", .{ .array = &.{} });
try reply.put("parsed", .{ .doc = argv });
@@ -1363,6 +1369,28 @@ test "ping and hello replies parse" {
try testing.expectEqual(@as(i32, 8), bson.get_pair(reply2.pairs.items, "maxWireVersion").?.int32);
}
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();

View File

@@ -549,13 +549,13 @@ pub const Engine = struct {
while (doc_it.next()) |doc_entry| {
const duplicate = ix.add_doc(self.gpa, doc_entry.value_ptr.*, doc_entry.key_ptr.*, false) catch |err| switch (err) {
error.ParallelArrays => {
std.debug.print("mongo-light: WARNING: index '{s}' cannot index an existing document; entry skipped\n", .{ix.name});
std.debug.print("mongo-lite: WARNING: index '{s}' cannot index an existing document; entry skipped\n", .{ix.name});
continue;
},
else => return err,
};
if (duplicate) {
std.debug.print("mongo-light: WARNING: unique index '{s}' has duplicate keys in existing data; duplicates not enforced for existing documents\n", .{ix.name});
std.debug.print("mongo-lite: WARNING: unique index '{s}' has duplicate keys in existing data; duplicates not enforced for existing documents\n", .{ix.name});
}
}
}
@@ -608,7 +608,7 @@ fn apply_record(ctx: *anyopaque, record: storage.Record, doc: *bson.Document) an
switch (record.type) {
storage.record_type_index_create => {
self.register_index_from_spec(coll, doc) catch |err| {
std.debug.print("mongo-light: index create record failed to apply: {s}\n", .{@errorName(err)});
std.debug.print("mongo-lite: index create record failed to apply: {s}\n", .{@errorName(err)});
return;
};
return;
@@ -626,7 +626,7 @@ fn apply_record(ctx: *anyopaque, record: storage.Record, doc: *bson.Document) an
}
const id_value = doc.get("_id") orelse {
std.debug.print("mongo-light: log record without _id, skipping\n", .{});
std.debug.print("mongo-lite: log record without _id, skipping\n", .{});
return;
};
const id_key = try bson.serialize_value(self.gpa, id_value);

View File

@@ -1,4 +1,4 @@
// mongo-light core library. Public entry point for tests and the server.
// mongo-lite core library. Public entry point for tests and the server.
pub const bson = @import("bson.zig");
pub const wire = @import("wire.zig");

View File

@@ -2,12 +2,12 @@ const std = @import("std");
const mongo = @import("mongo");
const usage =
\\mongo-light — lightweight MongoDB-compatible document database
\\mongo-lite — lightweight MongoDB-compatible document database
\\
\\usage: mongo-light [options]
\\usage: mongo-lite [options]
\\ --port <n> listen port (default 27017)
\\ --bind <ip> bind address (default 127.0.0.1)
\\ --db <path> database file (default mongo-light.log)
\\ --db <path> database file (default mongo-lite.log)
\\ --ttl-sweep-secs <n>
\\ seconds between TTL index sweeps (default 60, 0 disables)
\\ --help show this help
@@ -17,7 +17,7 @@ const usage =
pub fn main(init: std.process.Init) !void {
var port: u16 = 27017;
var bind_ip: []const u8 = "127.0.0.1";
var db_path: []const u8 = "mongo-light.log";
var db_path: []const u8 = "mongo-lite.log";
var ttl_sweep_secs: i64 = 60;
var it = std.process.Args.Iterator.init(init.minimal.args);
@@ -27,7 +27,7 @@ pub fn main(init: std.process.Init) !void {
if (std.mem.eql(u8, arg, "--port")) {
const v = it.next() orelse return error.MissingValue;
port = std.fmt.parseInt(u16, v, 10) catch {
std.debug.print("mongo-light: invalid port '{s}'\n", .{v});
std.debug.print("mongo-lite: invalid port '{s}'\n", .{v});
return error.InvalidPort;
};
} else if (std.mem.eql(u8, arg, "--bind")) {
@@ -41,14 +41,14 @@ pub fn main(init: std.process.Init) !void {
// only thing parseInt would otherwise let through.
ttl_sweep_secs = std.fmt.parseInt(i64, v, 10) catch -1;
if (ttl_sweep_secs < 0) {
std.debug.print("mongo-light: invalid ttl sweep interval '{s}'\n", .{v});
std.debug.print("mongo-lite: invalid ttl sweep interval '{s}'\n", .{v});
return error.InvalidTtlSweepSecs;
}
} else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
try std.Io.File.writeStreamingAll(.stdout(), init.io, usage);
return;
} else {
std.debug.print("mongo-light: unknown option '{s}'\n{s}", .{ arg, usage });
std.debug.print("mongo-lite: unknown option '{s}'\n{s}", .{ arg, usage });
return error.UnknownOption;
}
}
@@ -56,7 +56,7 @@ pub fn main(init: std.process.Init) !void {
const oid_gen = mongo.bson.ObjectIdGen.init(init.io);
var engine = try mongo.db.Engine.open(init.gpa, init.io, db_path);
defer engine.deinit();
std.debug.print("mongo-light: opened database '{s}'\n", .{db_path});
std.debug.print("mongo-lite: opened database '{s}'\n", .{db_path});
var server = mongo.server.Server{
.gpa = init.gpa,

View File

@@ -35,7 +35,7 @@ pub const Server = struct {
var listener = try addr.listen(io, .{ .reuse_address = true });
defer listener.deinit(io);
std.debug.print("mongo-light: listening on {s}:{d}\n", .{ self.bind_ip, self.port });
std.debug.print("mongo-lite: listening on {s}:{d}\n", .{ self.bind_ip, self.port });
var group: std.Io.Group = .init;
defer group.cancel(io);
@@ -48,7 +48,7 @@ pub const Server = struct {
const stream = listener.accept(io) catch |err| switch (err) {
error.Canceled => return,
else => {
std.debug.print("mongo-light: accept error: {s}\n", .{@errorName(err)});
std.debug.print("mongo-lite: accept error: {s}\n", .{@errorName(err)});
continue;
},
};
@@ -72,7 +72,7 @@ fn ttl_monitor(io: std.Io, server: *Server) error{Canceled}!void {
defer server.engine.unlock();
const now_ms = std.Io.Timestamp.now(io, .real).toMilliseconds();
_ = server.engine.ttl_sweep(now_ms) catch |err| {
std.debug.print("mongo-light: TTL sweep failed: {s}\n", .{@errorName(err)});
std.debug.print("mongo-lite: TTL sweep failed: {s}\n", .{@errorName(err)});
continue;
};
}
@@ -113,7 +113,7 @@ fn handle_connection_inner(io: std.Io, stream: std.Io.net.Stream, server: *Serve
reader.interface.readSliceAll(&len_bytes) catch return; // clean client disconnect (EOF or RST)
const total: u32 = std.mem.readInt(u32, &len_bytes, .little);
if (total < 16 or total > wire.max_message_size) {
std.debug.print("mongo-light: bad message length {d} on conn {d}\n", .{ total, connection_id });
std.debug.print("mongo-lite: bad message length {d} on conn {d}\n", .{ total, connection_id });
return;
}
@@ -122,14 +122,14 @@ fn handle_connection_inner(io: std.Io, stream: std.Io.net.Stream, server: *Serve
msg_buf.items.len = total;
std.mem.writeInt(u32, msg_buf.items[0..4], total, .little);
reader.interface.readSliceAll(msg_buf.items[4..]) catch |err| {
std.debug.print("mongo-light: read error on conn {d}: {s} (body, len {d})\n", .{ connection_id, @errorName(err), total });
std.debug.print("mongo-lite: read error on conn {d}: {s} (body, len {d})\n", .{ connection_id, @errorName(err), total });
return;
};
var msg = wire.Message.parse(server.gpa, msg_buf.items) catch |err| {
// Unparseable request: close the connection.
const op: i32 = if (msg_buf.items.len >= 16) std.mem.readInt(i32, msg_buf.items[12..16], .little) else 0;
std.debug.print("mongo-light: bad message on conn {d}: {s} (opCode {d})\n", .{ connection_id, @errorName(err), op });
std.debug.print("mongo-lite: bad message on conn {d}: {s} (opCode {d})\n", .{ connection_id, @errorName(err), op });
return;
};
defer msg.deinit();
@@ -154,16 +154,16 @@ fn handle_connection_inner(io: std.Io, stream: std.Io.net.Stream, server: *Serve
else
reply.build(server.gpa, reply_request_id, msg.request_id, &out_buf);
built catch |err| {
std.debug.print("mongo-light: reply build error on conn {d}: {s}\n", .{ connection_id, @errorName(err) });
std.debug.print("mongo-lite: reply build error on conn {d}: {s}\n", .{ connection_id, @errorName(err) });
return;
};
reply_request_id +%= 1;
writer.interface.writeAll(out_buf.items) catch |err| {
std.debug.print("mongo-light: write error on conn {d}: {s}\n", .{ connection_id, @errorName(err) });
std.debug.print("mongo-lite: write error on conn {d}: {s}\n", .{ connection_id, @errorName(err) });
return;
};
writer.interface.flush() catch |err| {
std.debug.print("mongo-light: flush error on conn {d}: {s}\n", .{ connection_id, @errorName(err) });
std.debug.print("mongo-lite: flush error on conn {d}: {s}\n", .{ connection_id, @errorName(err) });
return;
};
}

View File

@@ -91,21 +91,21 @@ pub const Log = struct {
while (true) {
const len_read = self.file.readPositionalAll(self.io, chunk[0..4], pos) catch |err| {
std.debug.print("mongo-light: log read error at {d}: {s}\n", .{ pos, @errorName(err) });
std.debug.print("mongo-lite: log read error at {d}: {s}\n", .{ pos, @errorName(err) });
return error.InvalidLog;
};
if (len_read == 0) return; // clean end
if (len_read < 4) return; // torn tail
const total: u32 = std.mem.readInt(u32, chunk[0..4], .little);
if (total < header_len) {
std.debug.print("mongo-light: corrupt record length {d} at {d}\n", .{ total, pos });
std.debug.print("mongo-lite: corrupt record length {d} at {d}\n", .{ total, pos });
return error.InvalidLog;
}
const payload_len: usize = total - 4;
// Documents up to maxBsonObjectSize are legal; anything larger is
// corruption. Covers a hostile length prefix from a truncated file.
if (payload_len > max_record_payload) {
std.debug.print("mongo-light: record too large at {d}\n", .{pos});
std.debug.print("mongo-lite: record too large at {d}\n", .{pos});
return error.InvalidLog;
}
const payload = if (payload_len <= chunk.len) chunk[0..payload_len] else blk: {
@@ -132,7 +132,7 @@ pub const Log = struct {
const doc = try self.gpa.create(bson.Document);
doc.* = bson.Document.parse(self.gpa, doc_bytes) catch {
self.gpa.destroy(doc);
std.debug.print("mongo-light: unparseable doc in log at {d}\n", .{pos});
std.debug.print("mongo-lite: unparseable doc in log at {d}\n", .{pos});
return error.InvalidLog;
};
try callback(ctx, .{