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.
This commit is contained in:
@@ -314,11 +314,38 @@ class Unsupported extends Error {}
|
||||
// Argument keys the spec passes positionally rather than as driver options.
|
||||
const POSITIONAL = new Set(['filter', 'document', 'documents', 'update', 'replacement', 'pipeline', 'fieldName', 'models', 'requests', 'keys', 'name', 'indexes', 'command', 'session', 'entity', 'to']);
|
||||
|
||||
// Driver options the driver only honours as a JavaScript number. The suites are
|
||||
// parsed with `EJSON.parse(text, {relaxed: false})` so that `$numberLong` and
|
||||
// friends keep their exact BSON type in *data* -- but that also turns a plain
|
||||
// JSON `2` in an *option* into a BSON Int32 object, and the driver gates every
|
||||
// one of these on `typeof options.skip === 'number'`
|
||||
// (node_modules/mongodb/lib/operations/find.js:68-95). A BSON wrapper therefore
|
||||
// failed the check and the option was dropped on the floor: `skip`, `limit` and
|
||||
// `batchSize` never reached the wire at all, and three find.json cases failed
|
||||
// with the *unclipped* match count while the engine was applying both correctly.
|
||||
// Read as an engine bug for a whole milestone. Coerce by name, not by shape:
|
||||
// unwrapping every numeric-looking value would rewrite the wire type of the
|
||||
// `comment` and `hint` values that other suites assert on.
|
||||
const NUMERIC_OPTIONS = new Set([
|
||||
'skip',
|
||||
'limit',
|
||||
'batchSize',
|
||||
'maxTimeMS',
|
||||
'maxAwaitTimeMS',
|
||||
'expireAfterSeconds',
|
||||
]);
|
||||
|
||||
function numeric_option(v) {
|
||||
if (v === null || typeof v !== 'object' || typeof v.valueOf !== 'function') return v;
|
||||
const n = v.valueOf();
|
||||
return typeof n === 'number' ? n : v;
|
||||
}
|
||||
|
||||
function options(args, drop = []) {
|
||||
const o = {};
|
||||
for (const [k, v] of Object.entries(args || {})) {
|
||||
if (POSITIONAL.has(k) || drop.includes(k)) continue;
|
||||
o[k] = v;
|
||||
o[k] = NUMERIC_OPTIONS.has(k) ? numeric_option(v) : v;
|
||||
}
|
||||
return Object.keys(o).length ? o : undefined;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
# semantics; ignoring them makes some cases pass that a full runner would
|
||||
# fail, so treat `pass` as an upper bound until M1 wires events up.
|
||||
|
||||
total 163 pass 129 fail 195 skip 175 files 0 errored
|
||||
total 168 pass 124 fail 195 skip 175 files 0 errored
|
||||
|
||||
# per-file: name pass fail skip
|
||||
aggregate-allowdiskuse.json 3 0 0
|
||||
@@ -78,7 +78,7 @@ client-bulkWrite-updateOne-sort.json 0 0 1
|
||||
count-collation.json 1 0 1
|
||||
count-empty.json 2 0 1
|
||||
count-rawdata.json 0 0 2
|
||||
count.json 3 1 3
|
||||
count.json 4 0 3
|
||||
countDocuments-comment.json 2 0 1
|
||||
countDocuments-rawdata.json 1 0 1
|
||||
create-null-ids.json 0 6 1
|
||||
@@ -116,8 +116,8 @@ find-collation.json 0 1 0
|
||||
find-comment.json 1 2 2
|
||||
find-let.json 0 1 1
|
||||
find-rawdata.json 1 0 1
|
||||
find.json 2 3 0
|
||||
findOne.json 1 1 0
|
||||
find.json 5 0 0
|
||||
findOne.json 2 0 0
|
||||
findOneAndDelete-collation.json 0 1 0
|
||||
findOneAndDelete-comment.json 2 0 1
|
||||
findOneAndDelete-hint-serverError.json 0 0 2
|
||||
@@ -292,7 +292,6 @@ count-collation.json SKIP Deprecated count with collation runner: operation coun
|
||||
count-empty.json SKIP Deprecated count with empty collection runner: operation count
|
||||
count-rawdata.json SKIP Deprecated count with rawData option needs server >= 8.2.0
|
||||
count-rawdata.json SKIP Deprecated count with rawData option on less than 8.2.0 - ignore argument runner: operation count
|
||||
count.json FAIL Count documents with skip and limit countDocuments: expected 2, got 3
|
||||
count.json SKIP Deprecated count without a filter runner: operation count
|
||||
count.json SKIP Deprecated count with a filter runner: operation count
|
||||
count.json SKIP Deprecated count with skip and limit runner: operation count
|
||||
@@ -354,10 +353,6 @@ find-comment.json SKIP find with comment does not set comment on getMore - pre 4
|
||||
find-let.json SKIP Find with let option needs server >= 5.0
|
||||
find-let.json FAIL Find with let option unsupported (server-side error) find: expected an error, the operation succeeded
|
||||
find-rawdata.json SKIP Find with rawData option needs server >= 8.2.0
|
||||
find.json FAIL Find with filter, sort, skip, and limit find: expected 2 elements, got 4
|
||||
find.json FAIL Find with limit, sort, and batchsize find: expected 4 elements, got 6
|
||||
find.json FAIL Find with batchSize equal to limit find: expected 4 elements, got 5
|
||||
findOne.json FAIL FindOne with filter, sort, and skip findOne._id: expected 5, got 3
|
||||
findOneAndDelete-collation.json FAIL FindOneAndDelete when one document matches with collation findOneAndDelete: expected a document, got null
|
||||
findOneAndDelete-comment.json SKIP findOneAndDelete with comment - pre 4.4 needs server <= 4.2.99
|
||||
findOneAndDelete-hint-serverError.json SKIP * needs server <= 4.3.3
|
||||
|
||||
Reference in New Issue
Block a user