Files
MultiforaDB/tests/spec
Aleksey Shakhmatov f2844e7894 cursors: server-side cursors for find, aggregate and the listing commands
Every reply came back in a single batch with `cursor.id = 0`, `getMore` was a
stub answering an empty `nextBatch` on the literal namespace `test.$cmd`, and
nothing read `batchSize`. That caps the useful collection size at what fits in
one 48 MiB message, which is the opposite of the tens-of-GB target and the
reason M0 made whole-index scans stream: the streaming candidate generator
existed with no consumer that could suspend.

## What a cursor is allowed to remember

A cursor holds no lock between requests, so everything it saves has to survive
arbitrary concurrent mutation. Nothing here is a pointer, and the two things
that look like stable addresses are not: `reset_tree` re-creates node ids 0 and
1 as different nodes, and `rebuild_collection` moves every document. Three
sources, chosen by query shape, each with a different memory contract:

- **stream** -- an index-ordered walk resumed from a `(key, off)` anchor plus a
  `(leaf, slot)` hint. O(key) state, so this is what lets a cursor walk a
  collection larger than memory. Survives a rebuild, because a repack changes
  no key.
- **offsets** -- the matched slab offsets a narrowed plan already materialized,
  8 bytes each. Killed by a rebuild with `QueryPlanKilled`, because those
  offsets now name unrelated bytes.
- **buffered** -- canonical BSON copies, for a sort no index provides and for
  aggregate/listing output. Depends on nothing, which is what lets a listing
  hold a cursor over a `$cmd.*` namespace no collection backs.

`Collection.layout_epoch` and `Index.epoch` are the invalidation tokens, both
checked as error returns rather than assertions since a client reaches them by
keeping a cursor open across maintenance.

## Resume

`resume_forward`/`resume_reverse` are O(1) while the hint holds and fall back to
an exact-order band walk bounded by `resume_walk_max`. Without the hint, `seek`
lands at the *start* of an equal-key band, so `sort({status: 1})` over three
distinct values across 10M documents would cost ~5e10 comparisons to drain.

Two hazards found by draining a collection while writing to it, neither
predictable from reading the code:

- A deleted anchor must resume at its *band position*, or the rest of an
  equal-key band is silently dropped -- most of the collection on a
  low-cardinality index. Hence `band_index`.
- On a **unique** index a same-key entry can only be the anchor rewritten, so
  resuming at it returned updated documents twice. Observed as duplicate `_id`s
  while updating underneath a drain.

## Protocol

Measured against mongod 8.3.7 rather than recalled, which corrected three
assumptions: a bare `getMore` does *not* inherit the find's `batchSize` (4998 of
5000 documents come back), a namespace mismatch is `Unauthorized` (13) not
`CursorNotFound`, and `CursorInUse` is 143 not 12051.
`internalQueryFindCommandBatchSize` is 101, `cursorTimeoutMillis` 600000,
`clientCursorMonitorFrequencySecs` 4.

The rule everything follows is **never look ahead**: a batch that met its target
leaves the cursor open even when the source is in fact exhausted, so four
documents at `batchSize: 2` take three commands. `limit` acts as an EOF source,
which is what makes `batchSize == limit` close in one round trip. `skip` is
consumed once. `batchSize: 0` returns an empty batch with a live cursor.

Cursor ids are `(nonce << 20) | slot`, always positive. The nonce is not
decoration: without it a recycled slot serves one client another's documents.
Cursors are not connection-pinned, since the driver spec allows a `getMore` on
any connection to the same server; they end at exhaustion, `killCursors`, or the
idle sweep (a second monitor fiber, separate from the TTL one because the
cadences differ by an order of magnitude and a TTL failure must not stop
reclamation). The registry is fixed-capacity and evicts the least recently used
cursor, whose client sees the same 43 an idle timeout gives.

Fixed alongside, because cursors are what expose them:

- `listCollections` reported `"<db>."` with an *empty* collection part, which
  makes the driver throw client-side -- so it would have broken the moment its
  cursor stopped being id 0. Now `<db>.$cmd.listCollections`, as mongod uses.
- `count` ignored `skip` and `limit` entirely.
- `wire.end_message` now bounds a reply by the 48 MiB we advertise rather than
  by `maxInt(u32)`; a reply past what we told the client to expect is not a large
  reply, it is a desynchronized connection.
- Two `codeName` strings were wrong: 72 is `InvalidOptions` (MongoDB has no
  `InvalidArgument`), and 40324 reports as `Location40324`.

## Verification

Unit 160/160 in ReleaseFast and ReleaseSafe; `tests/e2e/e2e7.js` adds 86 cursor
checks across five phases (batching/lifecycle/errors, streaming across churn,
aggregate+listings+count, expiry+capacity, restart) and is self-contained
because cursor behaviour is only observable with non-default flags. No
regressions: e2e 49, e2e3 16, e2e4 17, e2e2 2, e2e6 72. Spec 168 pass / 124
fail, +5 against the previous scorecard.

Mutation-checked, per the repo's second ground rule: `hint_slot + 1`, the
`band_index` off-by-one, both epoch bumps, the id nonce, the at-least-one-
document rule, and `stream_shape` returning null each turn the intended test
red. One claim was withdrawn rather than kept -- swapping `std.mem.order` for
`cmp_prefix` in the band walk changes nothing observable, so the comment now
says so instead of asserting a check that does not hold.
2026-08-04 14:54:27 +03:00
..

MongoDB spec tests

