query: support bare regex filter values and array-index dot paths

The Node driver sends {field: /re/} as a BSON regex element (type 0x0B),
which the matcher previously only handled via the $regex operator form;
and dot paths with numeric segments (tags.0) were ignored because array
descent only recursed into embedded docs. Both are part of standard
MongoDB query semantics and were caught by the driver e2e suite.

server: unbounded Io async limit so the accept loop never wedges, and
treat header-read failures (client RST on pool teardown) as clean
disconnects. With the default cpu_count-1 limit, groupAsync's eager
fallback ran connection handlers inline on the accept-loop fiber once
that many connections were alive, stalling accept() and timing out
handshakes for further clients.

Add tests/e2e/: official driver CRUD, concurrency, and kill -9 recovery
suites (29 + 2 + 3 checks), plus unit tests for the query fixes.
This commit is contained in:
mongo-light
2026-08-02 10:56:43 +03:00
parent 4b975d1c93
commit c29c09d6e8
5 changed files with 315 additions and 9 deletions

View File

@@ -98,6 +98,13 @@ fn field_matches(gpa: std.mem.Allocator, path: []const u8, expected: bson.Value,
}
return true;
}
// Bare BSON regex value: {field: /re/} behaves like {$regex: "re"}.
if (expected == .regex) {
for (candidates.items) |a| {
if (a == .string and regex_match(expected.regex.pattern, expected.regex.options, a.string)) return true;
}
return false;
}
// Bare equality — matches if any candidate equals the expected value.
for (candidates.items) |actual| {
if (bson.compare(actual, expected) == .eq) return true;
@@ -274,6 +281,21 @@ fn collect_from_value(gpa: std.mem.Allocator, v: bson.Value, path: []const u8, o
switch (v) {
.doc => |pairs| try collect_values(gpa, pairs, path, out, depth),
.array => |items| {
// Numeric first segment: address an element by index ("tags.0").
var pit = std.mem.splitScalar(u8, path, '.');
const seg = pit.next() orelse return;
if (std.fmt.parseInt(usize, seg, 10)) |idx| {
if (idx < items.len) {
const rest = pit.rest();
if (rest.len == 0) {
if (depth < 8) try out.append(gpa, items[idx]);
} else {
try collect_from_value(gpa, items[idx], rest, out, depth + 1);
}
}
return;
} else |_| {}
// Multikey semantics: descend into embedded documents of the array.
for (items) |item| {
switch (item) {
.doc => try collect_values(gpa, item.doc, path, out, depth),
@@ -828,6 +850,23 @@ test "dot path filters" {
try testing.expect(try matches(testing.allocator, &doc_of(&.{.{ .key = "items.sku", .value = .{ .string = "y" } }}), &d));
}
test "array index dot path and bare regex value" {
const d = doc_of(&.{
.{ .key = "tags", .value = .{ .array = &.{
.{ .string = "c" },
.{ .string = "a" },
} } },
.{ .key = "name", .value = .{ .string = "carol" } },
});
// tags.0 addresses the first element.
try testing.expect(try matches(testing.allocator, &doc_of(&.{.{ .key = "tags.0", .value = .{ .string = "c" } }}), &d));
try testing.expect(!try matches(testing.allocator, &doc_of(&.{.{ .key = "tags.1", .value = .{ .string = "c" } }}), &d));
try testing.expect(!try matches(testing.allocator, &doc_of(&.{.{ .key = "tags.5", .value = .{ .string = "c" } }}), &d));
// Bare BSON regex value behaves like {$regex: "re"}.
try testing.expect(try matches(testing.allocator, &doc_of(&.{.{ .key = "name", .value = .{ .regex = .{ .pattern = "^c", .options = "" } } }}), &d));
try testing.expect(!try matches(testing.allocator, &doc_of(&.{.{ .key = "name", .value = .{ .regex = .{ .pattern = "^z", .options = "" } } }}), &d));
}
test "sort compares by BSON order" {
const a = doc_of(&.{ .{ .key = "n", .value = .{ .int32 = 2 } }, .{ .key = "x", .value = .{ .string = "a" } } });
const b = doc_of(&.{ .{ .key = "n", .value = .{ .int32 = 10 } }, .{ .key = "x", .value = .{ .string = "b" } } });

View File

@@ -18,7 +18,12 @@ pub const Server = struct {
start_time: std.Io.Timestamp,
pub fn run(self: *Server) !void {
var threaded: std.Io.Threaded = std.Io.Threaded.init(self.gpa, .{});
// Unbounded async limit: connection handlers otherwise fall back to
// running inline on the accept-loop fiber once busy_count hits the
// default cpu_count-1, which blocks accept() for the handler's
// lifetime and stalls new connections (handshake timeouts). With an
// unlimited limit the pool spawns a thread per live connection.
var threaded: std.Io.Threaded = std.Io.Threaded.init(self.gpa, .{ .async_limit = .unlimited });
defer threaded.deinit();
const io = threaded.io();
@@ -76,13 +81,7 @@ fn handle_connection_inner(io: std.Io, stream: std.Io.net.Stream, server: *Serve
while (true) {
var len_bytes: [4]u8 = undefined;
reader.interface.readSliceAll(&len_bytes) catch |err| switch (err) {
error.EndOfStream => return, // clean client disconnect
else => {
std.debug.print("mongo-light: read error on conn {d}: {s}\n", .{ connection_id, @errorName(err) });
return;
},
};
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 });
@@ -94,7 +93,7 @@ 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}\n", .{ connection_id, @errorName(err) });
std.debug.print("mongo-light: read error on conn {d}: {s} (body, len {d})\n", .{ connection_id, @errorName(err), total });
return;
};