tests/spec: a collection entity gets the options it was declared with

`buildEntities` built every collection as `db.collection(name)` and every
database as `client.db(name)`, dropping `collectionOptions` and
`databaseOptions` on the floor. 15 collection entities declare a
`writeConcern`, 7 a `readConcern`, one a `readPreference` -- and the 15 are all
`{w: 0}`, so every "unacknowledged write" case in this corpus has been running
an acknowledged write against a driver that was never told otherwise.

They passed anyway, because an acknowledged and an unacknowledged write of the
same document produce results a `$$unsetOrMatches` expectation accepts either
way. Only the command on the wire distinguished them, and nothing was reading
the command until the previous commits. This is the first thing the event
assertions found, and it is a fair answer to what they cost.

Option documents are unwrapped from their BSON types on the way to the driver.
The suites are parsed with `relaxed: false`, so `{w: 0}` arrives as an Int32
and the driver gates `writeConcern.w` on `typeof w === 'number'` -- the same
trap NUMERIC_OPTIONS already documents for operation options, and a silent one:
the option would simply not apply. Wholesale unwrapping is safe here in a way
it is not there, since these are settings the driver consumes rather than
values an assertion compares. An option key outside the spec's
`collectionOrDatabaseOptions` set is reported unsupported rather than ignored,
which is the lesson of the bug itself.

159/132/196 becomes 173/118/196. 14 cases fixed, none broken.

The other 10 unacknowledged cases now fail differently, and that is progress
of a sort: with `w: 0` actually applied, the driver refuses client-side to send
`hint` on a delete or findAndModify to a server older than 4.4. This engine
reports itself as 4.4.0 with maxWireVersion 8, and 4.4 is wire 9. That
inconsistency is ours, it is the same one behind the `comment`-on-getMore
failures, and it gets the next commit.
This commit is contained in:
A.Shakhmatov
2026-08-09 13:17:03 +03:00
parent bb8cdd964b
commit 7f426cdd33
2 changed files with 55 additions and 39 deletions

View File

@@ -542,6 +542,32 @@ function disableEvents(events) {
for (const buf of events.values()) buf.enabled = false;
}
// `collectionOrDatabaseOptions` (unified-test-format.md, entity definitions).
// Anything outside this set is refused rather than silently ignored -- that is
// the whole lesson of the bug this function exists to fix.
const ENTITY_OPTIONS = new Set(['readConcern', 'readPreference', 'writeConcern']);
// These documents are parsed with `relaxed: false` like everything else in the
// file, so a plain JSON `0` arrives as a BSON Int32 -- and the driver gates
// `writeConcern.w` on `typeof w === 'number'`, which is exactly the trap
// NUMERIC_OPTIONS documents for operation options. Unwrapping wholesale is safe
// here in a way it is not there: these are settings the driver consumes, not
// values any assertion ever compares.
function plainOptions(spec, what) {
for (const k of Object.keys(spec || {})) {
if (!ENTITY_OPTIONS.has(k)) throw new Unsupported(`${what} ${k}`);
}
const walk = (v) => {
if (Array.isArray(v)) return v.map(walk);
if (typeof v === 'object' && v !== null && numeric(v) !== null) return numeric(v);
if (!isPlainDoc(v)) return v;
const out = {};
for (const [k, x] of Object.entries(v)) out[k] = walk(x);
return out;
};
return walk(spec || {});
}
// `clients` is supplied by the caller so that entities created before a
// failure are still closed: returning them only on success is what leaked.
// `events` is supplied for the same reason -- a case that dies partway still
@@ -596,8 +622,12 @@ async function buildEntities(url, createEntities, clients, events) {
map[def.id] = c;
break;
}
case 'database': map[def.id] = map[def.client].db(def.databaseName); break;
case 'collection': map[def.id] = map[def.database].collection(def.collectionName); break;
case 'database':
map[def.id] = map[def.client].db(def.databaseName, plainOptions(def.databaseOptions, 'databaseOptions'));
break;
case 'collection':
map[def.id] = map[def.database].collection(def.collectionName, plainOptions(def.collectionOptions, 'collectionOptions'));
break;
case 'session': throw new Unsupported('session entities (M4)');
case 'bucket': throw new Unsupported('gridfs bucket entities');
case 'clientEncryption': throw new Unsupported('client-side encryption');