PLAN D2 makes the official MongoDB JSON specification suites the gate for command semantics: it turns "maximally compatible" into a concrete list of test files rather than a judgement call. This directory holds the runner and the committed scorecard.

bash tests/spec/fetch.sh                  # pinned suites (~175 files, gitignored)
zig build                                 # the runner spawns this binary
node tests/spec/run.js                    # run everything
node tests/spec/run.js --scorecard        # ... and rewrite scorecard.txt
node tests/spec/run.js --file find.json --verbose
node tests/spec/run.js --url mongodb://127.0.0.1:27020   # use a server you started

What is pinned, and why both halves matter

  • Suites: mongodb/specifications @ 615e0f9, in fetch.sh.
  • Driver: mongodb@7.5.0, via tests/e2e/package-lock.json.

A scorecard is only comparable across milestones if both are pinned — otherwise a delta could be an upstream test change rather than an engine change. Bump either one in its own commit and re-record the scorecard in that same commit.

The suites are fetched rather than vendored: they are someone else's corpus, upstream rewrites them wholesale, and a pinned commit gives the same reproducibility without putting them in this repo's history.

Scope

source/crud/tests/unified/ — 175 files. The aggregate tests live there too (aggregate*.json), so this one directory is PLAN M0's "crud + aggregate".

The runner implements the unified test format's Evaluating Matches algorithm as written in the spec, including the two rules that decide whether a result is a real pass:

  • extra keys in the actual document are tolerated only in a root document;
  • numeric types (int32 / int64 / double) compare flexibly.

Supported: client/database/collection entities, initialData, outcome, expectError (code, codeName, contains, labels, errorResponse), saveResultAsEntity, runOnRequirements gating, and the $$type, $$exists, $$unsetOrMatches, $$matchesEntity, $$matchesHexBytes operators.

Not asserted yet: expectEvents (command monitoring). Those assertions are about the command shape the driver emits rather than result semantics. Ignoring them lets some cases pass that a complete runner would fail, so treat pass as an upper bound until M1 wires events up. This is stated again at the top of scorecard.txt so the number is never read out of context.

Not supported, each reported as SKIP with a reason and never as PASS: session and bucket entities (M4 / GridFS), failPoint, client-side encryption, testRunner operations, and any operation or matcher the runner does not know.

Reading the scorecard

scorecard.txt records the totals, a per-file breakdown, and every non-passing case with its reason. The distinction that matters:

  • FAIL — the engine answered, and answered differently from the spec. Real work. An operation that never answered inside --op-timeout-ms (default 3 s, enforced by the driver itself via CSOT timeoutMS) is also a FAIL, because "no answer" is a result. There is a second, much longer --case-timeout-ms backstop for a hang the driver cannot see; if it ever fires, treat the run with suspicion — see the trap below.
  • SKIP — nobody claims anything. Either the suite needs a feature whose milestone has not landed, or the runner does not implement it yet.

M0's gate (PLAN D7.6) is only that the harness exists and the baseline is recorded. A red baseline is the expected state, so run.js exits 0 as long as it ran; it is a measuring tool, not a pass/fail gate. Later milestones move the numbers, and each one commits the new scorecard (PLAN D9).

A trap worth knowing about: the harness can invent failures

The first baseline attempt reported ~77 timeout FAILs that did not exist. Every case from one point onward timed out, while a ping from a separate process answered instantly — which read convincingly as a server-side wedge, and was not.

The cause was in this runner. buildEntities opened MongoClients, and a case that timed out before it returned left them unclosed; each one keeps a connection pool and a heartbeat timer. Once enough accumulated, Node's event loop was starved badly enough that the per-case timer fired before operations could finish. Then every later case "failed".

Two things guard it now: per-test clients are owned by the caller and closed unconditionally, including on a partial failure; and the run ends by checking how many timers are still active, warning loudly if the answer is more than a handful.

The general rule, since it will come up again: a run with a long unbroken tail of timeouts is a harness bug until proven otherwise. Confirm it by running the first timing-out file on its own — if it passes in isolation, the failures are this runner's, not the engine's.

... but the third time it was the engine

A later attempt produced 166 timeout FAILs starting at file 70. I first blamed machine load — a concurrent zig build test against a then-3-second budget — and that was wrong. The evidence against it: the collapse reproduced on an idle machine, at the same file, with a 10 s budget.

The actual cause was a leaked catalog lock in the engine, and it is worth knowing how it hid. db-aggregate.json sends {aggregate: 1}, which names no collection; dispatch resolved the namespace after taking the catalog lock and bailed with a plain return, holding it shared forever. A leaked shared lock is invisible to readers, so the server stayed perfectly responsive — an external prober got ok 15ms right through the hang — and only the next write that had to take the catalog exclusive to create a collection blocked. The failure therefore surfaced one file later, on a different connection, as a client-side timeout with nothing pointing at its cause.

Two lessons for using this runner:

  • A healthy-looking server does not exonerate the engine. Probe with the operation that is actually stuck, not with ping.

  • The driver's own command log is the fastest way in. It showed an insert sitting for exactly socketTimeoutMS against an idle engine, which is what turned a week-long-looking mystery into a five-line fix:

    MONGODB_LOG_COMMAND=debug MONGODB_LOG_PATH=stderr \
      node tests/spec/run.js --skip 68 --limit 2 2>drv.log
    

--skip/--limit exist for exactly this: the collapse reduced to a reproducible two-file window, which is what made it tractable.

Still worth recording the baseline on an otherwise idle machine, and do not tighten --op-timeout-ms to make a run finish sooner — a tight budget turns load into apparent engine failures, which is how I misdiagnosed this once already.