From acfb4fc8c323b71930ad08f6b976b14d92ccb0b2 Mon Sep 17 00:00:00 2001 From: Neal Beeken Date: Fri, 26 Jul 2024 09:55:20 -0400 Subject: [PATCH] feat(NODE-5682): set maxTimeMS on commands and preempt I/O (#4174) Co-authored-by: Warren James --- src/admin.ts | 5 +- src/cmap/connection.ts | 66 ++++++++++++++++--- src/cmap/wire_protocol/on_data.ts | 17 ++++- src/db.ts | 2 +- src/sdam/topology.ts | 17 +++-- src/timeout.ts | 43 ++++++++++-- ...ient_side_operations_timeout.prose.test.ts | 20 +++--- ...lient_side_operations_timeout.spec.test.ts | 33 +++++++++- .../node_csot.test.ts | 1 - test/integration/node-specific/db.test.js | 22 ++----- test/spec/{index.js => index.ts} | 19 ++---- test/tools/cmap_spec_runner.ts | 3 +- test/tools/unified-spec-runner/entities.ts | 4 +- test/tools/unified-spec-runner/match.ts | 15 ++++- test/tools/unified-spec-runner/operations.ts | 8 +-- test/unit/tools/unified_spec_runner.test.ts | 2 +- 16 files changed, 200 insertions(+), 77 deletions(-) rename test/spec/{index.js => index.ts} (67%) diff --git a/src/admin.ts b/src/admin.ts index e030384eaf..0f03023a95 100644 --- a/src/admin.ts +++ b/src/admin.ts @@ -155,7 +155,10 @@ export class Admin { * @param options - Optional settings for the command */ async listDatabases(options?: ListDatabasesOptions): Promise { - return await executeOperation(this.s.db.client, new ListDatabasesOperation(this.s.db, options)); + return await executeOperation( + this.s.db.client, + new ListDatabasesOperation(this.s.db, { timeoutMS: this.s.db.timeoutMS, ...options }) + ); } /** diff --git a/src/cmap/connection.ts b/src/cmap/connection.ts index e88e784b45..3f391bea40 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -19,6 +19,7 @@ import { MongoMissingDependencyError, MongoNetworkError, MongoNetworkTimeoutError, + MongoOperationTimeoutError, MongoParseError, MongoServerError, MongoUnexpectedServerResponseError @@ -30,7 +31,7 @@ import { type CancellationToken, TypedEventEmitter } from '../mongo_types'; import { ReadPreference, type ReadPreferenceLike } from '../read_preference'; import { ServerType } from '../sdam/common'; import { applySession, type ClientSession, updateSessionFromResponse } from '../sessions'; -import { type TimeoutContext } from '../timeout'; +import { type TimeoutContext, TimeoutError } from '../timeout'; import { BufferPool, calculateDurationInMs, @@ -417,6 +418,11 @@ export class Connection extends TypedEventEmitter { ...options }; + if (options.timeoutContext?.csotEnabled()) { + const { maxTimeMS } = options.timeoutContext; + if (maxTimeMS > 0 && Number.isFinite(maxTimeMS)) cmd.maxTimeMS = maxTimeMS; + } + const message = this.supportsOpMsg ? new OpMsgRequest(db, cmd, commandOptions) : new OpQueryRequest(db, cmd, commandOptions); @@ -431,7 +437,9 @@ export class Connection extends TypedEventEmitter { ): AsyncGenerator { this.throwIfAborted(); - if (typeof options.socketTimeoutMS === 'number') { + if (options.timeoutContext?.csotEnabled()) { + this.socket.setTimeout(0); + } else if (typeof options.socketTimeoutMS === 'number') { this.socket.setTimeout(options.socketTimeoutMS); } else if (this.socketTimeoutMS !== 0) { this.socket.setTimeout(this.socketTimeoutMS); @@ -440,7 +448,8 @@ export class Connection extends TypedEventEmitter { try { await this.writeCommand(message, { agreedCompressor: this.description.compressor ?? 'none', - zlibCompressionLevel: this.description.zlibCompressionLevel + zlibCompressionLevel: this.description.zlibCompressionLevel, + timeoutContext: options.timeoutContext }); if (options.noResponse || message.moreToCome) { @@ -450,7 +459,17 @@ export class Connection extends TypedEventEmitter { this.throwIfAborted(); - for await (const response of this.readMany()) { + if ( + options.timeoutContext?.csotEnabled() && + options.timeoutContext.minRoundTripTime != null && + options.timeoutContext.remainingTimeMS < options.timeoutContext.minRoundTripTime + ) { + throw new MongoOperationTimeoutError( + 'Server roundtrip time is greater than the time remaining' + ); + } + + for await (const response of this.readMany({ timeoutContext: options.timeoutContext })) { this.socket.setTimeout(0); const bson = response.parse(); @@ -627,7 +646,11 @@ export class Connection extends TypedEventEmitter { */ private async writeCommand( command: WriteProtocolMessageType, - options: { agreedCompressor?: CompressorName; zlibCompressionLevel?: number } + options: { + agreedCompressor?: CompressorName; + zlibCompressionLevel?: number; + timeoutContext?: TimeoutContext; + } ): Promise { const finalCommand = options.agreedCompressor === 'none' || !OpCompressedRequest.canCompress(command) @@ -639,8 +662,32 @@ export class Connection extends TypedEventEmitter { const buffer = Buffer.concat(await finalCommand.toBin()); + if (options.timeoutContext?.csotEnabled()) { + if ( + options.timeoutContext.minRoundTripTime != null && + options.timeoutContext.remainingTimeMS < options.timeoutContext.minRoundTripTime + ) { + throw new MongoOperationTimeoutError( + 'Server roundtrip time is greater than the time remaining' + ); + } + } + if (this.socket.write(buffer)) return; - return await once(this.socket, 'drain'); + + const drainEvent = once(this.socket, 'drain'); + const timeout = options?.timeoutContext?.timeoutForSocketWrite; + if (timeout) { + try { + return await Promise.race([drainEvent, timeout]); + } catch (error) { + if (TimeoutError.is(error)) { + throw new MongoOperationTimeoutError('Timed out at socket write'); + } + throw error; + } + } + return await drainEvent; } /** @@ -652,9 +699,12 @@ export class Connection extends TypedEventEmitter { * * Note that `for-await` loops call `return` automatically when the loop is exited. */ - private async *readMany(): AsyncGenerator { + private async *readMany(options: { + timeoutContext?: TimeoutContext; + }): AsyncGenerator { try { - this.dataEvents = onData(this.messageStream); + this.dataEvents = onData(this.messageStream, options); + for await (const message of this.dataEvents) { const response = await decompressResponse(message); yield response; diff --git a/src/cmap/wire_protocol/on_data.ts b/src/cmap/wire_protocol/on_data.ts index b99c950d96..a32c6b1b48 100644 --- a/src/cmap/wire_protocol/on_data.ts +++ b/src/cmap/wire_protocol/on_data.ts @@ -1,5 +1,7 @@ import { type EventEmitter } from 'events'; +import { MongoOperationTimeoutError } from '../../error'; +import { type TimeoutContext, TimeoutError } from '../../timeout'; import { List, promiseWithResolvers } from '../../utils'; /** @@ -18,7 +20,10 @@ type PendingPromises = Omit< * Returns an AsyncIterator that iterates each 'data' event emitted from emitter. * It will reject upon an error event. */ -export function onData(emitter: EventEmitter) { +export function onData( + emitter: EventEmitter, + { timeoutContext }: { timeoutContext?: TimeoutContext } +) { // Setup pending events and pending promise lists /** * When the caller has not yet called .next(), we store the @@ -86,6 +91,8 @@ export function onData(emitter: EventEmitter) { // Adding event handlers emitter.on('data', eventHandler); emitter.on('error', errorHandler); + // eslint-disable-next-line github/no-then + timeoutContext?.timeoutForSocketRead?.then(undefined, errorHandler); return iterator; @@ -97,8 +104,12 @@ export function onData(emitter: EventEmitter) { function errorHandler(err: Error) { const promise = unconsumedPromises.shift(); - if (promise != null) promise.reject(err); - else error = err; + const timeoutError = TimeoutError.is(err) + ? new MongoOperationTimeoutError('Timed out during socket read') + : undefined; + + if (promise != null) promise.reject(timeoutError ?? err); + else error = timeoutError ?? err; void closeHandler(); } diff --git a/src/db.ts b/src/db.ts index 6e1aa194ac..48501bc497 100644 --- a/src/db.ts +++ b/src/db.ts @@ -277,7 +277,7 @@ export class Db { this.client, new RunCommandOperation(this, command, { ...resolveBSONOptions(options), - timeoutMS: options?.timeoutMS, + timeoutMS: options?.timeoutMS ?? this.timeoutMS, session: options?.session, readPreference: options?.readPreference }) diff --git a/src/sdam/topology.ts b/src/sdam/topology.ts index 6117b5317c..479003f0e3 100644 --- a/src/sdam/topology.ts +++ b/src/sdam/topology.ts @@ -460,29 +460,28 @@ export class Topology extends TypedEventEmitter { } } - const timeoutMS = this.client.s.options.timeoutMS; + // TODO(NODE-6223): auto connect cannot use timeoutMS + // const timeoutMS = this.client.s.options.timeoutMS; const serverSelectionTimeoutMS = this.client.s.options.serverSelectionTimeoutMS; const readPreference = options.readPreference ?? ReadPreference.primary; - const timeoutContext = TimeoutContext.create({ - timeoutMS, + timeoutMS: undefined, serverSelectionTimeoutMS, waitQueueTimeoutMS: this.client.s.options.waitQueueTimeoutMS }); - const selectServerOptions = { operationName: 'ping', ...options, timeoutContext }; + try { const server = await this.selectServer( readPreferenceServerSelector(readPreference), selectServerOptions ); - const skipPingOnConnect = this.s.options[Symbol.for('@@mdb.skipPingOnConnect')] === true; - if (!skipPingOnConnect && server && this.s.credentials) { + if (!skipPingOnConnect && this.s.credentials) { await server.command(ns('admin.$cmd'), { ping: 1 }, { timeoutContext }); stateTransition(this, STATE_CONNECTED); this.emit(Topology.OPEN, this); @@ -623,7 +622,11 @@ export class Topology extends TypedEventEmitter { try { timeout?.throwIfExpired(); - return await (timeout ? Promise.race([serverPromise, timeout]) : serverPromise); + const server = await (timeout ? Promise.race([serverPromise, timeout]) : serverPromise); + if (options.timeoutContext?.csotEnabled() && server.description.minRoundTripTime !== 0) { + options.timeoutContext.minRoundTripTime = server.description.minRoundTripTime; + } + return server; } catch (error) { if (TimeoutError.is(error)) { // Timeout diff --git a/src/timeout.ts b/src/timeout.ts index 3d65992a02..cc90b8c2e7 100644 --- a/src/timeout.ts +++ b/src/timeout.ts @@ -1,6 +1,6 @@ import { clearTimeout, setTimeout } from 'timers'; -import { MongoInvalidArgumentError, MongoRuntimeError } from './error'; +import { MongoInvalidArgumentError, MongoOperationTimeoutError, MongoRuntimeError } from './error'; import { csotMin, noop } from './utils'; /** @internal */ @@ -51,7 +51,7 @@ export class Timeout extends Promise { } /** Create a new timeout that expires in `duration` ms */ - private constructor(executor: Executor = () => null, duration: number, unref = false) { + private constructor(executor: Executor = () => null, duration: number, unref = true) { let reject!: Reject; if (duration < 0) { @@ -163,6 +163,10 @@ export abstract class TimeoutContext { abstract get clearConnectionCheckoutTimeout(): boolean; + abstract get timeoutForSocketWrite(): Timeout | null; + + abstract get timeoutForSocketRead(): Timeout | null; + abstract csotEnabled(): this is CSOTTimeoutContext; } @@ -175,13 +179,15 @@ export class CSOTTimeoutContext extends TimeoutContext { clearConnectionCheckoutTimeout: boolean; clearServerSelectionTimeout: boolean; - private _maxTimeMS?: number; - private _serverSelectionTimeout?: Timeout | null; private _connectionCheckoutTimeout?: Timeout | null; + public minRoundTripTime = 0; + private start: number; constructor(options: CSOTTimeoutContextOptions) { super(); + this.start = Math.trunc(performance.now()); + this.timeoutMS = options.timeoutMS; this.serverSelectionTimeoutMS = options.serverSelectionTimeoutMS; @@ -193,11 +199,12 @@ export class CSOTTimeoutContext extends TimeoutContext { } get maxTimeMS(): number { - return this._maxTimeMS ?? -1; + return this.remainingTimeMS - this.minRoundTripTime; } - set maxTimeMS(v: number) { - this._maxTimeMS = v; + get remainingTimeMS() { + const timePassed = Math.trunc(performance.now()) - this.start; + return this.timeoutMS <= 0 ? Infinity : this.timeoutMS - timePassed; } csotEnabled(): this is CSOTTimeoutContext { @@ -238,6 +245,20 @@ export class CSOTTimeoutContext extends TimeoutContext { } return this._connectionCheckoutTimeout; } + + get timeoutForSocketWrite(): Timeout | null { + const { remainingTimeMS } = this; + if (!Number.isFinite(remainingTimeMS)) return null; + if (remainingTimeMS > 0) return Timeout.expires(remainingTimeMS); + throw new MongoOperationTimeoutError('Timed out before socket write'); + } + + get timeoutForSocketRead(): Timeout | null { + const { remainingTimeMS } = this; + if (!Number.isFinite(remainingTimeMS)) return null; + if (remainingTimeMS > 0) return Timeout.expires(remainingTimeMS); + throw new MongoOperationTimeoutError('Timed out before socket read'); + } } /** @internal */ @@ -268,4 +289,12 @@ export class LegacyTimeoutContext extends TimeoutContext { return Timeout.expires(this.options.waitQueueTimeoutMS); return null; } + + get timeoutForSocketWrite(): Timeout | null { + return null; + } + + get timeoutForSocketRead(): Timeout | null { + return null; + } } diff --git a/test/integration/client-side-operations-timeout/client_side_operations_timeout.prose.test.ts b/test/integration/client-side-operations-timeout/client_side_operations_timeout.prose.test.ts index 903ea9c3bb..729bed4219 100644 --- a/test/integration/client-side-operations-timeout/client_side_operations_timeout.prose.test.ts +++ b/test/integration/client-side-operations-timeout/client_side_operations_timeout.prose.test.ts @@ -384,7 +384,7 @@ describe('CSOT spec prose tests', function () { clock.restore(); }); - it('serverSelectionTimeoutMS honored if timeoutMS is not set', async function () { + it.skip('serverSelectionTimeoutMS honored if timeoutMS is not set', async function () { /** * 1. Create a MongoClient (referred to as `client`) with URI `mongodb://invalid/?serverSelectionTimeoutMS=10`. * 1. Using `client`, execute the command `{ ping: 1 }` against the `admin` database. @@ -416,10 +416,11 @@ describe('CSOT spec prose tests', function () { await clock.tickAsync(11); expect(await maybeError).to.be.instanceof(MongoServerSelectionError); - }); + }).skipReason = + 'TODO(NODE-6223): Auto connect performs extra server selection. Explicit connect throws on invalid host name'; }); - it("timeoutMS honored for server selection if it's lower than serverSelectionTimeoutMS", async function () { + it.skip("timeoutMS honored for server selection if it's lower than serverSelectionTimeoutMS", async function () { /** * 1. Create a MongoClient (referred to as `client`) with URI `mongodb://invalid/?timeoutMS=10&serverSelectionTimeoutMS=20`. * 1. Using `client`, run the command `{ ping: 1 }` against the `admin` database. @@ -440,9 +441,10 @@ describe('CSOT spec prose tests', function () { expect(maybeError).to.be.instanceof(MongoOperationTimeoutError); expect(end - start).to.be.lte(15); - }); + }).skipReason = + 'TODO(NODE-6223): Auto connect performs extra server selection. Explicit connect throws on invalid host name'; - it("serverSelectionTimeoutMS honored for server selection if it's lower than timeoutMS", async function () { + it.skip("serverSelectionTimeoutMS honored for server selection if it's lower than timeoutMS", async function () { /** * 1. Create a MongoClient (referred to as `client`) with URI `mongodb://invalid/?timeoutMS=20&serverSelectionTimeoutMS=10`. * 1. Using `client`, run the command `{ ping: 1 }` against the `admin` database. @@ -462,9 +464,10 @@ describe('CSOT spec prose tests', function () { expect(maybeError).to.be.instanceof(MongoOperationTimeoutError); expect(end - start).to.be.lte(15); - }); + }).skipReason = + 'TODO(NODE-6223): Auto connect performs extra server selection. Explicit connect throws on invalid host name'; - it('serverSelectionTimeoutMS honored for server selection if timeoutMS=0', async function () { + it.skip('serverSelectionTimeoutMS honored for server selection if timeoutMS=0', async function () { /** * 1. Create a MongoClient (referred to as `client`) with URI `mongodb://invalid/?timeoutMS=0&serverSelectionTimeoutMS=10`. * 1. Using `client`, run the command `{ ping: 1 }` against the `admin` database. @@ -484,7 +487,8 @@ describe('CSOT spec prose tests', function () { expect(maybeError).to.be.instanceof(MongoOperationTimeoutError); expect(end - start).to.be.lte(15); - }); + }).skipReason = + 'TODO(NODE-6223): Auto connect performs extra server selection. Explicit connect throws on invalid host name'; it.skip("timeoutMS honored for connection handshake commands if it's lower than serverSelectionTimeoutMS", async function () { /** diff --git a/test/integration/client-side-operations-timeout/client_side_operations_timeout.spec.test.ts b/test/integration/client-side-operations-timeout/client_side_operations_timeout.spec.test.ts index 2e2cd0fa8e..f73f162204 100644 --- a/test/integration/client-side-operations-timeout/client_side_operations_timeout.spec.test.ts +++ b/test/integration/client-side-operations-timeout/client_side_operations_timeout.spec.test.ts @@ -3,7 +3,34 @@ import { join } from 'path'; import { loadSpecTests } from '../../spec'; import { runUnifiedSuite } from '../../tools/unified-spec-runner/runner'; -// TODO(NODE-5823): Implement unified runner operations and options support for CSOT -describe.skip('CSOT spec tests', function () { - runUnifiedSuite(loadSpecTests(join('client-side-operations-timeout'))); +const enabled = [ + 'override-collection-timeoutMS', + 'override-database-timeoutMS', + 'override-operation-timeoutMS' +]; + +const cursorOperations = [ + 'aggregate', + 'countDocuments', + 'listIndexes', + 'createChangeStream', + 'listCollections', + 'listCollectionNames' +]; + +describe('CSOT spec tests', function () { + const specs = loadSpecTests(join('client-side-operations-timeout')); + for (const spec of specs) { + for (const test of spec.tests) { + // not one of the test suites listed in kickoff + if (!enabled.includes(spec.name)) { + test.skipReason = 'TODO(NODE-5684): Not working yet'; + } + + // Cursor operation + if (test.operations.find(operation => cursorOperations.includes(operation.name))) + test.skipReason = 'TODO(NODE-5684): Not working yet'; + } + } + runUnifiedSuite(specs); }); diff --git a/test/integration/client-side-operations-timeout/node_csot.test.ts b/test/integration/client-side-operations-timeout/node_csot.test.ts index 17d85ba5b2..0c97b91083 100644 --- a/test/integration/client-side-operations-timeout/node_csot.test.ts +++ b/test/integration/client-side-operations-timeout/node_csot.test.ts @@ -48,7 +48,6 @@ describe('CSOT driver tests', () => { afterEach(async () => { await cursor?.close(); await session?.endSession(); - await session.endSession(); }); it('throws an error', async () => { diff --git a/test/integration/node-specific/db.test.js b/test/integration/node-specific/db.test.js index 338e136c12..a092a8d888 100644 --- a/test/integration/node-specific/db.test.js +++ b/test/integration/node-specific/db.test.js @@ -45,22 +45,12 @@ describe('Db', function () { }); }); - it('shouldCorrectlyHandleFailedConnection', { - metadata: { - requires: { topology: ['single', 'replicaset', 'sharded'] } - }, - - test: function (done) { - var configuration = this.configuration; - var fs_client = configuration.newClient('mongodb://127.0.0.1:25117/test', { - serverSelectionTimeoutMS: 10 - }); - - fs_client.connect(function (err) { - test.ok(err != null); - done(); - }); - } + it('should correctly handle failed connection', async function () { + const client = this.configuration.newClient('mongodb://iLoveJS', { + serverSelectionTimeoutMS: 10 + }); + const error = await client.connect().catch(error => error); + expect(error).to.be.instanceOf(Error); }); it('shouldCorrectlyGetErrorDroppingNonExistingDb', { diff --git a/test/spec/index.js b/test/spec/index.ts similarity index 67% rename from test/spec/index.js rename to test/spec/index.ts index f9e6dccf02..221d667189 100644 --- a/test/spec/index.js +++ b/test/spec/index.ts @@ -1,7 +1,7 @@ -'use strict'; -const path = require('path'); -const fs = require('fs'); -const { EJSON } = require('bson'); +import * as fs from 'fs'; +import * as path from 'path'; + +import { EJSON } from '../mongodb'; function hasDuplicates(testArray) { const testNames = testArray.map(test => test.description); @@ -12,17 +12,16 @@ function hasDuplicates(testArray) { /** * Given spec test folder names, loads the corresponding JSON * - * @param {...string} args - the spec test name to load - * @returns {any[]} + * @param args - the spec test name to load */ -function loadSpecTests(...args) { +export function loadSpecTests(...args: string[]): any[] { const specPath = path.resolve(...[__dirname].concat(args)); const suites = fs .readdirSync(specPath) .filter(x => x.includes('.json')) .map(x => ({ - ...EJSON.parse(fs.readFileSync(path.join(specPath, x)), { relaxed: true }), + ...EJSON.parse(fs.readFileSync(path.join(specPath, x), 'utf8'), { relaxed: true }), name: path.basename(x, '.json') })); @@ -36,7 +35,3 @@ function loadSpecTests(...args) { return suites; } - -module.exports = { - loadSpecTests -}; diff --git a/test/tools/cmap_spec_runner.ts b/test/tools/cmap_spec_runner.ts index 9bb2abdb87..892f6311df 100644 --- a/test/tools/cmap_spec_runner.ts +++ b/test/tools/cmap_spec_runner.ts @@ -1,6 +1,7 @@ import { expect } from 'chai'; import { EventEmitter } from 'events'; import { clearTimeout, setTimeout } from 'timers'; +import { inspect } from 'util'; import { addContainerMetadata, @@ -427,7 +428,7 @@ async function runCmapTest(test: CmapTest, threadContext: ThreadContext) { } compareInputToSpec(actualError, errorPropsToCheck, `failed while checking ${errorType}`); } else { - expect(actualError).to.not.exist; + expect(actualError, inspect(actualError)).to.not.exist; } const actualEvents = threadContext.poolEvents.filter( diff --git a/test/tools/unified-spec-runner/entities.ts b/test/tools/unified-spec-runner/entities.ts index 65b5242cf0..9f4e20a828 100644 --- a/test/tools/unified-spec-runner/entities.ts +++ b/test/tools/unified-spec-runner/entities.ts @@ -44,7 +44,7 @@ import { type TopologyOpeningEvent, WriteConcern } from '../../mongodb'; -import { ejson, getEnvironmentalOptions } from '../../tools/utils'; +import { getEnvironmentalOptions } from '../../tools/utils'; import type { TestConfiguration } from '../runner/config'; import { EntityEventRegistry } from './entity_event_registry'; import { trace } from './runner'; @@ -590,7 +590,7 @@ export class EntitiesMap extends Map { new EntityEventRegistry(client, entity.client, map).register(); await client.connect(); } catch (error) { - console.error(ejson`failed to connect entity ${entity}`); + console.error('failed to connect entity', entity); // In the case where multiple clients are defined in the test and any one of them failed // to connect, but others did succeed, we need to ensure all open clients are closed. const clients = map.mapOf('client'); diff --git a/test/tools/unified-spec-runner/match.ts b/test/tools/unified-spec-runner/match.ts index 7b2668e88a..3e3ba86d0e 100644 --- a/test/tools/unified-spec-runner/match.ts +++ b/test/tools/unified-spec-runner/match.ts @@ -173,7 +173,8 @@ TYPE_MAP.set('minKey', actual => actual._bsontype === 'MinKey'); TYPE_MAP.set('maxKey', actual => actual._bsontype === 'MaxKey'); TYPE_MAP.set( 'int', - actual => (typeof actual === 'number' && Number.isInteger(actual)) || actual._bsontype === 'Int32' + actual => + (typeof actual === 'number' && Number.isInteger(actual)) || actual?._bsontype === 'Int32' ); TYPE_MAP.set( 'long', @@ -218,6 +219,10 @@ export function resultCheck( resultCheck(objFromActual, value, entities, path, checkExtraKeys); } else if (key === 'createIndexes') { for (const [i, userIndex] of actual.indexes.entries()) { + if (expected?.indexes?.[i]?.key == null) { + // The expectation does not include an assertion for the index key + continue; + } expect(expected).to.have.nested.property(`.indexes[${i}].key`).to.be.a('object'); // @ts-expect-error: Not worth narrowing to a document expect(Object.keys(expected.indexes[i].key)).to.have.lengthOf(1); @@ -371,7 +376,7 @@ export function specialCheck( for (const type of types) { ok ||= TYPE_MAP.get(type)(actual); } - expect(ok, `Expected [${actual}] to be one of [${types}]`).to.be.true; + expect(ok, `Expected ${path.join('.')} [${actual}] to be one of [${types}]`).to.be.true; } else if (isExistsOperator(expected)) { // $$exists const actualExists = actual !== undefined && actual !== null; @@ -784,6 +789,12 @@ export function expectErrorCheck( expect(error).to.be.instanceof(MongoOperationTimeoutError); } + if (expected.isTimeoutError === false) { + expect(error).to.not.be.instanceof(MongoOperationTimeoutError); + } else if (expected.isTimeoutError === true) { + expect(error).to.be.instanceof(MongoOperationTimeoutError); + } + if (expected.errorContains != null) { expect(error.message.toLowerCase(), expectMessage.toLowerCase()).to.include( expected.errorContains.toLowerCase() diff --git a/test/tools/unified-spec-runner/operations.ts b/test/tools/unified-spec-runner/operations.ts index 9cc67174f3..7a98c7ac97 100644 --- a/test/tools/unified-spec-runner/operations.ts +++ b/test/tools/unified-spec-runner/operations.ts @@ -303,6 +303,7 @@ operations.set('dropCollection', async ({ entities, operation }) => { if (!/ns not found/.test(err.message)) { throw err; } + return false; } }); @@ -313,7 +314,7 @@ operations.set('drop', async ({ entities, operation }) => { operations.set('dropIndexes', async ({ entities, operation }) => { const collection = entities.getEntity('collection', operation.object); - return collection.dropIndexes(); + return collection.dropIndexes(operation.arguments); }); operations.set('endSession', async ({ entities, operation }) => { @@ -767,11 +768,10 @@ operations.set('runCommand', async ({ entities, operation }: OperationFunctionPa throw new AssertionError('runCommand requires a command'); const { command } = operation.arguments; - if (operation.arguments.timeoutMS != null) throw new AssertionError('timeoutMS not supported'); - const options = { readPreference: operation.arguments.readPreference, - session: operation.arguments.session + session: operation.arguments.session, + timeoutMS: operation.arguments.timeoutMS }; return db.command(command, options); diff --git a/test/unit/tools/unified_spec_runner.test.ts b/test/unit/tools/unified_spec_runner.test.ts index a0887be959..7ebee16859 100644 --- a/test/unit/tools/unified_spec_runner.test.ts +++ b/test/unit/tools/unified_spec_runner.test.ts @@ -100,7 +100,7 @@ describe('Unified Spec Runner', function () { expect(() => resultCheckSpy(actual, expected, entitiesMap, [])).to.throw( AssertionError, - /Expected \[string\] to be one of \[int\]/ + /\[string\] to be one of \[int\]/ ); }); });