From 3c2ec0a59fbe70b3c20bb1a23fc0fe528465e5a9 Mon Sep 17 00:00:00 2001 From: Warren James Date: Thu, 11 Apr 2024 17:15:41 -0400 Subject: [PATCH 01/39] feat(NODE-6090): Implement CSOT logic for connection checkout and server selection --- src/admin.ts | 3 +- src/cmap/connection.ts | 4 + src/cmap/connection_pool.ts | 53 ++- src/collection.ts | 5 + src/db.ts | 6 + src/error.ts | 9 + src/index.ts | 1 + src/operations/command.ts | 2 + src/operations/find.ts | 3 +- src/operations/operation.ts | 8 + src/operations/run_command.ts | 9 +- src/sdam/server.ts | 3 +- src/sdam/topology.ts | 54 ++- src/timeout.ts | 14 + src/utils.ts | 10 + ...ient_side_operations_timeout.prose.test.ts | 315 +++++++++++++----- ...lient_side_operations_timeout.unit.test.ts | 140 +++++--- .../node_csot.test.ts | 75 ++++- test/unit/cmap/connection_pool.test.js | 33 +- test/unit/index.test.ts | 1 + 20 files changed, 570 insertions(+), 178 deletions(-) diff --git a/src/admin.ts b/src/admin.ts index a71ac4be1d..e030384eaf 100644 --- a/src/admin.ts +++ b/src/admin.ts @@ -78,7 +78,8 @@ export class Admin { new RunAdminCommandOperation(command, { ...resolveBSONOptions(options), session: options?.session, - readPreference: options?.readPreference + readPreference: options?.readPreference, + timeoutMS: options?.timeoutMS ?? this.s.db.timeoutMS }) ); } diff --git a/src/cmap/connection.ts b/src/cmap/connection.ts index 666e92fb8c..202be1e48a 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -30,6 +30,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 Timeout } from '../timeout'; import { BufferPool, calculateDurationInMs, @@ -94,6 +95,9 @@ export interface CommandOptions extends BSONSerializeOptions { writeConcern?: WriteConcern; directConnection?: boolean; + + /** @internal */ + timeout?: Timeout; } /** @public */ diff --git a/src/cmap/connection_pool.ts b/src/cmap/connection_pool.ts index 5a858a5121..79440db1e0 100644 --- a/src/cmap/connection_pool.ts +++ b/src/cmap/connection_pool.ts @@ -21,13 +21,14 @@ import { MongoInvalidArgumentError, MongoMissingCredentialsError, MongoNetworkError, + MongoOperationTimeoutError, MongoRuntimeError, MongoServerError } from '../error'; import { CancellationToken, TypedEventEmitter } from '../mongo_types'; import type { Server } from '../sdam/server'; import { Timeout, TimeoutError } from '../timeout'; -import { type Callback, List, makeCounter, now, promiseWithResolvers } from '../utils'; +import { type Callback, csotMin, List, makeCounter, promiseWithResolvers } from '../utils'; import { connect } from './connect'; import { Connection, type ConnectionEvents, type ConnectionOptions } from './connection'; import { @@ -102,7 +103,6 @@ export interface ConnectionPoolOptions extends Omit void; reject: (err: AnyError) => void; - timeout: Timeout; [kCancelled]?: boolean; checkoutTime: number; } @@ -355,37 +355,57 @@ export class ConnectionPool extends TypedEventEmitter { * will be held by the pool. This means that if a connection is checked out it MUST be checked back in or * explicitly destroyed by the new owner. */ - async checkOut(): Promise { - const checkoutTime = now(); + async checkOut(options?: { timeout?: Timeout }): Promise { this.emitAndLog( ConnectionPool.CONNECTION_CHECK_OUT_STARTED, new ConnectionCheckOutStartedEvent(this) ); const waitQueueTimeoutMS = this.options.waitQueueTimeoutMS; + const serverSelectionTimeoutMS = this[kServer].topology.s.serverSelectionTimeoutMS; const { promise, resolve, reject } = promiseWithResolvers(); - const timeout = Timeout.expires(waitQueueTimeoutMS); + let timeout: Timeout | null = null; + if (options?.timeout) { + // CSOT enabled + // Determine if we're using the timeout passed in or a new timeout + if (options.timeout.duration > 0 || serverSelectionTimeoutMS > 0) { + // This check determines whether or not Topology.selectServer used the configured + // `timeoutMS` or `serverSelectionTimeoutMS` value for its timeout + if ( + options.timeout.duration === serverSelectionTimeoutMS || + csotMin(options.timeout.duration, serverSelectionTimeoutMS) < serverSelectionTimeoutMS + ) { + // server selection used `timeoutMS`, so we should use the existing timeout as the timeout + // here + timeout = options.timeout; + } else { + // server selection used `serverSelectionTimeoutMS`, so we construct a new timeout with + // the time remaining to ensure that Topology.selectServer and ConnectionPool.checkOut + // cumulatively don't spend more than `serverSelectionTimeoutMS` blocking + timeout = Timeout.expires(serverSelectionTimeoutMS - options.timeout.timeElapsed); + } + } + } else { + timeout = Timeout.expires(waitQueueTimeoutMS); + } const waitQueueMember: WaitQueueMember = { resolve, - reject, - timeout, - checkoutTime + reject }; this[kWaitQueue].push(waitQueueMember); process.nextTick(() => this.processWaitQueue()); try { - return await Promise.race([promise, waitQueueMember.timeout]); + timeout?.throwIfExpired(); + return await (timeout ? Promise.race([promise, timeout]) : promise); } catch (error) { if (TimeoutError.is(error)) { waitQueueMember[kCancelled] = true; - waitQueueMember.timeout.clear(); - this.emitAndLog( ConnectionPool.CONNECTION_CHECK_OUT_FAILED, new ConnectionCheckOutFailedEvent(this, 'timeout', waitQueueMember.checkoutTime) @@ -396,9 +416,16 @@ export class ConnectionPool extends TypedEventEmitter { : 'Timed out while checking out a connection from connection pool', this.address ); + if (options?.timeout) { + throw new MongoOperationTimeoutError('Timed out during connection checkout', { + cause: timeoutError + }); + } throw timeoutError; } throw error; + } finally { + if (timeout !== options?.timeout) timeout?.clear(); } } @@ -764,7 +791,6 @@ export class ConnectionPool extends TypedEventEmitter { ConnectionPool.CONNECTION_CHECK_OUT_FAILED, new ConnectionCheckOutFailedEvent(this, reason, waitQueueMember.checkoutTime, error) ); - waitQueueMember.timeout.clear(); this[kWaitQueue].shift(); waitQueueMember.reject(error); continue; @@ -785,7 +811,6 @@ export class ConnectionPool extends TypedEventEmitter { ConnectionPool.CONNECTION_CHECKED_OUT, new ConnectionCheckedOutEvent(this, connection, waitQueueMember.checkoutTime) ); - waitQueueMember.timeout.clear(); this[kWaitQueue].shift(); waitQueueMember.resolve(connection); @@ -828,8 +853,6 @@ export class ConnectionPool extends TypedEventEmitter { ); waitQueueMember.resolve(connection); } - - waitQueueMember.timeout.clear(); } process.nextTick(() => this.processWaitQueue()); }); diff --git a/src/collection.ts b/src/collection.ts index ccc6fe2da6..dbd91371cc 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -262,6 +262,11 @@ export class Collection { this.s.collectionHint = normalizeHintField(v); } + /** @internal */ + get timeoutMS(): number | undefined { + return this.s.options.timeoutMS; + } + /** * Inserts a single document into MongoDB. If documents passed in do not contain the **_id** field, * one will be added to each of the documents missing it by the driver, mutating the document. This behavior diff --git a/src/db.ts b/src/db.ts index 0dcd24dbb1..538c8d2c9b 100644 --- a/src/db.ts +++ b/src/db.ts @@ -222,6 +222,11 @@ export class Db { return this.s.namespace.toString(); } + /** @internal */ + get timeoutMS(): number | undefined { + return this.s.options?.timeoutMS; + } + /** * Create a new collection on a server with the specified options. Use this to create capped collections. * More information about command options available at https://www.mongodb.com/docs/manual/reference/command/create/ @@ -272,6 +277,7 @@ export class Db { this.client, new RunCommandOperation(this, command, { ...resolveBSONOptions(options), + timeoutMS: options?.timeoutMS, session: options?.session, readPreference: options?.readPreference }) diff --git a/src/error.ts b/src/error.ts index 668e9cdbf5..0620d3069f 100644 --- a/src/error.ts +++ b/src/error.ts @@ -761,6 +761,15 @@ export class MongoUnexpectedServerResponseError extends MongoRuntimeError { } } +/** + * @internal + */ +export class MongoOperationTimeoutError extends MongoRuntimeError { + override get name(): string { + return 'MongoOperationTimeoutError'; + } +} + /** * An error thrown when the user attempts to add options to a cursor that has already been * initialized diff --git a/src/index.ts b/src/index.ts index eeebbd1154..0134027a5f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,6 +63,7 @@ export { MongoNetworkTimeoutError, MongoNotConnectedError, MongoOIDCError, + MongoOperationTimeoutError, MongoParseError, MongoRuntimeError, MongoServerClosedError, diff --git a/src/operations/command.ts b/src/operations/command.ts index 94ccc6ceaf..c64b4ae963 100644 --- a/src/operations/command.ts +++ b/src/operations/command.ts @@ -65,6 +65,7 @@ export interface OperationParent { writeConcern?: WriteConcern; readPreference?: ReadPreference; bsonOptions?: BSONSerializeOptions; + timeoutMS?: number; } /** @internal */ @@ -131,6 +132,7 @@ export abstract class CommandOperation extends AbstractOperation { const options = { ...this.options, ...this.bsonOptions, + timeout: this.timeout, readPreference: this.readPreference, session }; diff --git a/src/operations/find.ts b/src/operations/find.ts index a040af73bc..0f81f2d61f 100644 --- a/src/operations/find.ts +++ b/src/operations/find.ts @@ -116,7 +116,8 @@ export class FindOperation extends CommandOperation { ...this.options, ...this.bsonOptions, documentsReturnedIn: 'firstBatch', - session + session, + timeout: this.timeout }, this.explain ? ExplainedCursorResponse : CursorResponse ); diff --git a/src/operations/operation.ts b/src/operations/operation.ts index b51cca4020..0599b72b96 100644 --- a/src/operations/operation.ts +++ b/src/operations/operation.ts @@ -2,6 +2,7 @@ import { type BSONSerializeOptions, type Document, resolveBSONOptions } from '.. import { ReadPreference, type ReadPreferenceLike } from '../read_preference'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type Timeout } from '../timeout'; import type { MongoDBNamespace } from '../utils'; export const Aspect = { @@ -56,6 +57,11 @@ export abstract class AbstractOperation { options: OperationOptions; + /** @internal */ + timeout?: Timeout; + /** @internal */ + timeoutMS?: number; + [kSession]: ClientSession | undefined; static aspects?: Set; @@ -73,6 +79,8 @@ export abstract class AbstractOperation { this.options = options; this.bypassPinningCheck = !!options.bypassPinningCheck; this.trySecondaryWrite = false; + + this.timeoutMS = options.timeoutMS; } /** Must match the first key of the command object sent to the server. diff --git a/src/operations/run_command.ts b/src/operations/run_command.ts index ad7d02c044..56462fa884 100644 --- a/src/operations/run_command.ts +++ b/src/operations/run_command.ts @@ -14,6 +14,8 @@ export type RunCommandOptions = { session?: ClientSession; /** The read preference */ readPreference?: ReadPreferenceLike; + /** @internal */ + timeoutMS?: number; } & BSONSerializeOptions; /** @internal */ @@ -39,10 +41,12 @@ export class RunCommandOperation extends AbstractOperation { { ...this.options, readPreference: this.readPreference, - session + session, + timeout: this.timeout }, this.options.responseType ); + return res; } } @@ -68,7 +72,8 @@ export class RunAdminCommandOperation extends AbstractOperation const res: TODO_NODE_3286 = await server.command(this.ns, this.command, { ...this.options, readPreference: this.readPreference, - session + session, + timeout: this.timeout }); return res; } diff --git a/src/sdam/server.ts b/src/sdam/server.ts index b4450f0072..a1b885382e 100644 --- a/src/sdam/server.ts +++ b/src/sdam/server.ts @@ -311,7 +311,7 @@ export class Server extends TypedEventEmitter { this.incrementOperationCount(); if (conn == null) { try { - conn = await this.pool.checkOut(); + conn = await this.pool.checkOut(options); if (this.loadBalanced && isPinnableCommand(cmd, session)) { session?.pin(conn); } @@ -336,6 +336,7 @@ export class Server extends TypedEventEmitter { operationError.code === MONGODB_ERROR_CODES.Reauthenticate ) { await this.pool.reauthenticate(conn); + // TODO(NODE-5682): Implement CSOT support for socket read/write at the connection layer try { const res = await conn.command(ns, cmd, finalOptions, responseType); throwIfWriteConcernError(res); diff --git a/src/sdam/topology.ts b/src/sdam/topology.ts index 73b0e92a09..4c9d71d807 100644 --- a/src/sdam/topology.ts +++ b/src/sdam/topology.ts @@ -24,6 +24,7 @@ import { type MongoDriverError, MongoError, MongoErrorLabel, + MongoOperationTimeoutError, MongoRuntimeError, MongoServerSelectionError, MongoTopologyClosedError @@ -37,6 +38,7 @@ import { Timeout, TimeoutError } from '../timeout'; import type { Transaction } from '../transactions'; import { type Callback, + csotMin, type EventEmitterWithState, HostAddress, List, @@ -107,7 +109,6 @@ export interface ServerSelectionRequest { resolve: (server: Server) => void; reject: (error: MongoError) => void; [kCancelled]?: boolean; - timeout: Timeout; operationName: string; waitingLogged: boolean; previousServer?: ServerDescription; @@ -457,8 +458,14 @@ export class Topology extends TypedEventEmitter { } } + const timeoutMS = this.client.options.timeoutMS; + const timeout = timeoutMS != null ? Timeout.expires(timeoutMS) : undefined; const readPreference = options.readPreference ?? ReadPreference.primary; - const selectServerOptions = { operationName: 'ping', ...options }; + const selectServerOptions = { + operationName: 'ping', + timeout, + ...options + }; try { const server = await this.selectServer( readPreferenceServerSelector(readPreference), @@ -467,7 +474,7 @@ export class Topology extends TypedEventEmitter { const skipPingOnConnect = this.s.options[Symbol.for('@@mdb.skipPingOnConnect')] === true; if (!skipPingOnConnect && server && this.s.credentials) { - await server.command(ns('admin.$cmd'), { ping: 1 }, {}); + await server.command(ns('admin.$cmd'), { ping: 1 }, { timeout }); stateTransition(this, STATE_CONNECTED); this.emit(Topology.OPEN, this); this.emit(Topology.CONNECT, this); @@ -556,6 +563,25 @@ export class Topology extends TypedEventEmitter { new ServerSelectionStartedEvent(selector, this.description, options.operationName) ); } + const serverSelectionTimeoutMS = options.serverSelectionTimeoutMS ?? 0; + let timeout: Timeout | null; + if (options.timeout) { + // CSOT Enabled + if (options.timeout.duration > 0 || serverSelectionTimeoutMS > 0) { + if ( + options.timeout.duration === serverSelectionTimeoutMS || + csotMin(options.timeout.duration, serverSelectionTimeoutMS) < serverSelectionTimeoutMS + ) { + timeout = options.timeout; + } else { + timeout = Timeout.expires(serverSelectionTimeoutMS); + } + } else { + timeout = null; + } + } else { + timeout = Timeout.expires(serverSelectionTimeoutMS); + } const isSharded = this.description.type === TopologyType.Sharded; const session = options.session; @@ -578,11 +604,12 @@ export class Topology extends TypedEventEmitter { ) ); } + if (timeout !== options.timeout) timeout?.clear(); return transaction.server; } const { promise: serverPromise, resolve, reject } = promiseWithResolvers(); - const timeout = Timeout.expires(options.serverSelectionTimeoutMS ?? 0); + const waitQueueMember: ServerSelectionRequest = { serverSelector, topologyDescription: this.description, @@ -590,7 +617,6 @@ export class Topology extends TypedEventEmitter { transaction, resolve, reject, - timeout, startTime: now(), operationName: options.operationName, waitingLogged: false, @@ -601,14 +627,14 @@ export class Topology extends TypedEventEmitter { processWaitQueue(this); try { - return await Promise.race([serverPromise, waitQueueMember.timeout]); + timeout?.throwIfExpired(); + return await (timeout ? Promise.race([serverPromise, timeout]) : serverPromise); } catch (error) { if (TimeoutError.is(error)) { // Timeout waitQueueMember[kCancelled] = true; - timeout.clear(); const timeoutError = new MongoServerSelectionError( - `Server selection timed out after ${options.serverSelectionTimeoutMS} ms`, + `Server selection timed out after ${timeout?.duration} ms`, this.description ); if ( @@ -628,10 +654,17 @@ export class Topology extends TypedEventEmitter { ); } + if (options.timeout) { + throw new MongoOperationTimeoutError('Timed out during server selection', { + cause: timeoutError + }); + } throw timeoutError; } // Other server selection error throw error; + } finally { + if (timeout !== options.timeout) timeout?.clear(); } } /** @@ -889,8 +922,6 @@ function drainWaitQueue(queue: List, drainError: MongoDr continue; } - waitQueueMember.timeout.clear(); - if (!waitQueueMember[kCancelled]) { if ( waitQueueMember.mongoLogger?.willLog( @@ -944,7 +975,6 @@ function processWaitQueue(topology: Topology) { ) : serverDescriptions; } catch (selectorError) { - waitQueueMember.timeout.clear(); if ( topology.client.mongoLogger?.willLog( MongoLoggableComponent.SERVER_SELECTION, @@ -1032,8 +1062,6 @@ function processWaitQueue(topology: Topology) { transaction.pinServer(selectedServer); } - waitQueueMember.timeout.clear(); - if ( topology.client.mongoLogger?.willLog( MongoLoggableComponent.SERVER_SELECTION, diff --git a/src/timeout.ts b/src/timeout.ts index cd48ec385d..7af1a23f26 100644 --- a/src/timeout.ts +++ b/src/timeout.ts @@ -40,6 +40,16 @@ export class Timeout extends Promise { public duration: number; public timedOut = false; + get remainingTime(): number { + if (this.timedOut) return 0; + if (this.duration === 0) return Infinity; + return this.start + this.duration - Math.trunc(performance.now()); + } + + get timeElapsed(): number { + return Math.trunc(performance.now()) - this.start; + } + /** Create a new timeout that expires in `duration` ms */ private constructor(executor: Executor = () => null, duration: number, unref = false) { let reject!: Reject; @@ -78,6 +88,10 @@ export class Timeout extends Promise { this.id = undefined; } + throwIfExpired(): void { + if (this.timedOut) throw new TimeoutError('Timed out'); + } + public static expires(durationMS: number, unref?: boolean): Timeout { return new Timeout(undefined, durationMS, unref); } diff --git a/src/utils.ts b/src/utils.ts index 07fe8e56a6..fa7a3f6509 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -533,6 +533,10 @@ export function resolveOptions( result.readPreference = readPreference; } + const timeoutMS = options?.timeoutMS; + + result.timeoutMS = timeoutMS ?? parent?.timeoutMS; + return result; } @@ -1368,6 +1372,12 @@ export async function fileIsAccessible(fileName: string, mode?: number) { } } +export function csotMin(duration1: number, duration2: number): number { + if (duration1 === 0) return duration2; + if (duration2 === 0) return duration1; + return Math.min(duration1, duration2); +} + export function noop() { return; } 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 1ed88f34d8..903ea9c3bb 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 @@ -1,8 +1,30 @@ /* Specification prose tests */ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { + MongoClient, + MongoOperationTimeoutError, + MongoServerSelectionError, + now +} from '../../mongodb'; + // TODO(NODE-5824): Implement CSOT prose tests -describe.skip('CSOT spec prose tests', () => { - context('1. Multi-batch writes', () => { +describe('CSOT spec prose tests', function () { + let internalClient: MongoClient; + let client: MongoClient; + + beforeEach(async function () { + internalClient = this.configuration.newClient(); + }); + + afterEach(async function () { + await internalClient?.close(); + await client?.close(); + }); + + context.skip('1. Multi-batch writes', () => { /** * This test MUST only run against standalones on server versions 4.4 and higher. * The `insertMany` call takes an exceedingly long time on replicasets and sharded @@ -31,7 +53,7 @@ describe.skip('CSOT spec prose tests', () => { */ }); - context('2. maxTimeMS is not set for commands sent to mongocryptd', () => { + context.skip('2. maxTimeMS is not set for commands sent to mongocryptd', () => { /** * This test MUST only be run against enterprise server versions 4.2 and higher. * @@ -42,7 +64,7 @@ describe.skip('CSOT spec prose tests', () => { */ }); - context('3. ClientEncryption', () => { + context.skip('3. ClientEncryption', () => { /** * Each test under this category MUST only be run against server versions 4.4 and higher. In these tests, * `LOCAL_MASTERKEY` refers to the following base64: @@ -132,7 +154,7 @@ describe.skip('CSOT spec prose tests', () => { }); }); - context('4. Background Connection Pooling', () => { + context.skip('4. Background Connection Pooling', () => { /** * The tests in this section MUST only be run if the server version is 4.4 or higher and the URI has authentication * fields (i.e. a username and password). Each test in this section requires drivers to create a MongoClient and then wait @@ -192,7 +214,7 @@ describe.skip('CSOT spec prose tests', () => { }); }); - context('5. Blocking Iteration Methods', () => { + context.skip('5. Blocking Iteration Methods', () => { /** * Tests in this section MUST only be run against server versions 4.4 and higher and only apply to drivers that have a * blocking method for cursor iteration that executes `getMore` commands in a loop until a document is available or an @@ -251,7 +273,7 @@ describe.skip('CSOT spec prose tests', () => { }); }); - context('6. GridFS - Upload', () => { + context.skip('6. GridFS - Upload', () => { /** Tests in this section MUST only be run against server versions 4.4 and higher. */ context('uploads via openUploadStream can be timed out', () => { @@ -306,7 +328,7 @@ describe.skip('CSOT spec prose tests', () => { }); }); - context('7. GridFS - Download', () => { + context.skip('7. GridFS - Download', () => { /** * This test MUST only be run against server versions 4.4 and higher. * 1. Using `internalClient`, drop and re-create the `db.fs.files` and `db.fs.chunks` collections. @@ -351,96 +373,225 @@ describe.skip('CSOT spec prose tests', () => { }); context('8. Server Selection', () => { - context('serverSelectionTimeoutMS honored if timeoutMS is not set', () => { - /** - * 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. - * - Expect this to fail with a server selection timeout error after no more than 15ms. - */ - }); + context('using sinon timer', function () { + let clock: sinon.SinonFakeTimers; + + beforeEach(function () { + clock = sinon.useFakeTimers(); + }); + + afterEach(function () { + clock.restore(); + }); - context( - "timeoutMS honored for server selection if it's lower than serverSelectionTimeoutMS", - () => { + it('serverSelectionTimeoutMS honored if timeoutMS is not set', 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. + * 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. * - Expect this to fail with a server selection timeout error after no more than 15ms. */ - } - ); - context( - "serverSelectionTimeoutMS honored for server selection if it's lower than timeoutMS", - () => { - /** - * 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. - * - Expect this to fail with a server selection timeout error after no more than 15ms. + /** NOTE: This is the original implementation of this test, but it was flaky, so was + * replaced by the current implementation using sinon fake timers + * ```ts + * client = new MongoClient('mongodb://invalid/?serverSelectionTimeoutMS=10'); + * const admin = client.db('test').admin(); + * const start = performance.now(); + * const maybeError = await admin.ping().then( + * () => null, + * e => e + * ); + * const end = performance.now(); + * + * expect(maybeError).to.be.instanceof(MongoServerSelectionError); + * expect(end - start).to.be.lte(15) + * ``` */ - } - ); + client = new MongoClient('mongodb://invalid/?serverSelectionTimeoutMS=10'); + const admin = client.db('test').admin(); + const maybeError = admin.ping().then( + () => null, + e => e + ); + + await clock.tickAsync(11); + expect(await maybeError).to.be.instanceof(MongoServerSelectionError); + }); + }); + + it("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. + * - Expect this to fail with a server selection timeout error after no more than 15ms. + */ + client = new MongoClient('mongodb://invalid/?timeoutMS=10&serverSelectionTimeoutMS=20'); + const start = now(); + + const maybeError = await client + .db('test') + .admin() + .ping() + .then( + () => null, + e => e + ); + const end = now(); + + expect(maybeError).to.be.instanceof(MongoOperationTimeoutError); + expect(end - start).to.be.lte(15); + }); + + it("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. + * - Expect this to fail with a server selection timeout error after no more than 15ms. + */ + client = new MongoClient('mongodb://invalid/?timeoutMS=20&serverSelectionTimeoutMS=10'); + const start = now(); + const maybeError = await client + .db('test') + .admin() + .ping() + .then( + () => null, + e => e + ); + const end = now(); + + expect(maybeError).to.be.instanceof(MongoOperationTimeoutError); + expect(end - start).to.be.lte(15); + }); - context('serverSelectionTimeoutMS honored for server selection if timeoutMS=0', () => { + it('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. * - Expect this to fail with a server selection timeout error after no more than 15ms. */ + client = new MongoClient('mongodb://invalid/?timeoutMS=0&serverSelectionTimeoutMS=10'); + const start = now(); + const maybeError = await client + .db('test') + .admin() + .ping() + .then( + () => null, + e => e + ); + const end = now(); + + expect(maybeError).to.be.instanceof(MongoOperationTimeoutError); + expect(end - start).to.be.lte(15); }); - context( - "timeoutMS honored for connection handshake commands if it's lower than serverSelectionTimeoutMS", - () => { - /** - * This test MUST only be run if the server version is 4.4 or higher and the URI has authentication fields (i.e. a - * username and password). - * 1. Using `internalClient`, set the following fail point: - * ```js - * { - * configureFailPoint: failCommand, - * mode: { times: 1 }, - * data: { - * failCommands: ["saslContinue"], - * blockConnection: true, - * blockTimeMS: 15 - * } - * } - * ``` - * 1. Create a new MongoClient (referred to as `client`) with `timeoutMS=10` and `serverSelectionTimeoutMS=20`. - * 1. Using `client`, insert the document `{ x: 1 }` into collection `db.coll`. - * - Expect this to fail with a timeout error after no more than 15ms. - */ - } - ); + it.skip("timeoutMS honored for connection handshake commands if it's lower than serverSelectionTimeoutMS", async function () { + /** + * This test MUST only be run if the server version is 4.4 or higher and the URI has authentication fields (i.e. a + * username and password). + * 1. Using `internalClient`, set the following fail point: + * ```js + * { + * configureFailPoint: failCommand, + * mode: { times: 1 }, + * data: { + * failCommands: ["saslContinue"], + * blockConnection: true, + * blockTimeMS: 15 + * } + * } + * ``` + * 1. Create a new MongoClient (referred to as `client`) with `timeoutMS=10` and `serverSelectionTimeoutMS=20`. + * 1. Using `client`, insert the document `{ x: 1 }` into collection `db.coll`. + * - Expect this to fail with a timeout error after no more than 15ms. + */ + await internalClient + .db('db') + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: { times: 1 }, + data: { + failCommands: ['saslContinue'], + blockConnection: true, + blockTimeMS: 15 + } + }); - context( - "serverSelectionTimeoutMS honored for connection handshake commands if it's lower than timeoutMS", - () => { - /** - * This test MUST only be run if the server version is 4.4 or higher and the URI has authentication fields (i.e. a - * username and password). - * 1. Using `internalClient`, set the following fail point: - * ```js - * { - * configureFailPoint: failCommand, - * mode: { times: 1 }, - * data: { - * failCommands: ["saslContinue"], - * blockConnection: true, - * blockTimeMS: 15 - * } - * } - * ``` - * 1. Create a new MongoClient (referred to as `client`) with `timeoutMS=20` and `serverSelectionTimeoutMS=10`. - * 1. Using `client`, insert the document `{ x: 1 }` into collection `db.coll`. - * - Expect this to fail with a timeout error after no more than 15ms. - */ - } - ); + client = this.configuration.newClient({ + serverSelectionTimeoutMS: 20, + timeoutMS: 10 + }); + const start = now(); + const maybeError = await client + .db('db') + .collection('coll') + .insertOne({ x: 1 }) + .then( + () => null, + e => e + ); + const end = now(); + expect(maybeError).to.be.instanceof(MongoOperationTimeoutError); + expect(end - start).to.be.lte(15); + }).skipReason = + 'TODO(DRIVERS-2347): Requires this ticket to be implemented before we can assert on connection CSOT behaviour'; + + it.skip("serverSelectionTimeoutMS honored for connection handshake commands if it's lower than timeoutMS", async function () { + /** + * This test MUST only be run if the server version is 4.4 or higher and the URI has authentication fields (i.e. a + * username and password). + * 1. Using `internalClient`, set the following fail point: + * ```js + * { + * configureFailPoint: failCommand, + * mode: { times: 1 }, + * data: { + * failCommands: ["saslContinue"], + * blockConnection: true, + * blockTimeMS: 15 + * } + * } + * ``` + * 1. Create a new MongoClient (referred to as `client`) with `timeoutMS=20` and `serverSelectionTimeoutMS=10`. + * 1. Using `client`, insert the document `{ x: 1 }` into collection `db.coll`. + * - Expect this to fail with a timeout error after no more than 15ms. + */ + await internalClient + .db('db') + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: { times: 1 }, + data: { + failCommands: ['saslContinue'], + blockConnection: true, + blockTimeMS: 15 + } + }); + + client = this.configuration.newClient({ + serverSelectionTimeoutMS: 10, + timeoutMS: 20 + }); + const start = now(); + const maybeError = await client + .db('db') + .collection('coll') + .insertOne({ x: 1 }) + .then( + () => null, + e => e + ); + const end = now(); + expect(maybeError).to.be.instanceof(MongoOperationTimeoutError); + expect(end - start).to.be.lte(15); + }).skipReason = + 'TODO(DRIVERS-2347): Requires this ticket to be implemented before we can assert on connection CSOT behaviour'; }); - context('9. endSession', () => { + context.skip('9. endSession', () => { /** * This test MUST only be run against replica sets and sharded clusters with server version 4.4 or higher. It MUST be * run three times: once with the timeout specified via the MongoClient `timeoutMS` option, once with the timeout @@ -472,7 +623,7 @@ describe.skip('CSOT spec prose tests', () => { */ }); - context('10. Convenient Transactions', () => { + context.skip('10. Convenient Transactions', () => { /** Tests in this section MUST only run against replica sets and sharded clusters with server versions 4.4 or higher. */ context('timeoutMS is refreshed for abortTransaction if the callback fails', () => { diff --git a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts index cf9c5f736f..c1426d8db1 100644 --- a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts +++ b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts @@ -1,51 +1,105 @@ -/* eslint-disable @typescript-eslint/no-empty-function */ /** * The following tests are described in CSOTs spec prose tests as "unit" tests * The tests enumerated in this section could not be expressed in either spec or prose format. * Drivers SHOULD implement these if it is possible to do so using the driver's existing test infrastructure. */ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { ConnectionPool, type MongoClient, Timeout, Topology } from '../../mongodb'; + // TODO(NODE-5824): Implement CSOT prose tests -describe.skip('CSOT spec unit tests', () => { - context('Operations should ignore waitQueueTimeoutMS if timeoutMS is also set.', () => {}); - - context( - 'If timeoutMS is set for an operation, the remaining timeoutMS value should apply to connection checkout after a server has been selected.', - () => {} - ); - - context( - 'If timeoutMS is not set for an operation, waitQueueTimeoutMS should apply to connection checkout after a server has been selected.', - () => {} - ); - - context( - 'If a new connection is required to execute an operation, min(remaining computedServerSelectionTimeout, connectTimeoutMS) should apply to socket establishment.', - () => {} - ); - - context( - 'For drivers that have control over OCSP behavior, min(remaining computedServerSelectionTimeout, 5 seconds) should apply to HTTP requests against OCSP responders.', - () => {} - ); - - context( - 'If timeoutMS is unset, operations fail after two non-consecutive socket timeouts.', - () => {} - ); - - context( - 'The remaining timeoutMS value should apply to HTTP requests against KMS servers for CSFLE.', - () => {} - ); - - context( - 'The remaining timeoutMS value should apply to commands sent to mongocryptd as part of automatic encryption.', - () => {} - ); - - context( - 'When doing minPoolSize maintenance, connectTimeoutMS is used as the timeout for socket establishment.', - () => {} - ); +describe('CSOT spec unit tests', function () { + let client: MongoClient; + + afterEach(async function () { + sinon.restore(); + await client?.close(); + }); + + context('Server Selection and Connection Checkout', function () { + it('Operations should ignore waitQueueTimeoutMS if timeoutMS is also set.', async function () { + client = this.configuration.newClient({ waitQueueTimeoutMS: 999999, timeoutMS: 10000 }); + sinon.spy(Timeout, 'expires'); + + await client.db('db').collection('collection').insertOne({ x: 1 }); + + expect(Timeout.expires).to.have.been.calledWith(10000); + expect(Timeout.expires).to.not.have.been.calledWith(999999); + }); + + it('If timeoutMS is set for an operation, the remaining timeoutMS value should apply to connection checkout after a server has been selected.', async function () { + client = this.configuration.newClient({ timeoutMS: 1000 }); + // Spy on connection checkout and pull options argument + const checkoutSpy = sinon.spy(ConnectionPool.prototype, 'checkOut'); + const selectServerSpy = sinon.spy(Topology.prototype, 'selectServer'); + const expiresSpy = sinon.spy(Timeout, 'expires'); + + await client.db('db').collection('collection').insertOne({ x: 1 }); + + expect(checkoutSpy).to.have.been.calledOnce; + expect(checkoutSpy.firstCall.args[0].timeout).to.exist; + // Check that we passed through the timeout + expect(checkoutSpy.firstCall.args[0].timeout).to.equal( + selectServerSpy.lastCall.lastArg.timeout + ); + + // Check that no more Timeouts are constructed after we enter checkout + expect(!expiresSpy.calledAfter(checkoutSpy)); + }); + + it('If timeoutMS is not set for an operation, waitQueueTimeoutMS should apply to connection checkout after a server has been selected.', async function () { + client = this.configuration.newClient({ waitQueueTimeoutMS: 123456 }); + + const checkoutSpy = sinon.spy(ConnectionPool.prototype, 'checkOut'); + const selectServerSpy = sinon.spy(Topology.prototype, 'selectServer'); + const expiresSpy = sinon.spy(Timeout, 'expires'); + + await client.db('db').collection('collection').insertOne({ x: 1 }); + expect(checkoutSpy).to.have.been.calledAfter(selectServerSpy); + + expect(expiresSpy).to.have.been.calledWith(123456); + }); + + /* eslint-disable @typescript-eslint/no-empty-function */ + context.skip( + 'If a new connection is required to execute an operation, min(remaining computedServerSelectionTimeout, connectTimeoutMS) should apply to socket establishment.', + () => {} + ).skipReason = + 'TODO(DRIVERS-2347): Requires this ticket to be implemented before we can assert on connection CSOT behaviour'; + + context( + 'For drivers that have control over OCSP behavior, min(remaining computedServerSelectionTimeout, 5 seconds) should apply to HTTP requests against OCSP responders.', + () => {} + ); + }); + + context.skip('Socket timeouts', function () { + context( + 'If timeoutMS is unset, operations fail after two non-consecutive socket timeouts.', + () => {} + ); + }).skipReason = + 'TODO(NODE-5682): Add CSOT support for socket read/write at the connection layer for CRUD APIs'; + + context.skip('Client side encryption', function () { + context( + 'The remaining timeoutMS value should apply to HTTP requests against KMS servers for CSFLE.', + () => {} + ); + + context( + 'The remaining timeoutMS value should apply to commands sent to mongocryptd as part of automatic encryption.', + () => {} + ); + }).skipReason = 'TODO(NODE-5686): Add CSOT support to client side encryption'; + + context.skip('Background Connection Pooling', function () { + context( + 'When doing minPoolSize maintenance, connectTimeoutMS is used as the timeout for socket establishment.', + () => {} + ); + }).skipReason = 'TODO(NODE-6091): Implement CSOT logic for Background Connection Pooling'; + /* eslint-enable @typescript-eslint/no-empty-function */ }); 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 b6a936afbb..5636eb00db 100644 --- a/test/integration/client-side-operations-timeout/node_csot.test.ts +++ b/test/integration/client-side-operations-timeout/node_csot.test.ts @@ -7,7 +7,9 @@ import { type Collection, type Db, type FindCursor, - type MongoClient + LEGACY_HELLO_COMMAND, + type MongoClient, + MongoOperationTimeoutError } from '../../mongodb'; describe('CSOT driver tests', () => { @@ -94,4 +96,75 @@ describe('CSOT driver tests', () => { }); }); }); + + describe('autoconnect', () => { + let client: MongoClient; + + afterEach(async function () { + await client?.close(); + client = undefined; + }); + + describe('when failing autoconnect with timeoutMS defined', () => { + let configClient: MongoClient; + + beforeEach(async function () { + configClient = this.configuration.newClient(); + const result = await configClient + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: 'alwaysOn', + data: { + failCommands: ['ping', 'hello', LEGACY_HELLO_COMMAND], + blockConnection: true, + blockTimeMS: 10 + } + }); + expect(result).to.have.property('ok', 1); + }); + + afterEach(async function () { + const result = await configClient + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: 'off', + data: { + failCommands: ['ping', 'hello', LEGACY_HELLO_COMMAND], + blockConnection: true, + blockTimeMS: 10 + } + }); + expect(result).to.have.property('ok', 1); + await configClient.close(); + }); + + it('throws a MongoOperationTimeoutError', { + metadata: { requires: { mongodb: '>=4.4' } }, + test: async function () { + const commandsStarted = []; + client = this.configuration.newClient(undefined, { timeoutMS: 1, monitorCommands: true }); + + client.on('commandStarted', ev => commandsStarted.push(ev)); + + const maybeError = await client + .db('test') + .collection('test') + .insertOne({ a: 19 }) + .then( + () => null, + e => e + ); + + expect(maybeError).to.exist; + expect(maybeError).to.be.instanceof(MongoOperationTimeoutError); + + expect(commandsStarted).to.have.length(0); // Ensure that we fail before we start the insertOne + } + }); + }); + }); }); diff --git a/test/unit/cmap/connection_pool.test.js b/test/unit/cmap/connection_pool.test.js index 69102e1f15..18048befab 100644 --- a/test/unit/cmap/connection_pool.test.js +++ b/test/unit/cmap/connection_pool.test.js @@ -5,7 +5,7 @@ const { WaitQueueTimeoutError } = require('../../mongodb'); const mock = require('../../tools/mongodb-mock/index'); const sinon = require('sinon'); const { expect } = require('chai'); -const { setImmediate } = require('timers'); +const { setImmediate } = require('timers/promises'); const { ns, isHello } = require('../../mongodb'); const { createTimerSandbox } = require('../timer_sandbox'); const { topologyWithPlaceholderClient } = require('../../tools/utils'); @@ -26,6 +26,9 @@ describe('Connection Pool', function () { options: { extendedMetadata: {} } + }, + s: { + serverSelectionTimeoutMS: 0 } } }; @@ -98,7 +101,7 @@ describe('Connection Pool', function () { pool.checkIn(conn); }); - it('should clear timed out wait queue members if no connections are available', function (done) { + it('should clear timed out wait queue members if no connections are available', async function () { mockMongod.setMessageHandler(request => { const doc = request.document; if (isHello(doc)) { @@ -114,23 +117,15 @@ describe('Connection Pool', function () { pool.ready(); - pool.checkOut().then(conn => { - expect(conn).to.exist; - pool.checkOut().then(expect.fail, err => { - expect(err).to.exist.and.be.instanceOf(WaitQueueTimeoutError); - - // We can only process the wait queue with `checkIn` and `checkOut`, so we - // force the pool here to think there are no available connections, even though - // we are checking the connection back in. This simulates a slow leak where - // incoming requests outpace the ability of the queue to fully process cancelled - // wait queue members - sinon.stub(pool, 'availableConnectionCount').get(() => 0); - pool.checkIn(conn); - - setImmediate(() => expect(pool).property('waitQueueSize').to.equal(0)); - done(); - }); - }, expect.fail); + const conn = await pool.checkOut(); + const err = await pool.checkOut().catch(e => e); + expect(err).to.exist.and.be.instanceOf(WaitQueueTimeoutError); + sinon.stub(pool, 'availableConnectionCount').get(() => 0); + pool.checkIn(conn); + + await setImmediate(); + + expect(pool).property('waitQueueSize').to.equal(0); }); describe('minPoolSize population', function () { diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index 980747c8c7..2766c71771 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -107,6 +107,7 @@ const EXPECTED_EXPORTS = [ 'MongoTailableCursorError', 'MongoTopologyClosedError', 'MongoTransactionError', + 'MongoOperationTimeoutError', 'MongoUnexpectedServerResponseError', 'MongoWriteConcernError', 'WriteConcernErrorResult', From 909578fcf0b50753b9c43ca3826099dfc6643030 Mon Sep 17 00:00:00 2001 From: Warren James Date: Mon, 10 Jun 2024 10:46:02 -0400 Subject: [PATCH 02/39] test(NODE-6120): Implement Unified test runner changes for CSOT (#4121) --- test/spec/unified-test-format/Makefile | 37 +++++- .../collectionData-createOptions.yml | 7 +- .../valid-pass/createEntities-operation.json | 74 ++++++++++++ .../valid-pass/createEntities-operation.yml | 38 ++++++ .../valid-pass/entity-cursor-iterateOnce.json | 111 ++++++++++++++++++ .../valid-pass/entity-cursor-iterateOnce.yml | 59 ++++++++++ .../valid-pass/entity-find-cursor.json | 15 ++- .../valid-pass/entity-find-cursor.yml | 6 +- ...ectedEventsForClient-ignoreExtraEvents.yml | 2 +- .../valid-pass/matches-lte-operator.json | 78 ++++++++++++ .../valid-pass/matches-lte-operator.yml | 41 +++++++ .../valid-pass/poc-change-streams.json | 36 ++++++ .../valid-pass/poc-change-streams.yml | 18 +++ .../valid-pass/poc-crud.json | 2 +- .../valid-pass/poc-crud.yml | 2 +- .../valid-pass/poc-sessions.json | 2 +- .../valid-pass/poc-sessions.yml | 3 +- .../poc-transactions-convenient-api.json | 2 +- .../poc-transactions-convenient-api.yml | 2 +- .../poc-transactions-mongos-pin-auto.json | 2 +- .../poc-transactions-mongos-pin-auto.yml | 2 +- .../valid-pass/poc-transactions.json | 6 +- .../valid-pass/poc-transactions.yml | 6 +- test/tools/unified-spec-runner/match.ts | 32 ++++- test/tools/unified-spec-runner/schema.ts | 1 + 25 files changed, 547 insertions(+), 37 deletions(-) create mode 100644 test/spec/unified-test-format/valid-pass/createEntities-operation.json create mode 100644 test/spec/unified-test-format/valid-pass/createEntities-operation.yml create mode 100644 test/spec/unified-test-format/valid-pass/entity-cursor-iterateOnce.json create mode 100644 test/spec/unified-test-format/valid-pass/entity-cursor-iterateOnce.yml create mode 100644 test/spec/unified-test-format/valid-pass/matches-lte-operator.json create mode 100644 test/spec/unified-test-format/valid-pass/matches-lte-operator.yml diff --git a/test/spec/unified-test-format/Makefile b/test/spec/unified-test-format/Makefile index 9711d9eee0..a2b79e3f70 100644 --- a/test/spec/unified-test-format/Makefile +++ b/test/spec/unified-test-format/Makefile @@ -1,8 +1,8 @@ -SCHEMA=../schema-1.5.json +SCHEMA=../schema-1.21.json -.PHONY: all invalid valid-fail valid-pass versioned-api load-balancers gridfs transactions crud collection-management sessions command-monitoring HAS_AJV +.PHONY: all invalid valid-fail valid-pass atlas-data-lake versioned-api load-balancers gridfs transactions transactions-convenient-api crud collection-management read-write-concern retryable-reads retryable-writes sessions command-logging-and-monitoring client-side-operations-timeout HAS_AJV -all: invalid valid-fail valid-pass versioned-api load-balancers gridfs transactions crud collection-management sessions command-monitoring +all: invalid valid-fail valid-pass atlas-data-lake versioned-api load-balancers gridfs transactions transactions-convenient-api change-streams crud collection-management read-write-concern retryable-reads retryable-writes sessions command-logging-and-monitoring client-side-operations-timeout client-side-encryption invalid: HAS_AJV @# Redirect stdout to hide expected validation errors @@ -14,6 +14,9 @@ valid-fail: HAS_AJV valid-pass: HAS_AJV @ajv test -s $(SCHEMA) -d "valid-pass/*.yml" --valid +atlas-data-lake: HAS_AJV + @ajv test -s $(SCHEMA) -d "../../atlas-data-lake-testing/tests/unified/*.yml" --valid + versioned-api: HAS_AJV @ajv test -s $(SCHEMA) -d "../../versioned-api/tests/*.yml" --valid @@ -26,17 +29,39 @@ gridfs: HAS_AJV transactions: HAS_AJV @ajv test -s $(SCHEMA) -d "../../transactions/tests/unified/*.yml" --valid +transactions-convenient-api: HAS_AJV + @ajv test -s $(SCHEMA) -d "../../transactions-convenient-api/tests/unified/*.yml" --valid + +change-streams: HAS_AJV + @ajv test -s $(SCHEMA) -d "../../change-streams/tests/unified/*.yml" --valid + +client-side-operations-timeout: HAS_AJV + @ajv test -s $(SCHEMA) -d "../../client-side-operations-timeout/tests/*.yml" --valid + crud: HAS_AJV @ajv test -s $(SCHEMA) -d "../../crud/tests/unified/*.yml" --valid collection-management: HAS_AJV @ajv test -s $(SCHEMA) -d "../../collection-management/tests/*.yml" --valid +read-write-concern: HAS_AJV + @ajv test -s $(SCHEMA) -d "../../read-write-concern/tests/operation/*.yml" --valid + +retryable-reads: HAS_AJV + @ajv test -s $(SCHEMA) -d "../../retryable-reads/tests/unified/*.yml" --valid + +retryable-writes: HAS_AJV + @ajv test -s $(SCHEMA) -d "../../retryable-writes/tests/unified/*.yml" --valid + sessions: HAS_AJV - @ajv test -s $(SCHEMA) -d "../../sessions/tests/unified/*.yml" --valid + @ajv test -s $(SCHEMA) -d "../../sessions/tests/*.yml" --valid + +command-logging-and-monitoring: HAS_AJV + @ajv test -s $(SCHEMA) -d "../../command-logging-and-monitoring/tests/logging/*.yml" --valid + @ajv test -s $(SCHEMA) -d "../../command-logging-and-monitoring/tests/monitoring/*.yml" --valid -command-monitoring: HAS_AJV - @ajv test -s $(SCHEMA) -d "../../command-monitoring/tests/unified/*.yml" --valid +client-side-encryption: HAS_AJV + @ajv test -s $(SCHEMA) -d "../../client-side-encryption/tests/unified/*.yml" --valid HAS_AJV: @if ! command -v ajv > /dev/null; then \ diff --git a/test/spec/unified-test-format/valid-pass/collectionData-createOptions.yml b/test/spec/unified-test-format/valid-pass/collectionData-createOptions.yml index 3b1c0c3a41..c6afedcfa9 100644 --- a/test/spec/unified-test-format/valid-pass/collectionData-createOptions.yml +++ b/test/spec/unified-test-format/valid-pass/collectionData-createOptions.yml @@ -1,12 +1,9 @@ description: collectionData-createOptions - schemaVersion: "1.9" - runOnRequirements: - minServerVersion: "3.6" # Capped collections cannot be created on serverless instances. serverless: forbid - createEntities: - client: id: &client0 client0 @@ -18,7 +15,6 @@ createEntities: id: &collection0 collection0 database: *database0 collectionName: &collection0Name coll0 - initialData: - collectionName: *collection0Name databaseName: *database0Name @@ -28,7 +24,6 @@ initialData: size: &cappedSize 4096 documents: - { _id: 1, x: 11 } - tests: - description: collection is created with the correct options operations: @@ -39,4 +34,4 @@ tests: - $collStats: { storageStats: {} } - $project: { capped: '$storageStats.capped', maxSize: '$storageStats.maxSize'} expectResult: - - { capped: true, maxSize: *cappedSize } + - { capped: true, maxSize: *cappedSize } \ No newline at end of file diff --git a/test/spec/unified-test-format/valid-pass/createEntities-operation.json b/test/spec/unified-test-format/valid-pass/createEntities-operation.json new file mode 100644 index 0000000000..3fde42919d --- /dev/null +++ b/test/spec/unified-test-format/valid-pass/createEntities-operation.json @@ -0,0 +1,74 @@ +{ + "description": "createEntities-operation", + "schemaVersion": "1.9", + "tests": [ + { + "description": "createEntities operation", + "operations": [ + { + "name": "createEntities", + "object": "testRunner", + "arguments": { + "entities": [ + { + "client": { + "id": "client1", + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database1", + "client": "client1", + "databaseName": "database1" + } + }, + { + "collection": { + "id": "collection1", + "database": "database1", + "collectionName": "coll1" + } + } + ] + } + }, + { + "name": "deleteOne", + "object": "collection1", + "arguments": { + "filter": { + "_id": 1 + } + } + } + ], + "expectEvents": [ + { + "client": "client1", + "events": [ + { + "commandStartedEvent": { + "command": { + "delete": "coll1", + "deletes": [ + { + "q": { + "_id": 1 + }, + "limit": 1 + } + ] + }, + "commandName": "delete", + "databaseName": "database1" + } + } + ] + } + ] + } + ] +} diff --git a/test/spec/unified-test-format/valid-pass/createEntities-operation.yml b/test/spec/unified-test-format/valid-pass/createEntities-operation.yml new file mode 100644 index 0000000000..ee8acd7368 --- /dev/null +++ b/test/spec/unified-test-format/valid-pass/createEntities-operation.yml @@ -0,0 +1,38 @@ +description: createEntities-operation + +# Note: createEntities is not technically in the 1.9 schema but was introduced at the same time. +schemaVersion: "1.9" + +tests: + - description: createEntities operation + operations: + - name: createEntities + object: testRunner + arguments: + entities: + - client: + id: &client1 client1 + observeEvents: [ commandStartedEvent ] + - database: + id: &database1 database1 + client: *client1 + databaseName: &database1Name database1 + - collection: + id: &collection1 collection1 + database: *database1 + collectionName: &collection1Name coll1 + - name: deleteOne + object: *collection1 + arguments: + filter: { _id : 1 } + expectEvents: + - client: *client1 + events: + - commandStartedEvent: + command: + delete: *collection1Name + deletes: + - q: { _id: 1 } + limit: 1 + commandName: delete + databaseName: *database1Name diff --git a/test/spec/unified-test-format/valid-pass/entity-cursor-iterateOnce.json b/test/spec/unified-test-format/valid-pass/entity-cursor-iterateOnce.json new file mode 100644 index 0000000000..b17ae78b94 --- /dev/null +++ b/test/spec/unified-test-format/valid-pass/entity-cursor-iterateOnce.json @@ -0,0 +1,111 @@ +{ + "description": "entity-cursor-iterateOnce", + "schemaVersion": "1.9", + "createEntities": [ + { + "client": { + "id": "client0", + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "database0" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "coll0" + } + } + ], + "initialData": [ + { + "databaseName": "database0", + "collectionName": "coll0", + "documents": [ + { + "_id": 1 + }, + { + "_id": 2 + }, + { + "_id": 3 + } + ] + } + ], + "tests": [ + { + "description": "iterateOnce", + "operations": [ + { + "name": "createFindCursor", + "object": "collection0", + "arguments": { + "filter": {}, + "batchSize": 2 + }, + "saveResultAsEntity": "cursor0" + }, + { + "name": "iterateUntilDocumentOrError", + "object": "cursor0", + "expectResult": { + "_id": 1 + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "cursor0", + "expectResult": { + "_id": 2 + } + }, + { + "name": "iterateOnce", + "object": "cursor0" + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "coll0", + "filter": {}, + "batchSize": 2 + }, + "commandName": "find", + "databaseName": "database0" + } + }, + { + "commandStartedEvent": { + "command": { + "getMore": { + "$$type": [ + "int", + "long" + ] + }, + "collection": "coll0" + }, + "commandName": "getMore" + } + } + ] + } + ] + } + ] +} diff --git a/test/spec/unified-test-format/valid-pass/entity-cursor-iterateOnce.yml b/test/spec/unified-test-format/valid-pass/entity-cursor-iterateOnce.yml new file mode 100644 index 0000000000..508e594a53 --- /dev/null +++ b/test/spec/unified-test-format/valid-pass/entity-cursor-iterateOnce.yml @@ -0,0 +1,59 @@ +description: entity-cursor-iterateOnce + +# Note: iterateOnce is not technically in the 1.9 schema but was introduced at the same time. +schemaVersion: "1.9" + +createEntities: + - client: + id: &client0 client0 + observeEvents: [ commandStartedEvent ] + - database: + id: &database0 database0 + client: *client0 + databaseName: &database0Name database0 + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: &collection0Name coll0 + +initialData: + - databaseName: *database0Name + collectionName: *collection0Name + documents: + - _id: 1 + - _id: 2 + - _id: 3 + +tests: + - description: iterateOnce + operations: + - name: createFindCursor + object: *collection0 + arguments: + filter: {} + batchSize: 2 + saveResultAsEntity: &cursor0 cursor0 + - name: iterateUntilDocumentOrError + object: *cursor0 + expectResult: { _id: 1 } + - name: iterateUntilDocumentOrError + object: *cursor0 + expectResult: { _id: 2 } + # This operation could be iterateUntilDocumentOrError, but we use iterateOne to ensure that drivers support it. + - name: iterateOnce + object: *cursor0 + expectEvents: + - client: *client0 + events: + - commandStartedEvent: + command: + find: *collection0Name + filter: {} + batchSize: 2 + commandName: find + databaseName: *database0Name + - commandStartedEvent: + command: + getMore: { $$type: [ int, long ] } + collection: *collection0Name + commandName: getMore diff --git a/test/spec/unified-test-format/valid-pass/entity-find-cursor.json b/test/spec/unified-test-format/valid-pass/entity-find-cursor.json index 85b8f69d7f..6f955d81f4 100644 --- a/test/spec/unified-test-format/valid-pass/entity-find-cursor.json +++ b/test/spec/unified-test-format/valid-pass/entity-find-cursor.json @@ -109,7 +109,10 @@ "reply": { "cursor": { "id": { - "$$type": "long" + "$$type": [ + "int", + "long" + ] }, "ns": { "$$type": "string" @@ -126,7 +129,10 @@ "commandStartedEvent": { "command": { "getMore": { - "$$type": "long" + "$$type": [ + "int", + "long" + ] }, "collection": "coll0" }, @@ -138,7 +144,10 @@ "reply": { "cursor": { "id": { - "$$type": "long" + "$$type": [ + "int", + "long" + ] }, "ns": { "$$type": "string" diff --git a/test/spec/unified-test-format/valid-pass/entity-find-cursor.yml b/test/spec/unified-test-format/valid-pass/entity-find-cursor.yml index 61c9f8835a..3ecdf6da1d 100644 --- a/test/spec/unified-test-format/valid-pass/entity-find-cursor.yml +++ b/test/spec/unified-test-format/valid-pass/entity-find-cursor.yml @@ -61,19 +61,19 @@ tests: - commandSucceededEvent: reply: cursor: - id: { $$type: long } + id: { $$type: [ int, long ] } ns: { $$type: string } firstBatch: { $$type: array } commandName: find - commandStartedEvent: command: - getMore: { $$type: long } + getMore: { $$type: [ int, long ] } collection: *collection0Name commandName: getMore - commandSucceededEvent: reply: cursor: - id: { $$type: long } + id: { $$type: [ int, long ] } ns: { $$type: string } nextBatch: { $$type: array } commandName: getMore diff --git a/test/spec/unified-test-format/valid-pass/expectedEventsForClient-ignoreExtraEvents.yml b/test/spec/unified-test-format/valid-pass/expectedEventsForClient-ignoreExtraEvents.yml index 162d0e3c04..d6d87094f6 100644 --- a/test/spec/unified-test-format/valid-pass/expectedEventsForClient-ignoreExtraEvents.yml +++ b/test/spec/unified-test-format/valid-pass/expectedEventsForClient-ignoreExtraEvents.yml @@ -75,4 +75,4 @@ tests: insert: *collection0Name documents: - *insertDocument4 - commandName: insert + commandName: insert \ No newline at end of file diff --git a/test/spec/unified-test-format/valid-pass/matches-lte-operator.json b/test/spec/unified-test-format/valid-pass/matches-lte-operator.json new file mode 100644 index 0000000000..4de65c5838 --- /dev/null +++ b/test/spec/unified-test-format/valid-pass/matches-lte-operator.json @@ -0,0 +1,78 @@ +{ + "description": "matches-lte-operator", + "schemaVersion": "1.9", + "createEntities": [ + { + "client": { + "id": "client0", + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "database0Name" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "coll0" + } + } + ], + "initialData": [ + { + "collectionName": "coll0", + "databaseName": "database0Name", + "documents": [] + } + ], + "tests": [ + { + "description": "special lte matching operator", + "operations": [ + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "document": { + "_id": 1, + "y": 1 + } + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "coll0", + "documents": [ + { + "_id": { + "$$lte": 1 + }, + "y": { + "$$lte": 2 + } + } + ] + }, + "commandName": "insert", + "databaseName": "database0Name" + } + } + ] + } + ] + } + ] +} diff --git a/test/spec/unified-test-format/valid-pass/matches-lte-operator.yml b/test/spec/unified-test-format/valid-pass/matches-lte-operator.yml new file mode 100644 index 0000000000..4bec571f02 --- /dev/null +++ b/test/spec/unified-test-format/valid-pass/matches-lte-operator.yml @@ -0,0 +1,41 @@ +description: matches-lte-operator + +# Note: $$lte is not technically in the 1.8 schema but was introduced at the same time. +schemaVersion: "1.9" + +createEntities: + - client: + id: &client0 client0 + observeEvents: [ commandStartedEvent ] + - database: + id: &database0 database0 + client: *client0 + databaseName: &database0Name database0Name + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: &collection0Name coll0 + +initialData: + - collectionName: *collection0Name + databaseName: *database0Name + documents: [] + +tests: + - description: special lte matching operator + operations: + - name: insertOne + object: *collection0 + arguments: + document: { _id : 1, y: 1 } + expectEvents: + - client: *client0 + events: + - commandStartedEvent: + command: + insert: *collection0Name + documents: + # We can make exact assertions here but we use the $$lte operator to ensure drivers support it. + - { _id: { $$lte: 1 }, y: { $$lte: 2 } } + commandName: insert + databaseName: *database0Name diff --git a/test/spec/unified-test-format/valid-pass/poc-change-streams.json b/test/spec/unified-test-format/valid-pass/poc-change-streams.json index 4194005eb4..50f0d06f08 100644 --- a/test/spec/unified-test-format/valid-pass/poc-change-streams.json +++ b/test/spec/unified-test-format/valid-pass/poc-change-streams.json @@ -94,6 +94,42 @@ } ], "tests": [ + { + "description": "saveResultAsEntity is optional for createChangeStream", + "runOnRequirements": [ + { + "minServerVersion": "3.8.0", + "topologies": [ + "replicaset" + ] + } + ], + "operations": [ + { + "name": "createChangeStream", + "object": "client0", + "arguments": { + "pipeline": [] + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "aggregate": 1 + }, + "commandName": "aggregate", + "databaseName": "admin" + } + } + ] + } + ] + }, { "description": "Executing a watch helper on a MongoClient results in notifications for changes to all collections in all databases in the cluster.", "runOnRequirements": [ diff --git a/test/spec/unified-test-format/valid-pass/poc-change-streams.yml b/test/spec/unified-test-format/valid-pass/poc-change-streams.yml index b066cf0b89..a7daafceb7 100644 --- a/test/spec/unified-test-format/valid-pass/poc-change-streams.yml +++ b/test/spec/unified-test-format/valid-pass/poc-change-streams.yml @@ -59,6 +59,24 @@ initialData: documents: [] tests: + - description: "saveResultAsEntity is optional for createChangeStream" + runOnRequirements: + - minServerVersion: "3.8.0" + topologies: [ replicaset ] + operations: + - name: createChangeStream + object: *client0 + arguments: + pipeline: [] + expectEvents: + - client: *client0 + events: + - commandStartedEvent: + command: + aggregate: 1 + commandName: aggregate + databaseName: admin + - description: "Executing a watch helper on a MongoClient results in notifications for changes to all collections in all databases in the cluster." runOnRequirements: - minServerVersion: "3.8.0" diff --git a/test/spec/unified-test-format/valid-pass/poc-crud.json b/test/spec/unified-test-format/valid-pass/poc-crud.json index 0790d9b789..94e4ec5682 100644 --- a/test/spec/unified-test-format/valid-pass/poc-crud.json +++ b/test/spec/unified-test-format/valid-pass/poc-crud.json @@ -322,7 +322,7 @@ "minServerVersion": "4.1.0", "topologies": [ "replicaset", - "sharded-replicaset" + "sharded" ], "serverless": "forbid" } diff --git a/test/spec/unified-test-format/valid-pass/poc-crud.yml b/test/spec/unified-test-format/valid-pass/poc-crud.yml index b7d05d75af..5748c0779f 100644 --- a/test/spec/unified-test-format/valid-pass/poc-crud.yml +++ b/test/spec/unified-test-format/valid-pass/poc-crud.yml @@ -143,7 +143,7 @@ tests: - description: "readConcern majority with out stage" runOnRequirements: - minServerVersion: "4.1.0" - topologies: [ replicaset, sharded-replicaset ] + topologies: [ replicaset, sharded ] serverless: "forbid" operations: - name: aggregate diff --git a/test/spec/unified-test-format/valid-pass/poc-sessions.json b/test/spec/unified-test-format/valid-pass/poc-sessions.json index 75f3489428..117c9e7d00 100644 --- a/test/spec/unified-test-format/valid-pass/poc-sessions.json +++ b/test/spec/unified-test-format/valid-pass/poc-sessions.json @@ -264,7 +264,7 @@ { "minServerVersion": "4.1.8", "topologies": [ - "sharded-replicaset" + "sharded" ] } ], diff --git a/test/spec/unified-test-format/valid-pass/poc-sessions.yml b/test/spec/unified-test-format/valid-pass/poc-sessions.yml index cb16657da3..2090258328 100644 --- a/test/spec/unified-test-format/valid-pass/poc-sessions.yml +++ b/test/spec/unified-test-format/valid-pass/poc-sessions.yml @@ -124,12 +124,11 @@ tests: - description: "Dirty explicit session is discarded" # Original test specified retryWrites=true, but that is now the default. - # Retryable writes will require a sharded-replicaset, though. runOnRequirements: - minServerVersion: "4.0" topologies: [ replicaset ] - minServerVersion: "4.1.8" - topologies: [ sharded-replicaset ] + topologies: [ sharded ] operations: - name: failPoint object: testRunner diff --git a/test/spec/unified-test-format/valid-pass/poc-transactions-convenient-api.json b/test/spec/unified-test-format/valid-pass/poc-transactions-convenient-api.json index 820ed65927..9ab44a9c54 100644 --- a/test/spec/unified-test-format/valid-pass/poc-transactions-convenient-api.json +++ b/test/spec/unified-test-format/valid-pass/poc-transactions-convenient-api.json @@ -11,7 +11,7 @@ { "minServerVersion": "4.1.8", "topologies": [ - "sharded-replicaset" + "sharded" ] } ], diff --git a/test/spec/unified-test-format/valid-pass/poc-transactions-convenient-api.yml b/test/spec/unified-test-format/valid-pass/poc-transactions-convenient-api.yml index 4f981d15dd..94fadda0aa 100644 --- a/test/spec/unified-test-format/valid-pass/poc-transactions-convenient-api.yml +++ b/test/spec/unified-test-format/valid-pass/poc-transactions-convenient-api.yml @@ -6,7 +6,7 @@ runOnRequirements: - minServerVersion: "4.0" topologies: [ replicaset ] - minServerVersion: "4.1.8" - topologies: [ sharded-replicaset ] + topologies: [ sharded ] createEntities: - client: diff --git a/test/spec/unified-test-format/valid-pass/poc-transactions-mongos-pin-auto.json b/test/spec/unified-test-format/valid-pass/poc-transactions-mongos-pin-auto.json index a0b297d59a..de08edec44 100644 --- a/test/spec/unified-test-format/valid-pass/poc-transactions-mongos-pin-auto.json +++ b/test/spec/unified-test-format/valid-pass/poc-transactions-mongos-pin-auto.json @@ -5,7 +5,7 @@ { "minServerVersion": "4.1.8", "topologies": [ - "sharded-replicaset" + "sharded" ] } ], diff --git a/test/spec/unified-test-format/valid-pass/poc-transactions-mongos-pin-auto.yml b/test/spec/unified-test-format/valid-pass/poc-transactions-mongos-pin-auto.yml index 47db7c3188..33cd2a2521 100644 --- a/test/spec/unified-test-format/valid-pass/poc-transactions-mongos-pin-auto.yml +++ b/test/spec/unified-test-format/valid-pass/poc-transactions-mongos-pin-auto.yml @@ -4,7 +4,7 @@ schemaVersion: "1.0" runOnRequirements: - minServerVersion: "4.1.8" - topologies: [ sharded-replicaset ] + topologies: [ sharded ] createEntities: - client: diff --git a/test/spec/unified-test-format/valid-pass/poc-transactions.json b/test/spec/unified-test-format/valid-pass/poc-transactions.json index 0355ca2060..2055a3b705 100644 --- a/test/spec/unified-test-format/valid-pass/poc-transactions.json +++ b/test/spec/unified-test-format/valid-pass/poc-transactions.json @@ -11,7 +11,7 @@ { "minServerVersion": "4.1.8", "topologies": [ - "sharded-replicaset" + "sharded" ] } ], @@ -93,7 +93,7 @@ "minServerVersion": "4.3.4", "topologies": [ "replicaset", - "sharded-replicaset" + "sharded" ] } ], @@ -203,7 +203,7 @@ "minServerVersion": "4.3.4", "topologies": [ "replicaset", - "sharded-replicaset" + "sharded" ] } ], diff --git a/test/spec/unified-test-format/valid-pass/poc-transactions.yml b/test/spec/unified-test-format/valid-pass/poc-transactions.yml index 0a66b9bd7f..8a12c8b39a 100644 --- a/test/spec/unified-test-format/valid-pass/poc-transactions.yml +++ b/test/spec/unified-test-format/valid-pass/poc-transactions.yml @@ -6,7 +6,7 @@ runOnRequirements: - minServerVersion: "4.0" topologies: [ replicaset ] - minServerVersion: "4.1.8" - topologies: [ sharded-replicaset ] + topologies: [ sharded ] createEntities: - client: @@ -51,7 +51,7 @@ tests: - description: "explicitly create collection using create command" runOnRequirements: - minServerVersion: "4.3.4" - topologies: [ replicaset, sharded-replicaset ] + topologies: [ replicaset, sharded ] operations: - name: dropCollection object: *database0 @@ -109,7 +109,7 @@ tests: - description: "create index on a non-existing collection" runOnRequirements: - minServerVersion: "4.3.4" - topologies: [ replicaset, sharded-replicaset ] + topologies: [ replicaset, sharded ] operations: - name: dropCollection object: *database0 diff --git a/test/tools/unified-spec-runner/match.ts b/test/tools/unified-spec-runner/match.ts index 3a4d4e5e3d..32f6870d99 100644 --- a/test/tools/unified-spec-runner/match.ts +++ b/test/tools/unified-spec-runner/match.ts @@ -24,6 +24,7 @@ import { Long, MongoBulkWriteError, MongoError, + MongoOperationTimeoutError, MongoServerError, ObjectId, type OneOrMore, @@ -97,6 +98,19 @@ export function isMatchAsRootOperator(value: unknown): value is MatchAsRootOpera return typeof value === 'object' && value != null && '$$matchAsRoot' in value; } +export interface LteOperator { + $$lte: number; +} + +export function isLteOperator(value: unknown): value is LteOperator { + return ( + typeof value === 'object' && + value != null && + '$$lte' in value && + typeof value['$$lte'] === 'number' + ); +} + export const SpecialOperatorKeys = [ '$$exists', '$$type', @@ -105,7 +119,8 @@ export const SpecialOperatorKeys = [ '$$matchAsRoot', '$$matchAsDocument', '$$unsetOrMatches', - '$$sessionLsid' + '$$sessionLsid', + '$$lte' ]; export type SpecialOperator = @@ -116,7 +131,8 @@ export type SpecialOperator = | UnsetOrMatchesOperator | SessionLsidOperator | MatchAsDocumentOperator - | MatchAsRootOperator; + | MatchAsRootOperator + | LteOperator; type KeysOfUnion = T extends object ? keyof T : never; export type SpecialOperatorKey = KeysOfUnion; @@ -129,7 +145,8 @@ export function isSpecialOperator(value: unknown): value is SpecialOperator { isUnsetOrMatchesOperator(value) || isSessionLsidOperator(value) || isMatchAsRootOperator(value) || - isMatchAsDocumentOperator(value) + isMatchAsDocumentOperator(value) || + isLteOperator(value) ); } @@ -378,6 +395,9 @@ export function specialCheck( ); resultCheck(actual, expected.$$matchAsRoot as any, entities, path, false); + } else if (isLteOperator(expected)) { + expect(typeof actual).to.equal('number'); + expect(actual).to.be.lte(expected.$$lte); } else { expect.fail(`Unknown special operator: ${JSON.stringify(expected)}`); } @@ -747,6 +767,12 @@ export function expectErrorCheck( } } + 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/schema.ts b/test/tools/unified-spec-runner/schema.ts index 81b8172463..ce722b2e70 100644 --- a/test/tools/unified-spec-runner/schema.ts +++ b/test/tools/unified-spec-runner/schema.ts @@ -386,6 +386,7 @@ export interface StoreEventsAsEntity { } export interface ExpectedError { isError?: true; + isTimeoutError?: boolean; isClientError?: boolean; errorContains?: string; errorCode?: number; From e1017502b1b0d07f8f3c7455cc70bc68eaa785b9 Mon Sep 17 00:00:00 2001 From: Warren James Date: Fri, 21 Jun 2024 12:06:30 -0400 Subject: [PATCH 03/39] refactor(NODE-6187): refactor to use TimeoutContext abstraction (#4131) --- src/bulk/common.ts | 4 + src/cmap/connection.ts | 4 +- src/cmap/connection_pool.ts | 39 +--- src/index.ts | 18 +- src/operations/aggregate.ts | 5 +- src/operations/bulk_write.ts | 11 +- src/operations/command.ts | 8 +- src/operations/count.ts | 9 +- src/operations/create_collection.ts | 18 +- src/operations/delete.ts | 21 +- src/operations/distinct.ts | 9 +- src/operations/drop.ts | 24 ++- src/operations/estimated_document_count.ts | 9 +- src/operations/execute_operation.ts | 16 +- src/operations/find.ts | 6 +- src/operations/find_and_modify.ts | 9 +- src/operations/get_more.ts | 5 +- src/operations/indexes.ts | 22 +- src/operations/insert.ts | 19 +- src/operations/kill_cursors.ts | 12 +- src/operations/list_collections.ts | 5 +- src/operations/list_databases.ts | 11 +- src/operations/operation.ts | 10 +- src/operations/profiling_level.ts | 9 +- src/operations/remove_user.ts | 9 +- src/operations/rename.ts | 9 +- src/operations/run_command.ts | 17 +- src/operations/search_indexes/create.ts | 12 +- src/operations/search_indexes/drop.ts | 9 +- src/operations/search_indexes/update.ts | 9 +- src/operations/set_profiling_level.ts | 6 +- src/operations/stats.ts | 9 +- src/operations/update.ts | 24 ++- src/operations/validate_collection.ts | 9 +- src/sdam/server.ts | 12 +- src/sdam/topology.ts | 55 +++-- src/timeout.ts | 166 +++++++++++++- ...lient_side_operations_timeout.unit.test.ts | 12 +- .../node_csot.test.ts | 2 +- test/tools/cmap_spec_runner.ts | 12 +- test/unit/cmap/connection_pool.test.js | 22 +- test/unit/error.test.ts | 19 +- test/unit/operations/get_more.test.ts | 2 +- test/unit/sdam/topology.test.ts | 76 +++++-- test/unit/timeout.test.ts | 204 +++++++++++++++++- 45 files changed, 796 insertions(+), 202 deletions(-) diff --git a/src/bulk/common.ts b/src/bulk/common.ts index c133a57d22..9eb6338244 100644 --- a/src/bulk/common.ts +++ b/src/bulk/common.ts @@ -20,6 +20,7 @@ import { makeUpdateStatement, UpdateOperation, type UpdateStatement } from '../o import type { Server } from '../sdam/server'; import type { Topology } from '../sdam/topology'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { applyRetryableWrites, type Callback, @@ -873,6 +874,9 @@ export interface BulkWriteOptions extends CommandOperationOptions { forceServerObjectId?: boolean; /** Map of parameter names and values that can be accessed using $$var (requires MongoDB 5.0). */ let?: Document; + + /** @internal */ + timeoutContext?: TimeoutContext; } const executeCommandsAsync = promisify(executeCommands); diff --git a/src/cmap/connection.ts b/src/cmap/connection.ts index 202be1e48a..9defa144f3 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -30,7 +30,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 Timeout } from '../timeout'; +import { type TimeoutContext } from '../timeout'; import { BufferPool, calculateDurationInMs, @@ -97,7 +97,7 @@ export interface CommandOptions extends BSONSerializeOptions { directConnection?: boolean; /** @internal */ - timeout?: Timeout; + timeoutContext?: TimeoutContext; } /** @public */ diff --git a/src/cmap/connection_pool.ts b/src/cmap/connection_pool.ts index 79440db1e0..5369cc155a 100644 --- a/src/cmap/connection_pool.ts +++ b/src/cmap/connection_pool.ts @@ -27,8 +27,8 @@ import { } from '../error'; import { CancellationToken, TypedEventEmitter } from '../mongo_types'; import type { Server } from '../sdam/server'; -import { Timeout, TimeoutError } from '../timeout'; -import { type Callback, csotMin, List, makeCounter, promiseWithResolvers } from '../utils'; +import { type TimeoutContext, TimeoutError } from '../timeout'; +import { type Callback, List, makeCounter, promiseWithResolvers } from '../utils'; import { connect } from './connect'; import { Connection, type ConnectionEvents, type ConnectionOptions } from './connection'; import { @@ -355,41 +355,15 @@ export class ConnectionPool extends TypedEventEmitter { * will be held by the pool. This means that if a connection is checked out it MUST be checked back in or * explicitly destroyed by the new owner. */ - async checkOut(options?: { timeout?: Timeout }): Promise { + async checkOut(options: { timeoutContext: TimeoutContext }): Promise { this.emitAndLog( ConnectionPool.CONNECTION_CHECK_OUT_STARTED, new ConnectionCheckOutStartedEvent(this) ); - const waitQueueTimeoutMS = this.options.waitQueueTimeoutMS; - const serverSelectionTimeoutMS = this[kServer].topology.s.serverSelectionTimeoutMS; - const { promise, resolve, reject } = promiseWithResolvers(); - let timeout: Timeout | null = null; - if (options?.timeout) { - // CSOT enabled - // Determine if we're using the timeout passed in or a new timeout - if (options.timeout.duration > 0 || serverSelectionTimeoutMS > 0) { - // This check determines whether or not Topology.selectServer used the configured - // `timeoutMS` or `serverSelectionTimeoutMS` value for its timeout - if ( - options.timeout.duration === serverSelectionTimeoutMS || - csotMin(options.timeout.duration, serverSelectionTimeoutMS) < serverSelectionTimeoutMS - ) { - // server selection used `timeoutMS`, so we should use the existing timeout as the timeout - // here - timeout = options.timeout; - } else { - // server selection used `serverSelectionTimeoutMS`, so we construct a new timeout with - // the time remaining to ensure that Topology.selectServer and ConnectionPool.checkOut - // cumulatively don't spend more than `serverSelectionTimeoutMS` blocking - timeout = Timeout.expires(serverSelectionTimeoutMS - options.timeout.timeElapsed); - } - } - } else { - timeout = Timeout.expires(waitQueueTimeoutMS); - } + const timeout = options.timeoutContext.connectionCheckoutTimeout; const waitQueueMember: WaitQueueMember = { resolve, @@ -404,6 +378,7 @@ export class ConnectionPool extends TypedEventEmitter { return await (timeout ? Promise.race([promise, timeout]) : promise); } catch (error) { if (TimeoutError.is(error)) { + timeout?.clear(); waitQueueMember[kCancelled] = true; this.emitAndLog( @@ -416,7 +391,7 @@ export class ConnectionPool extends TypedEventEmitter { : 'Timed out while checking out a connection from connection pool', this.address ); - if (options?.timeout) { + if (options.timeoutContext.csotEnabled()) { throw new MongoOperationTimeoutError('Timed out during connection checkout', { cause: timeoutError }); @@ -425,7 +400,7 @@ export class ConnectionPool extends TypedEventEmitter { } throw error; } finally { - if (timeout !== options?.timeout) timeout?.clear(); + if (options.timeoutContext.clearConnectionCheckoutTimeout) timeout?.clear(); } } diff --git a/src/index.ts b/src/index.ts index 0134027a5f..31ef5720e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -542,7 +542,13 @@ export type { RTTSampler, ServerMonitoringMode } from './sdam/monitor'; -export type { Server, ServerEvents, ServerOptions, ServerPrivate } from './sdam/server'; +export type { + Server, + ServerCommandOptions, + ServerEvents, + ServerOptions, + ServerPrivate +} from './sdam/server'; export type { ServerDescription, ServerDescriptionOptions, @@ -573,7 +579,15 @@ export type { WithTransactionCallback } from './sessions'; export type { Sort, SortDirection, SortDirectionForCmd, SortForCmd } from './sort'; -export type { Timeout } from './timeout'; +export type { + CSOTTimeoutContext, + CSOTTimeoutContextOptions, + LegacyTimeoutContext, + LegacyTimeoutContextOptions, + Timeout, + TimeoutContext, + TimeoutContextOptions +} from './timeout'; export type { Transaction, TransactionOptions, TxnState } from './transactions'; export type { BufferPool, diff --git a/src/operations/aggregate.ts b/src/operations/aggregate.ts index a5a267ac3e..50494cbba7 100644 --- a/src/operations/aggregate.ts +++ b/src/operations/aggregate.ts @@ -3,6 +3,7 @@ import { CursorResponse, ExplainedCursorResponse } from '../cmap/wire_protocol/r import { MongoInvalidArgumentError } from '../error'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { maxWireVersion, type MongoDBNamespace } from '../utils'; import { WriteConcern } from '../write_concern'; import { type CollationOptions, CommandOperation, type CommandOperationOptions } from './command'; @@ -97,7 +98,8 @@ export class AggregateOperation extends CommandOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { const options: AggregateOptions = this.options; const serverWireVersion = maxWireVersion(server); @@ -142,6 +144,7 @@ export class AggregateOperation extends CommandOperation { server, session, command, + timeoutContext, this.explain ? ExplainedCursorResponse : CursorResponse ); } diff --git a/src/operations/bulk_write.ts b/src/operations/bulk_write.ts index 0a855644f0..55b61ef73b 100644 --- a/src/operations/bulk_write.ts +++ b/src/operations/bulk_write.ts @@ -7,6 +7,7 @@ import type { import type { Collection } from '../collection'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { AbstractOperation, Aspect, defineAspects } from './operation'; /** @internal */ @@ -32,11 +33,17 @@ export class BulkWriteOperation extends AbstractOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { const coll = this.collection; const operations = this.operations; - const options = { ...this.options, ...this.bsonOptions, readPreference: this.readPreference }; + const options = { + ...this.options, + ...this.bsonOptions, + readPreference: this.readPreference, + timeoutContext + }; // Create the bulk operation const bulk: BulkOperationBase = diff --git a/src/operations/command.ts b/src/operations/command.ts index c64b4ae963..5bd80f796d 100644 --- a/src/operations/command.ts +++ b/src/operations/command.ts @@ -7,6 +7,7 @@ import type { ReadPreference } from '../read_preference'; import type { Server } from '../sdam/server'; import { MIN_SECONDARY_WRITE_WIRE_VERSION } from '../sdam/server_selection'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { commandSupportsReadConcern, decorateWithExplain, @@ -112,19 +113,22 @@ export abstract class CommandOperation extends AbstractOperation { server: Server, session: ClientSession | undefined, cmd: Document, + timeoutContext: TimeoutContext, responseType: T | undefined ): Promise>; public async executeCommand( server: Server, session: ClientSession | undefined, - cmd: Document + cmd: Document, + timeoutContext: TimeoutContext ): Promise; async executeCommand( server: Server, session: ClientSession | undefined, cmd: Document, + timeoutContext: TimeoutContext, responseType?: MongoDBResponseConstructor ): Promise { this.server = server; @@ -132,7 +136,7 @@ export abstract class CommandOperation extends AbstractOperation { const options = { ...this.options, ...this.bsonOptions, - timeout: this.timeout, + timeoutContext, readPreference: this.readPreference, session }; diff --git a/src/operations/count.ts b/src/operations/count.ts index 00aae50172..82330a11e7 100644 --- a/src/operations/count.ts +++ b/src/operations/count.ts @@ -2,6 +2,7 @@ import type { Document } from '../bson'; import type { Collection } from '../collection'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import type { MongoDBNamespace } from '../utils'; import { CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects } from './operation'; @@ -36,7 +37,11 @@ export class CountOperation extends CommandOperation { return 'count' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const options = this.options; const cmd: Document = { count: this.collectionName, @@ -59,7 +64,7 @@ export class CountOperation extends CommandOperation { cmd.maxTimeMS = options.maxTimeMS; } - const result = await super.executeCommand(server, session, cmd); + const result = await super.executeCommand(server, session, cmd, timeoutContext); return result ? result.n : 0; } } diff --git a/src/operations/create_collection.ts b/src/operations/create_collection.ts index 8edc7e9a1c..afb2680b9a 100644 --- a/src/operations/create_collection.ts +++ b/src/operations/create_collection.ts @@ -9,6 +9,7 @@ import { MongoCompatibilityError } from '../error'; import type { PkFactory } from '../mongo_client'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { CommandOperation, type CommandOperationOptions } from './command'; import { CreateIndexesOperation } from './indexes'; import { Aspect, defineAspects } from './operation'; @@ -124,7 +125,11 @@ export class CreateCollectionOperation extends CommandOperation { return 'create' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const db = this.db; const name = this.name; const options = this.options; @@ -155,7 +160,7 @@ export class CreateCollectionOperation extends CommandOperation { unique: true } }); - await createOp.executeWithoutEncryptedFieldsCheck(server, session); + await createOp.executeWithoutEncryptedFieldsCheck(server, session, timeoutContext); } if (!options.encryptedFields) { @@ -163,7 +168,7 @@ export class CreateCollectionOperation extends CommandOperation { } } - const coll = await this.executeWithoutEncryptedFieldsCheck(server, session); + const coll = await this.executeWithoutEncryptedFieldsCheck(server, session, timeoutContext); if (encryptedFields) { // Create the required index for queryable encryption support. @@ -173,7 +178,7 @@ export class CreateCollectionOperation extends CommandOperation { { __safeContent__: 1 }, {} ); - await createIndexOp.execute(server, session); + await createIndexOp.execute(server, session, timeoutContext); } return coll; @@ -181,7 +186,8 @@ export class CreateCollectionOperation extends CommandOperation { private async executeWithoutEncryptedFieldsCheck( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { const db = this.db; const name = this.name; @@ -198,7 +204,7 @@ export class CreateCollectionOperation extends CommandOperation { } } // otherwise just execute the command - await super.executeCommand(server, session, cmd); + await super.executeCommand(server, session, cmd, timeoutContext); return new Collection(db, name, options); } } diff --git a/src/operations/delete.ts b/src/operations/delete.ts index f0ef61cb7b..0e93ead36a 100644 --- a/src/operations/delete.ts +++ b/src/operations/delete.ts @@ -4,6 +4,7 @@ import { MongoCompatibilityError, MongoServerError } from '../error'; import { type TODO_NODE_3286 } from '../mongo_types'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { type MongoDBNamespace } from '../utils'; import { type WriteConcernOptions } from '../write_concern'; import { type CollationOptions, CommandOperation, type CommandOperationOptions } from './command'; @@ -67,7 +68,8 @@ export class DeleteOperation extends CommandOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { const options = this.options ?? {}; const ordered = typeof options.ordered === 'boolean' ? options.ordered : true; @@ -95,7 +97,12 @@ export class DeleteOperation extends CommandOperation { } } - const res: TODO_NODE_3286 = await super.executeCommand(server, session, command); + const res: TODO_NODE_3286 = await super.executeCommand( + server, + session, + command, + timeoutContext + ); return res; } } @@ -107,9 +114,10 @@ export class DeleteOneOperation extends DeleteOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { - const res: TODO_NODE_3286 = await super.execute(server, session); + const res: TODO_NODE_3286 = await super.execute(server, session, timeoutContext); if (this.explain) return res; if (res.code) throw new MongoServerError(res); if (res.writeErrors) throw new MongoServerError(res.writeErrors[0]); @@ -127,9 +135,10 @@ export class DeleteManyOperation extends DeleteOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { - const res: TODO_NODE_3286 = await super.execute(server, session); + const res: TODO_NODE_3286 = await super.execute(server, session, timeoutContext); if (this.explain) return res; if (res.code) throw new MongoServerError(res); if (res.writeErrors) throw new MongoServerError(res.writeErrors[0]); diff --git a/src/operations/distinct.ts b/src/operations/distinct.ts index 4fda285d88..51f2a362d8 100644 --- a/src/operations/distinct.ts +++ b/src/operations/distinct.ts @@ -2,6 +2,7 @@ import type { Document } from '../bson'; import type { Collection } from '../collection'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { decorateWithCollation, decorateWithReadConcern } from '../utils'; import { CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects } from './operation'; @@ -42,7 +43,11 @@ export class DistinctOperation extends CommandOperation { return 'distinct' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const coll = this.collection; const key = this.key; const query = this.query; @@ -72,7 +77,7 @@ export class DistinctOperation extends CommandOperation { // Have we specified collation decorateWithCollation(cmd, coll, options); - const result = await super.executeCommand(server, session, cmd); + const result = await super.executeCommand(server, session, cmd, timeoutContext); return this.explain ? result : result.values; } diff --git a/src/operations/drop.ts b/src/operations/drop.ts index 15624d4c07..787bb6e7d0 100644 --- a/src/operations/drop.ts +++ b/src/operations/drop.ts @@ -3,6 +3,7 @@ import type { Db } from '../db'; import { MONGODB_ERROR_CODES, MongoServerError } from '../error'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects } from './operation'; @@ -29,7 +30,11 @@ export class DropCollectionOperation extends CommandOperation { return 'drop' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const db = this.db; const options = this.options; const name = this.name; @@ -57,7 +62,7 @@ export class DropCollectionOperation extends CommandOperation { // Drop auxilliary collections, ignoring potential NamespaceNotFound errors. const dropOp = new DropCollectionOperation(db, collectionName); try { - await dropOp.executeWithoutEncryptedFieldsCheck(server, session); + await dropOp.executeWithoutEncryptedFieldsCheck(server, session, timeoutContext); } catch (err) { if ( !(err instanceof MongoServerError) || @@ -69,14 +74,15 @@ export class DropCollectionOperation extends CommandOperation { } } - return await this.executeWithoutEncryptedFieldsCheck(server, session); + return await this.executeWithoutEncryptedFieldsCheck(server, session, timeoutContext); } private async executeWithoutEncryptedFieldsCheck( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { - await super.executeCommand(server, session, { drop: this.name }); + await super.executeCommand(server, session, { drop: this.name }, timeoutContext); return true; } } @@ -96,8 +102,12 @@ export class DropDatabaseOperation extends CommandOperation { return 'dropDatabase' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { - await super.executeCommand(server, session, { dropDatabase: 1 }); + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { + await super.executeCommand(server, session, { dropDatabase: 1 }, timeoutContext); return true; } } diff --git a/src/operations/estimated_document_count.ts b/src/operations/estimated_document_count.ts index c1d6c38199..5ab5aa4c30 100644 --- a/src/operations/estimated_document_count.ts +++ b/src/operations/estimated_document_count.ts @@ -2,6 +2,7 @@ import type { Document } from '../bson'; import type { Collection } from '../collection'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects } from './operation'; @@ -30,7 +31,11 @@ export class EstimatedDocumentCountOperation extends CommandOperation { return 'count' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const cmd: Document = { count: this.collectionName }; if (typeof this.options.maxTimeMS === 'number') { @@ -43,7 +48,7 @@ export class EstimatedDocumentCountOperation extends CommandOperation { cmd.comment = this.options.comment; } - const response = await super.executeCommand(server, session, cmd); + const response = await super.executeCommand(server, session, cmd, timeoutContext); return response?.n || 0; } diff --git a/src/operations/execute_operation.ts b/src/operations/execute_operation.ts index 97e6045073..39937c8abf 100644 --- a/src/operations/execute_operation.ts +++ b/src/operations/execute_operation.ts @@ -24,7 +24,8 @@ import { } from '../sdam/server_selection'; import type { Topology } from '../sdam/topology'; import type { ClientSession } from '../sessions'; -import { supportsRetryableWrites } from '../utils'; +import { TimeoutContext } from '../timeout'; +import { squashError, supportsRetryableWrites } from '../utils'; import { AbstractOperation, Aspect } from './operation'; const MMAPv1_RETRY_WRITES_ERROR_CODE = MONGODB_ERROR_CODES.IllegalOperation; @@ -57,7 +58,7 @@ type ResultTypeFromOperation = export async function executeOperation< T extends AbstractOperation, TResult = ResultTypeFromOperation ->(client: MongoClient, operation: T): Promise { +>(client: MongoClient, operation: T, timeoutContext?: TimeoutContext): Promise { if (!(operation instanceof AbstractOperation)) { // TODO(NODE-3483): Extend MongoRuntimeError throw new MongoRuntimeError('This method requires a valid operation instance'); @@ -86,6 +87,12 @@ export async function executeOperation< ); } + timeoutContext ??= TimeoutContext.create({ + serverSelectionTimeoutMS: client.s.options.serverSelectionTimeoutMS, + waitQueueTimeoutMS: client.s.options.waitQueueTimeoutMS, + timeoutMS: operation.options.timeoutMS + }); + const readPreference = operation.readPreference ?? ReadPreference.primary; const inTransaction = !!session?.inTransaction(); @@ -109,7 +116,8 @@ export async function executeOperation< return await tryOperation(operation, { topology, session, - readPreference + readPreference, + timeoutContext }); } finally { if (session?.owner != null && session.owner === owner) { @@ -260,7 +268,7 @@ async function tryOperation< } try { - return await operation.execute(server, session); + return await operation.execute(server, session, timeoutContext); } catch (operationError) { if (!(operationError instanceof MongoError)) throw operationError; diff --git a/src/operations/find.ts b/src/operations/find.ts index 0f81f2d61f..5f359324d5 100644 --- a/src/operations/find.ts +++ b/src/operations/find.ts @@ -5,6 +5,7 @@ import { ReadConcern } from '../read_concern'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; import { formatSort, type Sort } from '../sort'; +import { type TimeoutContext } from '../timeout'; import { decorateWithExplain, type MongoDBNamespace, normalizeHintField } from '../utils'; import { type CollationOptions, CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects, type Hint } from './operation'; @@ -98,7 +99,8 @@ export class FindOperation extends CommandOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { this.server = server; @@ -117,7 +119,7 @@ export class FindOperation extends CommandOperation { ...this.bsonOptions, documentsReturnedIn: 'firstBatch', session, - timeout: this.timeout + timeoutContext }, this.explain ? ExplainedCursorResponse : CursorResponse ); diff --git a/src/operations/find_and_modify.ts b/src/operations/find_and_modify.ts index 92b17e93b3..651bcccb62 100644 --- a/src/operations/find_and_modify.ts +++ b/src/operations/find_and_modify.ts @@ -5,6 +5,7 @@ import { ReadPreference } from '../read_preference'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; import { formatSort, type Sort, type SortForCmd } from '../sort'; +import { type TimeoutContext } from '../timeout'; import { decorateWithCollation, hasAtomicOperators, maxWireVersion } from '../utils'; import { type WriteConcern, type WriteConcernSettings } from '../write_concern'; import { CommandOperation, type CommandOperationOptions } from './command'; @@ -180,7 +181,11 @@ export class FindAndModifyOperation extends CommandOperation { return 'findAndModify' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const coll = this.collection; const query = this.query; const options = { ...this.options, ...this.bsonOptions }; @@ -208,7 +213,7 @@ export class FindAndModifyOperation extends CommandOperation { } // Execute the command - const result = await super.executeCommand(server, session, cmd); + const result = await super.executeCommand(server, session, cmd, timeoutContext); return options.includeResultMetadata ? result : (result.value ?? null); } } diff --git a/src/operations/get_more.ts b/src/operations/get_more.ts index aa550721b6..34317d533b 100644 --- a/src/operations/get_more.ts +++ b/src/operations/get_more.ts @@ -3,6 +3,7 @@ import { CursorResponse } from '../cmap/wire_protocol/responses'; import { MongoRuntimeError } from '../error'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { maxWireVersion, type MongoDBNamespace } from '../utils'; import { AbstractOperation, Aspect, defineAspects, type OperationOptions } from './operation'; @@ -58,7 +59,8 @@ export class GetMoreOperation extends AbstractOperation { */ override async execute( server: Server, - _session: ClientSession | undefined + _session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { if (server !== this.server) { throw new MongoRuntimeError('Getmore must run on the same server operation began on'); @@ -97,6 +99,7 @@ export class GetMoreOperation extends AbstractOperation { const commandOptions = { returnFieldSelector: null, documentsReturnedIn: 'nextBatch', + timeoutContext, ...this.options }; diff --git a/src/operations/indexes.ts b/src/operations/indexes.ts index fda3fa80dd..c96a5d7345 100644 --- a/src/operations/indexes.ts +++ b/src/operations/indexes.ts @@ -6,6 +6,7 @@ import { MongoCompatibilityError } from '../error'; import { type OneOrMore } from '../mongo_types'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { isObject, maxWireVersion, type MongoDBNamespace } from '../utils'; import { type CollationOptions, @@ -296,7 +297,11 @@ export class CreateIndexesOperation extends CommandOperation { return 'createIndexes'; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const options = this.options; const indexes = this.indexes; @@ -316,7 +321,7 @@ export class CreateIndexesOperation extends CommandOperation { // collation is set on each index, it should not be defined at the root this.options.collation = undefined; - await super.executeCommand(server, session, cmd); + await super.executeCommand(server, session, cmd, timeoutContext); const indexNames = indexes.map(index => index.name || ''); return indexNames; @@ -344,9 +349,13 @@ export class DropIndexOperation extends CommandOperation { return 'dropIndexes' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const cmd = { dropIndexes: this.collection.collectionName, index: this.indexName }; - return await super.executeCommand(server, session, cmd); + return await super.executeCommand(server, session, cmd, timeoutContext); } } @@ -379,7 +388,8 @@ export class ListIndexesOperation extends CommandOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { const serverWireVersion = maxWireVersion(server); @@ -393,7 +403,7 @@ export class ListIndexesOperation extends CommandOperation { command.comment = this.options.comment; } - return await super.executeCommand(server, session, command, CursorResponse); + return await super.executeCommand(server, session, command, timeoutContext, CursorResponse); } } diff --git a/src/operations/insert.ts b/src/operations/insert.ts index 35a050ed1c..1a40763e31 100644 --- a/src/operations/insert.ts +++ b/src/operations/insert.ts @@ -5,6 +5,7 @@ import { MongoInvalidArgumentError, MongoServerError } from '../error'; import type { InferIdType } from '../mongo_types'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { maybeAddIdToDocuments, type MongoDBNamespace } from '../utils'; import { WriteConcern } from '../write_concern'; import { BulkWriteOperation } from './bulk_write'; @@ -27,7 +28,11 @@ export class InsertOperation extends CommandOperation { return 'insert' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const options = this.options ?? {}; const ordered = typeof options.ordered === 'boolean' ? options.ordered : true; const command: Document = { @@ -46,7 +51,7 @@ export class InsertOperation extends CommandOperation { command.comment = options.comment; } - return await super.executeCommand(server, session, command); + return await super.executeCommand(server, session, command, timeoutContext); } } @@ -73,9 +78,10 @@ export class InsertOneOperation extends InsertOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { - const res = await super.execute(server, session); + const res = await super.execute(server, session, timeoutContext); if (res.code) throw new MongoServerError(res); if (res.writeErrors) { // This should be a WriteError but we can't change it now because of error hierarchy @@ -123,7 +129,8 @@ export class InsertManyOperation extends AbstractOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { const coll = this.collection; const options = { ...this.options, ...this.bsonOptions, readPreference: this.readPreference }; @@ -137,7 +144,7 @@ export class InsertManyOperation extends AbstractOperation { ); try { - const res = await bulkWriteOperation.execute(server, session); + const res = await bulkWriteOperation.execute(server, session, timeoutContext); return { acknowledged: writeConcern?.w !== 0, insertedCount: res.insertedCount, diff --git a/src/operations/kill_cursors.ts b/src/operations/kill_cursors.ts index 356230e9c7..72c6a04b27 100644 --- a/src/operations/kill_cursors.ts +++ b/src/operations/kill_cursors.ts @@ -2,6 +2,7 @@ import type { Long } from '../bson'; import { MongoRuntimeError } from '../error'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { type MongoDBNamespace, squashError } from '../utils'; import { AbstractOperation, Aspect, defineAspects, type OperationOptions } from './operation'; @@ -29,7 +30,11 @@ export class KillCursorsOperation extends AbstractOperation { return 'killCursors' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { if (server !== this.server) { throw new MongoRuntimeError('Killcursor must run on the same server operation began on'); } @@ -46,7 +51,10 @@ export class KillCursorsOperation extends AbstractOperation { cursors: [this.cursorId] }; try { - await server.command(this.ns, killCursorsCommand, { session }); + await server.command(this.ns, killCursorsCommand, { + session, + timeoutContext + }); } catch (error) { // The driver should never emit errors from killCursors, this is spec-ed behavior squashError(error); diff --git a/src/operations/list_collections.ts b/src/operations/list_collections.ts index e94300f120..702db0fe3f 100644 --- a/src/operations/list_collections.ts +++ b/src/operations/list_collections.ts @@ -3,6 +3,7 @@ import { CursorResponse } from '../cmap/wire_protocol/responses'; import type { Db } from '../db'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { maxWireVersion } from '../utils'; import { CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects } from './operation'; @@ -54,12 +55,14 @@ export class ListCollectionsOperation extends CommandOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { return await super.executeCommand( server, session, this.generateCommand(maxWireVersion(server)), + timeoutContext, CursorResponse ); } diff --git a/src/operations/list_databases.ts b/src/operations/list_databases.ts index 5ad9142a1a..bd740d50c6 100644 --- a/src/operations/list_databases.ts +++ b/src/operations/list_databases.ts @@ -3,6 +3,7 @@ import type { Db } from '../db'; import { type TODO_NODE_3286 } from '../mongo_types'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { maxWireVersion, MongoDBNamespace } from '../utils'; import { CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects } from './operation'; @@ -41,7 +42,8 @@ export class ListDatabasesOperation extends CommandOperation { const cmd: Document = { listDatabases: 1 }; @@ -63,7 +65,12 @@ export class ListDatabasesOperation extends CommandOperation); + return await (super.executeCommand( + server, + session, + cmd, + timeoutContext + ) as Promise); } } diff --git a/src/operations/operation.ts b/src/operations/operation.ts index 0599b72b96..97e12871ee 100644 --- a/src/operations/operation.ts +++ b/src/operations/operation.ts @@ -2,7 +2,7 @@ import { type BSONSerializeOptions, type Document, resolveBSONOptions } from '.. import { ReadPreference, type ReadPreferenceLike } from '../read_preference'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; -import { type Timeout } from '../timeout'; +import { type Timeout, type TimeoutContext } from '../timeout'; import type { MongoDBNamespace } from '../utils'; export const Aspect = { @@ -79,15 +79,17 @@ export abstract class AbstractOperation { this.options = options; this.bypassPinningCheck = !!options.bypassPinningCheck; this.trySecondaryWrite = false; - - this.timeoutMS = options.timeoutMS; } /** Must match the first key of the command object sent to the server. Command name should be stateless (should not use 'this' keyword) */ abstract get commandName(): string; - abstract execute(server: Server, session: ClientSession | undefined): Promise; + abstract execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise; hasAspect(aspect: symbol): boolean { const ctor = this.constructor as { aspects?: Set }; diff --git a/src/operations/profiling_level.ts b/src/operations/profiling_level.ts index 383062c2a4..7c860a244b 100644 --- a/src/operations/profiling_level.ts +++ b/src/operations/profiling_level.ts @@ -2,6 +2,7 @@ import type { Db } from '../db'; import { MongoUnexpectedServerResponseError } from '../error'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { CommandOperation, type CommandOperationOptions } from './command'; /** @public */ @@ -20,8 +21,12 @@ export class ProfilingLevelOperation extends CommandOperation { return 'profile' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { - const doc = await super.executeCommand(server, session, { profile: -1 }); + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { + const doc = await super.executeCommand(server, session, { profile: -1 }, timeoutContext); if (doc.ok === 1) { const was = doc.was; if (was === 0) return 'off'; diff --git a/src/operations/remove_user.ts b/src/operations/remove_user.ts index ced8e4e1ca..7f484ba89a 100644 --- a/src/operations/remove_user.ts +++ b/src/operations/remove_user.ts @@ -1,6 +1,7 @@ import type { Db } from '../db'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects } from './operation'; @@ -22,8 +23,12 @@ export class RemoveUserOperation extends CommandOperation { return 'dropUser' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { - await super.executeCommand(server, session, { dropUser: this.username }); + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { + await super.executeCommand(server, session, { dropUser: this.username }, timeoutContext); return true; } } diff --git a/src/operations/rename.ts b/src/operations/rename.ts index a27d4afe45..883be282b6 100644 --- a/src/operations/rename.ts +++ b/src/operations/rename.ts @@ -2,6 +2,7 @@ import type { Document } from '../bson'; import { Collection } from '../collection'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { MongoDBNamespace } from '../utils'; import { CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects } from './operation'; @@ -29,7 +30,11 @@ export class RenameOperation extends CommandOperation { return 'renameCollection' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { // Build the command const renameCollection = this.collection.namespace; const toCollection = this.collection.s.namespace.withCollection(this.newName).toString(); @@ -42,7 +47,7 @@ export class RenameOperation extends CommandOperation { dropTarget: dropTarget }; - await super.executeCommand(server, session, command); + await super.executeCommand(server, session, command, timeoutContext); return new Collection(this.collection.s.db, this.newName, this.collection.s.options); } } diff --git a/src/operations/run_command.ts b/src/operations/run_command.ts index 56462fa884..b91e2d0344 100644 --- a/src/operations/run_command.ts +++ b/src/operations/run_command.ts @@ -5,6 +5,7 @@ import { type TODO_NODE_3286 } from '../mongo_types'; import type { ReadPreferenceLike } from '../read_preference'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { MongoDBNamespace } from '../utils'; import { AbstractOperation } from './operation'; @@ -33,7 +34,11 @@ export class RunCommandOperation extends AbstractOperation { return 'runCommand' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { this.server = server; const res: TODO_NODE_3286 = await server.command( this.ns, @@ -42,7 +47,7 @@ export class RunCommandOperation extends AbstractOperation { ...this.options, readPreference: this.readPreference, session, - timeout: this.timeout + timeoutContext }, this.options.responseType ); @@ -67,13 +72,17 @@ export class RunAdminCommandOperation extends AbstractOperation return 'runCommand' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { this.server = server; const res: TODO_NODE_3286 = await server.command(this.ns, this.command, { ...this.options, readPreference: this.readPreference, session, - timeout: this.timeout + timeoutContext }); return res; } diff --git a/src/operations/search_indexes/create.ts b/src/operations/search_indexes/create.ts index 7e5e55d18d..9661026e3e 100644 --- a/src/operations/search_indexes/create.ts +++ b/src/operations/search_indexes/create.ts @@ -3,6 +3,7 @@ import type { Document } from 'bson'; import type { Collection } from '../../collection'; import type { Server } from '../../sdam/server'; import type { ClientSession } from '../../sessions'; +import { type TimeoutContext } from '../../timeout'; import { AbstractOperation } from '../operation'; /** @@ -32,14 +33,21 @@ export class CreateSearchIndexesOperation extends AbstractOperation { return 'createSearchIndexes' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const namespace = this.collection.fullNamespace; const command = { createSearchIndexes: namespace.collection, indexes: this.descriptions }; - const res = await server.command(namespace, command, { session }); + const res = await server.command(namespace, command, { + session, + timeoutContext + }); const indexesCreated: Array<{ name: string }> = res?.indexesCreated ?? []; return indexesCreated.map(({ name }) => name); diff --git a/src/operations/search_indexes/drop.ts b/src/operations/search_indexes/drop.ts index 4e287cca01..e9ea0ad01c 100644 --- a/src/operations/search_indexes/drop.ts +++ b/src/operations/search_indexes/drop.ts @@ -4,6 +4,7 @@ import type { Collection } from '../../collection'; import { MONGODB_ERROR_CODES, MongoServerError } from '../../error'; import type { Server } from '../../sdam/server'; import type { ClientSession } from '../../sessions'; +import { type TimeoutContext } from '../../timeout'; import { AbstractOperation } from '../operation'; /** @internal */ @@ -19,7 +20,11 @@ export class DropSearchIndexOperation extends AbstractOperation { return 'dropSearchIndex' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const namespace = this.collection.fullNamespace; const command: Document = { @@ -31,7 +36,7 @@ export class DropSearchIndexOperation extends AbstractOperation { } try { - await server.command(namespace, command, { session }); + await server.command(namespace, command, { session, timeoutContext }); } catch (error) { const isNamespaceNotFoundError = error instanceof MongoServerError && error.code === MONGODB_ERROR_CODES.NamespaceNotFound; diff --git a/src/operations/search_indexes/update.ts b/src/operations/search_indexes/update.ts index aad7f93536..e88e777d67 100644 --- a/src/operations/search_indexes/update.ts +++ b/src/operations/search_indexes/update.ts @@ -3,6 +3,7 @@ import type { Document } from 'bson'; import type { Collection } from '../../collection'; import type { Server } from '../../sdam/server'; import type { ClientSession } from '../../sessions'; +import { type TimeoutContext } from '../../timeout'; import { AbstractOperation } from '../operation'; /** @internal */ @@ -19,7 +20,11 @@ export class UpdateSearchIndexOperation extends AbstractOperation { return 'updateSearchIndex' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const namespace = this.collection.fullNamespace; const command = { updateSearchIndex: namespace.collection, @@ -27,7 +32,7 @@ export class UpdateSearchIndexOperation extends AbstractOperation { definition: this.definition }; - await server.command(namespace, command, { session }); + await server.command(namespace, command, { session, timeoutContext }); return; } } diff --git a/src/operations/set_profiling_level.ts b/src/operations/set_profiling_level.ts index 9969b2ea3c..d76473f263 100644 --- a/src/operations/set_profiling_level.ts +++ b/src/operations/set_profiling_level.ts @@ -2,6 +2,7 @@ import type { Db } from '../db'; import { MongoInvalidArgumentError } from '../error'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { enumToString } from '../utils'; import { CommandOperation, type CommandOperationOptions } from './command'; @@ -53,7 +54,8 @@ export class SetProfilingLevelOperation extends CommandOperation override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { const level = this.level; @@ -64,7 +66,7 @@ export class SetProfilingLevelOperation extends CommandOperation } // TODO(NODE-3483): Determine error to put here - await super.executeCommand(server, session, { profile: this.profile }); + await super.executeCommand(server, session, { profile: this.profile }, timeoutContext); return level; } } diff --git a/src/operations/stats.ts b/src/operations/stats.ts index 41c9faf6e2..aafd3bf1ba 100644 --- a/src/operations/stats.ts +++ b/src/operations/stats.ts @@ -2,6 +2,7 @@ import type { Document } from '../bson'; import type { Db } from '../db'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects } from './operation'; @@ -24,13 +25,17 @@ export class DbStatsOperation extends CommandOperation { return 'dbStats' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const command: Document = { dbStats: true }; if (this.options.scale != null) { command.scale = this.options.scale; } - return await super.executeCommand(server, session, command); + return await super.executeCommand(server, session, command, timeoutContext); } } diff --git a/src/operations/update.ts b/src/operations/update.ts index ba0ad6d95f..5b6f396afe 100644 --- a/src/operations/update.ts +++ b/src/operations/update.ts @@ -4,6 +4,7 @@ import { MongoCompatibilityError, MongoInvalidArgumentError, MongoServerError } import type { InferIdType, TODO_NODE_3286 } from '../mongo_types'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { hasAtomicOperators, type MongoDBNamespace } from '../utils'; import { type CollationOptions, CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects, type Hint } from './operation'; @@ -91,7 +92,11 @@ export class UpdateOperation extends CommandOperation { return this.statements.every(op => op.multi == null || op.multi === false); } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const options = this.options ?? {}; const ordered = typeof options.ordered === 'boolean' ? options.ordered : true; const command: Document = { @@ -122,7 +127,7 @@ export class UpdateOperation extends CommandOperation { } } - const res = await super.executeCommand(server, session, command); + const res = await super.executeCommand(server, session, command, timeoutContext); return res; } } @@ -143,9 +148,10 @@ export class UpdateOneOperation extends UpdateOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { - const res: TODO_NODE_3286 = await super.execute(server, session); + const res: TODO_NODE_3286 = await super.execute(server, session, timeoutContext); if (this.explain != null) return res; if (res.code) throw new MongoServerError(res); if (res.writeErrors) throw new MongoServerError(res.writeErrors[0]); @@ -177,9 +183,10 @@ export class UpdateManyOperation extends UpdateOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { - const res: TODO_NODE_3286 = await super.execute(server, session); + const res: TODO_NODE_3286 = await super.execute(server, session, timeoutContext); if (this.explain != null) return res; if (res.code) throw new MongoServerError(res); if (res.writeErrors) throw new MongoServerError(res.writeErrors[0]); @@ -230,9 +237,10 @@ export class ReplaceOneOperation extends UpdateOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { - const res: TODO_NODE_3286 = await super.execute(server, session); + const res: TODO_NODE_3286 = await super.execute(server, session, timeoutContext); if (this.explain != null) return res; if (res.code) throw new MongoServerError(res); if (res.writeErrors) throw new MongoServerError(res.writeErrors[0]); diff --git a/src/operations/validate_collection.ts b/src/operations/validate_collection.ts index 4880a703a7..16ae4cad9e 100644 --- a/src/operations/validate_collection.ts +++ b/src/operations/validate_collection.ts @@ -3,6 +3,7 @@ import type { Document } from '../bson'; import { MongoUnexpectedServerResponseError } from '../error'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { CommandOperation, type CommandOperationOptions } from './command'; /** @public */ @@ -37,10 +38,14 @@ export class ValidateCollectionOperation extends CommandOperation { return 'validate' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const collectionName = this.collectionName; - const doc = await super.executeCommand(server, session, this.command); + const doc = await super.executeCommand(server, session, this.command, timeoutContext); if (doc.result != null && typeof doc.result !== 'string') throw new MongoUnexpectedServerResponseError('Error with validation data'); if (doc.result != null && doc.result.match(/exception|corrupt/) != null) diff --git a/src/sdam/server.ts b/src/sdam/server.ts index a1b885382e..20cb13423c 100644 --- a/src/sdam/server.ts +++ b/src/sdam/server.ts @@ -40,6 +40,7 @@ import type { ServerApi } from '../mongo_client'; import { TypedEventEmitter } from '../mongo_types'; import type { GetMoreOptions } from '../operations/get_more'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { isTransactionCommand } from '../transactions'; import { type EventEmitterWithState, @@ -104,6 +105,11 @@ export type ServerEvents = { } & ConnectionPoolEvents & EventEmitterWithState; +/** @internal */ +export type ServerCommandOptions = Omit & { + timeoutContext: TimeoutContext; +}; + /** @internal */ export class Server extends TypedEventEmitter { /** @internal */ @@ -267,20 +273,20 @@ export class Server extends TypedEventEmitter { public async command( ns: MongoDBNamespace, command: Document, - options: CommandOptions | undefined, + options: ServerCommandOptions, responseType: T | undefined ): Promise>; public async command( ns: MongoDBNamespace, command: Document, - options?: CommandOptions + options: ServerCommandOptions ): Promise; public async command( ns: MongoDBNamespace, cmd: Document, - options: CommandOptions, + options: ServerCommandOptions, responseType?: MongoDBResponseConstructor ): Promise { if (ns.db == null || typeof ns === 'string') { diff --git a/src/sdam/topology.ts b/src/sdam/topology.ts index 4c9d71d807..6117b5317c 100644 --- a/src/sdam/topology.ts +++ b/src/sdam/topology.ts @@ -34,11 +34,10 @@ import { MongoLoggableComponent, type MongoLogger, SeverityLevel } from '../mong import { TypedEventEmitter } from '../mongo_types'; import { ReadPreference, type ReadPreferenceLike } from '../read_preference'; import type { ClientSession } from '../sessions'; -import { Timeout, TimeoutError } from '../timeout'; +import { Timeout, TimeoutContext, TimeoutError } from '../timeout'; import type { Transaction } from '../transactions'; import { type Callback, - csotMin, type EventEmitterWithState, HostAddress, List, @@ -179,8 +178,11 @@ export interface SelectServerOptions { session?: ClientSession; operationName: string; previousServer?: ServerDescription; - /** @internal*/ - timeout?: Timeout; + /** + * @internal + * TODO(NODE-5685): Make this required + * */ + timeoutContext?: TimeoutContext; } /** @public */ @@ -458,13 +460,20 @@ export class Topology extends TypedEventEmitter { } } - const timeoutMS = this.client.options.timeoutMS; - const timeout = timeoutMS != null ? Timeout.expires(timeoutMS) : undefined; + 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, + serverSelectionTimeoutMS, + waitQueueTimeoutMS: this.client.s.options.waitQueueTimeoutMS + }); + const selectServerOptions = { operationName: 'ping', - timeout, - ...options + ...options, + timeoutContext }; try { const server = await this.selectServer( @@ -474,7 +483,7 @@ export class Topology extends TypedEventEmitter { const skipPingOnConnect = this.s.options[Symbol.for('@@mdb.skipPingOnConnect')] === true; if (!skipPingOnConnect && server && this.s.credentials) { - await server.command(ns('admin.$cmd'), { ping: 1 }, { timeout }); + await server.command(ns('admin.$cmd'), { ping: 1 }, { timeoutContext }); stateTransition(this, STATE_CONNECTED); this.emit(Topology.OPEN, this); this.emit(Topology.CONNECT, this); @@ -563,24 +572,10 @@ export class Topology extends TypedEventEmitter { new ServerSelectionStartedEvent(selector, this.description, options.operationName) ); } - const serverSelectionTimeoutMS = options.serverSelectionTimeoutMS ?? 0; - let timeout: Timeout | null; - if (options.timeout) { - // CSOT Enabled - if (options.timeout.duration > 0 || serverSelectionTimeoutMS > 0) { - if ( - options.timeout.duration === serverSelectionTimeoutMS || - csotMin(options.timeout.duration, serverSelectionTimeoutMS) < serverSelectionTimeoutMS - ) { - timeout = options.timeout; - } else { - timeout = Timeout.expires(serverSelectionTimeoutMS); - } - } else { - timeout = null; - } - } else { - timeout = Timeout.expires(serverSelectionTimeoutMS); + let timeout; + if (options.timeoutContext) timeout = options.timeoutContext.serverSelectionTimeout; + else { + timeout = Timeout.expires(options.serverSelectionTimeoutMS ?? 0); } const isSharded = this.description.type === TopologyType.Sharded; @@ -604,7 +599,7 @@ export class Topology extends TypedEventEmitter { ) ); } - if (timeout !== options.timeout) timeout?.clear(); + if (options.timeoutContext?.clearServerSelectionTimeout) timeout?.clear(); return transaction.server; } @@ -654,7 +649,7 @@ export class Topology extends TypedEventEmitter { ); } - if (options.timeout) { + if (options.timeoutContext?.csotEnabled()) { throw new MongoOperationTimeoutError('Timed out during server selection', { cause: timeoutError }); @@ -664,7 +659,7 @@ export class Topology extends TypedEventEmitter { // Other server selection error throw error; } finally { - if (timeout !== options.timeout) timeout?.clear(); + if (options.timeoutContext?.clearServerSelectionTimeout) timeout?.clear(); } } /** diff --git a/src/timeout.ts b/src/timeout.ts index 7af1a23f26..3d65992a02 100644 --- a/src/timeout.ts +++ b/src/timeout.ts @@ -1,7 +1,7 @@ import { clearTimeout, setTimeout } from 'timers'; -import { MongoInvalidArgumentError } from './error'; -import { noop } from './utils'; +import { MongoInvalidArgumentError, MongoRuntimeError } from './error'; +import { csotMin, noop } from './utils'; /** @internal */ export class TimeoutError extends Error { @@ -107,3 +107,165 @@ export class Timeout extends Promise { ); } } + +/** @internal */ +export type TimeoutContextOptions = LegacyTimeoutContextOptions | CSOTTimeoutContextOptions; + +/** @internal */ +export type LegacyTimeoutContextOptions = { + serverSelectionTimeoutMS: number; + waitQueueTimeoutMS: number; + socketTimeoutMS?: number; +}; + +/** @internal */ +export type CSOTTimeoutContextOptions = { + timeoutMS: number; + serverSelectionTimeoutMS: number; + socketTimeoutMS?: number; +}; + +function isLegacyTimeoutContextOptions(v: unknown): v is LegacyTimeoutContextOptions { + return ( + v != null && + typeof v === 'object' && + 'serverSelectionTimeoutMS' in v && + typeof v.serverSelectionTimeoutMS === 'number' && + 'waitQueueTimeoutMS' in v && + typeof v.waitQueueTimeoutMS === 'number' + ); +} + +function isCSOTTimeoutContextOptions(v: unknown): v is CSOTTimeoutContextOptions { + return ( + v != null && + typeof v === 'object' && + 'serverSelectionTimeoutMS' in v && + typeof v.serverSelectionTimeoutMS === 'number' && + 'timeoutMS' in v && + typeof v.timeoutMS === 'number' + ); +} + +/** @internal */ +export abstract class TimeoutContext { + static create(options: TimeoutContextOptions): TimeoutContext { + if (isCSOTTimeoutContextOptions(options)) return new CSOTTimeoutContext(options); + else if (isLegacyTimeoutContextOptions(options)) return new LegacyTimeoutContext(options); + else throw new MongoRuntimeError('Unrecognized options'); + } + + abstract get serverSelectionTimeout(): Timeout | null; + + abstract get connectionCheckoutTimeout(): Timeout | null; + + abstract get clearServerSelectionTimeout(): boolean; + + abstract get clearConnectionCheckoutTimeout(): boolean; + + abstract csotEnabled(): this is CSOTTimeoutContext; +} + +/** @internal */ +export class CSOTTimeoutContext extends TimeoutContext { + timeoutMS: number; + serverSelectionTimeoutMS: number; + socketTimeoutMS?: number; + + clearConnectionCheckoutTimeout: boolean; + clearServerSelectionTimeout: boolean; + + private _maxTimeMS?: number; + + private _serverSelectionTimeout?: Timeout | null; + private _connectionCheckoutTimeout?: Timeout | null; + + constructor(options: CSOTTimeoutContextOptions) { + super(); + this.timeoutMS = options.timeoutMS; + + this.serverSelectionTimeoutMS = options.serverSelectionTimeoutMS; + + this.socketTimeoutMS = options.socketTimeoutMS; + + this.clearServerSelectionTimeout = false; + this.clearConnectionCheckoutTimeout = true; + } + + get maxTimeMS(): number { + return this._maxTimeMS ?? -1; + } + + set maxTimeMS(v: number) { + this._maxTimeMS = v; + } + + csotEnabled(): this is CSOTTimeoutContext { + return true; + } + + get serverSelectionTimeout(): Timeout | null { + // check for undefined + if (typeof this._serverSelectionTimeout !== 'object') { + const usingServerSelectionTimeoutMS = + this.serverSelectionTimeoutMS !== 0 && + csotMin(this.timeoutMS, this.serverSelectionTimeoutMS) === this.serverSelectionTimeoutMS; + + if (usingServerSelectionTimeoutMS) { + this._serverSelectionTimeout = Timeout.expires(this.serverSelectionTimeoutMS); + } else { + if (this.timeoutMS > 0) { + this._serverSelectionTimeout = Timeout.expires(this.timeoutMS); + } else { + this._serverSelectionTimeout = null; + } + } + } + + return this._serverSelectionTimeout; + } + + get connectionCheckoutTimeout(): Timeout | null { + if (typeof this._connectionCheckoutTimeout !== 'object') { + if (typeof this._serverSelectionTimeout === 'object') { + // null or Timeout + this._connectionCheckoutTimeout = this._serverSelectionTimeout; + } else { + throw new MongoRuntimeError( + 'Unreachable. If you are seeing this error, please file a ticket on the NODE driver project on Jira' + ); + } + } + return this._connectionCheckoutTimeout; + } +} + +/** @internal */ +export class LegacyTimeoutContext extends TimeoutContext { + options: LegacyTimeoutContextOptions; + clearServerSelectionTimeout: boolean; + clearConnectionCheckoutTimeout: boolean; + + constructor(options: LegacyTimeoutContextOptions) { + super(); + this.options = options; + this.clearServerSelectionTimeout = true; + this.clearConnectionCheckoutTimeout = true; + } + + csotEnabled(): this is CSOTTimeoutContext { + return false; + } + + get serverSelectionTimeout(): Timeout | null { + if (this.options.serverSelectionTimeoutMS != null && this.options.serverSelectionTimeoutMS > 0) + return Timeout.expires(this.options.serverSelectionTimeoutMS); + return null; + } + + get connectionCheckoutTimeout(): Timeout | null { + if (this.options.waitQueueTimeoutMS != null && this.options.waitQueueTimeoutMS > 0) + return Timeout.expires(this.options.waitQueueTimeoutMS); + return null; + } +} diff --git a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts index c1426d8db1..c4989f58d7 100644 --- a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts +++ b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts @@ -33,16 +33,20 @@ describe('CSOT spec unit tests', function () { client = this.configuration.newClient({ timeoutMS: 1000 }); // Spy on connection checkout and pull options argument const checkoutSpy = sinon.spy(ConnectionPool.prototype, 'checkOut'); - const selectServerSpy = sinon.spy(Topology.prototype, 'selectServer'); const expiresSpy = sinon.spy(Timeout, 'expires'); await client.db('db').collection('collection').insertOne({ x: 1 }); expect(checkoutSpy).to.have.been.calledOnce; - expect(checkoutSpy.firstCall.args[0].timeout).to.exist; + const timeoutContext = checkoutSpy.lastCall.args[0].timeoutContext; + expect(timeoutContext).to.exist; // Check that we passed through the timeout - expect(checkoutSpy.firstCall.args[0].timeout).to.equal( - selectServerSpy.lastCall.lastArg.timeout + // @ts-expect-error accessing private properties + expect(timeoutContext._serverSelectionTimeout).to.be.instanceOf(Timeout); + // @ts-expect-error accessing private properties + expect(timeoutContext._serverSelectionTimeout).to.equal( + // @ts-expect-error accessing private properties + timeoutContext._connectionCheckoutTimeout ); // Check that no more Timeouts are constructed after we enter checkout 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 5636eb00db..17d85ba5b2 100644 --- a/test/integration/client-side-operations-timeout/node_csot.test.ts +++ b/test/integration/client-side-operations-timeout/node_csot.test.ts @@ -143,7 +143,7 @@ describe('CSOT driver tests', () => { }); it('throws a MongoOperationTimeoutError', { - metadata: { requires: { mongodb: '>=4.4' } }, + metadata: { requires: { mongodb: '>=4.4', topology: '!load-balanced' } }, test: async function () { const commandsStarted = []; client = this.configuration.newClient(undefined, { timeoutMS: 1, monitorCommands: true }); diff --git a/test/tools/cmap_spec_runner.ts b/test/tools/cmap_spec_runner.ts index f6d7e68bed..9bb2abdb87 100644 --- a/test/tools/cmap_spec_runner.ts +++ b/test/tools/cmap_spec_runner.ts @@ -12,7 +12,8 @@ import { makeClientMetadata, type MongoClient, type Server, - shuffle + shuffle, + TimeoutContext } from '../mongodb'; import { isAnyRequirementSatisfied } from './unified-spec-runner/unified-utils'; import { type FailPoint, sleep } from './utils'; @@ -185,7 +186,14 @@ const compareInputToSpec = (input, expected, message) => { const getTestOpDefinitions = (threadContext: ThreadContext) => ({ checkOut: async function (op) { - const connection: Connection = await ConnectionPool.prototype.checkOut.call(threadContext.pool); + const timeoutContext = TimeoutContext.create({ + serverSelectionTimeoutMS: 0, + waitQueueTimeoutMS: threadContext.pool.options.waitQueueTimeoutMS + }); + const connection: Connection = await ConnectionPool.prototype.checkOut.call( + threadContext.pool, + { timeoutContext } + ); if (op.label != null) { threadContext.connections.set(op.label, connection); } else { diff --git a/test/unit/cmap/connection_pool.test.js b/test/unit/cmap/connection_pool.test.js index 18048befab..1604cd82d8 100644 --- a/test/unit/cmap/connection_pool.test.js +++ b/test/unit/cmap/connection_pool.test.js @@ -10,8 +10,10 @@ const { ns, isHello } = require('../../mongodb'); const { createTimerSandbox } = require('../timer_sandbox'); const { topologyWithPlaceholderClient } = require('../../tools/utils'); const { MongoClientAuthProviders } = require('../../mongodb'); +const { TimeoutContext } = require('../../mongodb'); describe('Connection Pool', function () { + let timeoutContext; let mockMongod; const stubServer = { topology: { @@ -44,6 +46,10 @@ describe('Connection Pool', function () { }) ); + beforeEach(() => { + timeoutContext = TimeoutContext.create({ waitQueueTimeoutMS: 0, serverSelectionTimeoutMS: 0 }); + }); + it('should destroy connections which have been closed', async function () { mockMongod.setMessageHandler(request => { const doc = request.document; @@ -64,8 +70,10 @@ describe('Connection Pool', function () { const events = []; pool.on('connectionClosed', event => events.push(event)); - const conn = await pool.checkOut(); - const error = await conn.command(ns('admin.$cmd'), { ping: 1 }, {}).catch(error => error); + const conn = await pool.checkOut({ timeoutContext }); + const error = await conn + .command(ns('admin.$cmd'), { ping: 1 }, { timeoutContext }) + .catch(error => error); expect(error).to.be.instanceOf(Error); pool.checkIn(conn); @@ -93,7 +101,7 @@ describe('Connection Pool', function () { pool.ready(); - const conn = await pool.checkOut(); + const conn = await pool.checkOut({ timeoutContext }); const maybeError = await conn.command(ns('admin.$cmd'), { ping: 1 }, undefined).catch(e => e); expect(maybeError).to.be.instanceOf(MongoError); expect(maybeError).to.match(/timed out/); @@ -114,11 +122,15 @@ describe('Connection Pool', function () { waitQueueTimeoutMS: 200, hostAddress: mockMongod.hostAddress() }); + const timeoutContext = TimeoutContext.create({ + waitQueueTimeoutMS: 200, + serverSelectionTimeoutMS: 0 + }); pool.ready(); - const conn = await pool.checkOut(); - const err = await pool.checkOut().catch(e => e); + const conn = await pool.checkOut({ timeoutContext }); + const err = await pool.checkOut({ timeoutContext }).catch(e => e); expect(err).to.exist.and.be.instanceOf(WaitQueueTimeoutError); sinon.stub(pool, 'availableConnectionCount').get(() => 0); pool.checkIn(conn); diff --git a/test/unit/error.test.ts b/test/unit/error.test.ts index 6bab40d031..bdc049cbc4 100644 --- a/test/unit/error.test.ts +++ b/test/unit/error.test.ts @@ -28,6 +28,7 @@ import { ns, PoolClosedError as MongoPoolClosedError, setDifference, + TimeoutContext, type TopologyDescription, type TopologyOptions, WaitQueueTimeoutError as MongoWaitQueueTimeoutError @@ -376,11 +377,17 @@ describe('MongoErrors', () => { { replicaSet: 'rs' } as TopologyOptions ); + const timeoutContext = TimeoutContext.create({ + serverSelectionTimeoutMS: 0, + waitQueueTimeoutMS: 0 + }); return replSet .connect() - .then(topology => topology.selectServer('primary', {})) + .then(topology => topology.selectServer('primary', { timeoutContext })) .then(server => - server.command(ns('db1'), Object.assign({}, RAW_USER_WRITE_CONCERN_CMD), {}) + server.command(ns('db1'), Object.assign({}, RAW_USER_WRITE_CONCERN_CMD), { + timeoutContext + }) ) .then( () => expect.fail('expected command to fail'), @@ -419,10 +426,14 @@ describe('MongoErrors', () => { if (err) { return cleanup(err); } + const timeoutContext = TimeoutContext.create({ + serverSelectionTimeoutMS: 0, + waitQueueTimeoutMS: 0 + }); - topology.selectServer('primary', {}).then(server => { + topology.selectServer('primary', { timeoutContext }).then(server => { server - .command(ns('db1'), Object.assign({}, RAW_USER_WRITE_CONCERN_CMD), {}) + .command(ns('db1'), Object.assign({}, RAW_USER_WRITE_CONCERN_CMD), { timeoutContext }) .then(expect.fail, err => { let _err; try { diff --git a/test/unit/operations/get_more.test.ts b/test/unit/operations/get_more.test.ts index f79da44e22..17bc20f6fa 100644 --- a/test/unit/operations/get_more.test.ts +++ b/test/unit/operations/get_more.test.ts @@ -69,7 +69,7 @@ describe('GetMoreOperation', function () { const call = stub.getCall(0); expect(call.args[0]).to.equal(namespace); expect(call.args[1]).to.deep.equal(expectedGetMoreCommand); - expect(call.args[2]).to.deep.equal(opts); + expect(call.args[2]).to.containSubset(opts); }); }); diff --git a/test/unit/sdam/topology.test.ts b/test/unit/sdam/topology.test.ts index e4a34417d5..5264b5d9c4 100644 --- a/test/unit/sdam/topology.test.ts +++ b/test/unit/sdam/topology.test.ts @@ -17,6 +17,7 @@ import { Server, SrvPoller, SrvPollingEvent, + TimeoutContext, Topology, TopologyDescription, TopologyDescriptionChangedEvent, @@ -108,17 +109,28 @@ describe('Topology (unit)', function () { const topology = topologyWithPlaceholderClient(mockServer.hostAddress(), {}); topology.connect().then(() => { - topology.selectServer('primary', {}).then(server => { - server.command(ns('admin.$cmd'), { ping: 1 }, { socketTimeoutMS: 250 }).then( - () => expect.fail('expected command to fail'), - err => { - expect(err).to.exist; - expect(err).to.match(/timed out/); - topology.close(); - done(); - } - ); - }, expect.fail); + const ctx = TimeoutContext.create({ + waitQueueTimeoutMS: 0, + serverSelectionTimeoutMS: 0, + socketTimeoutMS: 250 + }); + topology + .selectServer('primary', { + timeoutContext: ctx + }) + .then(server => { + server + .command(ns('admin.$cmd'), { ping: 1 }, { socketTimeoutMS: 250, timeoutContext: ctx }) + .then( + () => expect.fail('expected command to fail'), + err => { + expect(err).to.exist; + expect(err).to.match(/timed out/); + topology.close(); + done(); + } + ); + }, expect.fail); }, expect.fail); }); }); @@ -217,10 +229,16 @@ describe('Topology (unit)', function () { let poolCleared = false; topology.on('connectionPoolCleared', () => (poolCleared = true)); - const err = await server.command(ns('test.test'), { insert: { a: 42 } }, {}).then( - () => null, - e => e - ); + const timeoutContext = TimeoutContext.create({ + serverSelectionTimeoutMS: 0, + waitQueueTimeoutMS: 0 + }); + const err = await server + .command(ns('test.test'), { insert: { a: 42 } }, { timeoutContext }) + .then( + () => null, + e => e + ); expect(err).to.eql(serverDescription.error); expect(poolCleared).to.be.true; }); @@ -245,11 +263,17 @@ describe('Topology (unit)', function () { let poolCleared = false; topology.on('connectionPoolCleared', () => (poolCleared = true)); + const timeoutContext = TimeoutContext.create({ + serverSelectionTimeoutMS: 0, + waitQueueTimeoutMS: 0 + }); - const err = await server.command(ns('test.test'), { insert: { a: 42 } }, {}).then( - () => null, - e => e - ); + const err = await server + .command(ns('test.test'), { insert: { a: 42 } }, { timeoutContext }) + .then( + () => null, + e => e + ); expect(err).to.eql(serverDescription.error); expect(poolCleared).to.be.false; topology.close(); @@ -269,14 +293,20 @@ describe('Topology (unit)', function () { topology = topologyWithPlaceholderClient(mockServer.hostAddress(), {}); await topology.connect(); + const timeoutContext = TimeoutContext.create({ + waitQueueTimeoutMS: 0, + serverSelectionTimeoutMS: 0 + }); const server = await topology.selectServer('primary', {}); let serverDescription; server.on('descriptionReceived', sd => (serverDescription = sd)); - const err = await server.command(ns('test.test'), { insert: { a: 42 } }, {}).then( - () => null, - e => e - ); + const err = await server + .command(ns('test.test'), { insert: { a: 42 } }, { timeoutContext }) + .then( + () => null, + e => e + ); expect(err).to.eql(serverDescription.error); expect(server.description.type).to.equal('Unknown'); }); diff --git a/test/unit/timeout.test.ts b/test/unit/timeout.test.ts index 3fafc21b35..119d0516a9 100644 --- a/test/unit/timeout.test.ts +++ b/test/unit/timeout.test.ts @@ -1,6 +1,14 @@ import { expect } from 'chai'; -import { MongoInvalidArgumentError, Timeout, TimeoutError } from '../mongodb'; +import { + CSOTTimeoutContext, + LegacyTimeoutContext, + MongoInvalidArgumentError, + MongoRuntimeError, + Timeout, + TimeoutContext, + TimeoutError +} from '../mongodb'; describe('Timeout', function () { let timeout: Timeout; @@ -115,3 +123,197 @@ describe('Timeout', function () { }); }); }); + +describe('TimeoutContext', function () { + describe('TimeoutContext.create', function () { + context('when timeoutMS is a number', function () { + it('returns a CSOTTimeoutContext instance', function () { + const ctx = TimeoutContext.create({ + timeoutMS: 0, + serverSelectionTimeoutMS: 0, + waitQueueTimeoutMS: 0 + }); + + expect(ctx).to.be.instanceOf(CSOTTimeoutContext); + }); + }); + + context('when timeoutMS is undefined', function () { + it('returns a LegacyTimeoutContext instance', function () { + const ctx = TimeoutContext.create({ + serverSelectionTimeoutMS: 0, + waitQueueTimeoutMS: 0 + }); + + expect(ctx).to.be.instanceOf(LegacyTimeoutContext); + }); + }); + }); + + describe('CSOTTimeoutContext', function () { + let ctx: CSOTTimeoutContext; + + describe('get serverSelectionTimeout()', function () { + let timeout: Timeout | null; + + afterEach(() => { + timeout?.clear(); + }); + + context('when timeoutMS is 0 and serverSelectionTimeoutMS is 0', function () { + it('returns null', function () { + ctx = new CSOTTimeoutContext({ + timeoutMS: 0, + serverSelectionTimeoutMS: 0 + }); + + expect(ctx.serverSelectionTimeout).to.be.null; + }); + }); + + context('when timeoutMS is 0 and serverSelectionTimeoutMS is >0', function () { + it('returns a Timeout instance with duration set to serverSelectionTimeoutMS', function () { + ctx = new CSOTTimeoutContext({ + timeoutMS: 0, + serverSelectionTimeoutMS: 10 + }); + + timeout = ctx.serverSelectionTimeout; + expect(timeout).to.be.instanceOf(Timeout); + + expect(timeout.duration).to.equal(ctx.serverSelectionTimeoutMS); + }); + }); + + context( + 'when timeoutMS is >0 serverSelectionTimeoutMS is >0 and timeoutMS > serverSelectionTimeoutMS', + function () { + it('returns a Timeout instance with duration set to serverSelectionTimeoutMS', function () { + ctx = new CSOTTimeoutContext({ + timeoutMS: 15, + serverSelectionTimeoutMS: 10 + }); + + timeout = ctx.serverSelectionTimeout; + expect(timeout).to.exist; + expect(timeout).to.be.instanceOf(Timeout); + expect(timeout.duration).to.equal(ctx.serverSelectionTimeoutMS); + }); + } + ); + + context( + 'when timeoutMS is >0, serverSelectionTimeoutMS is >0 and timeoutMS < serverSelectionTimeoutMS', + function () { + it('returns a Timeout instance with duration set to timeoutMS', function () { + ctx = new CSOTTimeoutContext({ + timeoutMS: 10, + serverSelectionTimeoutMS: 15 + }); + + timeout = ctx.serverSelectionTimeout; + expect(timeout).to.exist; + expect(timeout).to.be.instanceOf(Timeout); + expect(timeout.duration).to.equal(ctx.timeoutMS); + }); + } + ); + }); + + describe('get connectionCheckoutTimeout()', function () { + context('when called before get serverSelectionTimeout()', function () { + it('throws a MongoRuntimeError', function () { + ctx = new CSOTTimeoutContext({ + timeoutMS: 100, + serverSelectionTimeoutMS: 15 + }); + + expect(() => ctx.connectionCheckoutTimeout).to.throw(MongoRuntimeError); + }); + }); + + context('when called after get serverSelectionTimeout()', function () { + let serverSelectionTimeout: Timeout; + let connectionCheckoutTimeout: Timeout; + + afterEach(() => { + serverSelectionTimeout.clear(); + connectionCheckoutTimeout.clear(); + }); + + it('returns same timeout as serverSelectionTimeout', function () { + ctx = new CSOTTimeoutContext({ + timeoutMS: 100, + serverSelectionTimeoutMS: 86 + }); + serverSelectionTimeout = ctx.serverSelectionTimeout; + connectionCheckoutTimeout = ctx.connectionCheckoutTimeout; + + expect(connectionCheckoutTimeout).to.exist; + expect(connectionCheckoutTimeout).to.equal(serverSelectionTimeout); + }); + }); + }); + }); + + describe('LegacyTimeoutContext', function () { + let timeout: Timeout | null; + + afterEach(() => { + timeout?.clear(); + }); + + describe('get serverSelectionTimeout()', function () { + context('when serverSelectionTimeoutMS > 0', function () { + it('returns a Timeout instance with duration set to serverSelectionTimeoutMS', function () { + const ctx = new LegacyTimeoutContext({ + serverSelectionTimeoutMS: 100, + waitQueueTimeoutMS: 10 + }); + + timeout = ctx.serverSelectionTimeout; + expect(timeout).to.be.instanceOf(Timeout); + expect(timeout.duration).to.equal(ctx.options.serverSelectionTimeoutMS); + }); + }); + + context('when serverSelectionTimeoutMS = 0', function () { + it('returns null', function () { + const ctx = new LegacyTimeoutContext({ + serverSelectionTimeoutMS: 0, + waitQueueTimeoutMS: 10 + }); + + timeout = ctx.serverSelectionTimeout; + expect(timeout).to.be.null; + }); + }); + }); + + describe('get connectionCheckoutTimeout()', function () { + context('when waitQueueTimeoutMS > 0', function () { + it('returns a Timeout instance with duration set to waitQueueTimeoutMS', function () { + const ctx = new LegacyTimeoutContext({ + serverSelectionTimeoutMS: 10, + waitQueueTimeoutMS: 20 + }); + timeout = ctx.connectionCheckoutTimeout; + + expect(timeout).to.be.instanceOf(Timeout); + expect(timeout.duration).to.equal(ctx.options.waitQueueTimeoutMS); + }); + }); + + context('when waitQueueTimeoutMS = 0', function () { + it('returns null', function () { + const ctx = new LegacyTimeoutContext({ + serverSelectionTimeoutMS: 10, + waitQueueTimeoutMS: 0 + }); + + expect(ctx.connectionCheckoutTimeout).to.be.null; + }); + }); + }); + }); +}); From e4efd3fde0807c9afd33c44c7d700aa074942008 Mon Sep 17 00:00:00 2001 From: Neal Beeken Date: Mon, 22 Jul 2024 11:17:22 -0400 Subject: [PATCH 04/39] refactor(NODE-6230): executeOperation to use iterative retry mechanism (#4157) --- src/cmap/connection_pool.ts | 6 ++++-- src/operations/execute_operation.ts | 27 ++++++++++++++++----------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/cmap/connection_pool.ts b/src/cmap/connection_pool.ts index 5369cc155a..2cd2bcc2c1 100644 --- a/src/cmap/connection_pool.ts +++ b/src/cmap/connection_pool.ts @@ -28,7 +28,7 @@ import { import { CancellationToken, TypedEventEmitter } from '../mongo_types'; import type { Server } from '../sdam/server'; import { type TimeoutContext, TimeoutError } from '../timeout'; -import { type Callback, List, makeCounter, promiseWithResolvers } from '../utils'; +import { type Callback, List, makeCounter, now, promiseWithResolvers } from '../utils'; import { connect } from './connect'; import { Connection, type ConnectionEvents, type ConnectionOptions } from './connection'; import { @@ -356,6 +356,7 @@ export class ConnectionPool extends TypedEventEmitter { * explicitly destroyed by the new owner. */ async checkOut(options: { timeoutContext: TimeoutContext }): Promise { + const checkoutTime = now(); this.emitAndLog( ConnectionPool.CONNECTION_CHECK_OUT_STARTED, new ConnectionCheckOutStartedEvent(this) @@ -367,7 +368,8 @@ export class ConnectionPool extends TypedEventEmitter { const waitQueueMember: WaitQueueMember = { resolve, - reject + reject, + checkoutTime }; this[kWaitQueue].push(waitQueueMember); diff --git a/src/operations/execute_operation.ts b/src/operations/execute_operation.ts index 39937c8abf..efd92f19de 100644 --- a/src/operations/execute_operation.ts +++ b/src/operations/execute_operation.ts @@ -25,7 +25,7 @@ import { import type { Topology } from '../sdam/topology'; import type { ClientSession } from '../sessions'; import { TimeoutContext } from '../timeout'; -import { squashError, supportsRetryableWrites } from '../utils'; +import { supportsRetryableWrites } from '../utils'; import { AbstractOperation, Aspect } from './operation'; const MMAPv1_RETRY_WRITES_ERROR_CODE = MONGODB_ERROR_CODES.IllegalOperation; @@ -87,12 +87,6 @@ export async function executeOperation< ); } - timeoutContext ??= TimeoutContext.create({ - serverSelectionTimeoutMS: client.s.options.serverSelectionTimeoutMS, - waitQueueTimeoutMS: client.s.options.waitQueueTimeoutMS, - timeoutMS: operation.options.timeoutMS - }); - const readPreference = operation.readPreference ?? ReadPreference.primary; const inTransaction = !!session?.inTransaction(); @@ -112,12 +106,18 @@ export async function executeOperation< session.unpin(); } + timeoutContext ??= TimeoutContext.create({ + serverSelectionTimeoutMS: client.s.options.serverSelectionTimeoutMS, + waitQueueTimeoutMS: client.s.options.waitQueueTimeoutMS, + timeoutMS: operation.options.timeoutMS + }); + try { return await tryOperation(operation, { topology, + timeoutContext, session, - readPreference, - timeoutContext + readPreference }); } finally { if (session?.owner != null && session.owner === owner) { @@ -156,6 +156,7 @@ type RetryOptions = { session: ClientSession | undefined; readPreference: ReadPreference; topology: Topology; + timeoutContext: TimeoutContext; }; /** @@ -179,7 +180,10 @@ type RetryOptions = { async function tryOperation< T extends AbstractOperation, TResult = ResultTypeFromOperation ->(operation: T, { topology, session, readPreference }: RetryOptions): Promise { +>( + operation: T, + { topology, timeoutContext, session, readPreference }: RetryOptions +): Promise { let selector: ReadPreference | ServerSelector; if (operation.hasAspect(Aspect.MUST_SELECT_SAME_SERVER)) { @@ -197,7 +201,8 @@ async function tryOperation< let server = await topology.selectServer(selector, { session, - operationName: operation.commandName + operationName: operation.commandName, + timeoutContext }); const hasReadAspect = operation.hasAspect(Aspect.READ_OPERATION); From 22082c9563d1f913b0b87f8f3c9ddff86427adfe Mon Sep 17 00:00:00 2001 From: Neal Beeken Date: Fri, 26 Jul 2024 09:55:20 -0400 Subject: [PATCH 05/39] 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 9defa144f3..e3f1b0d753 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) { @@ -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(); @@ -623,7 +642,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) @@ -635,8 +658,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; } /** @@ -648,9 +695,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 538c8d2c9b..07a0c928cc 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 32f6870d99..fb1a759bf5 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); @@ -360,7 +365,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; @@ -773,6 +778,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 51d458a185..0d7fc18970 100644 --- a/test/tools/unified-spec-runner/operations.ts +++ b/test/tools/unified-spec-runner/operations.ts @@ -293,6 +293,7 @@ operations.set('dropCollection', async ({ entities, operation }) => { if (!/ns not found/.test(err.message)) { throw err; } + return false; } }); @@ -303,7 +304,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 }) => { @@ -757,11 +758,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\]/ ); }); }); From bf95fa497276b7031b1140a2910a188b230cc6f1 Mon Sep 17 00:00:00 2001 From: Warren James Date: Thu, 1 Aug 2024 16:08:39 -0400 Subject: [PATCH 06/39] feat(NODE-6231): Add CSOT behaviour for retryable reads and writes (#4186) --- src/operations/execute_operation.ts | 9 ++++--- src/timeout.ts | 26 ++++++++++++------- ...lient_side_operations_timeout.spec.test.ts | 13 +++++++++- ...lient_side_operations_timeout.unit.test.ts | 10 +++++-- .../node_csot.test.ts | 5 ---- test/tools/unified-spec-runner/match.ts | 2 ++ 6 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/operations/execute_operation.ts b/src/operations/execute_operation.ts index efd92f19de..c9135fa1c3 100644 --- a/src/operations/execute_operation.ts +++ b/src/operations/execute_operation.ts @@ -227,12 +227,10 @@ async function tryOperation< session.incrementTransactionNumber(); } - // TODO(NODE-6231): implement infinite retry within CSOT timeout here - const maxTries = willRetry ? 2 : 1; + const maxTries = willRetry ? (timeoutContext.csotEnabled() ? Infinity : 2) : 1; let previousOperationError: MongoError | undefined; let previousServer: ServerDescription | undefined; - // TODO(NODE-6231): implement infinite retry within CSOT timeout here for (let tries = 0; tries < maxTries; tries++) { if (previousOperationError) { if (hasWriteAspect && previousOperationError.code === MMAPv1_RETRY_WRITES_ERROR_CODE) { @@ -276,7 +274,6 @@ async function tryOperation< return await operation.execute(server, session, timeoutContext); } catch (operationError) { if (!(operationError instanceof MongoError)) throw operationError; - if ( previousOperationError != null && operationError.hasErrorLabel(MongoErrorLabel.NoWritesPerformed) @@ -285,6 +282,10 @@ async function tryOperation< } previousServer = server.description; previousOperationError = operationError; + + // Reset timeouts + timeoutContext.serverSelectionTimeout?.clear(); + timeoutContext.connectionCheckoutTimeout?.clear(); } } diff --git a/src/timeout.ts b/src/timeout.ts index cc90b8c2e7..297a484b4e 100644 --- a/src/timeout.ts +++ b/src/timeout.ts @@ -39,6 +39,7 @@ export class Timeout extends Promise { public ended: number | null = null; public duration: number; public timedOut = false; + public cleared = false; get remainingTime(): number { if (this.timedOut) return 0; @@ -53,7 +54,6 @@ export class Timeout extends Promise { /** Create a new timeout that expires in `duration` ms */ private constructor(executor: Executor = () => null, duration: number, unref = true) { let reject!: Reject; - if (duration < 0) { throw new MongoInvalidArgumentError('Cannot create a Timeout with a negative duration'); } @@ -86,6 +86,7 @@ export class Timeout extends Promise { clear(): void { clearTimeout(this.id); this.id = undefined; + this.cleared = true; } throwIfExpired(): void { @@ -213,16 +214,20 @@ export class CSOTTimeoutContext extends TimeoutContext { get serverSelectionTimeout(): Timeout | null { // check for undefined - if (typeof this._serverSelectionTimeout !== 'object') { + if (typeof this._serverSelectionTimeout !== 'object' || this._serverSelectionTimeout?.cleared) { + const { remainingTimeMS, serverSelectionTimeoutMS } = this; + if (remainingTimeMS <= 0) + throw new MongoOperationTimeoutError( + `Timed out in server selection after ${this.timeoutMS}ms` + ); const usingServerSelectionTimeoutMS = - this.serverSelectionTimeoutMS !== 0 && - csotMin(this.timeoutMS, this.serverSelectionTimeoutMS) === this.serverSelectionTimeoutMS; - + serverSelectionTimeoutMS !== 0 && + csotMin(remainingTimeMS, serverSelectionTimeoutMS) === serverSelectionTimeoutMS; if (usingServerSelectionTimeoutMS) { - this._serverSelectionTimeout = Timeout.expires(this.serverSelectionTimeoutMS); + this._serverSelectionTimeout = Timeout.expires(serverSelectionTimeoutMS); } else { - if (this.timeoutMS > 0) { - this._serverSelectionTimeout = Timeout.expires(this.timeoutMS); + if (remainingTimeMS > 0 && Number.isFinite(remainingTimeMS)) { + this._serverSelectionTimeout = Timeout.expires(remainingTimeMS); } else { this._serverSelectionTimeout = null; } @@ -233,7 +238,10 @@ export class CSOTTimeoutContext extends TimeoutContext { } get connectionCheckoutTimeout(): Timeout | null { - if (typeof this._connectionCheckoutTimeout !== 'object') { + if ( + typeof this._connectionCheckoutTimeout !== 'object' || + this._connectionCheckoutTimeout?.cleared + ) { if (typeof this._serverSelectionTimeout === 'object') { // null or Timeout this._connectionCheckoutTimeout = this._serverSelectionTimeout; 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 f73f162204..e4c9eb3027 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 @@ -6,7 +6,9 @@ import { runUnifiedSuite } from '../../tools/unified-spec-runner/runner'; const enabled = [ 'override-collection-timeoutMS', 'override-database-timeoutMS', - 'override-operation-timeoutMS' + 'override-operation-timeoutMS', + 'retryability-legacy-timeouts', + 'retryability-timeoutMS' ]; const cursorOperations = [ @@ -18,6 +20,11 @@ const cursorOperations = [ 'listCollectionNames' ]; +const bulkWriteOperations = [ + 'timeoutMS applies to whole operation, not individual attempts - bulkWrite on collection', + 'timeoutMS applies to whole operation, not individual attempts - insertMany on collection' +]; + describe('CSOT spec tests', function () { const specs = loadSpecTests(join('client-side-operations-timeout')); for (const spec of specs) { @@ -30,6 +37,10 @@ describe('CSOT spec tests', function () { // Cursor operation if (test.operations.find(operation => cursorOperations.includes(operation.name))) test.skipReason = 'TODO(NODE-5684): Not working yet'; + + if (bulkWriteOperations.includes(test.description)) + test.skipReason = + 'TODO(NODE-6274): update test runner to check errorResponse field of MongoBulkWriteError in isTimeoutError assertion'; } } runUnifiedSuite(specs); diff --git a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts index c4989f58d7..944d9b9604 100644 --- a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts +++ b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts @@ -7,7 +7,7 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; -import { ConnectionPool, type MongoClient, Timeout, Topology } from '../../mongodb'; +import { ConnectionPool, type MongoClient, Timeout, TimeoutContext, Topology } from '../../mongodb'; // TODO(NODE-5824): Implement CSOT prose tests describe('CSOT spec unit tests', function () { @@ -22,10 +22,16 @@ describe('CSOT spec unit tests', function () { it('Operations should ignore waitQueueTimeoutMS if timeoutMS is also set.', async function () { client = this.configuration.newClient({ waitQueueTimeoutMS: 999999, timeoutMS: 10000 }); sinon.spy(Timeout, 'expires'); + const timeoutContextSpy = sinon.spy(TimeoutContext, 'create'); await client.db('db').collection('collection').insertOne({ x: 1 }); - expect(Timeout.expires).to.have.been.calledWith(10000); + const createCalls = timeoutContextSpy.getCalls().filter( + // @ts-expect-error accessing concrete field + call => call.args[0].timeoutMS === 10000 + ); + + expect(createCalls).to.have.length.greaterThanOrEqual(1); expect(Timeout.expires).to.not.have.been.calledWith(999999); }); 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 0c97b91083..63e2d97dd9 100644 --- a/test/integration/client-side-operations-timeout/node_csot.test.ts +++ b/test/integration/client-side-operations-timeout/node_csot.test.ts @@ -1,6 +1,5 @@ /* Anything javascript specific relating to timeouts */ import { expect } from 'chai'; -import * as sinon from 'sinon'; import { type ClientSession, @@ -13,10 +12,6 @@ import { } from '../../mongodb'; describe('CSOT driver tests', () => { - afterEach(() => { - sinon.restore(); - }); - describe('timeoutMS inheritance', () => { let client: MongoClient; let db: Db; diff --git a/test/tools/unified-spec-runner/match.ts b/test/tools/unified-spec-runner/match.ts index fb1a759bf5..5c4ea000de 100644 --- a/test/tools/unified-spec-runner/match.ts +++ b/test/tools/unified-spec-runner/match.ts @@ -778,6 +778,8 @@ export function expectErrorCheck( expect(error).to.be.instanceof(MongoOperationTimeoutError); } + // TODO(NODE-6274): Check for MongoBulkWriteErrors that have a MongoOperationTimeoutError in their + // errorResponse field if (expected.isTimeoutError === false) { expect(error).to.not.be.instanceof(MongoOperationTimeoutError); } else if (expected.isTimeoutError === true) { From c63d102f13ae13c85663c4b9167bcb9b8692f932 Mon Sep 17 00:00:00 2001 From: Neal Beeken Date: Mon, 12 Aug 2024 16:46:06 -0400 Subject: [PATCH 07/39] feat(NODE-6312): add error transformation for server timeouts (#4192) --- src/cmap/connection.ts | 29 ++++ src/cmap/wire_protocol/responses.ts | 36 +++- .../node_csot.test.ts | 163 +++++++++++++++++- 3 files changed, 225 insertions(+), 3 deletions(-) diff --git a/src/cmap/connection.ts b/src/cmap/connection.ts index e3f1b0d753..fbc54c944c 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -16,6 +16,7 @@ import { } from '../constants'; import { MongoCompatibilityError, + MONGODB_ERROR_CODES, MongoMissingDependencyError, MongoNetworkError, MongoNetworkTimeoutError, @@ -538,6 +539,11 @@ export class Connection extends TypedEventEmitter { } if (document.ok === 0) { + if (options.timeoutContext?.csotEnabled() && document.isMaxTimeExpiredError) { + throw new MongoOperationTimeoutError('Server reported a timeout error', { + cause: new MongoServerError((object ??= document.toObject(bsonOptions))) + }); + } throw new MongoServerError((object ??= document.toObject(bsonOptions))); } @@ -607,6 +613,29 @@ export class Connection extends TypedEventEmitter { ): Promise { this.throwIfAborted(); for await (const document of this.sendCommand(ns, command, options, responseType)) { + if (options.timeoutContext?.csotEnabled()) { + if (MongoDBResponse.is(document)) { + // TODO(NODE-5684): test coverage to be added once cursors are enabling CSOT + if (document.isMaxTimeExpiredError) { + throw new MongoOperationTimeoutError('Server reported a timeout error', { + cause: new MongoServerError(document.toObject()) + }); + } + } else { + if ( + (Array.isArray(document?.writeErrors) && + document.writeErrors.some( + error => error?.code === MONGODB_ERROR_CODES.MaxTimeMSExpired + )) || + document?.writeConcernError?.code === MONGODB_ERROR_CODES.MaxTimeMSExpired + ) { + throw new MongoOperationTimeoutError('Server reported a timeout error', { + cause: new MongoServerError(document) + }); + } + } + } + return document; } throw new MongoUnexpectedServerResponseError('Unable to get response from server'); diff --git a/src/cmap/wire_protocol/responses.ts b/src/cmap/wire_protocol/responses.ts index e69cf84cfc..e0c48a2ea7 100644 --- a/src/cmap/wire_protocol/responses.ts +++ b/src/cmap/wire_protocol/responses.ts @@ -11,7 +11,7 @@ import { pluckBSONSerializeOptions, type Timestamp } from '../../bson'; -import { MongoUnexpectedServerResponseError } from '../../error'; +import { MONGODB_ERROR_CODES, MongoUnexpectedServerResponseError } from '../../error'; import { type ClusterTime } from '../../sdam/common'; import { decorateDecryptionResult, ns } from '../../utils'; import { @@ -111,6 +111,40 @@ export class MongoDBResponse extends OnDemandDocument { // {ok:1} static empty = new MongoDBResponse(new Uint8Array([13, 0, 0, 0, 16, 111, 107, 0, 1, 0, 0, 0, 0])); + /** + * Returns true iff: + * - ok is 0 and the top-level code === 50 + * - ok is 1 and the writeErrors array contains a code === 50 + * - ok is 1 and the writeConcern object contains a code === 50 + */ + get isMaxTimeExpiredError() { + // {ok: 0, code: 50 ... } + const isTopLevel = this.ok === 0 && this.code === MONGODB_ERROR_CODES.MaxTimeMSExpired; + if (isTopLevel) return true; + + if (this.ok === 0) return false; + + // {ok: 1, writeConcernError: {code: 50 ... }} + const isWriteConcern = + this.get('writeConcernError', BSONType.object)?.getNumber('code') === + MONGODB_ERROR_CODES.MaxTimeMSExpired; + if (isWriteConcern) return true; + + const writeErrors = this.get('writeErrors', BSONType.array); + if (writeErrors?.size()) { + for (let i = 0; i < writeErrors.size(); i++) { + const isWriteError = + writeErrors.get(i, BSONType.object)?.getNumber('code') === + MONGODB_ERROR_CODES.MaxTimeMSExpired; + + // {ok: 1, writeErrors: [{code: 50 ... }]} + if (isWriteError) return true; + } + } + + return false; + } + /** * Drivers can safely assume that the `recoveryToken` field is always a BSON document but drivers MUST NOT modify the * contents of the document. 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 63e2d97dd9..d7d4a4ede5 100644 --- a/test/integration/client-side-operations-timeout/node_csot.test.ts +++ b/test/integration/client-side-operations-timeout/node_csot.test.ts @@ -1,17 +1,23 @@ /* Anything javascript specific relating to timeouts */ import { expect } from 'chai'; +import * as semver from 'semver'; +import * as sinon from 'sinon'; import { + BSON, type ClientSession, type Collection, + Connection, type Db, type FindCursor, LEGACY_HELLO_COMMAND, type MongoClient, - MongoOperationTimeoutError + MongoOperationTimeoutError, + MongoServerError } from '../../mongodb'; +import { type FailPoint } from '../../tools/utils'; -describe('CSOT driver tests', () => { +describe('CSOT driver tests', { requires: { mongodb: '>=4.4' } }, () => { describe('timeoutMS inheritance', () => { let client: MongoClient; let db: Db; @@ -161,4 +167,157 @@ describe('CSOT driver tests', () => { }); }); }); + + describe('server-side maxTimeMS errors are transformed', () => { + let client: MongoClient; + let commandsSucceeded; + let commandsFailed; + + beforeEach(async function () { + client = this.configuration.newClient({ timeoutMS: 500_000, monitorCommands: true }); + commandsSucceeded = []; + commandsFailed = []; + client.on('commandSucceeded', event => { + if (event.commandName === 'configureFailPoint') return; + commandsSucceeded.push(event); + }); + client.on('commandFailed', event => commandsFailed.push(event)); + }); + + afterEach(async function () { + await client + .db() + .collection('a') + .drop() + .catch(() => null); + await client.close(); + commandsSucceeded = undefined; + commandsFailed = undefined; + }); + + describe('when a maxTimeExpired error is returned at the top-level', () => { + // {ok: 0, code: 50, codeName: "MaxTimeMSExpired", errmsg: "operation time limit exceeded"} + const failpoint: FailPoint = { + configureFailPoint: 'failCommand', + mode: { times: 1 }, + data: { + failCommands: ['ping'], + errorCode: 50 + } + }; + + beforeEach(async function () { + if (semver.satisfies(this.configuration.version, '>=4.4')) + await client.db('admin').command(failpoint); + else { + this.skipReason = 'Requires server version later than 4.4'; + this.skip(); + } + }); + + afterEach(async function () { + if (semver.satisfies(this.configuration.version, '>=4.4')) + await client.db('admin').command({ ...failpoint, mode: 'off' }); + }); + + it('throws a MongoOperationTimeoutError error and emits command failed', async () => { + const error = await client + .db() + .command({ ping: 1 }) + .catch(error => error); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(error.cause).to.be.instanceOf(MongoServerError); + expect(error.cause).to.have.property('code', 50); + + expect(commandsFailed).to.have.lengthOf(1); + expect(commandsFailed).to.have.nested.property('[0].failure.cause.code', 50); + }); + }); + + describe('when a maxTimeExpired error is returned inside a writeErrors array', () => { + // The server should always return one maxTimeExpiredError at the front of the writeErrors array + // But for the sake of defensive programming we will find any maxTime error in the array. + + beforeEach(async () => { + const writeErrorsReply = BSON.serialize({ + ok: 1, + writeErrors: [ + { code: 2, codeName: 'MaxTimeMSExpired', errmsg: 'operation time limit exceeded' }, + { code: 3, codeName: 'MaxTimeMSExpired', errmsg: 'operation time limit exceeded' }, + { code: 4, codeName: 'MaxTimeMSExpired', errmsg: 'operation time limit exceeded' }, + { code: 50, codeName: 'MaxTimeMSExpired', errmsg: 'operation time limit exceeded' } + ] + }); + const commandSpy = sinon.spy(Connection.prototype, 'command'); + const readManyStub = sinon + // @ts-expect-error: readMany is private + .stub(Connection.prototype, 'readMany') + .callsFake(async function* (...args) { + const realIterator = readManyStub.wrappedMethod.call(this, ...args); + const cmd = commandSpy.lastCall.args.at(1); + if ('giveMeWriteErrors' in cmd) { + await realIterator.next().catch(() => null); // dismiss response + yield { parse: () => writeErrorsReply }; + } else { + yield (await realIterator.next()).value; + } + }); + }); + + afterEach(() => sinon.restore()); + + it('throws a MongoOperationTimeoutError error and emits command succeeded', async () => { + const error = await client + .db('admin') + .command({ giveMeWriteErrors: 1 }) + .catch(error => error); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(error.cause).to.be.instanceOf(MongoServerError); + expect(error.cause).to.have.nested.property('writeErrors[3].code', 50); + + expect(commandsSucceeded).to.have.lengthOf(1); + expect(commandsSucceeded).to.have.nested.property('[0].reply.writeErrors[3].code', 50); + }); + }); + + describe('when a maxTimeExpired error is returned inside a writeConcernError embedded document', () => { + // {ok: 1, writeConcernError: {code: 50, codeName: "MaxTimeMSExpired"}} + const failpoint: FailPoint = { + configureFailPoint: 'failCommand', + mode: { times: 1 }, + data: { + failCommands: ['insert'], + writeConcernError: { code: 50, errmsg: 'times up buster', errorLabels: [] } + } + }; + + beforeEach(async function () { + if (semver.satisfies(this.configuration.version, '>=4.4')) + await client.db('admin').command(failpoint); + else { + this.skipReason = 'Requires server version later than 4.4'; + this.skip(); + } + }); + + afterEach(async function () { + if (semver.satisfies(this.configuration.version, '>=4.4')) + await client.db('admin').command({ ...failpoint, mode: 'off' }); + }); + + it('throws a MongoOperationTimeoutError error and emits command succeeded', async () => { + const error = await client + .db() + .collection('a') + .insertOne({}) + .catch(error => error); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(error.cause).to.be.instanceOf(MongoServerError); + expect(error.cause).to.have.nested.property('writeConcernError.code', 50); + + expect(commandsSucceeded).to.have.lengthOf(1); + expect(commandsSucceeded).to.have.nested.property('[0].reply.writeConcernError.code', 50); + }); + }); + }); }); From 1eab23d2ae74c401b6dfb2408931fdc219539c57 Mon Sep 17 00:00:00 2001 From: Neal Beeken Date: Mon, 9 Sep 2024 11:11:11 -0400 Subject: [PATCH 08/39] feat(NODE-6313): add CSOT support to sessions and transactions (#4199) --- package-lock.json | 82 +++--- package.json | 2 +- src/cmap/connection.ts | 7 + src/cmap/wire_protocol/on_data.ts | 15 +- src/collection.ts | 12 +- src/db.ts | 22 +- src/error.ts | 3 + src/operations/execute_operation.ts | 8 +- src/sessions.ts | 255 ++++++++++++------ src/timeout.ts | 49 +++- src/transactions.ts | 7 +- src/utils.ts | 13 +- ...ient_side_operations_timeout.prose.test.ts | 167 +++++++++++- ...lient_side_operations_timeout.spec.test.ts | 18 +- .../node_csot.test.ts | 150 +++++++++++ .../sessions-inherit-timeoutMS.json | 28 +- .../sessions-inherit-timeoutMS.yml | 19 +- ...sessions-override-operation-timeoutMS.json | 32 ++- .../sessions-override-operation-timeoutMS.yml | 23 +- .../sessions-override-timeoutMS.json | 28 +- .../sessions-override-timeoutMS.yml | 19 +- test/tools/unified-spec-runner/entities.ts | 4 + test/tools/unified-spec-runner/match.ts | 19 +- test/tools/unified-spec-runner/operations.ts | 27 +- 24 files changed, 776 insertions(+), 233 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2b07cd361d..1d9cebf509 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,7 +49,7 @@ "mocha": "^10.4.0", "mocha-sinon": "^2.1.2", "mongodb-client-encryption": "^6.1.0", - "mongodb-legacy": "^6.1.0", + "mongodb-legacy": "^6.1.1", "nyc": "^15.1.0", "prettier": "^3.3.3", "semver": "^7.6.3", @@ -6415,10 +6415,46 @@ "node": ">=10" } }, - "node_modules/mongodb": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.8.0.tgz", - "integrity": "sha512-HGQ9NWDle5WvwMnrvUxsFYPd3JEbqD3RgABHBQRuoCEND0qzhsd0iH5ypHsf1eJ+sXmvmyKpP+FLOKY8Il7jMw==", + "node_modules/mongodb-client-encryption": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/mongodb-client-encryption/-/mongodb-client-encryption-6.1.0.tgz", + "integrity": "sha512-Y3Hakre82nXD/pNDUzBjxfgwWSj5E1ar9ZLkqyXDfvirv4huHMbg8Q2qVO/TXlNJuf1B2bzrEDXsTqHKQSQLtw==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.1.2" + }, + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz", + "integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^13.0.0" + } + }, + "node_modules/mongodb-legacy": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/mongodb-legacy/-/mongodb-legacy-6.1.1.tgz", + "integrity": "sha512-u9Cl8UEzdtf7mhWrAEHHhfU0OCqahaOB5midwtyudWIuEz5t18DJFXfqJq3cbEypVfLkfF3zi6rkolKMU9uPjQ==", + "dev": true, + "dependencies": { + "mongodb": "^6.0.0" + }, + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/mongodb-legacy/node_modules/mongodb": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.7.0.tgz", + "integrity": "sha512-TMKyHdtMcO0fYBNORiYdmM25ijsHs+Njs963r4Tro4OQZzqYigAzYQouwWRg4OIaiLRUEGUh/1UAcH5lxdSLIA==", "dev": true, "dependencies": { "@mongodb-js/saslprep": "^1.1.5", @@ -6461,42 +6497,6 @@ } } }, - "node_modules/mongodb-client-encryption": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/mongodb-client-encryption/-/mongodb-client-encryption-6.1.0.tgz", - "integrity": "sha512-Y3Hakre82nXD/pNDUzBjxfgwWSj5E1ar9ZLkqyXDfvirv4huHMbg8Q2qVO/TXlNJuf1B2bzrEDXsTqHKQSQLtw==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "bindings": "^1.5.0", - "node-addon-api": "^4.3.0", - "prebuild-install": "^7.1.2" - }, - "engines": { - "node": ">=16.20.1" - } - }, - "node_modules/mongodb-connection-string-url": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz", - "integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==", - "dependencies": { - "@types/whatwg-url": "^11.0.2", - "whatwg-url": "^13.0.0" - } - }, - "node_modules/mongodb-legacy": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/mongodb-legacy/-/mongodb-legacy-6.1.1.tgz", - "integrity": "sha512-u9Cl8UEzdtf7mhWrAEHHhfU0OCqahaOB5midwtyudWIuEz5t18DJFXfqJq3cbEypVfLkfF3zi6rkolKMU9uPjQ==", - "dev": true, - "dependencies": { - "mongodb": "^6.0.0" - }, - "engines": { - "node": ">=16.20.1" - } - }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", diff --git a/package.json b/package.json index 479356905d..2de0e1811f 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "mocha": "^10.4.0", "mocha-sinon": "^2.1.2", "mongodb-client-encryption": "^6.1.0", - "mongodb-legacy": "^6.1.0", + "mongodb-legacy": "^6.1.1", "nyc": "^15.1.0", "prettier": "^3.3.3", "semver": "^7.6.3", diff --git a/src/cmap/connection.ts b/src/cmap/connection.ts index fbc54c944c..91fe37662b 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -738,6 +738,13 @@ export class Connection extends TypedEventEmitter { return; } } + } catch (readError) { + if (TimeoutError.is(readError)) { + throw new MongoOperationTimeoutError( + `Timed out during socket read (${readError.duration}ms)` + ); + } + throw readError; } finally { this.dataEvents = null; this.throwIfAborted(); diff --git a/src/cmap/wire_protocol/on_data.ts b/src/cmap/wire_protocol/on_data.ts index a32c6b1b48..23fd88e282 100644 --- a/src/cmap/wire_protocol/on_data.ts +++ b/src/cmap/wire_protocol/on_data.ts @@ -1,7 +1,6 @@ import { type EventEmitter } from 'events'; -import { MongoOperationTimeoutError } from '../../error'; -import { type TimeoutContext, TimeoutError } from '../../timeout'; +import { type TimeoutContext } from '../../timeout'; import { List, promiseWithResolvers } from '../../utils'; /** @@ -91,8 +90,11 @@ export function onData( // Adding event handlers emitter.on('data', eventHandler); emitter.on('error', errorHandler); + + const timeoutForSocketRead = timeoutContext?.timeoutForSocketRead; + timeoutForSocketRead?.throwIfExpired(); // eslint-disable-next-line github/no-then - timeoutContext?.timeoutForSocketRead?.then(undefined, errorHandler); + timeoutForSocketRead?.then(undefined, errorHandler); return iterator; @@ -104,12 +106,9 @@ export function onData( function errorHandler(err: Error) { const promise = unconsumedPromises.shift(); - const timeoutError = TimeoutError.is(err) - ? new MongoOperationTimeoutError('Timed out during socket read') - : undefined; - if (promise != null) promise.reject(timeoutError ?? err); - else error = timeoutError ?? err; + if (promise != null) promise.reject(err); + else error = err; void closeHandler(); } diff --git a/src/collection.ts b/src/collection.ts index dbd91371cc..f3a206b0c7 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -470,10 +470,14 @@ export class Collection { // Intentionally, we do not inherit options from parent for this operation. return await executeOperation( this.client, - new RenameOperation(this as TODO_NODE_3286, newName, { - ...options, - readPreference: ReadPreference.PRIMARY - }) as TODO_NODE_3286 + new RenameOperation( + this as TODO_NODE_3286, + newName, + resolveOptions(undefined, { + ...options, + readPreference: ReadPreference.PRIMARY + }) + ) as TODO_NODE_3286 ); } diff --git a/src/db.ts b/src/db.ts index 07a0c928cc..cbb0eac13f 100644 --- a/src/db.ts +++ b/src/db.ts @@ -275,12 +275,16 @@ export class Db { // Intentionally, we do not inherit options from parent for this operation. return await executeOperation( this.client, - new RunCommandOperation(this, command, { - ...resolveBSONOptions(options), - timeoutMS: options?.timeoutMS ?? this.timeoutMS, - session: options?.session, - readPreference: options?.readPreference - }) + new RunCommandOperation( + this, + command, + resolveOptions(undefined, { + ...resolveBSONOptions(options), + timeoutMS: options?.timeoutMS ?? this.timeoutMS, + session: options?.session, + readPreference: options?.readPreference + }) + ) ); } @@ -385,7 +389,11 @@ export class Db { new RenameOperation( this.collection(fromCollection) as TODO_NODE_3286, toCollection, - { ...options, new_collection: true, readPreference: ReadPreference.primary } + resolveOptions(undefined, { + ...options, + new_collection: true, + readPreference: ReadPreference.primary + }) ) as TODO_NODE_3286 ); } diff --git a/src/error.ts b/src/error.ts index 0620d3069f..3867553370 100644 --- a/src/error.ts +++ b/src/error.ts @@ -124,6 +124,9 @@ function isAggregateError(e: unknown): e is Error & { errors: Error[] } { * mongodb-client-encryption has a dependency on this error, it uses the constructor with a string argument */ export class MongoError extends Error { + get [Symbol.toStringTag]() { + return this.name; + } /** @internal */ [kErrorLabels]: Set; /** diff --git a/src/operations/execute_operation.ts b/src/operations/execute_operation.ts index c9135fa1c3..f9d9f9b63b 100644 --- a/src/operations/execute_operation.ts +++ b/src/operations/execute_operation.ts @@ -58,7 +58,7 @@ type ResultTypeFromOperation = export async function executeOperation< T extends AbstractOperation, TResult = ResultTypeFromOperation ->(client: MongoClient, operation: T, timeoutContext?: TimeoutContext): Promise { +>(client: MongoClient, operation: T, timeoutContext?: TimeoutContext | null): Promise { if (!(operation instanceof AbstractOperation)) { // TODO(NODE-3483): Extend MongoRuntimeError throw new MongoRuntimeError('This method requires a valid operation instance'); @@ -81,11 +81,6 @@ export async function executeOperation< } else if (session.client !== client) { throw new MongoInvalidArgumentError('ClientSession must be from the same MongoClient'); } - if (session.explicit && session?.timeoutMS != null && operation.options.timeoutMS != null) { - throw new MongoInvalidArgumentError( - 'Do not specify timeoutMS on operation if already specified on an explicit session' - ); - } const readPreference = operation.readPreference ?? ReadPreference.primary; const inTransaction = !!session?.inTransaction(); @@ -107,6 +102,7 @@ export async function executeOperation< } timeoutContext ??= TimeoutContext.create({ + session, serverSelectionTimeoutMS: client.s.options.serverSelectionTimeoutMS, waitQueueTimeoutMS: client.s.options.waitQueueTimeoutMS, timeoutMS: operation.options.timeoutMS diff --git a/src/sessions.ts b/src/sessions.ts index bad966ed71..bbd1785275 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -29,6 +29,7 @@ import { ReadConcernLevel } from './read_concern'; import { ReadPreference } from './read_preference'; import { type AsyncDisposable, configureResourceManagement } from './resource_management'; import { _advanceClusterTime, type ClusterTime, TopologyType } from './sdam/common'; +import { TimeoutContext } from './timeout'; import { isTransactionCommand, Transaction, @@ -58,8 +59,11 @@ export interface ClientSessionOptions { snapshot?: boolean; /** The default TransactionOptions to use for transactions started on this session. */ defaultTransactionOptions?: TransactionOptions; - /** @internal - * The value of timeoutMS used for CSOT. Used to override client timeoutMS */ + /** + * @public + * An overriding timeoutMS value to use for a client-side timeout. + * If not provided the session uses the timeoutMS specified on the MongoClient. + */ defaultTimeoutMS?: number; /** @internal */ @@ -98,6 +102,9 @@ export interface EndSessionOptions { error?: AnyError; force?: boolean; forceClear?: boolean; + + /** @internal */ + timeoutMS?: number; } /** @@ -115,7 +122,7 @@ export class ClientSession /** @internal */ sessionPool: ServerSessionPool; hasEnded: boolean; - clientOptions?: MongoOptions; + clientOptions: MongoOptions; supports: { causalConsistency: boolean }; clusterTime?: ClusterTime; operationTime?: Timestamp; @@ -137,6 +144,9 @@ export class ClientSession /** @internal */ timeoutMS?: number; + /** @internal */ + public timeoutContext: TimeoutContext | null = null; + /** * Create a client session. * @internal @@ -149,7 +159,7 @@ export class ClientSession client: MongoClient, sessionPool: ServerSessionPool, options: ClientSessionOptions, - clientOptions?: MongoOptions + clientOptions: MongoOptions ) { super(); @@ -269,8 +279,13 @@ export class ClientSession async endSession(options?: EndSessionOptions): Promise { try { if (this.inTransaction()) { - await this.abortTransaction(); + await this.abortTransaction({ ...options, throwTimeout: true }); } + } catch (error) { + // spec indicates that we should ignore all errors for `endSessions` + if (error.name === 'MongoOperationTimeoutError') throw error; + squashError(error); + } finally { if (!this.hasEnded) { const serverSession = this[kServerSession]; if (serverSession != null) { @@ -286,10 +301,6 @@ export class ClientSession this.hasEnded = true; this.emit('ended', this); } - } catch (error) { - // spec indicates that we should ignore all errors for `endSessions` - squashError(error); - } finally { maybeClearPinnedConnection(this, { force: true, ...options }); } } @@ -441,8 +452,10 @@ export class ClientSession /** * Commits the currently active transaction in this session. + * + * @param options - Optional options, can be used to override `defaultTimeoutMS`. */ - async commitTransaction(): Promise { + async commitTransaction(options?: { timeoutMS?: number }): Promise { if (this.transaction.state === TxnState.NO_TRANSACTION) { throw new MongoTransactionError('No transaction started'); } @@ -492,8 +505,25 @@ export class ClientSession bypassPinningCheck: true }); + const timeoutMS = + typeof options?.timeoutMS === 'number' + ? options.timeoutMS + : typeof this.timeoutMS === 'number' + ? this.timeoutMS + : null; + + const timeoutContext = + this.timeoutContext ?? + (typeof timeoutMS === 'number' + ? TimeoutContext.create({ + serverSelectionTimeoutMS: this.clientOptions.serverSelectionTimeoutMS, + socketTimeoutMS: this.clientOptions.socketTimeoutMS, + timeoutMS + }) + : null); + try { - await executeOperation(this.client, operation); + await executeOperation(this.client, operation, timeoutContext); return; } catch (firstCommitError) { if (firstCommitError instanceof MongoError && isRetryableWriteError(firstCommitError)) { @@ -503,7 +533,7 @@ export class ClientSession this.unpin({ force: true }); try { - await executeOperation(this.client, operation); + await executeOperation(this.client, operation, timeoutContext); return; } catch (retryCommitError) { // If the retry failed, we process that error instead of the original @@ -535,8 +565,13 @@ export class ClientSession /** * Aborts the currently active transaction in this session. + * + * @param options - Optional options, can be used to override `defaultTimeoutMS`. */ - async abortTransaction(): Promise { + async abortTransaction(options?: { timeoutMS?: number }): Promise; + /** @internal */ + async abortTransaction(options?: { timeoutMS?: number; throwTimeout?: true }): Promise; + async abortTransaction(options?: { timeoutMS?: number; throwTimeout?: true }): Promise { if (this.transaction.state === TxnState.NO_TRANSACTION) { throw new MongoTransactionError('No transaction started'); } @@ -581,18 +616,45 @@ export class ClientSession bypassPinningCheck: true }); + const timeoutMS = + typeof options?.timeoutMS === 'number' + ? options.timeoutMS + : this.timeoutContext?.csotEnabled() + ? this.timeoutContext.timeoutMS // refresh timeoutMS for abort operation + : typeof this.timeoutMS === 'number' + ? this.timeoutMS + : null; + + const timeoutContext = + timeoutMS != null + ? TimeoutContext.create({ + timeoutMS, + serverSelectionTimeoutMS: this.clientOptions.serverSelectionTimeoutMS, + socketTimeoutMS: this.clientOptions.socketTimeoutMS + }) + : null; + try { - await executeOperation(this.client, operation); + await executeOperation(this.client, operation, timeoutContext); this.unpin(); return; } catch (firstAbortError) { this.unpin(); + if (firstAbortError.name === 'MongoRuntimeError') throw firstAbortError; + if (options?.throwTimeout && firstAbortError.name === 'MongoOperationTimeoutError') { + throw firstAbortError; + } + if (firstAbortError instanceof MongoError && isRetryableWriteError(firstAbortError)) { try { - await executeOperation(this.client, operation); + await executeOperation(this.client, operation, timeoutContext); return; - } catch { + } catch (secondAbortError) { + if (secondAbortError.name === 'MongoRuntimeError') throw secondAbortError; + if (options?.throwTimeout && secondAbortError.name === 'MongoOperationTimeoutError') { + throw secondAbortError; + } // we do not retry the retry } } @@ -647,96 +709,119 @@ export class ClientSession */ async withTransaction( fn: WithTransactionCallback, - options?: TransactionOptions + options?: TransactionOptions & { + /** + * Configures a timeoutMS expiry for the entire withTransactionCallback. + * + * @remarks + * - The remaining timeout will not be applied to callback operations that do not use the ClientSession. + * - Overriding timeoutMS for operations executed using the explicit session inside the provided callback will result in a client-side error. + */ + timeoutMS?: number; + } ): Promise { const MAX_TIMEOUT = 120000; - const startTime = now(); - - let committed = false; - let result: any; - while (!committed) { - this.startTransaction(options); // may throw on error + const timeoutMS = options?.timeoutMS ?? this.timeoutMS ?? null; + this.timeoutContext = + timeoutMS != null + ? TimeoutContext.create({ + timeoutMS, + serverSelectionTimeoutMS: this.clientOptions.serverSelectionTimeoutMS, + socketTimeoutMS: this.clientOptions.socketTimeoutMS + }) + : null; - try { - const promise = fn(this); - if (!isPromiseLike(promise)) { - throw new MongoInvalidArgumentError( - 'Function provided to `withTransaction` must return a Promise' - ); - } + const startTime = this.timeoutContext?.csotEnabled() ? this.timeoutContext.start : now(); - result = await promise; + let committed = false; + let result: any; - if ( - this.transaction.state === TxnState.NO_TRANSACTION || - this.transaction.state === TxnState.TRANSACTION_COMMITTED || - this.transaction.state === TxnState.TRANSACTION_ABORTED - ) { - // Assume callback intentionally ended the transaction - return result; - } - } catch (fnError) { - if (!(fnError instanceof MongoError) || fnError instanceof MongoInvalidArgumentError) { - await this.abortTransaction(); - throw fnError; - } + try { + while (!committed) { + this.startTransaction(options); // may throw on error - if ( - this.transaction.state === TxnState.STARTING_TRANSACTION || - this.transaction.state === TxnState.TRANSACTION_IN_PROGRESS - ) { - await this.abortTransaction(); - } + try { + const promise = fn(this); + if (!isPromiseLike(promise)) { + throw new MongoInvalidArgumentError( + 'Function provided to `withTransaction` must return a Promise' + ); + } - if ( - fnError.hasErrorLabel(MongoErrorLabel.TransientTransactionError) && - now() - startTime < MAX_TIMEOUT - ) { - continue; - } + result = await promise; - throw fnError; - } + if ( + this.transaction.state === TxnState.NO_TRANSACTION || + this.transaction.state === TxnState.TRANSACTION_COMMITTED || + this.transaction.state === TxnState.TRANSACTION_ABORTED + ) { + // Assume callback intentionally ended the transaction + return result; + } + } catch (fnError) { + if (!(fnError instanceof MongoError) || fnError instanceof MongoInvalidArgumentError) { + await this.abortTransaction(); + throw fnError; + } - while (!committed) { - try { - /* - * We will rely on ClientSession.commitTransaction() to - * apply a majority write concern if commitTransaction is - * being retried (see: DRIVERS-601) - */ - await this.commitTransaction(); - committed = true; - } catch (commitError) { - /* - * Note: a maxTimeMS error will have the MaxTimeMSExpired - * code (50) and can be reported as a top-level error or - * inside writeConcernError, ex. - * { ok:0, code: 50, codeName: 'MaxTimeMSExpired' } - * { ok:1, writeConcernError: { code: 50, codeName: 'MaxTimeMSExpired' } } - */ if ( - !isMaxTimeMSExpiredError(commitError) && - commitError.hasErrorLabel(MongoErrorLabel.UnknownTransactionCommitResult) && - now() - startTime < MAX_TIMEOUT + this.transaction.state === TxnState.STARTING_TRANSACTION || + this.transaction.state === TxnState.TRANSACTION_IN_PROGRESS ) { - continue; + await this.abortTransaction(); } if ( - commitError.hasErrorLabel(MongoErrorLabel.TransientTransactionError) && - now() - startTime < MAX_TIMEOUT + fnError.hasErrorLabel(MongoErrorLabel.TransientTransactionError) && + (this.timeoutContext != null || now() - startTime < MAX_TIMEOUT) ) { - break; + continue; } - throw commitError; + throw fnError; + } + + while (!committed) { + try { + /* + * We will rely on ClientSession.commitTransaction() to + * apply a majority write concern if commitTransaction is + * being retried (see: DRIVERS-601) + */ + await this.commitTransaction(); + committed = true; + } catch (commitError) { + /* + * Note: a maxTimeMS error will have the MaxTimeMSExpired + * code (50) and can be reported as a top-level error or + * inside writeConcernError, ex. + * { ok:0, code: 50, codeName: 'MaxTimeMSExpired' } + * { ok:1, writeConcernError: { code: 50, codeName: 'MaxTimeMSExpired' } } + */ + if ( + !isMaxTimeMSExpiredError(commitError) && + commitError.hasErrorLabel(MongoErrorLabel.UnknownTransactionCommitResult) && + (this.timeoutContext != null || now() - startTime < MAX_TIMEOUT) + ) { + continue; + } + + if ( + commitError.hasErrorLabel(MongoErrorLabel.TransientTransactionError) && + (this.timeoutContext != null || now() - startTime < MAX_TIMEOUT) + ) { + break; + } + + throw commitError; + } } } + return result; + } finally { + this.timeoutContext = null; } - - return result; } } diff --git a/src/timeout.ts b/src/timeout.ts index 297a484b4e..f057bdb90b 100644 --- a/src/timeout.ts +++ b/src/timeout.ts @@ -1,16 +1,19 @@ import { clearTimeout, setTimeout } from 'timers'; import { MongoInvalidArgumentError, MongoOperationTimeoutError, MongoRuntimeError } from './error'; +import { type ClientSession } from './sessions'; import { csotMin, noop } from './utils'; /** @internal */ export class TimeoutError extends Error { + duration: number; override get name(): 'TimeoutError' { return 'TimeoutError'; } - constructor(message: string, options?: { cause?: Error }) { + constructor(message: string, options: { cause?: Error; duration: number }) { super(message, options); + this.duration = options.duration; } static is(error: unknown): error is TimeoutError { @@ -52,12 +55,19 @@ export class Timeout extends Promise { } /** Create a new timeout that expires in `duration` ms */ - private constructor(executor: Executor = () => null, duration: number, unref = true) { - let reject!: Reject; + private constructor( + executor: Executor = () => null, + options?: { duration: number; unref?: true; rejection?: Error } + ) { + const duration = options?.duration ?? 0; + const unref = !!options?.unref; + const rejection = options?.rejection; + if (duration < 0) { throw new MongoInvalidArgumentError('Cannot create a Timeout with a negative duration'); } + let reject!: Reject; super((_, promiseReject) => { reject = promiseReject; @@ -67,16 +77,20 @@ export class Timeout extends Promise { this.duration = duration; this.start = Math.trunc(performance.now()); - if (this.duration > 0) { + if (rejection == null && this.duration > 0) { this.id = setTimeout(() => { this.ended = Math.trunc(performance.now()); this.timedOut = true; - reject(new TimeoutError(`Expired after ${duration}ms`)); + reject(new TimeoutError(`Expired after ${duration}ms`, { duration })); }, this.duration); if (typeof this.id.unref === 'function' && unref) { // Ensure we do not keep the Node.js event loop running this.id.unref(); } + } else if (rejection != null) { + this.ended = Math.trunc(performance.now()); + this.timedOut = true; + reject(rejection); } } @@ -90,11 +104,11 @@ export class Timeout extends Promise { } throwIfExpired(): void { - if (this.timedOut) throw new TimeoutError('Timed out'); + if (this.timedOut) throw new TimeoutError('Timed out', { duration: this.duration }); } - public static expires(durationMS: number, unref?: boolean): Timeout { - return new Timeout(undefined, durationMS, unref); + public static expires(duration: number, unref?: true): Timeout { + return new Timeout(undefined, { duration, unref }); } static is(timeout: unknown): timeout is Timeout { @@ -107,10 +121,16 @@ export class Timeout extends Promise { typeof timeout.then === 'function' ); } + + static override reject(rejection?: Error): Timeout { + return new Timeout(undefined, { duration: 0, unref: true, rejection }); + } } /** @internal */ -export type TimeoutContextOptions = LegacyTimeoutContextOptions | CSOTTimeoutContextOptions; +export type TimeoutContextOptions = (LegacyTimeoutContextOptions | CSOTTimeoutContextOptions) & { + session?: ClientSession; +}; /** @internal */ export type LegacyTimeoutContextOptions = { @@ -151,6 +171,7 @@ function isCSOTTimeoutContextOptions(v: unknown): v is CSOTTimeoutContextOptions /** @internal */ export abstract class TimeoutContext { static create(options: TimeoutContextOptions): TimeoutContext { + if (options.session?.timeoutContext != null) return options.session?.timeoutContext; if (isCSOTTimeoutContextOptions(options)) return new CSOTTimeoutContext(options); else if (isLegacyTimeoutContextOptions(options)) return new LegacyTimeoutContext(options); else throw new MongoRuntimeError('Unrecognized options'); @@ -183,7 +204,7 @@ export class CSOTTimeoutContext extends TimeoutContext { private _serverSelectionTimeout?: Timeout | null; private _connectionCheckoutTimeout?: Timeout | null; public minRoundTripTime = 0; - private start: number; + public start: number; constructor(options: CSOTTimeoutContextOptions) { super(); @@ -217,8 +238,8 @@ export class CSOTTimeoutContext extends TimeoutContext { if (typeof this._serverSelectionTimeout !== 'object' || this._serverSelectionTimeout?.cleared) { const { remainingTimeMS, serverSelectionTimeoutMS } = this; if (remainingTimeMS <= 0) - throw new MongoOperationTimeoutError( - `Timed out in server selection after ${this.timeoutMS}ms` + return Timeout.reject( + new MongoOperationTimeoutError(`Timed out in server selection after ${this.timeoutMS}ms`) ); const usingServerSelectionTimeoutMS = serverSelectionTimeoutMS !== 0 && @@ -258,14 +279,14 @@ export class CSOTTimeoutContext extends TimeoutContext { const { remainingTimeMS } = this; if (!Number.isFinite(remainingTimeMS)) return null; if (remainingTimeMS > 0) return Timeout.expires(remainingTimeMS); - throw new MongoOperationTimeoutError('Timed out before socket write'); + return Timeout.reject(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'); + return Timeout.reject(new MongoOperationTimeoutError('Timed out before socket read')); } } diff --git a/src/transactions.ts b/src/transactions.ts index 53dcb84208..db251c82c1 100644 --- a/src/transactions.ts +++ b/src/transactions.ts @@ -60,7 +60,7 @@ const COMMITTED_STATES: Set = new Set([ * Configuration options for a transaction. * @public */ -export interface TransactionOptions extends CommandOperationOptions { +export interface TransactionOptions extends Omit { // TODO(NODE-3344): These options use the proper class forms of these settings, it should accept the basic enum values too /** A default read concern for commands in this transaction */ readConcern?: ReadConcernLike; @@ -68,7 +68,10 @@ export interface TransactionOptions extends CommandOperationOptions { writeConcern?: WriteConcern; /** A default read preference for commands in this transaction */ readPreference?: ReadPreferenceLike; - /** Specifies the maximum amount of time to allow a commit action on a transaction to run in milliseconds */ + /** + * Specifies the maximum amount of time to allow a commit action on a transaction to run in milliseconds + * @deprecated This option is deprecated in favor of `timeoutMS` or `defaultTimeoutMS`. + */ maxCommitTimeMS?: number; } diff --git a/src/utils.ts b/src/utils.ts index fa7a3f6509..11302b759b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -506,6 +506,10 @@ export function hasAtomicOperators(doc: Document | Document[]): boolean { /** * Merge inherited properties from parent into options, prioritizing values from options, * then values from parent. + * + * @param parent - An optional owning class of the operation being run. ex. Db/Collection/MongoClient. + * @param options - The options passed to the operation method. + * * @internal */ export function resolveOptions( @@ -533,9 +537,14 @@ export function resolveOptions( result.readPreference = readPreference; } - const timeoutMS = options?.timeoutMS; + const isConvenientTransaction = session?.explicit && session?.timeoutContext != null; + if (isConvenientTransaction && options?.timeoutMS != null) { + throw new MongoInvalidArgumentError( + 'An operation cannot be given a timeoutMS setting when inside a withTransaction call that has a timeoutMS setting' + ); + } - result.timeoutMS = timeoutMS ?? parent?.timeoutMS; + result.timeoutMS = options?.timeoutMS ?? parent?.timeoutMS; return result; } 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 729bed4219..406aa53ed6 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 @@ -1,6 +1,7 @@ /* Specification prose tests */ import { expect } from 'chai'; +import * as semver from 'semver'; import * as sinon from 'sinon'; import { @@ -9,6 +10,7 @@ import { MongoServerSelectionError, now } from '../../mongodb'; +import { type FailPoint } from '../../tools/utils'; // TODO(NODE-5824): Implement CSOT prose tests describe('CSOT spec prose tests', function () { @@ -595,7 +597,10 @@ describe('CSOT spec prose tests', function () { 'TODO(DRIVERS-2347): Requires this ticket to be implemented before we can assert on connection CSOT behaviour'; }); - context.skip('9. endSession', () => { + describe('9. endSession', () => { + const metadata: MongoDBMetadataUI = { + requires: { mongodb: '>=4.4', topology: ['replicaset', 'sharded'] } + }; /** * This test MUST only be run against replica sets and sharded clusters with server version 4.4 or higher. It MUST be * run three times: once with the timeout specified via the MongoClient `timeoutMS` option, once with the timeout @@ -625,12 +630,92 @@ describe('CSOT spec prose tests', function () { * 1. Using `session`, execute `session.end_session` * - Expect this to fail with a timeout error after no more than 15ms. */ + const failpoint: FailPoint = { + configureFailPoint: 'failCommand', + mode: { times: 1 }, + data: { + failCommands: ['abortTransaction'], + blockConnection: true, + blockTimeMS: 200 + } + }; + + beforeEach(async function () { + const internalClient = this.configuration.newClient(); + // End in-progress transactions otherwise "drop" will hang + await internalClient.db('admin').command({ killAllSessions: [] }); + await internalClient + .db('endSession_db') + .collection('endSession_coll') + .drop() + .catch(() => null); + await internalClient.db('endSession_db').createCollection('endSession_coll'); + await internalClient.db('admin').command(failpoint); + await internalClient.close(); + }); + + let client: MongoClient; + + afterEach(async function () { + const internalClient = this.configuration.newClient(); + await internalClient.db('admin').command({ ...failpoint, mode: 'off' }); + await internalClient.close(); + await client?.close(); + }); + + describe('when timeoutMS is provided to the client', () => { + it('throws a timeout error from endSession', metadata, async function () { + client = this.configuration.newClient({ timeoutMS: 150, monitorCommands: true }); + const coll = client.db('endSession_db').collection('endSession_coll'); + const session = client.startSession(); + session.startTransaction(); + await coll.insertOne({ x: 1 }, { session }); + const start = performance.now(); + const error = await session.endSession().catch(error => error); + const end = performance.now(); + expect(end - start).to.be.within(100, 170); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + }); + }); + + describe('when defaultTimeoutMS is provided to startSession', () => { + it('throws a timeout error from endSession', metadata, async function () { + client = this.configuration.newClient(); + const coll = client.db('endSession_db').collection('endSession_coll'); + const session = client.startSession({ defaultTimeoutMS: 150 }); + session.startTransaction(); + await coll.insertOne({ x: 1 }, { session }); + const start = performance.now(); + const error = await session.endSession().catch(error => error); + const end = performance.now(); + expect(end - start).to.be.within(100, 170); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + }); + }); + + describe('when timeoutMS is provided to endSession', () => { + it('throws a timeout error from endSession', metadata, async function () { + client = this.configuration.newClient(); + const coll = client.db('endSession_db').collection('endSession_coll'); + const session = client.startSession(); + session.startTransaction(); + await coll.insertOne({ x: 1 }, { session }); + const start = performance.now(); + const error = await session.endSession({ timeoutMS: 150 }).catch(error => error); + const end = performance.now(); + expect(end - start).to.be.within(100, 170); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + }); + }); }); - context.skip('10. Convenient Transactions', () => { + describe('10. Convenient Transactions', () => { /** Tests in this section MUST only run against replica sets and sharded clusters with server versions 4.4 or higher. */ + const metadata: MongoDBMetadataUI = { + requires: { topology: ['replicaset', 'sharded'], mongodb: '>=4.4' } + }; - context('timeoutMS is refreshed for abortTransaction if the callback fails', () => { + describe('when an operation fails inside withTransaction callback', () => { /** * 1. Using `internalClient`, drop the `db.coll` collection. * 1. Using `internalClient`, set the following fail point: @@ -641,7 +726,7 @@ describe('CSOT spec prose tests', function () { * data: { * failCommands: ["insert", "abortTransaction"], * blockConnection: true, - * blockTimeMS: 15 + * blockTimeMS: 200 * } * } * ``` @@ -658,6 +743,80 @@ describe('CSOT spec prose tests', function () { * 1. `command_started` and `command_failed` events for an `insert` command. * 1. `command_started` and `command_failed` events for an `abortTransaction` command. */ + + const failpoint: FailPoint = { + configureFailPoint: 'failCommand', + mode: { times: 2 }, + data: { + failCommands: ['insert', 'abortTransaction'], + blockConnection: true, + blockTimeMS: 200 + } + }; + + beforeEach(async function () { + if (!semver.satisfies(this.configuration.version, '>=4.4')) { + this.skipReason = 'Requires server version 4.4+'; + this.skip(); + } + const internalClient = this.configuration.newClient(); + await internalClient + .db('db') + .collection('coll') + .drop() + .catch(() => null); + await internalClient.db('admin').command(failpoint); + await internalClient.close(); + }); + + let client: MongoClient; + + afterEach(async function () { + if (semver.satisfies(this.configuration.version, '>=4.4')) { + const internalClient = this.configuration.newClient(); + await internalClient + .db('admin') + .command({ configureFailPoint: 'failCommand', mode: 'off' }); + await internalClient.close(); + } + await client?.close(); + }); + + it('timeoutMS is refreshed for abortTransaction', metadata, async function () { + if ( + this.configuration.topologyType === 'ReplicaSetWithPrimary' && + semver.satisfies(this.configuration.version, '<=4.4') + ) { + this.skipReason = '4.4 replicaset fail point does not blockConnection for requested time'; + this.skip(); + } + + const commandsFailed = []; + const commandsStarted = []; + + client = this.configuration + .newClient({ timeoutMS: 150, monitorCommands: true }) + .on('commandStarted', e => commandsStarted.push(e.commandName)) + .on('commandFailed', e => commandsFailed.push(e.commandName)); + + const coll = client.db('db').collection('coll'); + + const session = client.startSession(); + + const withTransactionError = await session + .withTransaction(async session => { + await coll.insertOne({ x: 1 }, { session }); + }) + .catch(error => error); + + try { + expect(withTransactionError).to.be.instanceOf(MongoOperationTimeoutError); + expect(commandsStarted, 'commands started').to.deep.equal(['insert', 'abortTransaction']); + expect(commandsFailed, 'commands failed').to.deep.equal(['insert', 'abortTransaction']); + } finally { + await session.endSession(); + } + }); }); }); }); 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 e4c9eb3027..a178cecc5d 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 @@ -1,4 +1,5 @@ import { join } from 'path'; +import * as semver from 'semver'; import { loadSpecTests } from '../../spec'; import { runUnifiedSuite } from '../../tools/unified-spec-runner/runner'; @@ -8,7 +9,10 @@ const enabled = [ 'override-database-timeoutMS', 'override-operation-timeoutMS', 'retryability-legacy-timeouts', - 'retryability-timeoutMS' + 'retryability-timeoutMS', + 'sessions-override-operation-timeoutMS', + 'sessions-override-timeoutMS', + 'sessions-inherit-timeoutMS' ]; const cursorOperations = [ @@ -43,5 +47,15 @@ describe('CSOT spec tests', function () { 'TODO(NODE-6274): update test runner to check errorResponse field of MongoBulkWriteError in isTimeoutError assertion'; } } - runUnifiedSuite(specs); + runUnifiedSuite(specs, (test, configuration) => { + const sessionCSOTTests = ['timeoutMS applied to withTransaction']; + if ( + sessionCSOTTests.includes(test.description) && + configuration.topologyType === 'ReplicaSetWithPrimary' && + semver.satisfies(configuration.version, '<=4.4') + ) { + return '4.4 replicaset fail point does not blockConnection for requested time'; + } + return false; + }); }); 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 d7d4a4ede5..cc767c1d80 100644 --- a/test/integration/client-side-operations-timeout/node_csot.test.ts +++ b/test/integration/client-side-operations-timeout/node_csot.test.ts @@ -12,6 +12,7 @@ import { type FindCursor, LEGACY_HELLO_COMMAND, type MongoClient, + MongoInvalidArgumentError, MongoOperationTimeoutError, MongoServerError } from '../../mongodb'; @@ -320,4 +321,153 @@ describe('CSOT driver tests', { requires: { mongodb: '>=4.4' } }, () => { }); }); }); + + describe('when using an explicit session', () => { + const metadata: MongoDBMetadataUI = { + requires: { topology: ['replicaset'], mongodb: '>=4.4' } + }; + + describe('created for a withTransaction callback', () => { + describe('passing a timeoutMS and a session with a timeoutContext', () => { + let client: MongoClient; + + beforeEach(async function () { + client = this.configuration.newClient({ timeoutMS: 123 }); + }); + + afterEach(async function () { + await client.close(); + }); + + it('throws a validation error from the operation', metadata, async () => { + // Drivers MUST raise a validation error if an explicit session with a timeout is used and + // the timeoutMS option is set at the operation level for operations executed as part of a withTransaction callback. + + const coll = client.db('db').collection('coll'); + + const session = client.startSession(); + + let insertError: Error | null = null; + const withTransactionError = await session + .withTransaction(async session => { + insertError = await coll + .insertOne({ x: 1 }, { session, timeoutMS: 1234 }) + .catch(error => error); + throw insertError; + }) + .catch(error => error); + + expect(insertError).to.be.instanceOf(MongoInvalidArgumentError); + expect(withTransactionError).to.be.instanceOf(MongoInvalidArgumentError); + }); + }); + }); + + describe('created manually', () => { + describe('passing a timeoutMS and a session with an inherited timeoutMS', () => { + let client: MongoClient; + + beforeEach(async function () { + client = this.configuration.newClient({ timeoutMS: 123 }); + }); + + afterEach(async function () { + await client.close(); + }); + + it('does not throw a validation error', metadata, async () => { + const coll = client.db('db').collection('coll'); + const session = client.startSession(); + session.startTransaction(); + await coll.insertOne({ x: 1 }, { session, timeoutMS: 1234 }); + await session.abortTransaction(); // this uses the inherited timeoutMS, not the insert + }); + }); + }); + }); + + describe('Convenient Transactions', () => { + /** Tests in this section MUST only run against replica sets and sharded clusters with server versions 4.4 or higher. */ + const metadata: MongoDBMetadataUI = { + requires: { topology: ['replicaset', 'sharded'], mongodb: '>=5.0' } + }; + + describe('when an operation fails inside withTransaction callback', () => { + const failpoint: FailPoint = { + configureFailPoint: 'failCommand', + mode: { times: 2 }, + data: { + failCommands: ['insert', 'abortTransaction'], + blockConnection: true, + blockTimeMS: 600 + } + }; + + beforeEach(async function () { + if (!semver.satisfies(this.configuration.version, '>=4.4')) { + this.skipReason = 'Requires server version 4.4+'; + this.skip(); + } + const internalClient = this.configuration.newClient(); + await internalClient + .db('db') + .collection('coll') + .drop() + .catch(() => null); + await internalClient.db('admin').command(failpoint); + await internalClient.close(); + }); + + let client: MongoClient; + + afterEach(async function () { + if (semver.satisfies(this.configuration.version, '>=4.4')) { + const internalClient = this.configuration.newClient(); + await internalClient + .db('admin') + .command({ configureFailPoint: 'failCommand', mode: 'off' }); + await internalClient.close(); + } + await client?.close(); + }); + + it( + 'timeoutMS is refreshed for abortTransaction and the timeout error is thrown from the operation', + metadata, + async function () { + const commandsFailed = []; + const commandsStarted = []; + + client = this.configuration + .newClient({ timeoutMS: 500, monitorCommands: true }) + .on('commandStarted', e => commandsStarted.push(e.commandName)) + .on('commandFailed', e => commandsFailed.push(e.commandName)); + + const coll = client.db('db').collection('coll'); + + const session = client.startSession(); + + let insertError: Error | null = null; + const withTransactionError = await session + .withTransaction(async session => { + insertError = await coll.insertOne({ x: 1 }, { session }).catch(error => error); + throw insertError; + }) + .catch(error => error); + + try { + expect(insertError).to.be.instanceOf(MongoOperationTimeoutError); + expect(withTransactionError).to.be.instanceOf(MongoOperationTimeoutError); + expect(commandsStarted, 'commands started').to.deep.equal([ + 'insert', + 'abortTransaction' + ]); + expect(commandsFailed, 'commands failed').to.deep.equal(['insert', 'abortTransaction']); + } finally { + await session.endSession(); + } + } + ); + }); + }); }); diff --git a/test/spec/client-side-operations-timeout/sessions-inherit-timeoutMS.json b/test/spec/client-side-operations-timeout/sessions-inherit-timeoutMS.json index abbc321732..13ea91c794 100644 --- a/test/spec/client-side-operations-timeout/sessions-inherit-timeoutMS.json +++ b/test/spec/client-side-operations-timeout/sessions-inherit-timeoutMS.json @@ -21,7 +21,7 @@ "client": { "id": "client", "uriOptions": { - "timeoutMS": 50 + "timeoutMS": 500 }, "useMultipleMongoses": false, "observeEvents": [ @@ -78,7 +78,7 @@ "commitTransaction" ], "blockConnection": true, - "blockTimeMS": 60 + "blockTimeMS": 600 } } } @@ -165,7 +165,7 @@ "abortTransaction" ], "blockConnection": true, - "blockTimeMS": 60 + "blockTimeMS": 600 } } } @@ -249,7 +249,7 @@ "insert" ], "blockConnection": true, - "blockTimeMS": 60 + "blockTimeMS": 600 } } } @@ -302,6 +302,26 @@ "commandFailedEvent": { "commandName": "insert" } + }, + { + "commandStartedEvent": { + "commandName": "abortTransaction", + "databaseName": "admin", + "command": { + "abortTransaction": 1, + "maxTimeMS": { + "$$type": [ + "int", + "long" + ] + } + } + } + }, + { + "commandFailedEvent": { + "commandName": "abortTransaction" + } } ] } diff --git a/test/spec/client-side-operations-timeout/sessions-inherit-timeoutMS.yml b/test/spec/client-side-operations-timeout/sessions-inherit-timeoutMS.yml index 184ef7eb9e..c79384e5f0 100644 --- a/test/spec/client-side-operations-timeout/sessions-inherit-timeoutMS.yml +++ b/test/spec/client-side-operations-timeout/sessions-inherit-timeoutMS.yml @@ -13,7 +13,7 @@ createEntities: - client: id: &client client uriOptions: - timeoutMS: 50 + timeoutMS: 500 useMultipleMongoses: false observeEvents: - commandStartedEvent @@ -52,7 +52,7 @@ tests: data: failCommands: ["commitTransaction"] blockConnection: true - blockTimeMS: 60 + blockTimeMS: 600 - name: startTransaction object: *session - name: insertOne @@ -95,7 +95,7 @@ tests: data: failCommands: ["abortTransaction"] blockConnection: true - blockTimeMS: 60 + blockTimeMS: 600 - name: startTransaction object: *session - name: insertOne @@ -136,7 +136,7 @@ tests: data: failCommands: ["insert"] blockConnection: true - blockTimeMS: 60 + blockTimeMS: 600 - name: withTransaction object: *session arguments: @@ -153,9 +153,6 @@ tests: expectEvents: - client: *client events: - # Because the insert expects an error and gets an error, it technically succeeds, so withTransaction will - # try to run commitTransaction. This will fail client-side, though, because the timeout has already expired, - # so no command is sent. - commandStartedEvent: commandName: insert databaseName: *databaseName @@ -166,3 +163,11 @@ tests: maxTimeMS: { $$type: ["int", "long"] } - commandFailedEvent: commandName: insert + - commandStartedEvent: + commandName: abortTransaction + databaseName: admin + command: + abortTransaction: 1 + maxTimeMS: { $$type: [ "int", "long" ] } + - commandFailedEvent: + commandName: abortTransaction diff --git a/test/spec/client-side-operations-timeout/sessions-override-operation-timeoutMS.json b/test/spec/client-side-operations-timeout/sessions-override-operation-timeoutMS.json index 0254b184a1..441c698328 100644 --- a/test/spec/client-side-operations-timeout/sessions-override-operation-timeoutMS.json +++ b/test/spec/client-side-operations-timeout/sessions-override-operation-timeoutMS.json @@ -75,7 +75,7 @@ "commitTransaction" ], "blockConnection": true, - "blockTimeMS": 60 + "blockTimeMS": 600 } } } @@ -98,7 +98,7 @@ "name": "commitTransaction", "object": "session", "arguments": { - "timeoutMS": 50 + "timeoutMS": 500 }, "expectError": { "isTimeoutError": true @@ -165,7 +165,7 @@ "abortTransaction" ], "blockConnection": true, - "blockTimeMS": 60 + "blockTimeMS": 600 } } } @@ -188,7 +188,7 @@ "name": "abortTransaction", "object": "session", "arguments": { - "timeoutMS": 50 + "timeoutMS": 500 } } ], @@ -252,7 +252,7 @@ "insert" ], "blockConnection": true, - "blockTimeMS": 60 + "blockTimeMS": 600 } } } @@ -261,7 +261,7 @@ "name": "withTransaction", "object": "session", "arguments": { - "timeoutMS": 50, + "timeoutMS": 500, "callback": [ { "name": "insertOne", @@ -306,6 +306,26 @@ "commandFailedEvent": { "commandName": "insert" } + }, + { + "commandStartedEvent": { + "commandName": "abortTransaction", + "databaseName": "admin", + "command": { + "abortTransaction": 1, + "maxTimeMS": { + "$$type": [ + "int", + "long" + ] + } + } + } + }, + { + "commandFailedEvent": { + "commandName": "abortTransaction" + } } ] } diff --git a/test/spec/client-side-operations-timeout/sessions-override-operation-timeoutMS.yml b/test/spec/client-side-operations-timeout/sessions-override-operation-timeoutMS.yml index 8a80a65720..bee91dc4cb 100644 --- a/test/spec/client-side-operations-timeout/sessions-override-operation-timeoutMS.yml +++ b/test/spec/client-side-operations-timeout/sessions-override-operation-timeoutMS.yml @@ -50,7 +50,7 @@ tests: data: failCommands: ["commitTransaction"] blockConnection: true - blockTimeMS: 60 + blockTimeMS: 600 - name: startTransaction object: *session - name: insertOne @@ -61,7 +61,7 @@ tests: - name: commitTransaction object: *session arguments: - timeoutMS: 50 + timeoutMS: 500 expectError: isTimeoutError: true expectEvents: @@ -95,7 +95,7 @@ tests: data: failCommands: ["abortTransaction"] blockConnection: true - blockTimeMS: 60 + blockTimeMS: 600 - name: startTransaction object: *session - name: insertOne @@ -106,7 +106,7 @@ tests: - name: abortTransaction object: *session arguments: - timeoutMS: 50 + timeoutMS: 500 expectEvents: - client: *client events: @@ -138,11 +138,11 @@ tests: data: failCommands: ["insert"] blockConnection: true - blockTimeMS: 60 + blockTimeMS: 600 - name: withTransaction object: *session arguments: - timeoutMS: 50 + timeoutMS: 500 callback: - name: insertOne object: *collection @@ -156,9 +156,6 @@ tests: expectEvents: - client: *client events: - # Because the insert expects an error and gets an error, it technically succeeds, so withTransaction will - # try to run commitTransaction. This will fail client-side, though, because the timeout has already expired, - # so no command is sent. - commandStartedEvent: commandName: insert databaseName: *databaseName @@ -169,3 +166,11 @@ tests: maxTimeMS: { $$type: ["int", "long"] } - commandFailedEvent: commandName: insert + - commandStartedEvent: + commandName: abortTransaction + databaseName: admin + command: + abortTransaction: 1 + maxTimeMS: { $$type: ["int", "long"] } + - commandFailedEvent: + commandName: abortTransaction diff --git a/test/spec/client-side-operations-timeout/sessions-override-timeoutMS.json b/test/spec/client-side-operations-timeout/sessions-override-timeoutMS.json index c46ae4dd50..d90152e909 100644 --- a/test/spec/client-side-operations-timeout/sessions-override-timeoutMS.json +++ b/test/spec/client-side-operations-timeout/sessions-override-timeoutMS.json @@ -47,7 +47,7 @@ "id": "session", "client": "client", "sessionOptions": { - "defaultTimeoutMS": 50 + "defaultTimeoutMS": 500 } } } @@ -78,7 +78,7 @@ "commitTransaction" ], "blockConnection": true, - "blockTimeMS": 60 + "blockTimeMS": 600 } } } @@ -165,7 +165,7 @@ "abortTransaction" ], "blockConnection": true, - "blockTimeMS": 60 + "blockTimeMS": 600 } } } @@ -249,7 +249,7 @@ "insert" ], "blockConnection": true, - "blockTimeMS": 60 + "blockTimeMS": 600 } } } @@ -302,6 +302,26 @@ "commandFailedEvent": { "commandName": "insert" } + }, + { + "commandStartedEvent": { + "commandName": "abortTransaction", + "databaseName": "admin", + "command": { + "abortTransaction": 1, + "maxTimeMS": { + "$$type": [ + "int", + "long" + ] + } + } + } + }, + { + "commandFailedEvent": { + "commandName": "abortTransaction" + } } ] } diff --git a/test/spec/client-side-operations-timeout/sessions-override-timeoutMS.yml b/test/spec/client-side-operations-timeout/sessions-override-timeoutMS.yml index 61aaab4d97..73aaf9ff2a 100644 --- a/test/spec/client-side-operations-timeout/sessions-override-timeoutMS.yml +++ b/test/spec/client-side-operations-timeout/sessions-override-timeoutMS.yml @@ -29,7 +29,7 @@ createEntities: id: &session session client: *client sessionOptions: - defaultTimeoutMS: 50 + defaultTimeoutMS: 500 initialData: - collectionName: *collectionName @@ -52,7 +52,7 @@ tests: data: failCommands: ["commitTransaction"] blockConnection: true - blockTimeMS: 60 + blockTimeMS: 600 - name: startTransaction object: *session - name: insertOne @@ -95,7 +95,7 @@ tests: data: failCommands: ["abortTransaction"] blockConnection: true - blockTimeMS: 60 + blockTimeMS: 600 - name: startTransaction object: *session - name: insertOne @@ -136,7 +136,7 @@ tests: data: failCommands: ["insert"] blockConnection: true - blockTimeMS: 60 + blockTimeMS: 600 - name: withTransaction object: *session arguments: @@ -153,9 +153,6 @@ tests: expectEvents: - client: *client events: - # Because the insert expects an error and gets an error, it technically succeeds, so withTransaction will - # try to run commitTransaction. This will fail client-side, though, because the timeout has already expired, - # so no command is sent. - commandStartedEvent: commandName: insert databaseName: *databaseName @@ -166,3 +163,11 @@ tests: maxTimeMS: { $$type: ["int", "long"] } - commandFailedEvent: commandName: insert + - commandStartedEvent: + commandName: abortTransaction + databaseName: admin + command: + abortTransaction: 1 + maxTimeMS: { $$type: [ "int", "long" ] } + - commandFailedEvent: + commandName: abortTransaction diff --git a/test/tools/unified-spec-runner/entities.ts b/test/tools/unified-spec-runner/entities.ts index 9f4e20a828..7f90e275dc 100644 --- a/test/tools/unified-spec-runner/entities.ts +++ b/test/tools/unified-spec-runner/entities.ts @@ -619,6 +619,10 @@ export class EntitiesMap extends Map { const options = Object.create(null); + if (entity.session.sessionOptions?.defaultTimeoutMS != null) { + options.defaultTimeoutMS = entity.session.sessionOptions?.defaultTimeoutMS; + } + if (entity.session.sessionOptions?.causalConsistency) { options.causalConsistency = entity.session.sessionOptions?.causalConsistency; } diff --git a/test/tools/unified-spec-runner/match.ts b/test/tools/unified-spec-runner/match.ts index 5c4ea000de..8cdcc765fc 100644 --- a/test/tools/unified-spec-runner/match.ts +++ b/test/tools/unified-spec-runner/match.ts @@ -501,6 +501,13 @@ function compareCommandFailedEvents( } } +function expectInstanceOf any>( + instance: any, + ctor: T +): asserts instance is InstanceType { + expect(instance).to.be.instanceOf(ctor); +} + function compareEvents( actual: CommandEvent[] | CmapEvent[] | SdamEvent[], expected: (ExpectedCommandEvent & ExpectedCmapEvent & ExpectedSdamEvent)[], @@ -515,9 +522,7 @@ function compareEvents( if (expectedEvent.commandStartedEvent) { const path = `${rootPrefix}.commandStartedEvent`; - if (!(actualEvent instanceof CommandStartedEvent)) { - expect.fail(`expected ${path} to be instanceof CommandStartedEvent`); - } + expectInstanceOf(actualEvent, CommandStartedEvent); compareCommandStartedEvents(actualEvent, expectedEvent.commandStartedEvent, entities, path); if (expectedEvent.commandStartedEvent.hasServerConnectionId) { expect(actualEvent).property('serverConnectionId').to.be.a('bigint'); @@ -526,9 +531,7 @@ function compareEvents( } } else if (expectedEvent.commandSucceededEvent) { const path = `${rootPrefix}.commandSucceededEvent`; - if (!(actualEvent instanceof CommandSucceededEvent)) { - expect.fail(`expected ${path} to be instanceof CommandSucceededEvent`); - } + expectInstanceOf(actualEvent, CommandSucceededEvent); compareCommandSucceededEvents( actualEvent, expectedEvent.commandSucceededEvent, @@ -542,9 +545,7 @@ function compareEvents( } } else if (expectedEvent.commandFailedEvent) { const path = `${rootPrefix}.commandFailedEvent`; - if (!(actualEvent instanceof CommandFailedEvent)) { - expect.fail(`expected ${path} to be instanceof CommandFailedEvent`); - } + expectInstanceOf(actualEvent, CommandFailedEvent); compareCommandFailedEvents(actualEvent, expectedEvent.commandFailedEvent, entities, path); if (expectedEvent.commandFailedEvent.hasServerConnectionId) { expect(actualEvent).property('serverConnectionId').to.be.a('bigint'); diff --git a/test/tools/unified-spec-runner/operations.ts b/test/tools/unified-spec-runner/operations.ts index 0d7fc18970..d43f541aae 100644 --- a/test/tools/unified-spec-runner/operations.ts +++ b/test/tools/unified-spec-runner/operations.ts @@ -19,6 +19,7 @@ import { ServerType, type TopologyDescription, type TopologyType, + type TransactionOptions, WriteConcern } from '../../mongodb'; import { sleep } from '../../tools/utils'; @@ -49,11 +50,6 @@ operations.set('createEntities', async ({ entities, operation, testConfig }) => await EntitiesMap.createEntities(testConfig, null, operation.arguments.entities!, entities); }); -operations.set('abortTransaction', async ({ entities, operation }) => { - const session = entities.getEntity('session', operation.object); - return session.abortTransaction(); -}); - operations.set('aggregate', async ({ entities, operation }) => { const dbOrCollection = entities.get(operation.object) as Db | Collection; if (!(dbOrCollection instanceof Db || dbOrCollection instanceof Collection)) { @@ -231,7 +227,12 @@ operations.set('close', async ({ entities, operation }) => { operations.set('commitTransaction', async ({ entities, operation }) => { const session = entities.getEntity('session', operation.object); - return session.commitTransaction(); + return await session.commitTransaction({ timeoutMS: operation.arguments?.timeoutMS }); +}); + +operations.set('abortTransaction', async ({ entities, operation }) => { + const session = entities.getEntity('session', operation.object); + return await session.abortTransaction({ timeoutMS: operation.arguments?.timeoutMS }); }); operations.set('createChangeStream', async ({ entities, operation }) => { @@ -361,7 +362,7 @@ operations.set('insertOne', async ({ entities, operation }) => { // Looping exposes the fact that we can generate _ids for inserted // documents and we don't want the original operation to get modified // and use the same _id for each insert. - return collection.insertOne({ ...document }, opts); + return await collection.insertOne({ ...document }, opts); }); operations.set('insertMany', async ({ entities, operation }) => { @@ -708,13 +709,17 @@ operations.set('waitForThread', async ({ entities, operation }) => { operations.set('withTransaction', async ({ entities, operation, client, testConfig }) => { const session = entities.getEntity('session', operation.object); - const options = { + const options: TransactionOptions = { readConcern: ReadConcern.fromOptions(operation.arguments), writeConcern: WriteConcern.fromOptions(operation.arguments), readPreference: ReadPreference.fromOptions(operation.arguments), - maxCommitTimeMS: operation.arguments!.maxCommitTimeMS + maxCommitTimeMS: operation.arguments?.maxCommitTimeMS }; + if (typeof operation.arguments?.timeoutMS === 'number') { + options.timeoutMS = operation.arguments.timeoutMS; + } + await session.withTransaction(async () => { for (const callbackOperation of operation.arguments!.callback) { await executeOperationAndCheck(callbackOperation, entities, client, testConfig, true); @@ -935,7 +940,7 @@ export async function executeOperationAndCheck( rethrow = false ): Promise { const opFunc = operations.get(operation.name); - expect(opFunc, `Unknown operation: ${operation.name}`).to.exist; + if (opFunc == null) expect.fail(`Unknown operation: ${operation.name}`); if (operation.arguments && operation.arguments.session) { // The session could need to be either pulled from the entity map or in the case where @@ -949,7 +954,7 @@ export async function executeOperationAndCheck( let result; try { - result = await opFunc!({ entities, operation, client, testConfig }); + result = await opFunc({ entities, operation, client, testConfig }); } catch (error) { if (operation.expectError) { expectErrorCheck(error, operation.expectError, entities); From 4c4b0a9f7a61202ef422e06aa4dc5ab2e941023b Mon Sep 17 00:00:00 2001 From: Warren James Date: Thu, 12 Sep 2024 11:35:27 -0400 Subject: [PATCH 09/39] feat(NODE-6304): add CSOT support for non-tailable cursors (#4195) --- src/cmap/connection.ts | 4 +- src/cmap/wire_protocol/on_data.ts | 1 - src/collection.ts | 6 +- src/cursor/abstract_cursor.ts | 146 +++- src/cursor/aggregation_cursor.ts | 20 +- src/cursor/change_stream_cursor.ts | 6 +- src/cursor/find_cursor.ts | 2 +- src/cursor/list_collections_cursor.ts | 2 +- src/cursor/list_indexes_cursor.ts | 2 +- src/cursor/run_command_cursor.ts | 14 +- src/index.ts | 2 +- src/operations/aggregate.ts | 4 + src/operations/execute_operation.ts | 3 +- src/operations/find.ts | 4 + src/operations/indexes.ts | 9 +- src/operations/list_collections.ts | 3 + src/operations/operation.ts | 3 + src/operations/run_command.ts | 2 + src/sessions.ts | 12 +- src/timeout.ts | 27 +- ...ient_side_operations_timeout.prose.test.ts | 84 ++- ...lient_side_operations_timeout.spec.test.ts | 83 ++- .../node_csot.test.ts | 335 ++++++++- .../command-execution.json | 153 ++++ .../client-side-operations-timeout/README.md | 661 ++++++++++++++++++ .../change-streams.json | 20 +- .../change-streams.yml | 30 +- .../close-cursors.json | 12 +- .../close-cursors.yml | 12 +- .../command-execution.json | 2 +- .../command-execution.yml | 5 +- .../convenient-transactions.json | 22 +- .../convenient-transactions.yml | 15 +- .../deprecated-options.json | 2 +- .../deprecated-options.yml | 2 +- .../gridfs-advanced.yml | 2 +- .../non-tailable-cursors.json | 20 +- .../non-tailable-cursors.yml | 32 +- .../retryability-timeoutMS.json | 250 +++++++ .../retryability-timeoutMS.yml | 100 +++ .../tailable-awaitData.json | 14 +- .../tailable-awaitData.yml | 18 +- .../tailable-non-awaitData.json | 10 +- .../tailable-non-awaitData.yml | 12 +- test/tools/unified-spec-runner/operations.ts | 7 +- test/unit/cursor/aggregation_cursor.test.ts | 67 +- 46 files changed, 2008 insertions(+), 234 deletions(-) create mode 100644 test/integration/client-side-operations-timeout/unified-csot-node-specs/command-execution.json create mode 100644 test/spec/client-side-operations-timeout/README.md diff --git a/src/cmap/connection.ts b/src/cmap/connection.ts index 91fe37662b..83f16ac9bd 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -86,6 +86,7 @@ export interface CommandOptions extends BSONSerializeOptions { documentsReturnedIn?: string; noResponse?: boolean; omitReadPreference?: boolean; + omitMaxTimeMS?: boolean; // TODO(NODE-2802): Currently the CommandOptions take a property willRetryWrite which is a hint // from executeOperation that the txnNum should be applied to this command. @@ -419,7 +420,7 @@ export class Connection extends TypedEventEmitter { ...options }; - if (options.timeoutContext?.csotEnabled()) { + if (!options.omitMaxTimeMS && options.timeoutContext?.csotEnabled()) { const { maxTimeMS } = options.timeoutContext; if (maxTimeMS > 0 && Number.isFinite(maxTimeMS)) cmd.maxTimeMS = maxTimeMS; } @@ -615,7 +616,6 @@ export class Connection extends TypedEventEmitter { for await (const document of this.sendCommand(ns, command, options, responseType)) { if (options.timeoutContext?.csotEnabled()) { if (MongoDBResponse.is(document)) { - // TODO(NODE-5684): test coverage to be added once cursors are enabling CSOT if (document.isMaxTimeExpiredError) { throw new MongoOperationTimeoutError('Server reported a timeout error', { cause: new MongoServerError(document.toObject()) diff --git a/src/cmap/wire_protocol/on_data.ts b/src/cmap/wire_protocol/on_data.ts index 23fd88e282..64c636f41f 100644 --- a/src/cmap/wire_protocol/on_data.ts +++ b/src/cmap/wire_protocol/on_data.ts @@ -93,7 +93,6 @@ export function onData( const timeoutForSocketRead = timeoutContext?.timeoutForSocketRead; timeoutForSocketRead?.throwIfExpired(); - // eslint-disable-next-line github/no-then timeoutForSocketRead?.then(undefined, errorHandler); return iterator; diff --git a/src/collection.ts b/src/collection.ts index f3a206b0c7..a73a5276f5 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -11,7 +11,7 @@ import { type ListSearchIndexesOptions } from './cursor/list_search_indexes_cursor'; import type { Db } from './db'; -import { MongoInvalidArgumentError } from './error'; +import { MongoInvalidArgumentError, MongoOperationTimeoutError } from './error'; import type { MongoClient, PkFactory } from './mongo_client'; import type { Filter, @@ -678,7 +678,9 @@ export class Collection { new DropIndexOperation(this as TODO_NODE_3286, '*', resolveOptions(this, options)) ); return true; - } catch { + } catch (error) { + if (error instanceof MongoOperationTimeoutError) throw error; // TODO: Check the spec for index management behaviour/file a drivers ticket for this + // Seems like we should throw all errors return false; } } diff --git a/src/cursor/abstract_cursor.ts b/src/cursor/abstract_cursor.ts index 51206b51a2..d0f386923a 100644 --- a/src/cursor/abstract_cursor.ts +++ b/src/cursor/abstract_cursor.ts @@ -21,6 +21,7 @@ import { ReadPreference, type ReadPreferenceLike } from '../read_preference'; import { type AsyncDisposable, configureResourceManagement } from '../resource_management'; import type { Server } from '../sdam/server'; import { ClientSession, maybeClearPinnedConnection } from '../sessions'; +import { TimeoutContext } from '../timeout'; import { type MongoDBNamespace, squashError } from '../utils'; /** @@ -60,6 +61,17 @@ export interface CursorStreamOptions { /** @public */ export type CursorFlag = (typeof CURSOR_FLAGS)[number]; +/** @public*/ +export const CursorTimeoutMode = Object.freeze({ + ITERATION: 'iteration', + LIFETIME: 'cursorLifetime' +} as const); + +/** @public + * TODO(NODE-5688): Document and release + * */ +export type CursorTimeoutMode = (typeof CursorTimeoutMode)[keyof typeof CursorTimeoutMode]; + /** @public */ export interface AbstractCursorOptions extends BSONSerializeOptions { session?: ClientSession; @@ -105,6 +117,8 @@ export interface AbstractCursorOptions extends BSONSerializeOptions { noCursorTimeout?: boolean; /** @internal TODO(NODE-5688): make this public */ timeoutMS?: number; + /** @internal TODO(NODE-5688): make this public */ + timeoutMode?: CursorTimeoutMode; } /** @internal */ @@ -117,6 +131,8 @@ export type InternalAbstractCursorOptions = Omit { - await this.cleanup(); + async close(options?: { timeoutMS?: number }): Promise { + await this.cleanup(options?.timeoutMS); } /** @@ -658,6 +727,8 @@ export abstract class AbstractCursor< this.cursorId = null; this.documents?.clear(); + this.timeoutContext?.clear(); + this.timeoutContext = undefined; this.isClosed = false; this.isKilled = false; this.initialized = false; @@ -707,7 +778,7 @@ export abstract class AbstractCursor< } ); - return await executeOperation(this.cursorClient, getMoreOperation); + return await executeOperation(this.cursorClient, getMoreOperation, this.timeoutContext); } /** @@ -718,6 +789,12 @@ export abstract class AbstractCursor< * a significant refactor. */ private async cursorInit(): Promise { + if (this.cursorOptions.timeoutMS != null) { + this.timeoutContext = TimeoutContext.create({ + serverSelectionTimeoutMS: this.client.options.serverSelectionTimeoutMS, + timeoutMS: this.cursorOptions.timeoutMS + }); + } try { const state = await this._initialize(this.cursorSession); const response = state.response; @@ -729,7 +806,7 @@ export abstract class AbstractCursor< } catch (error) { // the cursor is now initialized, even if an error occurred this.initialized = true; - await this.cleanup(error); + await this.cleanup(undefined, error); throw error; } @@ -763,6 +840,7 @@ export abstract class AbstractCursor< // otherwise need to call getMore const batchSize = this.cursorOptions.batchSize || 1000; + this.cursorOptions.omitMaxTimeMS = this.cursorOptions.timeoutMS != null; try { const response = await this.getMore(batchSize); @@ -770,7 +848,7 @@ export abstract class AbstractCursor< this.documents = response; } catch (error) { try { - await this.cleanup(error); + await this.cleanup(undefined, error); } catch (error) { // `cleanupCursor` should never throw, squash and throw the original error squashError(error); @@ -791,7 +869,7 @@ export abstract class AbstractCursor< } /** @internal */ - private async cleanup(error?: Error) { + private async cleanup(timeoutMS?: number, error?: Error) { this.isClosed = true; const session = this.cursorSession; try { @@ -806,11 +884,23 @@ export abstract class AbstractCursor< this.isKilled = true; const cursorId = this.cursorId; this.cursorId = Long.ZERO; + let timeoutContext: TimeoutContext | undefined; + if (timeoutMS != null) { + this.timeoutContext?.clear(); + timeoutContext = TimeoutContext.create({ + serverSelectionTimeoutMS: this.client.options.serverSelectionTimeoutMS, + timeoutMS + }); + } else { + this.timeoutContext?.refresh(); + timeoutContext = this.timeoutContext; + } await executeOperation( this.cursorClient, new KillCursorsOperation(cursorId, this.cursorNamespace, this.selectedServer, { session - }) + }), + timeoutContext ); } } catch (error) { diff --git a/src/cursor/aggregation_cursor.ts b/src/cursor/aggregation_cursor.ts index 622bce14aa..9c305f07e0 100644 --- a/src/cursor/aggregation_cursor.ts +++ b/src/cursor/aggregation_cursor.ts @@ -1,4 +1,5 @@ import type { Document } from '../bson'; +import { MongoAPIError } from '../error'; import type { ExplainVerbosityLike } from '../explain'; import type { MongoClient } from '../mongo_client'; import { AggregateOperation, type AggregateOptions } from '../operations/aggregate'; @@ -9,6 +10,7 @@ import { mergeOptions, type MongoDBNamespace } from '../utils'; import { AbstractCursor, type AbstractCursorOptions, + CursorTimeoutMode, type InitialCursorResponse } from './abstract_cursor'; @@ -38,6 +40,15 @@ export class AggregationCursor extends AbstractCursor { this.pipeline = pipeline; this.aggregateOptions = options; + + const lastStage: Document | undefined = this.pipeline[this.pipeline.length - 1]; + + if ( + this.cursorOptions.timeoutMS != null && + this.cursorOptions.timeoutMode === CursorTimeoutMode.ITERATION && + (lastStage?.$merge != null || lastStage?.$out != null) + ) + throw new MongoAPIError('Cannot use $out or $merge stage with ITERATION timeoutMode'); } clone(): AggregationCursor { @@ -60,7 +71,7 @@ export class AggregationCursor extends AbstractCursor { session }); - const response = await executeOperation(this.client, aggregateOperation); + const response = await executeOperation(this.client, aggregateOperation, this.timeoutContext); return { server: aggregateOperation.server, session, response }; } @@ -95,6 +106,13 @@ export class AggregationCursor extends AbstractCursor { addStage(stage: Document): AggregationCursor; addStage(stage: Document): AggregationCursor { this.throwIfInitialized(); + if ( + this.cursorOptions.timeoutMS != null && + this.cursorOptions.timeoutMode === CursorTimeoutMode.ITERATION && + (stage.$out != null || stage.$merge != null) + ) { + throw new MongoAPIError('Cannot use $out or $merge stage with ITERATION timeoutMode'); + } this.pipeline.push(stage); return this as unknown as AggregationCursor; } diff --git a/src/cursor/change_stream_cursor.ts b/src/cursor/change_stream_cursor.ts index b42ce3e130..13f5867555 100644 --- a/src/cursor/change_stream_cursor.ts +++ b/src/cursor/change_stream_cursor.ts @@ -133,7 +133,11 @@ export class ChangeStreamCursor< session }); - const response = await executeOperation(session.client, aggregateOperation); + const response = await executeOperation( + session.client, + aggregateOperation, + this.timeoutContext + ); const server = aggregateOperation.server; this.maxWireVersion = maxWireVersion(server); diff --git a/src/cursor/find_cursor.ts b/src/cursor/find_cursor.ts index ef21cea290..653b8c2bfc 100644 --- a/src/cursor/find_cursor.ts +++ b/src/cursor/find_cursor.ts @@ -69,7 +69,7 @@ export class FindCursor extends AbstractCursor { session }); - const response = await executeOperation(this.client, findOperation); + const response = await executeOperation(this.client, findOperation, this.timeoutContext); // the response is not a cursor when `explain` is enabled this.numReturned = response.batchSize; diff --git a/src/cursor/list_collections_cursor.ts b/src/cursor/list_collections_cursor.ts index a529709556..9b69de1b93 100644 --- a/src/cursor/list_collections_cursor.ts +++ b/src/cursor/list_collections_cursor.ts @@ -41,7 +41,7 @@ export class ListCollectionsCursor< session }); - const response = await executeOperation(this.parent.client, operation); + const response = await executeOperation(this.parent.client, operation, this.timeoutContext); return { server: operation.server, session, response }; } diff --git a/src/cursor/list_indexes_cursor.ts b/src/cursor/list_indexes_cursor.ts index 799ddf5bdb..0f768f3b69 100644 --- a/src/cursor/list_indexes_cursor.ts +++ b/src/cursor/list_indexes_cursor.ts @@ -30,7 +30,7 @@ export class ListIndexesCursor extends AbstractCursor { session }); - const response = await executeOperation(this.parent.client, operation); + const response = await executeOperation(this.parent.client, operation, this.timeoutContext); return { server: operation.server, session, response }; } diff --git a/src/cursor/run_command_cursor.ts b/src/cursor/run_command_cursor.ts index 78b9826b9b..6b31ce2263 100644 --- a/src/cursor/run_command_cursor.ts +++ b/src/cursor/run_command_cursor.ts @@ -9,12 +9,20 @@ import type { ReadConcernLike } from '../read_concern'; import type { ReadPreferenceLike } from '../read_preference'; import type { ClientSession } from '../sessions'; import { ns } from '../utils'; -import { AbstractCursor, type InitialCursorResponse } from './abstract_cursor'; +import { + AbstractCursor, + type CursorTimeoutMode, + type InitialCursorResponse +} from './abstract_cursor'; /** @public */ export type RunCursorCommandOptions = { readPreference?: ReadPreferenceLike; session?: ClientSession; + /** @internal */ + timeoutMS?: number; + /** @internal */ + timeoutMode?: CursorTimeoutMode; } & BSONSerializeOptions; /** @public */ @@ -105,7 +113,7 @@ export class RunCommandCursor extends AbstractCursor { responseType: CursorResponse }); - const response = await executeOperation(this.client, operation); + const response = await executeOperation(this.client, operation, this.timeoutContext); return { server: operation.server, @@ -123,6 +131,6 @@ export class RunCommandCursor extends AbstractCursor { ...this.getMoreOptions }); - return await executeOperation(this.client, getMoreOperation); + return await executeOperation(this.client, getMoreOperation, this.timeoutContext); } } diff --git a/src/index.ts b/src/index.ts index 31ef5720e1..adcb76635b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -106,7 +106,7 @@ export { AutoEncryptionLoggerLevel } from './client-side-encryption/auto_encrypt export { GSSAPICanonicalizationValue } from './cmap/auth/gssapi'; export { AuthMechanism } from './cmap/auth/providers'; export { Compressor } from './cmap/wire_protocol/compression'; -export { CURSOR_FLAGS } from './cursor/abstract_cursor'; +export { CURSOR_FLAGS, type CursorTimeoutMode } from './cursor/abstract_cursor'; export { MongoErrorLabel } from './error'; export { ExplainVerbosity } from './explain'; export { ServerApiVersion } from './mongo_client'; diff --git a/src/operations/aggregate.ts b/src/operations/aggregate.ts index 50494cbba7..096fe37271 100644 --- a/src/operations/aggregate.ts +++ b/src/operations/aggregate.ts @@ -1,5 +1,6 @@ import type { Document } from '../bson'; import { CursorResponse, ExplainedCursorResponse } from '../cmap/wire_protocol/responses'; +import { type CursorTimeoutMode } from '../cursor/abstract_cursor'; import { MongoInvalidArgumentError } from '../error'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; @@ -36,6 +37,9 @@ export interface AggregateOptions extends CommandOperationOptions { let?: Document; out?: string; + + /** @internal */ + timeoutMode?: CursorTimeoutMode; } /** @internal */ diff --git a/src/operations/execute_operation.ts b/src/operations/execute_operation.ts index f9d9f9b63b..dd9ba06c51 100644 --- a/src/operations/execute_operation.ts +++ b/src/operations/execute_operation.ts @@ -280,8 +280,7 @@ async function tryOperation< previousOperationError = operationError; // Reset timeouts - timeoutContext.serverSelectionTimeout?.clear(); - timeoutContext.connectionCheckoutTimeout?.clear(); + timeoutContext.clear(); } } diff --git a/src/operations/find.ts b/src/operations/find.ts index 5f359324d5..c39695cc0b 100644 --- a/src/operations/find.ts +++ b/src/operations/find.ts @@ -1,5 +1,6 @@ import type { Document } from '../bson'; import { CursorResponse, ExplainedCursorResponse } from '../cmap/wire_protocol/responses'; +import { type CursorTimeoutMode } from '../cursor/abstract_cursor'; import { MongoInvalidArgumentError } from '../error'; import { ReadConcern } from '../read_concern'; import type { Server } from '../sdam/server'; @@ -64,6 +65,9 @@ export interface FindOptions * @deprecated Starting from MongoDB 4.4 this flag is not needed and will be ignored. */ oplogReplay?: boolean; + + /** @internal*/ + timeoutMode?: CursorTimeoutMode; } /** @internal */ diff --git a/src/operations/indexes.ts b/src/operations/indexes.ts index c96a5d7345..220d438d83 100644 --- a/src/operations/indexes.ts +++ b/src/operations/indexes.ts @@ -1,7 +1,7 @@ import type { Document } from '../bson'; import { CursorResponse } from '../cmap/wire_protocol/responses'; import type { Collection } from '../collection'; -import { type AbstractCursorOptions } from '../cursor/abstract_cursor'; +import { type AbstractCursorOptions, type CursorTimeoutMode } from '../cursor/abstract_cursor'; import { MongoCompatibilityError } from '../error'; import { type OneOrMore } from '../mongo_types'; import type { Server } from '../sdam/server'; @@ -360,7 +360,12 @@ export class DropIndexOperation extends CommandOperation { } /** @public */ -export type ListIndexesOptions = AbstractCursorOptions; +export type ListIndexesOptions = AbstractCursorOptions & { + /** @internal TODO(NODE-5688): make this public */ + timeoutMode?: CursorTimeoutMode; + /** @internal */ + omitMaxTimeMS?: boolean; +}; /** @internal */ export class ListIndexesOperation extends CommandOperation { diff --git a/src/operations/list_collections.ts b/src/operations/list_collections.ts index 702db0fe3f..50df243a3f 100644 --- a/src/operations/list_collections.ts +++ b/src/operations/list_collections.ts @@ -1,5 +1,6 @@ import type { Binary, Document } from '../bson'; import { CursorResponse } from '../cmap/wire_protocol/responses'; +import { type CursorTimeoutMode } from '../cursor/abstract_cursor'; import type { Db } from '../db'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; @@ -16,6 +17,8 @@ export interface ListCollectionsOptions extends Omit { public readonly start: number; public ended: number | null = null; public duration: number; - public timedOut = false; + private timedOut = false; public cleared = false; get remainingTime(): number { @@ -100,6 +100,7 @@ export class Timeout extends Promise { clear(): void { clearTimeout(this.id); this.id = undefined; + this.timedOut = false; this.cleared = true; } @@ -190,6 +191,10 @@ export abstract class TimeoutContext { abstract get timeoutForSocketRead(): Timeout | null; abstract csotEnabled(): this is CSOTTimeoutContext; + + abstract refresh(): void; + + abstract clear(): void; } /** @internal */ @@ -288,6 +293,18 @@ export class CSOTTimeoutContext extends TimeoutContext { if (remainingTimeMS > 0) return Timeout.expires(remainingTimeMS); return Timeout.reject(new MongoOperationTimeoutError('Timed out before socket read')); } + + refresh(): void { + this.start = Math.trunc(performance.now()); + this.minRoundTripTime = 0; + this._serverSelectionTimeout?.clear(); + this._connectionCheckoutTimeout?.clear(); + } + + clear(): void { + this._serverSelectionTimeout?.clear(); + this._connectionCheckoutTimeout?.clear(); + } } /** @internal */ @@ -326,4 +343,12 @@ export class LegacyTimeoutContext extends TimeoutContext { get timeoutForSocketRead(): Timeout | null { return null; } + + refresh(): void { + return; + } + + clear(): void { + return; + } } 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 406aa53ed6..0d36998fd9 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 @@ -4,7 +4,9 @@ import { expect } from 'chai'; import * as semver from 'semver'; import * as sinon from 'sinon'; +import { type CommandStartedEvent } from '../../../mongodb'; import { + type CommandSucceededEvent, MongoClient, MongoOperationTimeoutError, MongoServerSelectionError, @@ -216,12 +218,52 @@ describe('CSOT spec prose tests', function () { }); }); - context.skip('5. Blocking Iteration Methods', () => { + context('5. Blocking Iteration Methods', () => { /** * Tests in this section MUST only be run against server versions 4.4 and higher and only apply to drivers that have a * blocking method for cursor iteration that executes `getMore` commands in a loop until a document is available or an * error occurs. */ + const failpoint: FailPoint = { + configureFailPoint: 'failCommand', + mode: 'alwaysOn', + data: { + failCommands: ['getMore'], + blockConnection: true, + blockTimeMS: 20 + } + }; + let internalClient: MongoClient; + let client: MongoClient; + let commandStarted: CommandStartedEvent[]; + let commandSucceeded: CommandSucceededEvent[]; + + beforeEach(async function () { + internalClient = this.configuration.newClient(); + await internalClient.db('db').dropCollection('coll'); + // Creating capped collection to be able to create tailable find cursor + const coll = await internalClient + .db('db') + .createCollection('coll', { capped: true, size: 1_000_000 }); + await coll.insertOne({ x: 1 }); + await internalClient.db().admin().command(failpoint); + + client = this.configuration.newClient(undefined, { timeoutMS: 20, monitorCommands: true }); + commandStarted = []; + commandSucceeded = []; + + client.on('commandStarted', ev => commandStarted.push(ev)); + client.on('commandSucceeded', ev => commandSucceeded.push(ev)); + }); + + afterEach(async function () { + await internalClient + .db() + .admin() + .command({ ...failpoint, mode: 'off' }); + await internalClient.close(); + await client.close(); + }); context('Tailable cursors', () => { /** @@ -248,6 +290,29 @@ describe('CSOT spec prose tests', function () { * - Expect this to fail with a timeout error. * 1. Verify that a `find` command and two `getMore` commands were executed against the `db.coll` collection during the test. */ + + it.skip('send correct number of finds and getMores', async function () { + const cursor = client + .db('db') + .collection('coll') + .find({}, { tailable: true, awaitData: true }) + .project({ _id: 0 }); + const doc = await cursor.next(); + expect(doc).to.deep.equal({ x: 1 }); + // Check that there are no getMores sent + expect(commandStarted.filter(e => e.command.getMore != null)).to.have.lengthOf(0); + + const maybeError = await cursor.next().then( + () => null, + e => e + ); + + expect(maybeError).to.be.instanceof(MongoOperationTimeoutError); + // Expect 1 find + expect(commandStarted.filter(e => e.command.find != null)).to.have.lengthOf(1); + // Expect 2 getMore + expect(commandStarted.filter(e => e.command.getMore != null)).to.have.lengthOf(2); + }).skipReason = 'TODO(NODE-6305)'; }); context('Change Streams', () => { @@ -272,6 +337,23 @@ describe('CSOT spec prose tests', function () { * - Expect this to fail with a timeout error. * 1. Verify that an `aggregate` command and two `getMore` commands were executed against the `db.coll` collection during the test. */ + it.skip('sends correct number of aggregate and getMores', async function () { + const changeStream = client.db('db').collection('coll').watch(); + const maybeError = await changeStream.next().then( + () => null, + e => e + ); + + expect(maybeError).to.be.instanceof(MongoOperationTimeoutError); + const aggregates = commandStarted + .filter(e => e.command.aggregate != null) + .map(e => e.command); + const getMores = commandStarted.filter(e => e.command.getMore != null).map(e => e.command); + // Expect 1 aggregate + expect(aggregates).to.have.lengthOf(1); + // Expect 1 getMore + expect(getMores).to.have.lengthOf(1); + }).skipReason = 'TODO(NODE-6305)'; }); }); 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 a178cecc5d..99914fa08e 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 @@ -4,49 +4,55 @@ import * as semver from 'semver'; import { loadSpecTests } from '../../spec'; import { runUnifiedSuite } from '../../tools/unified-spec-runner/runner'; -const enabled = [ - 'override-collection-timeoutMS', - 'override-database-timeoutMS', - 'override-operation-timeoutMS', - 'retryability-legacy-timeouts', - 'retryability-timeoutMS', - 'sessions-override-operation-timeoutMS', - 'sessions-override-timeoutMS', - 'sessions-inherit-timeoutMS' -]; +const skippedSpecs = { + bulkWrite: 'TODO(NODE-6274)', + 'change-streams': 'TODO(NODE-6035)', + 'convenient-transactions': 'TODO(NODE-5687)', + 'deprecated-options': 'TODO(NODE-5689)', + 'gridfs-advanced': 'TODO(NODE-6275)', + 'gridfs-delete': 'TODO(NODE-6275)', + 'gridfs-download': 'TODO(NODE-6275)', + 'gridfs-find': 'TODO(NODE-6275)', + 'gridfs-upload': 'TODO(NODE-6275)', + 'tailable-awaitData': 'TODO(NODE-6035)', + 'tailable-non-awaitData': 'TODO(NODE-6035)' +}; -const cursorOperations = [ - 'aggregate', - 'countDocuments', - 'listIndexes', - 'createChangeStream', - 'listCollections', - 'listCollectionNames' -]; - -const bulkWriteOperations = [ - 'timeoutMS applies to whole operation, not individual attempts - bulkWrite on collection', - 'timeoutMS applies to whole operation, not individual attempts - insertMany on collection' -]; +const skippedTests = { + 'timeoutMS can be configured on a MongoClient - insertMany on collection': 'TODO(NODE-6274)', + 'timeoutMS can be configured on a MongoClient - bulkWrite on collection': 'TODO(NODE-6274)', + 'timeoutMS can be configured on a MongoClient - createChangeStream on client': 'TODO(NODE-6305)', + 'timeoutMS applies to whole operation, not individual attempts - createChangeStream on client': + 'TODO(NODE-6305)', + 'Tailable cursor iteration timeoutMS is refreshed for getMore - failure': 'TODO(NODE-6305)', + 'Tailable cursor awaitData iteration timeoutMS is refreshed for getMore - failure': + 'TODO(NODE-6305)', + 'timeoutMS applies to whole operation, not individual attempts - insertMany on collection': + 'TODO(NODE-6274)', + 'timeoutMS applies to whole operation, not individual attempts - bulkWrite on collection': + 'TODO(NODE-6274)', + 'command is not sent if RTT is greater than timeoutMS': 'TODO(DRIVERS-2965)', + 'Non=tailable cursor iteration timeoutMS is refreshed for getMore if timeoutMode is iteration - failure': + 'TODO(DRIVERS-2965)', + 'Non-tailable cursor lifetime remaining timeoutMS applied to getMore if timeoutMode is unset': + 'TODO(DRIVERS-2965)', + 'maxTimeMS value in the command is less than timeoutMS': + 'TODO(DRIVERS-2970): see modified test in unified-csot-node-specs' +}; describe('CSOT spec tests', function () { - const specs = loadSpecTests(join('client-side-operations-timeout')); + const specs = loadSpecTests('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'; + if (skippedSpecs[spec.name] != null) { + test.skipReason = skippedSpecs[spec.name]; + } + if (skippedTests[test.description] != null) { + test.skipReason = skippedTests[test.description]; } - - // Cursor operation - if (test.operations.find(operation => cursorOperations.includes(operation.name))) - test.skipReason = 'TODO(NODE-5684): Not working yet'; - - if (bulkWriteOperations.includes(test.description)) - test.skipReason = - 'TODO(NODE-6274): update test runner to check errorResponse field of MongoBulkWriteError in isTimeoutError assertion'; } } + runUnifiedSuite(specs, (test, configuration) => { const sessionCSOTTests = ['timeoutMS applied to withTransaction']; if ( @@ -59,3 +65,10 @@ describe('CSOT spec tests', function () { return false; }); }); + +describe('CSOT modified spec tests', function () { + const specs = loadSpecTests( + join('..', 'integration', 'client-side-operations-timeout', 'unified-csot-node-specs') + ); + 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 cc767c1d80..f5ada7eef9 100644 --- a/test/integration/client-side-operations-timeout/node_csot.test.ts +++ b/test/integration/client-side-operations-timeout/node_csot.test.ts @@ -1,4 +1,6 @@ /* Anything javascript specific relating to timeouts */ +import { setTimeout } from 'node:timers/promises'; + import { expect } from 'chai'; import * as semver from 'semver'; import * as sinon from 'sinon'; @@ -7,6 +9,9 @@ import { BSON, type ClientSession, type Collection, + type CommandFailedEvent, + type CommandStartedEvent, + type CommandSucceededEvent, Connection, type Db, type FindCursor, @@ -18,7 +23,9 @@ import { } from '../../mongodb'; import { type FailPoint } from '../../tools/utils'; -describe('CSOT driver tests', { requires: { mongodb: '>=4.4' } }, () => { +const metadata = { requires: { mongodb: '>=4.4' } }; + +describe('CSOT driver tests', metadata, () => { describe('timeoutMS inheritance', () => { let client: MongoClient; let db: Db; @@ -171,8 +178,8 @@ describe('CSOT driver tests', { requires: { mongodb: '>=4.4' } }, () => { describe('server-side maxTimeMS errors are transformed', () => { let client: MongoClient; - let commandsSucceeded; - let commandsFailed; + let commandsSucceeded: CommandSucceededEvent[]; + let commandsFailed: CommandFailedEvent[]; beforeEach(async function () { client = this.configuration.newClient({ timeoutMS: 500_000, monitorCommands: true }); @@ -221,18 +228,22 @@ describe('CSOT driver tests', { requires: { mongodb: '>=4.4' } }, () => { await client.db('admin').command({ ...failpoint, mode: 'off' }); }); - it('throws a MongoOperationTimeoutError error and emits command failed', async () => { - const error = await client - .db() - .command({ ping: 1 }) - .catch(error => error); - expect(error).to.be.instanceOf(MongoOperationTimeoutError); - expect(error.cause).to.be.instanceOf(MongoServerError); - expect(error.cause).to.have.property('code', 50); - - expect(commandsFailed).to.have.lengthOf(1); - expect(commandsFailed).to.have.nested.property('[0].failure.cause.code', 50); - }); + it( + 'throws a MongoOperationTimeoutError error and emits command failed', + metadata, + async () => { + const error = await client + .db() + .command({ ping: 1 }) + .catch(error => error); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(error.cause).to.be.instanceOf(MongoServerError); + expect(error.cause).to.have.property('code', 50); + + expect(commandsFailed).to.have.lengthOf(1); + expect(commandsFailed).to.have.nested.property('[0].failure.cause.code', 50); + } + ); }); describe('when a maxTimeExpired error is returned inside a writeErrors array', () => { @@ -267,18 +278,22 @@ describe('CSOT driver tests', { requires: { mongodb: '>=4.4' } }, () => { afterEach(() => sinon.restore()); - it('throws a MongoOperationTimeoutError error and emits command succeeded', async () => { - const error = await client - .db('admin') - .command({ giveMeWriteErrors: 1 }) - .catch(error => error); - expect(error).to.be.instanceOf(MongoOperationTimeoutError); - expect(error.cause).to.be.instanceOf(MongoServerError); - expect(error.cause).to.have.nested.property('writeErrors[3].code', 50); - - expect(commandsSucceeded).to.have.lengthOf(1); - expect(commandsSucceeded).to.have.nested.property('[0].reply.writeErrors[3].code', 50); - }); + it( + 'throws a MongoOperationTimeoutError error and emits command succeeded', + metadata, + async () => { + const error = await client + .db('admin') + .command({ giveMeWriteErrors: 1 }) + .catch(error => error); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(error.cause).to.be.instanceOf(MongoServerError); + expect(error.cause).to.have.nested.property('writeErrors[3].code', 50); + + expect(commandsSucceeded).to.have.lengthOf(1); + expect(commandsSucceeded).to.have.nested.property('[0].reply.writeErrors[3].code', 50); + } + ); }); describe('when a maxTimeExpired error is returned inside a writeConcernError embedded document', () => { @@ -306,22 +321,266 @@ describe('CSOT driver tests', { requires: { mongodb: '>=4.4' } }, () => { await client.db('admin').command({ ...failpoint, mode: 'off' }); }); - it('throws a MongoOperationTimeoutError error and emits command succeeded', async () => { - const error = await client + it( + 'throws a MongoOperationTimeoutError error and emits command succeeded', + metadata, + async () => { + const error = await client + .db() + .collection('a') + .insertOne({}) + .catch(error => error); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(error.cause).to.be.instanceOf(MongoServerError); + expect(error.cause).to.have.nested.property('writeConcernError.code', 50); + + expect(commandsSucceeded).to.have.lengthOf(1); + expect(commandsSucceeded).to.have.nested.property('[0].reply.writeConcernError.code', 50); + } + ); + }); + }); + + describe('Non-Tailable cursors', () => { + let client: MongoClient; + let internalClient: MongoClient; + let commandStarted: CommandStartedEvent[]; + let commandSucceeded: CommandSucceededEvent[]; + const failpoint: FailPoint = { + configureFailPoint: 'failCommand', + mode: 'alwaysOn', + data: { + failCommands: ['find', 'getMore'], + blockConnection: true, + blockTimeMS: 50 + } + }; + + beforeEach(async function () { + internalClient = this.configuration.newClient(); + await internalClient + .db('db') + .dropCollection('coll') + .catch(() => null); + await internalClient + .db('db') + .collection('coll') + .insertMany( + Array.from({ length: 3 }, () => { + return { x: 1 }; + }) + ); + + await internalClient.db().admin().command(failpoint); + + client = this.configuration.newClient(undefined, { monitorCommands: true }); + commandStarted = []; + commandSucceeded = []; + client.on('commandStarted', ev => commandStarted.push(ev)); + client.on('commandSucceeded', ev => commandSucceeded.push(ev)); + }); + + afterEach(async function () { + await internalClient + .db() + .admin() + .command({ ...failpoint, mode: 'off' }); + await internalClient.close(); + await client.close(); + }); + + context('ITERATION mode', () => { + context('when executing an operation', () => { + it( + 'must apply the configured timeoutMS to the initial operation execution', + metadata, + async function () { + const cursor = client + .db('db') + .collection('coll') + .find({}, { batchSize: 3, timeoutMode: 'iteration', timeoutMS: 10 }) + .limit(3); + + const maybeError = await cursor.next().then( + () => null, + e => e + ); + + expect(maybeError).to.be.instanceOf(MongoOperationTimeoutError); + } + ); + + it('refreshes the timeout for any getMores', metadata, async function () { + const cursor = client + .db('db') + .collection('coll') + .find({}, { batchSize: 1, timeoutMode: 'iteration', timeoutMS: 100 }) + .project({ _id: 0 }); + + // Iterating over 3 documents in the collection, each artificially taking ~50 ms due to failpoint. If timeoutMS is not refreshed, then we'd expect to error + for await (const doc of cursor) { + expect(doc).to.deep.equal({ x: 1 }); + } + + const finds = commandSucceeded.filter(ev => ev.commandName === 'find'); + const getMores = commandSucceeded.filter(ev => ev.commandName === 'getMore'); + + expect(finds).to.have.length(1); // Expecting 1 find + expect(getMores).to.have.length(3); // Expecting 3 getMores (including final empty getMore) + }); + + it( + 'does not append a maxTimeMS to the original command or getMores', + metadata, + async function () { + const cursor = client + .db('db') + .collection('coll') + .find({}, { batchSize: 1, timeoutMode: 'iteration', timeoutMS: 100 }) + .project({ _id: 0 }); + await cursor.toArray(); + + expect(commandStarted).to.have.length.gte(3); // Find and 2 getMores + expect( + commandStarted.filter(ev => { + return ( + ev.command.find != null && + ev.command.getMore != null && + ev.command.maxTimeMS != null + ); + }) + ).to.have.lengthOf(0); + } + ); + }); + }); + + context('LIFETIME mode', () => { + let client: MongoClient; + let internalClient: MongoClient; + let commandStarted: CommandStartedEvent[]; + let commandSucceeded: CommandSucceededEvent[]; + const failpoint: FailPoint = { + configureFailPoint: 'failCommand', + mode: 'alwaysOn', + data: { + failCommands: ['find', 'getMore'], + blockConnection: true, + blockTimeMS: 50 + } + }; + + beforeEach(async function () { + internalClient = this.configuration.newClient(); + await internalClient + .db('db') + .dropCollection('coll') + .catch(() => null); + await internalClient + .db('db') + .collection('coll') + .insertMany( + Array.from({ length: 3 }, () => { + return { x: 1 }; + }) + ); + + await internalClient.db().admin().command(failpoint); + + client = this.configuration.newClient(undefined, { monitorCommands: true }); + commandStarted = []; + commandSucceeded = []; + client.on('commandStarted', ev => commandStarted.push(ev)); + client.on('commandSucceeded', ev => commandSucceeded.push(ev)); + }); + + afterEach(async function () { + await internalClient .db() - .collection('a') - .insertOne({}) - .catch(error => error); - expect(error).to.be.instanceOf(MongoOperationTimeoutError); - expect(error.cause).to.be.instanceOf(MongoServerError); - expect(error.cause).to.have.nested.property('writeConcernError.code', 50); - - expect(commandsSucceeded).to.have.lengthOf(1); - expect(commandsSucceeded).to.have.nested.property('[0].reply.writeConcernError.code', 50); + .admin() + .command({ ...failpoint, mode: 'off' }); + await internalClient.close(); + await client.close(); + }); + context('when executing a next call', () => { + context( + 'when there are documents available from previously retrieved batch and timeout has expired', + () => { + it('returns documents without error', metadata, async function () { + const cursor = client + .db('db') + .collection('coll') + .find({}, { timeoutMode: 'cursorLifetime', timeoutMS: 100 }) + .project({ _id: 0 }); + const doc = await cursor.next(); + expect(doc).to.deep.equal({ x: 1 }); + expect(cursor.documents.length).to.be.gt(0); + + await setTimeout(100); + + const docOrErr = await cursor.next().then( + d => d, + e => e + ); + + expect(docOrErr).to.not.be.instanceOf(MongoOperationTimeoutError); + expect(docOrErr).to.be.deep.equal({ x: 1 }); + }); + } + ); + context('when a getMore is required and the timeout has expired', () => { + it('throws a MongoOperationTimeoutError', metadata, async function () { + const cursor = client + .db('db') + .collection('coll') + .find({}, { batchSize: 1, timeoutMode: 'cursorLifetime', timeoutMS: 100 }) + + .project({ _id: 0 }); + + const doc = await cursor.next(); + expect(doc).to.deep.equal({ x: 1 }); + expect(cursor.documents.length).to.equal(0); + + await setTimeout(100); + + const docOrErr = await cursor.next().then( + d => d, + e => e + ); + + expect(docOrErr).to.be.instanceOf(MongoOperationTimeoutError); + }); + }); + + it('does not apply maxTimeMS to a getMore', metadata, async function () { + const cursor = client + .db('db') + .collection('coll') + .find({}, { batchSize: 1, timeoutMode: 'cursorLifetime', timeoutMS: 1000 }) + .project({ _id: 0 }); + + for await (const _doc of cursor) { + // Ignore _doc + } + + const getMores = commandStarted + .filter(ev => ev.command.getMore != null) + .map(ev => ev.command); + expect(getMores.length).to.be.gt(0); + + for (const getMore of getMores) { + expect(getMore.maxTimeMS).to.not.exist; + } + }); }); }); }); + describe.skip('Tailable non-awaitData cursors').skipReason = + 'TODO(NODE-6305): implement CSOT for Tailable cursors'; + describe.skip('Tailable awaitData cursors').skipReason = + 'TODO(NODE-6305): implement CSOT for Tailable cursors'; + describe('when using an explicit session', () => { const metadata: MongoDBMetadataUI = { requires: { topology: ['replicaset'], mongodb: '>=4.4' } diff --git a/test/integration/client-side-operations-timeout/unified-csot-node-specs/command-execution.json b/test/integration/client-side-operations-timeout/unified-csot-node-specs/command-execution.json new file mode 100644 index 0000000000..dd6fcb2cf8 --- /dev/null +++ b/test/integration/client-side-operations-timeout/unified-csot-node-specs/command-execution.json @@ -0,0 +1,153 @@ +{ + "description": "timeoutMS behaves correctly during command execution", + "schemaVersion": "1.9", + "runOnRequirements": [ + { + "minServerVersion": "4.4.7", + "topologies": [ + "single", + "replicaset", + "sharded" + ], + "serverless": "forbid" + } + ], + "createEntities": [ + { + "client": { + "id": "failPointClient", + "useMultipleMongoses": false + } + } + ], + "initialData": [ + { + "collectionName": "coll", + "databaseName": "test", + "documents": [] + }, + { + "collectionName": "timeoutColl", + "databaseName": "test", + "documents": [] + } + ], + "tests": [ + { + "description": "maxTimeMS value in the command is less than timeoutMS", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "failPointClient", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": "alwaysOn", + "data": { + "failCommands": [ + "hello", + "isMaster" + ], + "appName": "reduceMaxTimeMSTest", + "blockConnection": true, + "blockTimeMS": 50 + } + } + } + }, + { + "name": "createEntities", + "object": "testRunner", + "arguments": { + "entities": [ + { + "client": { + "id": "client", + "useMultipleMongoses": false, + "uriOptions": { + "appName": "reduceMaxTimeMSTest", + "w": 1, + "timeoutMS": 500, + "heartbeatFrequencyMS": 500 + }, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database", + "client": "client", + "databaseName": "test" + } + }, + { + "collection": { + "id": "timeoutCollection", + "database": "database", + "collectionName": "timeoutColl" + } + } + ] + } + }, + { + "name": "insertOne", + "object": "timeoutCollection", + "arguments": { + "document": { + "_id": 1 + }, + "timeoutMS": 100000 + } + }, + { + "name": "wait", + "object": "testRunner", + "arguments": { + "ms": 1500 + } + }, + { + "name": "insertOne", + "object": "timeoutCollection", + "arguments": { + "document": { + "_id": 2 + } + } + } + ], + "expectEvents": [ + { + "client": "client", + "events": [ + { + "commandStartedEvent": { + "commandName": "insert", + "databaseName": "test", + "command": { + "insert": "timeoutColl" + } + } + }, + { + "commandStartedEvent": { + "commandName": "insert", + "databaseName": "test", + "command": { + "insert": "timeoutColl", + "maxTimeMS": { + "$$lte": 500 + } + } + } + } + ] + } + ] + } + ] +} diff --git a/test/spec/client-side-operations-timeout/README.md b/test/spec/client-side-operations-timeout/README.md new file mode 100644 index 0000000000..a960c2de21 --- /dev/null +++ b/test/spec/client-side-operations-timeout/README.md @@ -0,0 +1,661 @@ +# Client Side Operations Timeouts Tests + +______________________________________________________________________ + +## Introduction + +This document describes the tests that drivers MUST run to validate the behavior of the timeoutMS option. These tests +are broken up into automated YAML/JSON tests and additional prose tests. + +## Spec Tests + +This directory contains a set of YAML and JSON spec tests. Drivers MUST run these as described in the "Unified Test +Runner" specification. Because the tests introduced in this specification are timing-based, there is a risk that some of +them may intermittently fail without any bugs being present in the driver. As a mitigation, drivers MAY execute these +tests in two new Evergreen tasks that use single-node replica sets: one with only authentication enabled and another +with both authentication and TLS enabled. Drivers that choose to do so SHOULD use the `single-node-auth.json` and +`single-node-auth-ssl.json` files in the `drivers-evergreen-tools` repository to create these clusters. + +## Prose Tests + +There are some tests that cannot be expressed in the unified YAML/JSON format. For each of these tests, drivers MUST +create a MongoClient without the `timeoutMS` option set (referred to as `internalClient`). Any fail points set during a +test MUST be unset using `internalClient` after the test has been executed. All MongoClient instances created for tests +MUST be configured with read/write concern `majority`, read preference `primary`, and command monitoring enabled to +listen for `command_started` events. + +### 1. Multi-batch inserts + +This test MUST only run against standalones on server versions 4.4 and higher. The `insertMany` call takes an +exceedingly long time on replicasets and sharded clusters. Drivers MAY adjust the timeouts used in this test to allow +for differing bulk encoding performance. + +1. Using `internalClient`, drop the `db.coll` collection. + +2. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: { + times: 2 + }, + data: { + failCommands: ["insert"], + blockConnection: true, + blockTimeMS: 1010 + } + } + ``` + +3. Create a new MongoClient (referred to as `client`) with `timeoutMS=2000`. + +4. Using `client`, insert 50 1-megabyte documents in a single `insertMany` call. + + - Expect this to fail with a timeout error. + +5. Verify that two `insert` commands were executed against `db.coll` as part of the `insertMany` call. + +### 2. maxTimeMS is not set for commands sent to mongocryptd + +This test MUST only be run against enterprise server versions 4.2 and higher. + +1. Launch a mongocryptd process on 23000. +2. Create a MongoClient (referred to as `client`) using the URI `mongodb://localhost:23000/?timeoutMS=1000`. +3. Using `client`, execute the `{ ping: 1 }` command against the `admin` database. +4. Verify via command monitoring that the `ping` command sent did not contain a `maxTimeMS` field. + +### 3. ClientEncryption + +Each test under this category MUST only be run against server versions 4.4 and higher. In these tests, `LOCAL_MASTERKEY` +refers to the following base64: + +```javascript +Mng0NCt4ZHVUYUJCa1kxNkVyNUR1QURhZ2h2UzR2d2RrZzh0cFBwM3R6NmdWMDFBMUN3YkQ5aXRRMkhGRGdQV09wOGVNYUMxT2k3NjZKelhaQmRCZGJkTXVyZG9uSjFk +``` + +For each test, perform the following setup: + +1. Using `internalClient`, drop and create the `keyvault.datakeys` collection. + +2. Create a MongoClient (referred to as `keyVaultClient`) with `timeoutMS=10`. + +3. Create a `ClientEncryption` object that wraps `keyVaultClient` (referred to as `clientEncryption`). Configure this + object with `keyVaultNamespace` set to `keyvault.datakeys` and the following KMS providers map: + + ```javascript + { + "local": { "key": } + } + ``` + +#### createDataKey + +1. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: { + times: 1 + }, + data: { + failCommands: ["insert"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +2. Call `clientEncryption.createDataKey()` with the `local` KMS provider. + + - Expect this to fail with a timeout error. + +3. Verify that an `insert` command was executed against to `keyvault.datakeys` as part of the `createDataKey` call. + +#### encrypt + +1. Call `client_encryption.createDataKey()` with the `local` KMS provider. + + - Expect a BSON binary with subtype 4 to be returned, referred to as `datakeyId`. + +2. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: { + times: 1 + }, + data: { + failCommands: ["find"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +3. Call `clientEncryption.encrypt()` with the value `hello`, the algorithm + `AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic`, and the keyId `datakeyId`. + + - Expect this to fail with a timeout error. + +4. Verify that a `find` command was executed against the `keyvault.datakeys` collection as part of the `encrypt` call. + +#### decrypt + +1. Call `clientEncryption.createDataKey()` with the `local` KMS provider. + + - Expect this to return a BSON binary with subtype 4, referred to as `dataKeyId`. + +2. Call `clientEncryption.encrypt()` with the value `hello`, the algorithm + `AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic`, and the keyId `dataKeyId`. + + - Expect this to return a BSON binary with subtype 6, referred to as `encrypted`. + +3. Close and re-create the `keyVaultClient` and `clientEncryption` objects. + +4. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: { + times: 1 + }, + data: { + failCommands: ["find"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +5. Call `clientEncryption.decrypt()` with the value `encrypted`. + + - Expect this to fail with a timeout error. + +6. Verify that a `find` command was executed against the `keyvault.datakeys` collection as part of the `decrypt` call. + +### 4. Background Connection Pooling + +The tests in this section MUST only be run if the server version is 4.4 or higher and the URI has authentication fields +(i.e. a username and password). Each test in this section requires drivers to create a MongoClient and then wait for +some CMAP events to be published. Drivers MUST wait for up to 10 seconds and fail the test if the specified events are +not published within that time. + +#### timeoutMS used for handshake commands + +1. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: { + times: 1 + }, + data: { + failCommands: ["saslContinue"], + blockConnection: true, + blockTimeMS: 15, + appName: "timeoutBackgroundPoolTest" + } + } + ``` + +2. Create a MongoClient (referred to as `client`) configured with the following: + + - `minPoolSize` of 1 + - `timeoutMS` of 10 + - `appName` of `timeoutBackgroundPoolTest` + - CMAP monitor configured to listen for `ConnectionCreatedEvent` and `ConnectionClosedEvent` events. + +3. Wait for a `ConnectionCreatedEvent` and a `ConnectionClosedEvent` to be published. + +#### timeoutMS is refreshed for each handshake command + +1. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: "alwaysOn", + data: { + failCommands: ["hello", "isMaster", "saslContinue"], + blockConnection: true, + blockTimeMS: 15, + appName: "refreshTimeoutBackgroundPoolTest" + } + } + ``` + +2. Create a MongoClient (referred to as `client`) configured with the following: + + - `minPoolSize` of 1 + - `timeoutMS` of 20 + - `appName` of `refreshTimeoutBackgroundPoolTest` + - CMAP monitor configured to listen for `ConnectionCreatedEvent` and `ConnectionReady` events. + +3. Wait for a `ConnectionCreatedEvent` and a `ConnectionReady` to be published. + +### 5. Blocking Iteration Methods + +Tests in this section MUST only be run against server versions 4.4 and higher and only apply to drivers that have a +blocking method for cursor iteration that executes `getMore` commands in a loop until a document is available or an +error occurs. + +#### Tailable cursors + +1. Using `internalClient`, drop the `db.coll` collection. + +2. Using `internalClient`, insert the document `{ x: 1 }` into `db.coll`. + +3. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: "alwaysOn", + data: { + failCommands: ["getMore"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +4. Create a new MongoClient (referred to as `client`) with `timeoutMS=20`. + +5. Using `client`, create a tailable cursor on `db.coll` with `cursorType=tailable`. + + - Expect this to succeed and return a cursor with a non-zero ID. + +6. Call either a blocking or non-blocking iteration method on the cursor. + + - Expect this to succeed and return the document `{ x: 1 }` without sending a `getMore` command. + +7. Call the blocking iteration method on the resulting cursor. + + - Expect this to fail with a timeout error. + +8. Verify that a `find` command and two `getMore` commands were executed against the `db.coll` collection during the + test. + +#### Change Streams + +1. Using `internalClient`, drop the `db.coll` collection. + +2. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: "alwaysOn", + data: { + failCommands: ["getMore"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +3. Create a new MongoClient (referred to as `client`) with `timeoutMS=20`. + +4. Using `client`, use the `watch` helper to create a change stream against `db.coll`. + + - Expect this to succeed and return a change stream with a non-zero ID. + +5. Call the blocking iteration method on the resulting change stream. + + - Expect this to fail with a timeout error. + +6. Verify that an `aggregate` command and two `getMore` commands were executed against the `db.coll` collection during + the test. + +### 6. GridFS - Upload + +Tests in this section MUST only be run against server versions 4.4 and higher. + +#### uploads via openUploadStream can be timed out + +1. Using `internalClient`, drop and re-create the `db.fs.files` and `db.fs.chunks` collections. + +2. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: { times: 1 }, + data: { + failCommands: ["insert"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +3. Create a new MongoClient (referred to as `client`) with `timeoutMS=10`. + +4. Using `client`, create a GridFS bucket (referred to as `bucket`) that wraps the `db` database. + +5. Call `bucket.open_upload_stream()` with the filename `filename` to create an upload stream (referred to as + `uploadStream`). + + - Expect this to succeed and return a non-null stream. + +6. Using `uploadStream`, upload a single `0x12` byte. + +7. Call `uploadStream.close()` to flush the stream and insert chunks. + + - Expect this to fail with a timeout error. + +#### Aborting an upload stream can be timed out + +This test only applies to drivers that provide an API to abort a GridFS upload stream. + +1. Using `internalClient`, drop and re-create the `db.fs.files` and `db.fs.chunks` collections. + +2. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: { times: 1 }, + data: { + failCommands: ["delete"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +3. Create a new MongoClient (referred to as `client`) with `timeoutMS=10`. + +4. Using `client`, create a GridFS bucket (referred to as `bucket`) that wraps the `db` database with + `chunkSizeBytes=2`. + +5. Call `bucket.open_upload_stream()` with the filename `filename` to create an upload stream (referred to as + `uploadStream`). + + - Expect this to succeed and return a non-null stream. + +6. Using `uploadStream`, upload the bytes `[0x01, 0x02, 0x03, 0x04]`. + +7. Call `uploadStream.abort()`. + + - Expect this to fail with a timeout error. + +### 7. GridFS - Download + +This test MUST only be run against server versions 4.4 and higher. + +1. Using `internalClient`, drop and re-create the `db.fs.files` and `db.fs.chunks` collections. + +2. Using `internalClient`, insert the following document into the `db.fs.files` collection: + + ```javascript + { + "_id": { + "$oid": "000000000000000000000005" + }, + "length": 10, + "chunkSize": 4, + "uploadDate": { + "$date": "1970-01-01T00:00:00.000Z" + }, + "md5": "57d83cd477bfb1ccd975ab33d827a92b", + "filename": "length-10", + "contentType": "application/octet-stream", + "aliases": [], + "metadata": {} + } + ``` + +3. Create a new MongoClient (referred to as `client`) with `timeoutMS=10`. + +4. Using `client`, create a GridFS bucket (referred to as `bucket`) that wraps the `db` database. + +5. Call `bucket.open_download_stream` with the id `{ "$oid": "000000000000000000000005" }` to create a download stream + (referred to as `downloadStream`). + + - Expect this to succeed and return a non-null stream. + +6. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: { times: 1 }, + data: { + failCommands: ["find"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +7. Read from the `downloadStream`. + + - Expect this to fail with a timeout error. + +8. Verify that two `find` commands were executed during the read: one against `db.fs.files` and another against + `db.fs.chunks`. + +### 8. Server Selection + +#### serverSelectionTimeoutMS honored if timeoutMS is not set + +1. Create a MongoClient (referred to as `client`) with URI `mongodb://invalid/?serverSelectionTimeoutMS=10`. +2. Using `client`, execute the command `{ ping: 1 }` against the `admin` database. + - Expect this to fail with a server selection timeout error after no more than 15ms. + +#### timeoutMS honored for server selection if it's lower than serverSelectionTimeoutMS + +1. Create a MongoClient (referred to as `client`) with URI + `mongodb://invalid/?timeoutMS=10&serverSelectionTimeoutMS=20`. +2. Using `client`, run the command `{ ping: 1 }` against the `admin` database. + - Expect this to fail with a server selection timeout error after no more than 15ms. + +#### serverSelectionTimeoutMS honored for server selection if it's lower than timeoutMS + +1. Create a MongoClient (referred to as `client`) with URI + `mongodb://invalid/?timeoutMS=20&serverSelectionTimeoutMS=10`. +2. Using `client`, run the command `{ ping: 1 }` against the `admin` database. + - Expect this to fail with a server selection timeout error after no more than 15ms. + +#### serverSelectionTimeoutMS honored for server selection if timeoutMS=0 + +1. Create a MongoClient (referred to as `client`) with URI `mongodb://invalid/?timeoutMS=0&serverSelectionTimeoutMS=10`. +2. Using `client`, run the command `{ ping: 1 }` against the `admin` database. + - Expect this to fail with a server selection timeout error after no more than 15ms. + +#### timeoutMS honored for connection handshake commands if it's lower than serverSelectionTimeoutMS + +This test MUST only be run if the server version is 4.4 or higher and the URI has authentication fields (i.e. a username +and password). + +1. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: failCommand, + mode: { times: 1 }, + data: { + failCommands: ["saslContinue"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +2. Create a new MongoClient (referred to as `client`) with `timeoutMS=10` and `serverSelectionTimeoutMS=20`. + +3. Using `client`, insert the document `{ x: 1 }` into collection `db.coll`. + + - Expect this to fail with a timeout error after no more than 15ms. + +#### serverSelectionTimeoutMS honored for connection handshake commands if it's lower than timeoutMS + +This test MUST only be run if the server version is 4.4 or higher and the URI has authentication fields (i.e. a username +and password). + +1. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: failCommand, + mode: { times: 1 }, + data: { + failCommands: ["saslContinue"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +2. Create a new MongoClient (referred to as `client`) with `timeoutMS=20` and `serverSelectionTimeoutMS=10`. + +3. Using `client`, insert the document `{ x: 1 }` into collection `db.coll`. + + - Expect this to fail with a timeout error after no more than 15ms. + +### 9. endSession + +This test MUST only be run against replica sets and sharded clusters with server version 4.4 or higher. It MUST be run +three times: once with the timeout specified via the MongoClient `timeoutMS` option, once with the timeout specified via +the ClientSession `defaultTimeoutMS` option, and once more with the timeout specified via the `timeoutMS` option for the +`endSession` operation. In all cases, the timeout MUST be set to 10 milliseconds. + +1. Using `internalClient`, drop the `db.coll` collection. + +2. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: failCommand, + mode: { times: 1 }, + data: { + failCommands: ["abortTransaction"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +3. Create a new MongoClient (referred to as `client`) and an explicit ClientSession derived from that MongoClient + (referred to as `session`). + +4. Execute the following code: + + ```typescript + coll = client.database("db").collection("coll") + session.start_transaction() + coll.insert_one({x: 1}, session=session) + ``` + +5. Using `session`, execute `session.end_session` + + - Expect this to fail with a timeout error after no more than 15ms. + +### 10. Convenient Transactions + +Tests in this section MUST only run against replica sets and sharded clusters with server versions 4.4 or higher. + +#### timeoutMS is refreshed for abortTransaction if the callback fails + +1. Using `internalClient`, drop the `db.coll` collection. + +2. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: failCommand, + mode: { times: 2 }, + data: { + failCommands: ["insert", "abortTransaction"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +3. Create a new MongoClient (referred to as `client`) configured with `timeoutMS=10` and an explicit ClientSession + derived from that MongoClient (referred to as `session`). + +4. Using `session`, execute a `withTransaction` operation with the following callback: + + ```typescript + def callback() { + coll = client.database("db").collection("coll") + coll.insert_one({ _id: 1 }, session=session) + } + ``` + +5. Expect the previous `withTransaction` call to fail with a timeout error. + +6. Verify that the following events were published during the `withTransaction` call: + + 1. `command_started` and `command_failed` events for an `insert` command. + 2. `command_started` and `command_failed` events for an `abortTransaction` command. + +### 11. Multi-batch bulkWrites + +This test MUST only run against server versions 8.0+. + +1. Using `internalClient`, drop the `db.coll` collection. + +2. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: { + times: 2 + }, + data: { + failCommands: ["bulkWrite"], + blockConnection: true, + blockTimeMS: 1010 + } + } + ``` + +3. Using `internalClient`, perform a `hello` command and record the `maxBsonObjectSize` and `maxMessageSizeBytes` values + in the response. + +4. Create a new MongoClient (referred to as `client`) with `timeoutMS=2000`. + +5. Create a list of write models (referred to as `models`) with the following write model repeated + (`maxMessageSizeBytes / maxBsonObjectSize + 1`) times: + + ```json + InsertOne { + "namespace": "db.coll", + "document": { "a": "b".repeat(maxBsonObjectSize - 500) } + } + ``` + +6. Call `bulkWrite` on `client` with `models`. + + - Expect this to fail with a timeout error. + +7. Verify that two `bulkWrite` commands were executed as part of the `MongoClient.bulkWrite` call. + +## Unit Tests + +The tests enumerated in this section could not be expressed in either spec or prose format. Drivers SHOULD implement +these if it is possible to do so using the driver's existing test infrastructure. + +- Operations should ignore `waitQueueTimeoutMS` if `timeoutMS` is also set. +- If `timeoutMS` is set for an operation, the remaining `timeoutMS` value should apply to connection checkout after a + server has been selected. +- If `timeoutMS` is not set for an operation, `waitQueueTimeoutMS` should apply to connection checkout after a server + has been selected. +- If a new connection is required to execute an operation, + `min(remaining computedServerSelectionTimeout, connectTimeoutMS)` should apply to socket establishment. +- For drivers that have control over OCSP behavior, `min(remaining computedServerSelectionTimeout, 5 seconds)` should + apply to HTTP requests against OCSP responders. +- If `timeoutMS` is unset, operations fail after two non-consecutive socket timeouts. +- The remaining `timeoutMS` value should apply to HTTP requests against KMS servers for CSFLE. +- The remaining `timeoutMS` value should apply to commands sent to mongocryptd as part of automatic encryption. +- When doing `minPoolSize` maintenance, `connectTimeoutMS` is used as the timeout for socket establishment. diff --git a/test/spec/client-side-operations-timeout/change-streams.json b/test/spec/client-side-operations-timeout/change-streams.json index aef77bb452..8cffb08e26 100644 --- a/test/spec/client-side-operations-timeout/change-streams.json +++ b/test/spec/client-side-operations-timeout/change-streams.json @@ -104,7 +104,7 @@ "aggregate" ], "blockConnection": true, - "blockTimeMS": 55 + "blockTimeMS": 250 } } } @@ -114,7 +114,7 @@ "object": "collection", "arguments": { "pipeline": [], - "timeoutMS": 50 + "timeoutMS": 200 }, "expectError": { "isTimeoutError": true @@ -242,7 +242,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 150 } } } @@ -252,7 +252,7 @@ "object": "collection", "arguments": { "pipeline": [], - "timeoutMS": 20, + "timeoutMS": 200, "batchSize": 2, "maxAwaitTimeMS": 1 }, @@ -310,7 +310,7 @@ "object": "collection", "arguments": { "pipeline": [], - "timeoutMS": 20 + "timeoutMS": 200 }, "saveResultAsEntity": "changeStream" }, @@ -330,7 +330,7 @@ "aggregate" ], "blockConnection": true, - "blockTimeMS": 12, + "blockTimeMS": 120, "errorCode": 7, "errorLabels": [ "ResumableChangeStreamError" @@ -412,7 +412,7 @@ "arguments": { "pipeline": [], "maxAwaitTimeMS": 1, - "timeoutMS": 100 + "timeoutMS": 200 }, "saveResultAsEntity": "changeStream" }, @@ -431,7 +431,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 150 + "blockTimeMS": 250 } } } @@ -534,7 +534,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 250 } } } @@ -544,7 +544,7 @@ "object": "collection", "arguments": { "pipeline": [], - "timeoutMS": 10 + "timeoutMS": 200 }, "saveResultAsEntity": "changeStream" }, diff --git a/test/spec/client-side-operations-timeout/change-streams.yml b/test/spec/client-side-operations-timeout/change-streams.yml index b2a052d01b..c813be035a 100644 --- a/test/spec/client-side-operations-timeout/change-streams.yml +++ b/test/spec/client-side-operations-timeout/change-streams.yml @@ -67,12 +67,12 @@ tests: data: failCommands: ["aggregate"] blockConnection: true - blockTimeMS: 55 + blockTimeMS: 250 - name: createChangeStream object: *collection arguments: pipeline: [] - timeoutMS: 50 + timeoutMS: 200 expectError: isTimeoutError: true expectEvents: @@ -142,12 +142,12 @@ tests: data: failCommands: ["aggregate", "getMore"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 150 - name: createChangeStream object: *collection arguments: pipeline: [] - timeoutMS: 20 + timeoutMS: 200 batchSize: 2 maxAwaitTimeMS: 1 saveResultAsEntity: &changeStream changeStream @@ -171,16 +171,16 @@ tests: maxTimeMS: 1 # The timeout should be applied to the entire resume attempt, not individually to each command. The test creates a - # change stream with timeoutMS=20 which returns an empty initial batch and then sets a fail point to block both - # getMore and aggregate for 12ms each and fail with a resumable error. When the resume attempt happens, the getMore - # and aggregate block for longer than 20ms total, so it times out. + # change stream with timeoutMS=200 which returns an empty initial batch and then sets a fail point to block both + # getMore and aggregate for 120ms each and fail with a resumable error. When the resume attempt happens, the getMore + # and aggregate block for longer than 200ms total, so it times out. - description: "timeoutMS applies to full resume attempt in a next call" operations: - name: createChangeStream object: *collection arguments: pipeline: [] - timeoutMS: 20 + timeoutMS: 200 saveResultAsEntity: &changeStream changeStream - name: failPoint object: testRunner @@ -192,7 +192,7 @@ tests: data: failCommands: ["getMore", "aggregate"] blockConnection: true - blockTimeMS: 12 + blockTimeMS: 120 errorCode: 7 # HostNotFound - resumable but does not require an SDAM state change. # failCommand doesn't correctly add the ResumableChangeStreamError by default. It needs to be specified # manually here so the error is considered resumable. The failGetMoreAfterCursorCheckout fail point @@ -234,9 +234,9 @@ tests: # Specify a short maxAwaitTimeMS because otherwise the getMore on the new cursor will wait for 1000ms and # time out. maxAwaitTimeMS: 1 - timeoutMS: 100 + timeoutMS: 200 saveResultAsEntity: &changeStream changeStream - # Block getMore for 150ms to force the next() call to time out. + # Block getMore for 250ms to force the next() call to time out. - name: failPoint object: testRunner arguments: @@ -247,7 +247,7 @@ tests: data: failCommands: ["getMore"] blockConnection: true - blockTimeMS: 150 + blockTimeMS: 250 # The original aggregate didn't return any events so this should do a getMore and return a timeout error. - name: iterateUntilDocumentOrError object: *changeStream @@ -290,7 +290,7 @@ tests: collection: *collectionName # The timeoutMS value should be refreshed for getMore's. This is a failure test. The createChangeStream operation - # sets timeoutMS=10 and the getMore blocks for 15ms, causing iteration to fail with a timeout error. + # sets timeoutMS=200 and the getMore blocks for 250ms, causing iteration to fail with a timeout error. - description: "timeoutMS is refreshed for getMore - failure" operations: - name: failPoint @@ -303,12 +303,12 @@ tests: data: failCommands: ["getMore"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 250 - name: createChangeStream object: *collection arguments: pipeline: [] - timeoutMS: 10 + timeoutMS: 200 saveResultAsEntity: &changeStream changeStream # The first iteration should do a getMore - name: iterateUntilDocumentOrError diff --git a/test/spec/client-side-operations-timeout/close-cursors.json b/test/spec/client-side-operations-timeout/close-cursors.json index 1361971c4c..79b0de7b6a 100644 --- a/test/spec/client-side-operations-timeout/close-cursors.json +++ b/test/spec/client-side-operations-timeout/close-cursors.json @@ -75,7 +75,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 50 + "blockTimeMS": 250 } } } @@ -86,7 +86,7 @@ "arguments": { "filter": {}, "batchSize": 2, - "timeoutMS": 20 + "timeoutMS": 200 }, "saveResultAsEntity": "cursor" }, @@ -175,7 +175,7 @@ "killCursors" ], "blockConnection": true, - "blockTimeMS": 30 + "blockTimeMS": 250 } } } @@ -186,7 +186,7 @@ "arguments": { "filter": {}, "batchSize": 2, - "timeoutMS": 20 + "timeoutMS": 200 }, "saveResultAsEntity": "cursor" }, @@ -194,7 +194,7 @@ "name": "close", "object": "cursor", "arguments": { - "timeoutMS": 40 + "timeoutMS": 400 } } ], @@ -215,7 +215,7 @@ { "commandStartedEvent": { "command": { - "killCursors": "collection", + "killCursors": "coll", "maxTimeMS": { "$$type": [ "int", diff --git a/test/spec/client-side-operations-timeout/close-cursors.yml b/test/spec/client-side-operations-timeout/close-cursors.yml index db26e79ca3..c4c4ea0acd 100644 --- a/test/spec/client-side-operations-timeout/close-cursors.yml +++ b/test/spec/client-side-operations-timeout/close-cursors.yml @@ -46,13 +46,13 @@ tests: data: failCommands: ["getMore"] blockConnection: true - blockTimeMS: 50 + blockTimeMS: 250 - name: createFindCursor object: *collection arguments: filter: {} batchSize: 2 - timeoutMS: 20 + timeoutMS: 200 saveResultAsEntity: &cursor cursor # Iterate the cursor three times. The third should do a getMore, which should fail with a timeout error. - name: iterateUntilDocumentOrError @@ -99,18 +99,18 @@ tests: data: failCommands: ["killCursors"] blockConnection: true - blockTimeMS: 30 + blockTimeMS: 250 - name: createFindCursor object: *collection arguments: filter: {} batchSize: 2 - timeoutMS: 20 + timeoutMS: 200 saveResultAsEntity: &cursor cursor - name: close object: *cursor arguments: - timeoutMS: 40 + timeoutMS: 400 expectEvents: - client: *client events: @@ -120,7 +120,7 @@ tests: commandName: find - commandStartedEvent: command: - killCursors: *collection + killCursors: *collectionName maxTimeMS: { $$type: ["int", "long"] } commandName: killCursors - commandSucceededEvent: diff --git a/test/spec/client-side-operations-timeout/command-execution.json b/test/spec/client-side-operations-timeout/command-execution.json index b9b306c7fb..aa9c3eb23f 100644 --- a/test/spec/client-side-operations-timeout/command-execution.json +++ b/test/spec/client-side-operations-timeout/command-execution.json @@ -3,7 +3,7 @@ "schemaVersion": "1.9", "runOnRequirements": [ { - "minServerVersion": "4.9", + "minServerVersion": "4.4.7", "topologies": [ "single", "replicaset", diff --git a/test/spec/client-side-operations-timeout/command-execution.yml b/test/spec/client-side-operations-timeout/command-execution.yml index 400a90867a..6ba0585b3c 100644 --- a/test/spec/client-side-operations-timeout/command-execution.yml +++ b/test/spec/client-side-operations-timeout/command-execution.yml @@ -3,9 +3,8 @@ description: "timeoutMS behaves correctly during command execution" schemaVersion: "1.9" runOnRequirements: - # The appName filter cannot be used to set a fail point on connection handshakes until server version 4.9 due to - # SERVER-49220/SERVER-49336. - - minServerVersion: "4.9" + # Require SERVER-49336 for failCommand + appName on the initial handshake. + - minServerVersion: "4.4.7" # Skip load-balanced and serverless which do not support RTT measurements. topologies: [ single, replicaset, sharded ] serverless: forbid diff --git a/test/spec/client-side-operations-timeout/convenient-transactions.json b/test/spec/client-side-operations-timeout/convenient-transactions.json index 07e676d5f5..3868b3026c 100644 --- a/test/spec/client-side-operations-timeout/convenient-transactions.json +++ b/test/spec/client-side-operations-timeout/convenient-transactions.json @@ -21,7 +21,7 @@ "client": { "id": "client", "uriOptions": { - "timeoutMS": 50 + "timeoutMS": 500 }, "useMultipleMongoses": false, "observeEvents": [ @@ -81,6 +81,9 @@ } } ] + }, + "expectError": { + "isClientError": true } } ], @@ -109,7 +112,7 @@ "insert" ], "blockConnection": true, - "blockTimeMS": 30 + "blockTimeMS": 300 } } } @@ -182,6 +185,21 @@ } } } + }, + { + "commandStartedEvent": { + "commandName": "abortTransaction", + "databaseName": "admin", + "command": { + "abortTransaction": 1, + "maxTimeMS": { + "$$type": [ + "int", + "long" + ] + } + } + } } ] } diff --git a/test/spec/client-side-operations-timeout/convenient-transactions.yml b/test/spec/client-side-operations-timeout/convenient-transactions.yml index d79aa4bd05..02d48b8324 100644 --- a/test/spec/client-side-operations-timeout/convenient-transactions.yml +++ b/test/spec/client-side-operations-timeout/convenient-transactions.yml @@ -13,7 +13,7 @@ createEntities: - client: id: &client client uriOptions: - timeoutMS: 50 + timeoutMS: 500 useMultipleMongoses: false observeEvents: - commandStartedEvent @@ -49,6 +49,8 @@ tests: timeoutMS: 100 expectError: isClientError: true + expectError: + isClientError: true expectEvents: # The only operation run fails with a client-side error, so there should be no events for the client. - client: *client @@ -66,7 +68,7 @@ tests: data: failCommands: ["insert"] blockConnection: true - blockTimeMS: 30 + blockTimeMS: 300 - name: withTransaction object: *session arguments: @@ -88,9 +90,6 @@ tests: expectEvents: - client: *client events: - # Because the second insert expects an error and gets an error, it technically succeeds, so withTransaction - # will try to run commitTransaction. This will fail client-side, though, because the timeout has already - # expired, so no command is sent. - commandStartedEvent: commandName: insert databaseName: *databaseName @@ -103,3 +102,9 @@ tests: command: insert: *collectionName maxTimeMS: { $$type: ["int", "long"] } + - commandStartedEvent: + commandName: abortTransaction + databaseName: admin + command: + abortTransaction: 1 + maxTimeMS: { $$type: [ "int", "long" ] } diff --git a/test/spec/client-side-operations-timeout/deprecated-options.json b/test/spec/client-side-operations-timeout/deprecated-options.json index 322e944910..d3e4631ff4 100644 --- a/test/spec/client-side-operations-timeout/deprecated-options.json +++ b/test/spec/client-side-operations-timeout/deprecated-options.json @@ -1,5 +1,5 @@ { - "description": "operations ignore deprected timeout options if timeoutMS is set", + "description": "operations ignore deprecated timeout options if timeoutMS is set", "schemaVersion": "1.9", "runOnRequirements": [ { diff --git a/test/spec/client-side-operations-timeout/deprecated-options.yml b/test/spec/client-side-operations-timeout/deprecated-options.yml index 461ba6ab13..582a8983ae 100644 --- a/test/spec/client-side-operations-timeout/deprecated-options.yml +++ b/test/spec/client-side-operations-timeout/deprecated-options.yml @@ -1,4 +1,4 @@ -description: "operations ignore deprected timeout options if timeoutMS is set" +description: "operations ignore deprecated timeout options if timeoutMS is set" schemaVersion: "1.9" diff --git a/test/spec/client-side-operations-timeout/gridfs-advanced.yml b/test/spec/client-side-operations-timeout/gridfs-advanced.yml index bc788bacc3..f6c37e165b 100644 --- a/test/spec/client-side-operations-timeout/gridfs-advanced.yml +++ b/test/spec/client-side-operations-timeout/gridfs-advanced.yml @@ -119,7 +119,7 @@ tests: update: *filesCollectionName maxTimeMS: { $$type: ["int", "long"] } - # Tests for the "drop" opration. Any tests that might result in multiple commands being sent do not have expectEvents + # Tests for the "drop" operation. Any tests that might result in multiple commands being sent do not have expectEvents # assertions as these assertions reduce test robustness and can cause flaky failures. - description: "timeoutMS can be overridden for drop" diff --git a/test/spec/client-side-operations-timeout/non-tailable-cursors.json b/test/spec/client-side-operations-timeout/non-tailable-cursors.json index 0a5448a6bb..291c6e72aa 100644 --- a/test/spec/client-side-operations-timeout/non-tailable-cursors.json +++ b/test/spec/client-side-operations-timeout/non-tailable-cursors.json @@ -17,7 +17,7 @@ "client": { "id": "client", "uriOptions": { - "timeoutMS": 10 + "timeoutMS": 200 }, "useMultipleMongoses": false, "observeEvents": [ @@ -84,7 +84,7 @@ "find" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 250 } } } @@ -143,7 +143,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 125 } } } @@ -153,7 +153,7 @@ "object": "collection", "arguments": { "filter": {}, - "timeoutMS": 20, + "timeoutMS": 200, "batchSize": 2 }, "expectError": { @@ -221,7 +221,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 150 } } } @@ -232,7 +232,7 @@ "arguments": { "filter": {}, "timeoutMode": "cursorLifetime", - "timeoutMS": 20, + "timeoutMS": 200, "batchSize": 2 }, "expectError": { @@ -299,7 +299,7 @@ "find" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 250 } } } @@ -355,7 +355,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 125 } } } @@ -366,7 +366,7 @@ "arguments": { "filter": {}, "timeoutMode": "iteration", - "timeoutMS": 20, + "timeoutMS": 200, "batchSize": 2 } } @@ -427,7 +427,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 250 } } } diff --git a/test/spec/client-side-operations-timeout/non-tailable-cursors.yml b/test/spec/client-side-operations-timeout/non-tailable-cursors.yml index 8cd953dec4..29037b4c0a 100644 --- a/test/spec/client-side-operations-timeout/non-tailable-cursors.yml +++ b/test/spec/client-side-operations-timeout/non-tailable-cursors.yml @@ -12,7 +12,7 @@ createEntities: - client: id: &client client uriOptions: - timeoutMS: 10 + timeoutMS: 200 useMultipleMongoses: false observeEvents: - commandStartedEvent @@ -53,7 +53,7 @@ tests: data: failCommands: ["find"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 250 - name: find object: *collection arguments: @@ -86,14 +86,14 @@ tests: data: failCommands: ["find", "getMore"] blockConnection: true - blockTimeMS: 15 - # Run a find with timeoutMS=20 and batchSize=1 to force two batches, which will cause a find and a getMore to be - # sent. Both will block for 15ms so together they will go over the timeout. + blockTimeMS: 125 + # Run a find with timeoutMS=200 and batchSize=1 to force two batches, which will cause a find and a getMore to be + # sent. Both will block for 125ms, so together they will go over the timeout. - name: find object: *collection arguments: filter: {} - timeoutMS: 20 + timeoutMS: 200 batchSize: 2 expectError: isTimeoutError: true @@ -127,13 +127,13 @@ tests: data: failCommands: ["find", "getMore"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 150 - name: find object: *collection arguments: filter: {} timeoutMode: cursorLifetime - timeoutMS: 20 + timeoutMS: 200 batchSize: 2 expectError: isTimeoutError: true @@ -168,7 +168,7 @@ tests: data: failCommands: ["find"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 250 - name: find object: *collection arguments: @@ -187,8 +187,8 @@ tests: maxTimeMS: { $$exists: false } # If timeoutMode=ITERATION, timeoutMS applies separately to the initial find and the getMore on the cursor. Neither - # command should have a maxTimeMS field. This is a success test. The "find" is executed with timeoutMS=20 and both - # "find" and "getMore" commands are blocked for 15ms each. Neither exceeds the timeout, so iteration succeeds. + # command should have a maxTimeMS field. This is a success test. The "find" is executed with timeoutMS=200 and both + # "find" and "getMore" commands are blocked for 125ms each. Neither exceeds the timeout, so iteration succeeds. - description: "timeoutMS is refreshed for getMore if timeoutMode is iteration - success" operations: - name: failPoint @@ -201,13 +201,13 @@ tests: data: failCommands: ["find", "getMore"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 125 - name: find object: *collection arguments: filter: {} timeoutMode: iteration - timeoutMS: 20 + timeoutMS: 200 batchSize: 2 expectEvents: - client: *client @@ -227,8 +227,8 @@ tests: maxTimeMS: { $$exists: false } # If timeoutMode=ITERATION, timeoutMS applies separately to the initial find and the getMore on the cursor. Neither - # command should have a maxTimeMS field. This is a failure test. The "find" inherits timeoutMS=10 and "getMore" - # commands are blocked for 15ms, causing iteration to fail with a timeout error. + # command should have a maxTimeMS field. This is a failure test. The "find" inherits timeoutMS=200 and "getMore" + # commands are blocked for 250ms, causing iteration to fail with a timeout error. - description: "timeoutMS is refreshed for getMore if timeoutMode is iteration - failure" operations: - name: failPoint @@ -241,7 +241,7 @@ tests: data: failCommands: ["getMore"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 250 - name: find object: *collection arguments: diff --git a/test/spec/client-side-operations-timeout/retryability-timeoutMS.json b/test/spec/client-side-operations-timeout/retryability-timeoutMS.json index a28dbd2685..9daad260ef 100644 --- a/test/spec/client-side-operations-timeout/retryability-timeoutMS.json +++ b/test/spec/client-side-operations-timeout/retryability-timeoutMS.json @@ -108,6 +108,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - insertOne on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -198,6 +203,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - insertOne on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -327,6 +337,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - insertMany on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -419,6 +434,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - insertMany on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -546,6 +566,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - deleteOne on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -634,6 +659,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - deleteOne on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -760,6 +790,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - replaceOne on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -851,6 +886,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - replaceOne on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -982,6 +1022,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - updateOne on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -1075,6 +1120,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - updateOne on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -1203,6 +1253,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - findOneAndDelete on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -1291,6 +1346,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - findOneAndDelete on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -1417,6 +1477,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - findOneAndReplace on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -1508,6 +1573,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - findOneAndReplace on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -1639,6 +1709,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - findOneAndUpdate on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -1732,6 +1807,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - findOneAndUpdate on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -1868,6 +1948,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - bulkWrite on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -1964,6 +2049,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - bulkWrite on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -2095,6 +2185,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - listDatabases on client", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -2183,6 +2278,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - listDatabases on client", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -2303,6 +2403,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - listDatabaseNames on client", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -2390,6 +2495,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - listDatabaseNames on client", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -2512,6 +2622,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - createChangeStream on client", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -2600,6 +2715,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - createChangeStream on client", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -2730,6 +2850,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - aggregate on database", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -2825,6 +2950,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - aggregate on database", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -2955,6 +3085,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - listCollections on database", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -3043,6 +3178,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - listCollections on database", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -3166,6 +3306,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - listCollectionNames on database", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -3254,6 +3399,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - listCollectionNames on database", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -3377,6 +3527,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - createChangeStream on database", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -3465,6 +3620,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - createChangeStream on database", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -3588,6 +3748,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - aggregate on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -3676,6 +3841,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - aggregate on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -3799,6 +3969,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - count on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -3887,6 +4062,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - count on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -4010,6 +4190,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - countDocuments on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -4098,6 +4283,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - countDocuments on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -4218,6 +4408,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - estimatedDocumentCount on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -4305,6 +4500,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - estimatedDocumentCount on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -4428,6 +4628,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - distinct on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -4517,6 +4722,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - distinct on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -4641,6 +4851,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - find on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -4729,6 +4944,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - find on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -4852,6 +5072,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - findOne on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -4940,6 +5165,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - findOne on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -5060,6 +5290,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - listIndexes on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -5147,6 +5382,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - listIndexes on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -5269,6 +5509,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - createChangeStream on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -5357,6 +5602,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - createChangeStream on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", diff --git a/test/spec/client-side-operations-timeout/retryability-timeoutMS.yml b/test/spec/client-side-operations-timeout/retryability-timeoutMS.yml index 039f7ca42e..6f47d6c2e4 100644 --- a/test/spec/client-side-operations-timeout/retryability-timeoutMS.yml +++ b/test/spec/client-side-operations-timeout/retryability-timeoutMS.yml @@ -84,6 +84,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - insertOne on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -125,6 +127,8 @@ tests: insert: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - insertOne on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -191,6 +195,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - insertMany on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -233,6 +239,8 @@ tests: insert: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - insertMany on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -299,6 +307,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - deleteOne on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -340,6 +350,8 @@ tests: delete: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - deleteOne on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -406,6 +418,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - replaceOne on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -448,6 +462,8 @@ tests: update: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - replaceOne on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -515,6 +531,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - updateOne on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -557,6 +575,8 @@ tests: update: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - updateOne on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -623,6 +643,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - findOneAndDelete on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -664,6 +686,8 @@ tests: findAndModify: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - findOneAndDelete on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -730,6 +754,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - findOneAndReplace on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -772,6 +798,8 @@ tests: findAndModify: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - findOneAndReplace on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -839,6 +867,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - findOneAndUpdate on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -881,6 +911,8 @@ tests: findAndModify: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - findOneAndUpdate on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -949,6 +981,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - bulkWrite on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -992,6 +1026,8 @@ tests: insert: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - bulkWrite on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1059,6 +1095,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - listDatabases on client" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1100,6 +1138,8 @@ tests: listDatabases: 1 maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - listDatabases on client" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1163,6 +1203,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - listDatabaseNames on client" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1203,6 +1245,8 @@ tests: listDatabases: 1 maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - listDatabaseNames on client" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1267,6 +1311,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - createChangeStream on client" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1308,6 +1354,8 @@ tests: aggregate: 1 maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - createChangeStream on client" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1373,6 +1421,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - aggregate on database" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1414,6 +1464,8 @@ tests: aggregate: 1 maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - aggregate on database" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1479,6 +1531,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - listCollections on database" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1520,6 +1574,8 @@ tests: listCollections: 1 maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - listCollections on database" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1585,6 +1641,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - listCollectionNames on database" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1626,6 +1684,8 @@ tests: listCollections: 1 maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - listCollectionNames on database" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1691,6 +1751,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - createChangeStream on database" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1732,6 +1794,8 @@ tests: aggregate: 1 maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - createChangeStream on database" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1797,6 +1861,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - aggregate on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1838,6 +1904,8 @@ tests: aggregate: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - aggregate on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1903,6 +1971,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - count on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1944,6 +2014,8 @@ tests: count: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - count on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2009,6 +2081,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - countDocuments on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2050,6 +2124,8 @@ tests: aggregate: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - countDocuments on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2113,6 +2189,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - estimatedDocumentCount on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2153,6 +2231,8 @@ tests: count: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - estimatedDocumentCount on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2218,6 +2298,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - distinct on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2260,6 +2342,8 @@ tests: distinct: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - distinct on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2326,6 +2410,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - find on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2367,6 +2453,8 @@ tests: find: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - find on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2432,6 +2520,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - findOne on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2473,6 +2563,8 @@ tests: find: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - findOne on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2536,6 +2628,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - listIndexes on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2576,6 +2670,8 @@ tests: listIndexes: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - listIndexes on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2640,6 +2736,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - createChangeStream on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2681,6 +2779,8 @@ tests: aggregate: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - createChangeStream on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner diff --git a/test/spec/client-side-operations-timeout/tailable-awaitData.json b/test/spec/client-side-operations-timeout/tailable-awaitData.json index 6da85c7783..535fb69243 100644 --- a/test/spec/client-side-operations-timeout/tailable-awaitData.json +++ b/test/spec/client-side-operations-timeout/tailable-awaitData.json @@ -17,7 +17,7 @@ "client": { "id": "client", "uriOptions": { - "timeoutMS": 10 + "timeoutMS": 200 }, "useMultipleMongoses": false, "observeEvents": [ @@ -130,7 +130,7 @@ "find" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 300 } } } @@ -188,7 +188,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 150 } } } @@ -199,7 +199,7 @@ "arguments": { "filter": {}, "cursorType": "tailableAwait", - "timeoutMS": 20, + "timeoutMS": 250, "batchSize": 1 }, "saveResultAsEntity": "tailableCursor" @@ -272,7 +272,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 150 } } } @@ -283,7 +283,7 @@ "arguments": { "filter": {}, "cursorType": "tailableAwait", - "timeoutMS": 20, + "timeoutMS": 250, "batchSize": 1, "maxAwaitTimeMS": 1 }, @@ -354,7 +354,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 250 } } } diff --git a/test/spec/client-side-operations-timeout/tailable-awaitData.yml b/test/spec/client-side-operations-timeout/tailable-awaitData.yml index 422c6fb537..52b9b3b456 100644 --- a/test/spec/client-side-operations-timeout/tailable-awaitData.yml +++ b/test/spec/client-side-operations-timeout/tailable-awaitData.yml @@ -12,7 +12,7 @@ createEntities: - client: id: &client client uriOptions: - timeoutMS: 10 + timeoutMS: 200 useMultipleMongoses: false observeEvents: - commandStartedEvent @@ -83,7 +83,7 @@ tests: data: failCommands: ["find"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 300 - name: find object: *collection arguments: @@ -117,13 +117,13 @@ tests: data: failCommands: ["find", "getMore"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 150 - name: createFindCursor object: *collection arguments: filter: {} cursorType: tailableAwait - timeoutMS: 20 + timeoutMS: 250 batchSize: 1 saveResultAsEntity: &tailableCursor tailableCursor # Iterate twice to force a getMore. The first iteration will return the document from the first batch and the @@ -165,13 +165,13 @@ tests: data: failCommands: ["find", "getMore"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 150 - name: createFindCursor object: *collection arguments: filter: {} cursorType: tailableAwait - timeoutMS: 20 + timeoutMS: 250 batchSize: 1 maxAwaitTimeMS: 1 saveResultAsEntity: &tailableCursor tailableCursor @@ -199,8 +199,8 @@ tests: collection: *collectionName maxTimeMS: 1 - # The timeoutMS value should be refreshed for getMore's. This is a failure test. The find inherits timeoutMS=10 from - # the collection and the getMore blocks for 15ms, causing iteration to fail with a timeout error. + # The timeoutMS value should be refreshed for getMore's. This is a failure test. The find inherits timeoutMS=200 from + # the collection and the getMore blocks for 250ms, causing iteration to fail with a timeout error. - description: "timeoutMS is refreshed for getMore - failure" operations: - name: failPoint @@ -213,7 +213,7 @@ tests: data: failCommands: ["getMore"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 250 - name: createFindCursor object: *collection arguments: diff --git a/test/spec/client-side-operations-timeout/tailable-non-awaitData.json b/test/spec/client-side-operations-timeout/tailable-non-awaitData.json index 34ee660963..e88230e4f7 100644 --- a/test/spec/client-side-operations-timeout/tailable-non-awaitData.json +++ b/test/spec/client-side-operations-timeout/tailable-non-awaitData.json @@ -17,7 +17,7 @@ "client": { "id": "client", "uriOptions": { - "timeoutMS": 10 + "timeoutMS": 200 }, "useMultipleMongoses": false, "observeEvents": [ @@ -94,7 +94,7 @@ "find" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 250 } } } @@ -154,7 +154,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 150 } } } @@ -165,7 +165,7 @@ "arguments": { "filter": {}, "cursorType": "tailable", - "timeoutMS": 20, + "timeoutMS": 200, "batchSize": 1 }, "saveResultAsEntity": "tailableCursor" @@ -239,7 +239,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 250 } } } diff --git a/test/spec/client-side-operations-timeout/tailable-non-awaitData.yml b/test/spec/client-side-operations-timeout/tailable-non-awaitData.yml index 766b46e658..eb75deaa65 100644 --- a/test/spec/client-side-operations-timeout/tailable-non-awaitData.yml +++ b/test/spec/client-side-operations-timeout/tailable-non-awaitData.yml @@ -12,7 +12,7 @@ createEntities: - client: id: &client client uriOptions: - timeoutMS: 10 + timeoutMS: 200 useMultipleMongoses: false observeEvents: - commandStartedEvent @@ -59,7 +59,7 @@ tests: data: failCommands: ["find"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 250 - name: find object: *collection arguments: @@ -96,13 +96,13 @@ tests: data: failCommands: ["find", "getMore"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 150 - name: createFindCursor object: *collection arguments: filter: {} cursorType: tailable - timeoutMS: 20 + timeoutMS: 200 batchSize: 1 saveResultAsEntity: &tailableCursor tailableCursor # Iterate the cursor twice: the first iteration will return the document from the batch in the find and the @@ -131,7 +131,7 @@ tests: maxTimeMS: { $$exists: false } # The timeoutMS option should apply separately to the initial "find" and each getMore. This is a failure test. The - # find inherits timeoutMS=10 from the collection and the getMore command blocks for 15ms, causing iteration to fail + # find inherits timeoutMS=200 from the collection and the getMore command blocks for 250ms, causing iteration to fail # with a timeout error. - description: "timeoutMS is refreshed for getMore - failure" operations: @@ -145,7 +145,7 @@ tests: data: failCommands: ["getMore"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 250 - name: createFindCursor object: *collection arguments: diff --git a/test/tools/unified-spec-runner/operations.ts b/test/tools/unified-spec-runner/operations.ts index d43f541aae..fd8f6f66fc 100644 --- a/test/tools/unified-spec-runner/operations.ts +++ b/test/tools/unified-spec-runner/operations.ts @@ -205,7 +205,8 @@ operations.set('close', async ({ entities, operation }) => { /* eslint-disable no-empty */ try { const cursor = entities.getEntity('cursor', operation.object); - await cursor.close(); + const timeoutMS = operation.arguments?.timeoutMS; + await cursor.close({ timeoutMS }); return; } catch {} @@ -777,7 +778,9 @@ operations.set('runCursorCommand', async ({ entities, operation }: OperationFunc const { command, ...opts } = operation.arguments!; const cursor = db.runCursorCommand(command, { readPreference: ReadPreference.fromOptions({ readPreference: opts.readPreference }), - session: opts.session + session: opts.session, + timeoutMode: opts.timeoutMode, + timeoutMS: opts.timeoutMS }); if (!Number.isNaN(+opts.batchSize)) cursor.setBatchSize(+opts.batchSize); diff --git a/test/unit/cursor/aggregation_cursor.test.ts b/test/unit/cursor/aggregation_cursor.test.ts index 32ca4125ff..82ae18745b 100644 --- a/test/unit/cursor/aggregation_cursor.test.ts +++ b/test/unit/cursor/aggregation_cursor.test.ts @@ -1,6 +1,12 @@ import { expect } from 'chai'; -import { type AggregationCursor, MongoClient } from '../../mongodb'; +import { + AggregationCursor, + CursorTimeoutMode, + MongoAPIError, + MongoClient, + ns +} from '../../mongodb'; describe('class AggregationCursor', () => { let client: MongoClient; @@ -126,6 +132,38 @@ describe('class AggregationCursor', () => { }); context('when addStage, bespoke stage methods, or array is used to construct pipeline', () => { + context('when CSOT is enabled', () => { + let aggregationCursor: AggregationCursor; + before(function () { + aggregationCursor = client + .db('test') + .collection('test') + .aggregate([], { timeoutMS: 100, timeoutMode: CursorTimeoutMode.ITERATION }); + }); + + context('when a $out stage is added with .addStage()', () => { + it('throws a MongoAPIError', function () { + expect(() => { + aggregationCursor.addStage({ $out: 'test' }); + }).to.throw(MongoAPIError); + }); + }); + context('when a $merge stage is added with .addStage()', () => { + it('throws a MongoAPIError', function () { + expect(() => { + aggregationCursor.addStage({ $merge: {} }); + }).to.throw(MongoAPIError); + }); + }); + context('when a $out stage is added with .out()', () => { + it('throws a MongoAPIError', function () { + expect(() => { + aggregationCursor.out('test'); + }).to.throw(MongoAPIError); + }); + }); + }); + it('sets deeply identical aggregations pipelines', () => { const collection = client.db().collection('test'); @@ -157,4 +195,31 @@ describe('class AggregationCursor', () => { expect(builderGenericStageCursor.pipeline).to.deep.equal(expectedPipeline); }); }); + + describe('constructor()', () => { + context('when CSOT is enabled', () => { + context('when timeoutMode=ITERATION and a $out stage is provided', function () { + it('throws a MongoAPIError', function () { + expect( + () => + new AggregationCursor(client, ns('db.coll'), [{ $out: 'test' }], { + timeoutMS: 100, + timeoutMode: 'iteration' + }) + ).to.throw(MongoAPIError); + }); + }); + context('when timeoutMode=ITERATION and a $merge stage is provided', function () { + it('throws a MongoAPIError', function () { + expect( + () => + new AggregationCursor(client, ns('db.coll'), [{ $merge: 'test' }], { + timeoutMS: 100, + timeoutMode: 'iteration' + }) + ).to.throw(MongoAPIError); + }); + }); + }); + }); }); From 558d416e3430db048a4561777a2d77f641252994 Mon Sep 17 00:00:00 2001 From: Neal Beeken Date: Thu, 12 Sep 2024 15:24:39 -0400 Subject: [PATCH 10/39] fix(NODE-6374): MongoOperationTimeoutError inherits MongoRuntimeError (#4237) --- etc/notes/errors.md | 6 +++++- src/error.ts | 21 ++++++++++++++++++--- test/unit/error.test.ts | 20 ++++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/etc/notes/errors.md b/etc/notes/errors.md index d0f8e6b6e9..114bc1b2e2 100644 --- a/etc/notes/errors.md +++ b/etc/notes/errors.md @@ -67,7 +67,7 @@ Children of `MongoError` include: ### `MongoDriverError` This class represents errors which originate in the driver itself or when the user incorrectly uses the driver. This class should **never** be directly instantiated. -Its children are the main classes of errors that most users will interact with: [**`MongoAPIError`**](#MongoAPIError) and [**`MongoRuntimeError`**](#MongoRuntimeError). +Its children are the main classes of errors that most users will interact with: [**`MongoAPIError`**](#MongoAPIError), [**`MongoRuntimeError`**](#MongoRuntimeError) and [**`MongoOperationTimeoutError`**](#MongoOperationTimeoutError). ### `MongoAPIError` @@ -109,6 +109,10 @@ This class should **never** be directly instantiated. | **MongoGridFSChunkError** | Thrown when a malformed or invalid chunk is encountered when reading from a GridFS Stream. | | **MongoUnexpectedServerResponseError** | Thrown when the driver receives a **parsable** response it did not expect from the server. | +### `MongoOperationTimeoutError` + +- TODO(NODE-5688): Add MongoOperationTimeoutError documentation + ### MongoUnexpectedServerResponseError Intended for the scenario where the MongoDB returns an unexpected response in relation to some state the driver is in. diff --git a/src/error.ts b/src/error.ts index 3867553370..729a4a51a7 100644 --- a/src/error.ts +++ b/src/error.ts @@ -310,7 +310,7 @@ export class MongoAPIError extends MongoDriverError { /** * An error generated when the driver encounters unexpected input - * or reaches an unexpected/invalid internal state + * or reaches an unexpected/invalid internal state. * * @privateRemarks * Should **never** be directly instantiated. @@ -765,9 +765,24 @@ export class MongoUnexpectedServerResponseError extends MongoRuntimeError { } /** - * @internal + * @public + * @category Error + * + * This error is thrown when an operation could not be completed within the specified `timeoutMS`. + * TODO(NODE-5688): expand this documentation. + * + * @example + * ```ts + * try { + * await blogs.insertOne(blogPost, { timeoutMS: 60_000 }) + * } catch (error) { + * if (error instanceof MongoOperationTimeoutError) { + * console.log(`Oh no! writer's block!`, error); + * } + * } + * ``` */ -export class MongoOperationTimeoutError extends MongoRuntimeError { +export class MongoOperationTimeoutError extends MongoDriverError { override get name(): string { return 'MongoOperationTimeoutError'; } diff --git a/test/unit/error.test.ts b/test/unit/error.test.ts index bdc049cbc4..dca792bd38 100644 --- a/test/unit/error.test.ts +++ b/test/unit/error.test.ts @@ -14,12 +14,15 @@ import { LEGACY_NOT_PRIMARY_OR_SECONDARY_ERROR_MESSAGE, LEGACY_NOT_WRITABLE_PRIMARY_ERROR_MESSAGE, MONGODB_ERROR_CODES, + MongoDriverError, MongoError, MongoErrorLabel, MongoMissingDependencyError, MongoNetworkError, MongoNetworkTimeoutError, + MongoOperationTimeoutError, MongoParseError, + MongoRuntimeError, MongoServerError, MongoSystemError, MongoWriteConcernError, @@ -173,6 +176,23 @@ describe('MongoErrors', () => { }); }); + describe('class MongoOperationTimeoutError', () => { + it('has a name property equal to MongoOperationTimeoutError', () => { + const error = new MongoOperationTimeoutError('time out!'); + expect(error).to.have.property('name', 'MongoOperationTimeoutError'); + }); + + it('is instanceof MongoDriverError', () => { + const error = new MongoOperationTimeoutError('time out!'); + expect(error).to.be.instanceOf(MongoDriverError); + }); + + it('is not instanceof MongoRuntimeError', () => { + const error = new MongoOperationTimeoutError('time out!'); + expect(error).to.not.be.instanceOf(MongoRuntimeError); + }); + }); + describe('MongoMissingDependencyError#constructor', () => { context('when options.cause is set', () => { it('attaches the cause property to the instance', () => { From 3ed4a149a4a878c3d68ca7226ddfd5fc5b78de8c Mon Sep 17 00:00:00 2001 From: Warren James Date: Thu, 12 Sep 2024 16:02:50 -0400 Subject: [PATCH 11/39] test: remove empty skipped context blocks (#4238) --- .../client-side-operations-timeout/node_csot.test.ts | 5 ----- 1 file changed, 5 deletions(-) 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 f5ada7eef9..56127cc8ac 100644 --- a/test/integration/client-side-operations-timeout/node_csot.test.ts +++ b/test/integration/client-side-operations-timeout/node_csot.test.ts @@ -576,11 +576,6 @@ describe('CSOT driver tests', metadata, () => { }); }); - describe.skip('Tailable non-awaitData cursors').skipReason = - 'TODO(NODE-6305): implement CSOT for Tailable cursors'; - describe.skip('Tailable awaitData cursors').skipReason = - 'TODO(NODE-6305): implement CSOT for Tailable cursors'; - describe('when using an explicit session', () => { const metadata: MongoDBMetadataUI = { requires: { topology: ['replicaset'], mongodb: '>=4.4' } From d3438ea60d9b80fe92048f6728920495f91a1f16 Mon Sep 17 00:00:00 2001 From: Neal Beeken Date: Tue, 17 Sep 2024 13:27:43 -0400 Subject: [PATCH 12/39] feat(NODE-5844): add iscryptd to ServerDescription (#4239) --- src/sdam/server_description.ts | 4 ++ .../server_description.test.ts | 56 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 test/integration/server-discovery-and-monitoring/server_description.test.ts diff --git a/src/sdam/server_description.ts b/src/sdam/server_description.ts index cd32f4968b..d5b67c1808 100644 --- a/src/sdam/server_description.ts +++ b/src/sdam/server_description.ts @@ -69,6 +69,8 @@ export class ServerDescription { setVersion: number | null; electionId: ObjectId | null; logicalSessionTimeoutMinutes: number | null; + /** Indicates server is a mongocryptd instance. */ + iscryptd: boolean; // NOTE: does this belong here? It seems we should gossip the cluster time at the CMAP level $clusterTime?: ClusterTime; @@ -114,6 +116,7 @@ export class ServerDescription { this.primary = hello?.primary ?? null; this.me = hello?.me?.toLowerCase() ?? null; this.$clusterTime = hello?.$clusterTime ?? null; + this.iscryptd = Boolean(hello?.iscryptd); } get hostAddress(): HostAddress { @@ -167,6 +170,7 @@ export class ServerDescription { return ( other != null && + other.iscryptd === this.iscryptd && errorStrictEqual(this.error, other.error) && this.type === other.type && this.minWireVersion === other.minWireVersion && diff --git a/test/integration/server-discovery-and-monitoring/server_description.test.ts b/test/integration/server-discovery-and-monitoring/server_description.test.ts new file mode 100644 index 0000000000..0a3c7eecbf --- /dev/null +++ b/test/integration/server-discovery-and-monitoring/server_description.test.ts @@ -0,0 +1,56 @@ +import { type ChildProcess, spawn } from 'node:child_process'; + +import { expect } from 'chai'; + +import { MongoClient } from '../../mongodb'; + +describe('class ServerDescription', function () { + describe('when connecting to mongocryptd', { requires: { mongodb: '>=4.4' } }, function () { + let client: MongoClient; + const mongocryptdTestPort = '27022'; + let childProcess: ChildProcess; + + beforeEach(async function () { + childProcess = spawn('mongocryptd', ['--port', mongocryptdTestPort, '--ipv6'], { + stdio: 'ignore', + detached: true + }); + + childProcess.on('error', error => console.warn(this.currentTest?.fullTitle(), error)); + client = new MongoClient(`mongodb://localhost:${mongocryptdTestPort}`); + }); + + afterEach(async function () { + await client?.close(); + childProcess.kill('SIGKILL'); + }); + + it('iscryptd is set to true ', async function () { + const descriptions = []; + client.on('serverDescriptionChanged', description => descriptions.push(description)); + const hello = await client.db().command({ hello: true }); + expect(hello).to.have.property('iscryptd', true); + expect(descriptions.at(-1)).to.have.nested.property('newDescription.iscryptd', true); + }); + }); + + describe('when connecting to anything other than mongocryptd', function () { + let client: MongoClient; + + beforeEach(async function () { + client = this.configuration.newClient(); + }); + + afterEach(async function () { + await client?.close(); + }); + + it('iscryptd is set to false ', async function () { + const descriptions = []; + client.on('serverDescriptionChanged', description => descriptions.push(description)); + const hello = await client.db().command({ hello: true }); + expect(hello).to.not.have.property('iscryptd'); + expect(descriptions.at(-1)).to.have.nested.property('newDescription.iscryptd', false); + }); + }); +}); From ff561e3b3beee17cf14ccf1ec09351e9a5da7b84 Mon Sep 17 00:00:00 2001 From: Aditi Khare Date: Thu, 19 Sep 2024 17:37:24 -0400 Subject: [PATCH 13/39] temp --- src/client-side-encryption/state_machine.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/client-side-encryption/state_machine.ts b/src/client-side-encryption/state_machine.ts index af3ea4c215..5318b250c5 100644 --- a/src/client-side-encryption/state_machine.ts +++ b/src/client-side-encryption/state_machine.ts @@ -18,6 +18,7 @@ import { autoSelectSocketOptions, type DataKey } from './client_encryption'; import { MongoCryptError } from './errors'; import { type MongocryptdManager } from './mongocryptd_manager'; import { type KMSProviders } from './providers'; +import { TimeoutContext } from '../timeout'; let socks: SocksLib | null = null; function loadSocks(): SocksLib { @@ -182,7 +183,7 @@ export class StateMachine { /** * Executes the state machine according to the specification */ - async execute(executor: StateMachineExecutable, context: MongoCryptContext): Promise { + async execute(executor: StateMachineExecutable, context: MongoCryptContext, timeoutContext?: TimeoutContext): Promise { const keyVaultNamespace = executor._keyVaultNamespace; const keyVaultClient = executor._keyVaultClient; const metaDataClient = executor._metaDataClient; @@ -201,6 +202,8 @@ export class StateMachine { 'unreachable state machine state: entered MONGOCRYPT_CTX_NEED_MONGO_COLLINFO but metadata client is undefined' ); } + + // TODO: timeout here const collInfo = await this.fetchCollectionInfo(metaDataClient, context.ns, filter); if (collInfo) { @@ -233,6 +236,7 @@ export class StateMachine { case MONGOCRYPT_CTX_NEED_MONGO_KEYS: { const filter = context.nextMongoOperation(); + // TODO: timeout here const keys = await this.fetchKeys(keyVaultClient, keyVaultNamespace, filter); if (keys.length === 0) { From 164780c5fab6d6179aee92af3bef99eda6ce6353 Mon Sep 17 00:00:00 2001 From: Aditi Khare Date: Fri, 20 Sep 2024 16:20:23 -0400 Subject: [PATCH 14/39] temp --- src/client-side-encryption/state_machine.ts | 55 +++++++++++++++------ src/operations/operation.ts | 2 +- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/client-side-encryption/state_machine.ts b/src/client-side-encryption/state_machine.ts index 5318b250c5..fbe37ad36d 100644 --- a/src/client-side-encryption/state_machine.ts +++ b/src/client-side-encryption/state_machine.ts @@ -18,7 +18,9 @@ import { autoSelectSocketOptions, type DataKey } from './client_encryption'; import { MongoCryptError } from './errors'; import { type MongocryptdManager } from './mongocryptd_manager'; import { type KMSProviders } from './providers'; -import { TimeoutContext } from '../timeout'; +import { CSOTTimeoutContext, TimeoutContext } from '../timeout'; +import { timeLog } from 'console'; +import { RunCommandOptions } from '../operations/run_command'; let socks: SocksLib | null = null; function loadSocks(): SocksLib { @@ -203,9 +205,12 @@ export class StateMachine { ); } - // TODO: timeout here - const collInfo = await this.fetchCollectionInfo(metaDataClient, context.ns, filter); - + const collInfo = await this.fetchCollectionInfo( + metaDataClient, + context.ns, + filter, + timeoutContext instanceof CSOTTimeoutContext ? timeoutContext?.remainingTimeMS : null + ) if (collInfo) { context.addMongoOperationResponse(collInfo); } @@ -225,9 +230,20 @@ export class StateMachine { // When we are using the shared library, we don't have a mongocryptd manager. const markedCommand: Uint8Array = mongocryptdManager ? await mongocryptdManager.withRespawn( - this.markCommand.bind(this, mongocryptdClient, context.ns, command) + this.markCommand.bind( + this, + mongocryptdClient, + context.ns, + command, + timeoutContext instanceof CSOTTimeoutContext ? timeoutContext?.remainingTimeMS : null + ) ) - : await this.markCommand(mongocryptdClient, context.ns, command); + : await this.markCommand( + mongocryptdClient, + context.ns, + command, + timeoutContext instanceof CSOTTimeoutContext ? timeoutContext?.remainingTimeMS : null + ); context.addMongoOperationResponse(markedCommand); context.finishMongoOperation(); @@ -236,8 +252,12 @@ export class StateMachine { case MONGOCRYPT_CTX_NEED_MONGO_KEYS: { const filter = context.nextMongoOperation(); - // TODO: timeout here - const keys = await this.fetchKeys(keyVaultClient, keyVaultNamespace, filter); + const keys = await this.fetchKeys( + keyVaultClient, + keyVaultNamespace, + filter, + timeoutContext instanceof CSOTTimeoutContext ? timeoutContext?.remainingTimeMS : null + ); if (keys.length === 0) { // See docs on EMPTY_V @@ -502,7 +522,8 @@ export class StateMachine { async fetchCollectionInfo( client: MongoClient, ns: string, - filter: Document + filter: Document, + timeoutMS?: number | null ): Promise { const { db } = MongoDBCollectionNamespace.fromString(ns); @@ -510,7 +531,8 @@ export class StateMachine { .db(db) .listCollections(filter, { promoteLongs: false, - promoteValues: false + promoteValues: false, + timeoutMS }) .toArray(); @@ -526,8 +548,12 @@ export class StateMachine { * @param command - The command to execute. * @param callback - Invoked with the serialized and marked bson command, or with an error */ - async markCommand(client: MongoClient, ns: string, command: Uint8Array): Promise { - const options = { promoteLongs: false, promoteValues: false }; + async markCommand(client: MongoClient, ns: string, command: Uint8Array, timeoutMS?: number | null): Promise { + const options: RunCommandOptions = { promoteLongs: false, promoteValues: false }; + if (timeoutMS != null) { + options.timeoutMS = timeoutMS; + options.omitMaxTimeMS = true; + } const { db } = MongoDBCollectionNamespace.fromString(ns); const rawCommand = deserialize(command, options); @@ -547,7 +573,8 @@ export class StateMachine { fetchKeys( client: MongoClient, keyVaultNamespace: string, - filter: Uint8Array + filter: Uint8Array, + timeoutMS?: number | null ): Promise> { const { db: dbName, collection: collectionName } = MongoDBCollectionNamespace.fromString(keyVaultNamespace); @@ -555,7 +582,7 @@ export class StateMachine { return client .db(dbName) .collection(collectionName, { readConcern: { level: 'majority' } }) - .find(deserialize(filter)) + .find(deserialize(filter), { timeoutMS }) .toArray(); } } diff --git a/src/operations/operation.ts b/src/operations/operation.ts index 90a17f1a6e..5ef459607d 100644 --- a/src/operations/operation.ts +++ b/src/operations/operation.ts @@ -35,7 +35,7 @@ export interface OperationOptions extends BSONSerializeOptions { omitMaxTimeMS?: boolean; /** @internal TODO(NODE-5688): make this public */ - timeoutMS?: number; + timeoutMS?: number | null; } /** @internal */ From 12a7e2eb0edd40f623b57e6cadeae2b7bdd307cd Mon Sep 17 00:00:00 2001 From: Neal Beeken Date: Tue, 24 Sep 2024 17:17:33 -0400 Subject: [PATCH 15/39] chore: plumb timeoutMS around more --- src/client-side-encryption/state_machine.ts | 83 +++++++++++-------- src/operations/operation.ts | 4 +- ...ient_side_operations_timeout.prose.test.ts | 53 +++++++++--- .../server_description.test.ts | 4 +- 4 files changed, 95 insertions(+), 49 deletions(-) diff --git a/src/client-side-encryption/state_machine.ts b/src/client-side-encryption/state_machine.ts index fbe37ad36d..23e83edf83 100644 --- a/src/client-side-encryption/state_machine.ts +++ b/src/client-side-encryption/state_machine.ts @@ -12,15 +12,14 @@ import { } from '../bson'; import { type ProxyOptions } from '../cmap/connection'; import { getSocks, type SocksLib } from '../deps'; +import { MongoOperationTimeoutError } from '../error'; import { type MongoClient, type MongoClientOptions } from '../mongo_client'; +import { type CSOTTimeoutContext, Timeout, type TimeoutContext } from '../timeout'; import { BufferPool, MongoDBCollectionNamespace, promiseWithResolvers } from '../utils'; import { autoSelectSocketOptions, type DataKey } from './client_encryption'; import { MongoCryptError } from './errors'; import { type MongocryptdManager } from './mongocryptd_manager'; import { type KMSProviders } from './providers'; -import { CSOTTimeoutContext, TimeoutContext } from '../timeout'; -import { timeLog } from 'console'; -import { RunCommandOptions } from '../operations/run_command'; let socks: SocksLib | null = null; function loadSocks(): SocksLib { @@ -185,7 +184,11 @@ export class StateMachine { /** * Executes the state machine according to the specification */ - async execute(executor: StateMachineExecutable, context: MongoCryptContext, timeoutContext?: TimeoutContext): Promise { + async execute( + executor: StateMachineExecutable, + context: MongoCryptContext, + timeoutContext?: CSOTTimeoutContext + ): Promise { const keyVaultNamespace = executor._keyVaultNamespace; const keyVaultClient = executor._keyVaultClient; const metaDataClient = executor._metaDataClient; @@ -209,8 +212,8 @@ export class StateMachine { metaDataClient, context.ns, filter, - timeoutContext instanceof CSOTTimeoutContext ? timeoutContext?.remainingTimeMS : null - ) + timeoutContext?.csotEnabled() ? timeoutContext.remainingTimeMS : null + ); if (collInfo) { context.addMongoOperationResponse(collInfo); } @@ -235,15 +238,15 @@ export class StateMachine { mongocryptdClient, context.ns, command, - timeoutContext instanceof CSOTTimeoutContext ? timeoutContext?.remainingTimeMS : null + timeoutContext?.csotEnabled() ? timeoutContext.remainingTimeMS : null ) ) : await this.markCommand( - mongocryptdClient, - context.ns, - command, - timeoutContext instanceof CSOTTimeoutContext ? timeoutContext?.remainingTimeMS : null - ); + mongocryptdClient, + context.ns, + command, + timeoutContext?.csotEnabled() ? timeoutContext.remainingTimeMS : null + ); context.addMongoOperationResponse(markedCommand); context.finishMongoOperation(); @@ -256,7 +259,7 @@ export class StateMachine { keyVaultClient, keyVaultNamespace, filter, - timeoutContext instanceof CSOTTimeoutContext ? timeoutContext?.remainingTimeMS : null + timeoutContext?.csotEnabled() ? timeoutContext.remainingTimeMS : null ); if (keys.length === 0) { @@ -279,9 +282,12 @@ export class StateMachine { } case MONGOCRYPT_CTX_NEED_KMS: { - const requests = Array.from(this.requests(context)); - await Promise.all(requests); - + await Promise.all( + this.requests( + context, + timeoutContext?.csotEnabled() ? timeoutContext.remainingTimeMS : null + ) + ); context.finishKMSRequests(); break; } @@ -323,7 +329,7 @@ export class StateMachine { * @param kmsContext - A C++ KMS context returned from the bindings * @returns A promise that resolves when the KMS reply has be fully parsed */ - async kmsRequest(request: MongoCryptKMSRequest): Promise { + async kmsRequest(request: MongoCryptKMSRequest, timeoutMS?: number | null): Promise { const parsedUrl = request.endpoint.split(':'); const port = parsedUrl[1] != null ? Number.parseInt(parsedUrl[1], 10) : HTTPS_PORT; const socketOptions = autoSelectSocketOptions(this.options.socketOptions || {}); @@ -353,10 +359,6 @@ export class StateMachine { } } - function ontimeout() { - return new MongoCryptError('KMS request timed out'); - } - function onerror(cause: Error) { return new MongoCryptError('KMS request failed', { cause }); } @@ -388,7 +390,6 @@ export class StateMachine { resolve: resolveOnNetSocketConnect } = promiseWithResolvers(); netSocket - .once('timeout', () => rejectOnNetSocketError(ontimeout())) .once('error', err => rejectOnNetSocketError(onerror(err))) .once('close', () => rejectOnNetSocketError(onclose())) .once('connect', () => resolveOnNetSocketConnect()); @@ -434,8 +435,8 @@ export class StateMachine { reject: rejectOnTlsSocketError, resolve } = promiseWithResolvers(); + socket - .once('timeout', () => rejectOnTlsSocketError(ontimeout())) .once('error', err => rejectOnTlsSocketError(onerror(err))) .once('close', () => rejectOnTlsSocketError(onclose())) .on('data', data => { @@ -449,20 +450,26 @@ export class StateMachine { resolve(); } }); - await willResolveKmsRequest; + + await (typeof timeoutMS === 'number' + ? Promise.all([willResolveKmsRequest, Timeout.expires(timeoutMS)]) + : willResolveKmsRequest); + } catch (error) { + if (Timeout.is(error)) throw new MongoOperationTimeoutError('KMS request timed out'); + throw error; } finally { // There's no need for any more activity on this socket at this point. destroySockets(); } } - *requests(context: MongoCryptContext) { + *requests(context: MongoCryptContext, timeoutMS?: number | null) { for ( let request = context.nextKMSRequest(); request != null; request = context.nextKMSRequest() ) { - yield this.kmsRequest(request); + yield this.kmsRequest(request, timeoutMS); } } @@ -532,7 +539,9 @@ export class StateMachine { .listCollections(filter, { promoteLongs: false, promoteValues: false, - timeoutMS + ...(typeof timeoutMS === 'number' + ? { timeoutMS, timeoutMode: 'cursorLifetime' } + : undefined) }) .toArray(); @@ -548,16 +557,20 @@ export class StateMachine { * @param command - The command to execute. * @param callback - Invoked with the serialized and marked bson command, or with an error */ - async markCommand(client: MongoClient, ns: string, command: Uint8Array, timeoutMS?: number | null): Promise { - const options: RunCommandOptions = { promoteLongs: false, promoteValues: false }; - if (timeoutMS != null) { - options.timeoutMS = timeoutMS; - options.omitMaxTimeMS = true; - } + async markCommand( + client: MongoClient, + ns: string, + command: Uint8Array, + timeoutMS?: number | null + ): Promise { const { db } = MongoDBCollectionNamespace.fromString(ns); - const rawCommand = deserialize(command, options); + const bsonOptions = { promoteLongs: false, promoteValues: false }; + const rawCommand = deserialize(command, bsonOptions); - const response = await client.db(db).command(rawCommand, options); + const response = await client.db(db).command(rawCommand, { + ...bsonOptions, + ...(typeof timeoutMS === 'number' ? { timeoutMS, omitMaxTimeMS: true } : undefined) + }); return serialize(response, this.bsonOptions); } diff --git a/src/operations/operation.ts b/src/operations/operation.ts index 5ef459607d..c979237649 100644 --- a/src/operations/operation.ts +++ b/src/operations/operation.ts @@ -34,8 +34,8 @@ export interface OperationOptions extends BSONSerializeOptions { /** @internal Hint to `executeOperation` to omit maxTimeMS */ omitMaxTimeMS?: boolean; - /** @internal TODO(NODE-5688): make this public */ - timeoutMS?: number | null; + /** @public The time limit an operation will be permitted to run for */ + timeoutMS?: number; } /** @internal */ 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 0d36998fd9..b02af22684 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 @@ -1,5 +1,7 @@ /* Specification prose tests */ +import { type ChildProcess, spawn } from 'node:child_process'; + import { expect } from 'chai'; import * as semver from 'semver'; import * as sinon from 'sinon'; @@ -57,16 +59,47 @@ describe('CSOT spec prose tests', function () { */ }); - context.skip('2. maxTimeMS is not set for commands sent to mongocryptd', () => { - /** - * This test MUST only be run against enterprise server versions 4.2 and higher. - * - * 1. Launch a mongocryptd process on 23000. - * 1. Create a MongoClient (referred to as `client`) using the URI `mongodb://localhost:23000/?timeoutMS=1000`. - * 1. Using `client`, execute the `{ ping: 1 }` command against the `admin` database. - * 1. Verify via command monitoring that the `ping` command sent did not contain a `maxTimeMS` field. - */ - }); + context.skip( + '2. maxTimeMS is not set for commands sent to mongocryptd', + { requires: { mongodb: '>=4.2' } }, + () => { + /** + * This test MUST only be run against enterprise server versions 4.2 and higher. + * + * 1. Launch a mongocryptd process on 23000. + * 1. Create a MongoClient (referred to as `client`) using the URI `mongodb://localhost:23000/?timeoutMS=1000`. + * 1. Using `client`, execute the `{ ping: 1 }` command against the `admin` database. + * 1. Verify via command monitoring that the `ping` command sent did not contain a `maxTimeMS` field. + */ + + let client: MongoClient; + const mongocryptdTestPort = '23000'; + let childProcess: ChildProcess; + + beforeEach(async function () { + childProcess = spawn('mongocryptd', ['--port', mongocryptdTestPort, '--ipv6'], { + stdio: 'ignore', + detached: true + }); + + childProcess.on('error', error => console.warn(this.currentTest?.fullTitle(), error)); + client = new MongoClient(`mongodb://localhost:23000/?timeoutMS=1000`); + }); + + afterEach(async function () { + await client?.close(); + childProcess.kill('SIGKILL'); + }); + + it('maxTimeMS is not set', async function () { + const commandStarted = []; + client.on('commandStarted', ev => commandStarted.push(ev)); + await client.db('admin').command({ ping: 1 }); + expect(commandStarted).to.have.lengthOf(1); + expect(commandStarted[0].command).to.not.have.property('maxTimeMS'); + }); + } + ); context.skip('3. ClientEncryption', () => { /** diff --git a/test/integration/server-discovery-and-monitoring/server_description.test.ts b/test/integration/server-discovery-and-monitoring/server_description.test.ts index 0a3c7eecbf..90743ba18d 100644 --- a/test/integration/server-discovery-and-monitoring/server_description.test.ts +++ b/test/integration/server-discovery-and-monitoring/server_description.test.ts @@ -25,7 +25,7 @@ describe('class ServerDescription', function () { childProcess.kill('SIGKILL'); }); - it('iscryptd is set to true ', async function () { + it('iscryptd is set to true', async function () { const descriptions = []; client.on('serverDescriptionChanged', description => descriptions.push(description)); const hello = await client.db().command({ hello: true }); @@ -45,7 +45,7 @@ describe('class ServerDescription', function () { await client?.close(); }); - it('iscryptd is set to false ', async function () { + it('iscryptd is set to false', async function () { const descriptions = []; client.on('serverDescriptionChanged', description => descriptions.push(description)); const hello = await client.db().command({ hello: true }); From 999f23d26a7813c3e111b4bce505030cf277e4a7 Mon Sep 17 00:00:00 2001 From: Warren James Date: Thu, 11 Apr 2024 17:15:41 -0400 Subject: [PATCH 16/39] feat(NODE-6090): Implement CSOT logic for connection checkout and server selection --- src/admin.ts | 3 +- src/cmap/connection.ts | 4 + src/cmap/connection_pool.ts | 53 ++- src/collection.ts | 5 + src/db.ts | 6 + src/error.ts | 9 + src/index.ts | 1 + src/operations/command.ts | 2 + src/operations/find.ts | 3 +- src/operations/operation.ts | 8 + src/operations/run_command.ts | 9 +- src/sdam/server.ts | 3 +- src/sdam/topology.ts | 54 ++- src/timeout.ts | 14 + src/utils.ts | 10 + ...ient_side_operations_timeout.prose.test.ts | 315 +++++++++++++----- ...lient_side_operations_timeout.unit.test.ts | 140 +++++--- .../node_csot.test.ts | 75 ++++- test/unit/cmap/connection_pool.test.js | 33 +- test/unit/index.test.ts | 1 + 20 files changed, 570 insertions(+), 178 deletions(-) diff --git a/src/admin.ts b/src/admin.ts index a71ac4be1d..e030384eaf 100644 --- a/src/admin.ts +++ b/src/admin.ts @@ -78,7 +78,8 @@ export class Admin { new RunAdminCommandOperation(command, { ...resolveBSONOptions(options), session: options?.session, - readPreference: options?.readPreference + readPreference: options?.readPreference, + timeoutMS: options?.timeoutMS ?? this.s.db.timeoutMS }) ); } diff --git a/src/cmap/connection.ts b/src/cmap/connection.ts index 986cce46b6..445967faa5 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -30,6 +30,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 Timeout } from '../timeout'; import { BufferPool, calculateDurationInMs, @@ -94,6 +95,9 @@ export interface CommandOptions extends BSONSerializeOptions { writeConcern?: WriteConcern; directConnection?: boolean; + + /** @internal */ + timeout?: Timeout; } /** @public */ diff --git a/src/cmap/connection_pool.ts b/src/cmap/connection_pool.ts index 5a858a5121..79440db1e0 100644 --- a/src/cmap/connection_pool.ts +++ b/src/cmap/connection_pool.ts @@ -21,13 +21,14 @@ import { MongoInvalidArgumentError, MongoMissingCredentialsError, MongoNetworkError, + MongoOperationTimeoutError, MongoRuntimeError, MongoServerError } from '../error'; import { CancellationToken, TypedEventEmitter } from '../mongo_types'; import type { Server } from '../sdam/server'; import { Timeout, TimeoutError } from '../timeout'; -import { type Callback, List, makeCounter, now, promiseWithResolvers } from '../utils'; +import { type Callback, csotMin, List, makeCounter, promiseWithResolvers } from '../utils'; import { connect } from './connect'; import { Connection, type ConnectionEvents, type ConnectionOptions } from './connection'; import { @@ -102,7 +103,6 @@ export interface ConnectionPoolOptions extends Omit void; reject: (err: AnyError) => void; - timeout: Timeout; [kCancelled]?: boolean; checkoutTime: number; } @@ -355,37 +355,57 @@ export class ConnectionPool extends TypedEventEmitter { * will be held by the pool. This means that if a connection is checked out it MUST be checked back in or * explicitly destroyed by the new owner. */ - async checkOut(): Promise { - const checkoutTime = now(); + async checkOut(options?: { timeout?: Timeout }): Promise { this.emitAndLog( ConnectionPool.CONNECTION_CHECK_OUT_STARTED, new ConnectionCheckOutStartedEvent(this) ); const waitQueueTimeoutMS = this.options.waitQueueTimeoutMS; + const serverSelectionTimeoutMS = this[kServer].topology.s.serverSelectionTimeoutMS; const { promise, resolve, reject } = promiseWithResolvers(); - const timeout = Timeout.expires(waitQueueTimeoutMS); + let timeout: Timeout | null = null; + if (options?.timeout) { + // CSOT enabled + // Determine if we're using the timeout passed in or a new timeout + if (options.timeout.duration > 0 || serverSelectionTimeoutMS > 0) { + // This check determines whether or not Topology.selectServer used the configured + // `timeoutMS` or `serverSelectionTimeoutMS` value for its timeout + if ( + options.timeout.duration === serverSelectionTimeoutMS || + csotMin(options.timeout.duration, serverSelectionTimeoutMS) < serverSelectionTimeoutMS + ) { + // server selection used `timeoutMS`, so we should use the existing timeout as the timeout + // here + timeout = options.timeout; + } else { + // server selection used `serverSelectionTimeoutMS`, so we construct a new timeout with + // the time remaining to ensure that Topology.selectServer and ConnectionPool.checkOut + // cumulatively don't spend more than `serverSelectionTimeoutMS` blocking + timeout = Timeout.expires(serverSelectionTimeoutMS - options.timeout.timeElapsed); + } + } + } else { + timeout = Timeout.expires(waitQueueTimeoutMS); + } const waitQueueMember: WaitQueueMember = { resolve, - reject, - timeout, - checkoutTime + reject }; this[kWaitQueue].push(waitQueueMember); process.nextTick(() => this.processWaitQueue()); try { - return await Promise.race([promise, waitQueueMember.timeout]); + timeout?.throwIfExpired(); + return await (timeout ? Promise.race([promise, timeout]) : promise); } catch (error) { if (TimeoutError.is(error)) { waitQueueMember[kCancelled] = true; - waitQueueMember.timeout.clear(); - this.emitAndLog( ConnectionPool.CONNECTION_CHECK_OUT_FAILED, new ConnectionCheckOutFailedEvent(this, 'timeout', waitQueueMember.checkoutTime) @@ -396,9 +416,16 @@ export class ConnectionPool extends TypedEventEmitter { : 'Timed out while checking out a connection from connection pool', this.address ); + if (options?.timeout) { + throw new MongoOperationTimeoutError('Timed out during connection checkout', { + cause: timeoutError + }); + } throw timeoutError; } throw error; + } finally { + if (timeout !== options?.timeout) timeout?.clear(); } } @@ -764,7 +791,6 @@ export class ConnectionPool extends TypedEventEmitter { ConnectionPool.CONNECTION_CHECK_OUT_FAILED, new ConnectionCheckOutFailedEvent(this, reason, waitQueueMember.checkoutTime, error) ); - waitQueueMember.timeout.clear(); this[kWaitQueue].shift(); waitQueueMember.reject(error); continue; @@ -785,7 +811,6 @@ export class ConnectionPool extends TypedEventEmitter { ConnectionPool.CONNECTION_CHECKED_OUT, new ConnectionCheckedOutEvent(this, connection, waitQueueMember.checkoutTime) ); - waitQueueMember.timeout.clear(); this[kWaitQueue].shift(); waitQueueMember.resolve(connection); @@ -828,8 +853,6 @@ export class ConnectionPool extends TypedEventEmitter { ); waitQueueMember.resolve(connection); } - - waitQueueMember.timeout.clear(); } process.nextTick(() => this.processWaitQueue()); }); diff --git a/src/collection.ts b/src/collection.ts index ccc6fe2da6..dbd91371cc 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -262,6 +262,11 @@ export class Collection { this.s.collectionHint = normalizeHintField(v); } + /** @internal */ + get timeoutMS(): number | undefined { + return this.s.options.timeoutMS; + } + /** * Inserts a single document into MongoDB. If documents passed in do not contain the **_id** field, * one will be added to each of the documents missing it by the driver, mutating the document. This behavior diff --git a/src/db.ts b/src/db.ts index 53c18e44af..6e1aa194ac 100644 --- a/src/db.ts +++ b/src/db.ts @@ -222,6 +222,11 @@ export class Db { return this.s.namespace.toString(); } + /** @internal */ + get timeoutMS(): number | undefined { + return this.s.options?.timeoutMS; + } + /** * Create a new collection on a server with the specified options. Use this to create capped collections. * More information about command options available at https://www.mongodb.com/docs/manual/reference/command/create/ @@ -272,6 +277,7 @@ export class Db { this.client, new RunCommandOperation(this, command, { ...resolveBSONOptions(options), + timeoutMS: options?.timeoutMS, session: options?.session, readPreference: options?.readPreference }) diff --git a/src/error.ts b/src/error.ts index c9652877cb..3f47e07d66 100644 --- a/src/error.ts +++ b/src/error.ts @@ -788,6 +788,15 @@ export class MongoUnexpectedServerResponseError extends MongoRuntimeError { } } +/** + * @internal + */ +export class MongoOperationTimeoutError extends MongoRuntimeError { + override get name(): string { + return 'MongoOperationTimeoutError'; + } +} + /** * An error thrown when the user attempts to add options to a cursor that has already been * initialized diff --git a/src/index.ts b/src/index.ts index f68dd7699e..ba28f50ebe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,6 +64,7 @@ export { MongoNetworkTimeoutError, MongoNotConnectedError, MongoOIDCError, + MongoOperationTimeoutError, MongoParseError, MongoRuntimeError, MongoServerClosedError, diff --git a/src/operations/command.ts b/src/operations/command.ts index 94ccc6ceaf..c64b4ae963 100644 --- a/src/operations/command.ts +++ b/src/operations/command.ts @@ -65,6 +65,7 @@ export interface OperationParent { writeConcern?: WriteConcern; readPreference?: ReadPreference; bsonOptions?: BSONSerializeOptions; + timeoutMS?: number; } /** @internal */ @@ -131,6 +132,7 @@ export abstract class CommandOperation extends AbstractOperation { const options = { ...this.options, ...this.bsonOptions, + timeout: this.timeout, readPreference: this.readPreference, session }; diff --git a/src/operations/find.ts b/src/operations/find.ts index a040af73bc..0f81f2d61f 100644 --- a/src/operations/find.ts +++ b/src/operations/find.ts @@ -116,7 +116,8 @@ export class FindOperation extends CommandOperation { ...this.options, ...this.bsonOptions, documentsReturnedIn: 'firstBatch', - session + session, + timeout: this.timeout }, this.explain ? ExplainedCursorResponse : CursorResponse ); diff --git a/src/operations/operation.ts b/src/operations/operation.ts index b51cca4020..0599b72b96 100644 --- a/src/operations/operation.ts +++ b/src/operations/operation.ts @@ -2,6 +2,7 @@ import { type BSONSerializeOptions, type Document, resolveBSONOptions } from '.. import { ReadPreference, type ReadPreferenceLike } from '../read_preference'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type Timeout } from '../timeout'; import type { MongoDBNamespace } from '../utils'; export const Aspect = { @@ -56,6 +57,11 @@ export abstract class AbstractOperation { options: OperationOptions; + /** @internal */ + timeout?: Timeout; + /** @internal */ + timeoutMS?: number; + [kSession]: ClientSession | undefined; static aspects?: Set; @@ -73,6 +79,8 @@ export abstract class AbstractOperation { this.options = options; this.bypassPinningCheck = !!options.bypassPinningCheck; this.trySecondaryWrite = false; + + this.timeoutMS = options.timeoutMS; } /** Must match the first key of the command object sent to the server. diff --git a/src/operations/run_command.ts b/src/operations/run_command.ts index ad7d02c044..56462fa884 100644 --- a/src/operations/run_command.ts +++ b/src/operations/run_command.ts @@ -14,6 +14,8 @@ export type RunCommandOptions = { session?: ClientSession; /** The read preference */ readPreference?: ReadPreferenceLike; + /** @internal */ + timeoutMS?: number; } & BSONSerializeOptions; /** @internal */ @@ -39,10 +41,12 @@ export class RunCommandOperation extends AbstractOperation { { ...this.options, readPreference: this.readPreference, - session + session, + timeout: this.timeout }, this.options.responseType ); + return res; } } @@ -68,7 +72,8 @@ export class RunAdminCommandOperation extends AbstractOperation const res: TODO_NODE_3286 = await server.command(this.ns, this.command, { ...this.options, readPreference: this.readPreference, - session + session, + timeout: this.timeout }); return res; } diff --git a/src/sdam/server.ts b/src/sdam/server.ts index b4450f0072..a1b885382e 100644 --- a/src/sdam/server.ts +++ b/src/sdam/server.ts @@ -311,7 +311,7 @@ export class Server extends TypedEventEmitter { this.incrementOperationCount(); if (conn == null) { try { - conn = await this.pool.checkOut(); + conn = await this.pool.checkOut(options); if (this.loadBalanced && isPinnableCommand(cmd, session)) { session?.pin(conn); } @@ -336,6 +336,7 @@ export class Server extends TypedEventEmitter { operationError.code === MONGODB_ERROR_CODES.Reauthenticate ) { await this.pool.reauthenticate(conn); + // TODO(NODE-5682): Implement CSOT support for socket read/write at the connection layer try { const res = await conn.command(ns, cmd, finalOptions, responseType); throwIfWriteConcernError(res); diff --git a/src/sdam/topology.ts b/src/sdam/topology.ts index 73b0e92a09..4c9d71d807 100644 --- a/src/sdam/topology.ts +++ b/src/sdam/topology.ts @@ -24,6 +24,7 @@ import { type MongoDriverError, MongoError, MongoErrorLabel, + MongoOperationTimeoutError, MongoRuntimeError, MongoServerSelectionError, MongoTopologyClosedError @@ -37,6 +38,7 @@ import { Timeout, TimeoutError } from '../timeout'; import type { Transaction } from '../transactions'; import { type Callback, + csotMin, type EventEmitterWithState, HostAddress, List, @@ -107,7 +109,6 @@ export interface ServerSelectionRequest { resolve: (server: Server) => void; reject: (error: MongoError) => void; [kCancelled]?: boolean; - timeout: Timeout; operationName: string; waitingLogged: boolean; previousServer?: ServerDescription; @@ -457,8 +458,14 @@ export class Topology extends TypedEventEmitter { } } + const timeoutMS = this.client.options.timeoutMS; + const timeout = timeoutMS != null ? Timeout.expires(timeoutMS) : undefined; const readPreference = options.readPreference ?? ReadPreference.primary; - const selectServerOptions = { operationName: 'ping', ...options }; + const selectServerOptions = { + operationName: 'ping', + timeout, + ...options + }; try { const server = await this.selectServer( readPreferenceServerSelector(readPreference), @@ -467,7 +474,7 @@ export class Topology extends TypedEventEmitter { const skipPingOnConnect = this.s.options[Symbol.for('@@mdb.skipPingOnConnect')] === true; if (!skipPingOnConnect && server && this.s.credentials) { - await server.command(ns('admin.$cmd'), { ping: 1 }, {}); + await server.command(ns('admin.$cmd'), { ping: 1 }, { timeout }); stateTransition(this, STATE_CONNECTED); this.emit(Topology.OPEN, this); this.emit(Topology.CONNECT, this); @@ -556,6 +563,25 @@ export class Topology extends TypedEventEmitter { new ServerSelectionStartedEvent(selector, this.description, options.operationName) ); } + const serverSelectionTimeoutMS = options.serverSelectionTimeoutMS ?? 0; + let timeout: Timeout | null; + if (options.timeout) { + // CSOT Enabled + if (options.timeout.duration > 0 || serverSelectionTimeoutMS > 0) { + if ( + options.timeout.duration === serverSelectionTimeoutMS || + csotMin(options.timeout.duration, serverSelectionTimeoutMS) < serverSelectionTimeoutMS + ) { + timeout = options.timeout; + } else { + timeout = Timeout.expires(serverSelectionTimeoutMS); + } + } else { + timeout = null; + } + } else { + timeout = Timeout.expires(serverSelectionTimeoutMS); + } const isSharded = this.description.type === TopologyType.Sharded; const session = options.session; @@ -578,11 +604,12 @@ export class Topology extends TypedEventEmitter { ) ); } + if (timeout !== options.timeout) timeout?.clear(); return transaction.server; } const { promise: serverPromise, resolve, reject } = promiseWithResolvers(); - const timeout = Timeout.expires(options.serverSelectionTimeoutMS ?? 0); + const waitQueueMember: ServerSelectionRequest = { serverSelector, topologyDescription: this.description, @@ -590,7 +617,6 @@ export class Topology extends TypedEventEmitter { transaction, resolve, reject, - timeout, startTime: now(), operationName: options.operationName, waitingLogged: false, @@ -601,14 +627,14 @@ export class Topology extends TypedEventEmitter { processWaitQueue(this); try { - return await Promise.race([serverPromise, waitQueueMember.timeout]); + timeout?.throwIfExpired(); + return await (timeout ? Promise.race([serverPromise, timeout]) : serverPromise); } catch (error) { if (TimeoutError.is(error)) { // Timeout waitQueueMember[kCancelled] = true; - timeout.clear(); const timeoutError = new MongoServerSelectionError( - `Server selection timed out after ${options.serverSelectionTimeoutMS} ms`, + `Server selection timed out after ${timeout?.duration} ms`, this.description ); if ( @@ -628,10 +654,17 @@ export class Topology extends TypedEventEmitter { ); } + if (options.timeout) { + throw new MongoOperationTimeoutError('Timed out during server selection', { + cause: timeoutError + }); + } throw timeoutError; } // Other server selection error throw error; + } finally { + if (timeout !== options.timeout) timeout?.clear(); } } /** @@ -889,8 +922,6 @@ function drainWaitQueue(queue: List, drainError: MongoDr continue; } - waitQueueMember.timeout.clear(); - if (!waitQueueMember[kCancelled]) { if ( waitQueueMember.mongoLogger?.willLog( @@ -944,7 +975,6 @@ function processWaitQueue(topology: Topology) { ) : serverDescriptions; } catch (selectorError) { - waitQueueMember.timeout.clear(); if ( topology.client.mongoLogger?.willLog( MongoLoggableComponent.SERVER_SELECTION, @@ -1032,8 +1062,6 @@ function processWaitQueue(topology: Topology) { transaction.pinServer(selectedServer); } - waitQueueMember.timeout.clear(); - if ( topology.client.mongoLogger?.willLog( MongoLoggableComponent.SERVER_SELECTION, diff --git a/src/timeout.ts b/src/timeout.ts index cd48ec385d..7af1a23f26 100644 --- a/src/timeout.ts +++ b/src/timeout.ts @@ -40,6 +40,16 @@ export class Timeout extends Promise { public duration: number; public timedOut = false; + get remainingTime(): number { + if (this.timedOut) return 0; + if (this.duration === 0) return Infinity; + return this.start + this.duration - Math.trunc(performance.now()); + } + + get timeElapsed(): number { + return Math.trunc(performance.now()) - this.start; + } + /** Create a new timeout that expires in `duration` ms */ private constructor(executor: Executor = () => null, duration: number, unref = false) { let reject!: Reject; @@ -78,6 +88,10 @@ export class Timeout extends Promise { this.id = undefined; } + throwIfExpired(): void { + if (this.timedOut) throw new TimeoutError('Timed out'); + } + public static expires(durationMS: number, unref?: boolean): Timeout { return new Timeout(undefined, durationMS, unref); } diff --git a/src/utils.ts b/src/utils.ts index 5ad754c932..ebc0784cb1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -544,6 +544,10 @@ export function resolveOptions( result.readPreference = readPreference; } + const timeoutMS = options?.timeoutMS; + + result.timeoutMS = timeoutMS ?? parent?.timeoutMS; + return result; } @@ -1379,6 +1383,12 @@ export async function fileIsAccessible(fileName: string, mode?: number) { } } +export function csotMin(duration1: number, duration2: number): number { + if (duration1 === 0) return duration2; + if (duration2 === 0) return duration1; + return Math.min(duration1, duration2); +} + export function noop() { return; } 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 1ed88f34d8..903ea9c3bb 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 @@ -1,8 +1,30 @@ /* Specification prose tests */ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { + MongoClient, + MongoOperationTimeoutError, + MongoServerSelectionError, + now +} from '../../mongodb'; + // TODO(NODE-5824): Implement CSOT prose tests -describe.skip('CSOT spec prose tests', () => { - context('1. Multi-batch writes', () => { +describe('CSOT spec prose tests', function () { + let internalClient: MongoClient; + let client: MongoClient; + + beforeEach(async function () { + internalClient = this.configuration.newClient(); + }); + + afterEach(async function () { + await internalClient?.close(); + await client?.close(); + }); + + context.skip('1. Multi-batch writes', () => { /** * This test MUST only run against standalones on server versions 4.4 and higher. * The `insertMany` call takes an exceedingly long time on replicasets and sharded @@ -31,7 +53,7 @@ describe.skip('CSOT spec prose tests', () => { */ }); - context('2. maxTimeMS is not set for commands sent to mongocryptd', () => { + context.skip('2. maxTimeMS is not set for commands sent to mongocryptd', () => { /** * This test MUST only be run against enterprise server versions 4.2 and higher. * @@ -42,7 +64,7 @@ describe.skip('CSOT spec prose tests', () => { */ }); - context('3. ClientEncryption', () => { + context.skip('3. ClientEncryption', () => { /** * Each test under this category MUST only be run against server versions 4.4 and higher. In these tests, * `LOCAL_MASTERKEY` refers to the following base64: @@ -132,7 +154,7 @@ describe.skip('CSOT spec prose tests', () => { }); }); - context('4. Background Connection Pooling', () => { + context.skip('4. Background Connection Pooling', () => { /** * The tests in this section MUST only be run if the server version is 4.4 or higher and the URI has authentication * fields (i.e. a username and password). Each test in this section requires drivers to create a MongoClient and then wait @@ -192,7 +214,7 @@ describe.skip('CSOT spec prose tests', () => { }); }); - context('5. Blocking Iteration Methods', () => { + context.skip('5. Blocking Iteration Methods', () => { /** * Tests in this section MUST only be run against server versions 4.4 and higher and only apply to drivers that have a * blocking method for cursor iteration that executes `getMore` commands in a loop until a document is available or an @@ -251,7 +273,7 @@ describe.skip('CSOT spec prose tests', () => { }); }); - context('6. GridFS - Upload', () => { + context.skip('6. GridFS - Upload', () => { /** Tests in this section MUST only be run against server versions 4.4 and higher. */ context('uploads via openUploadStream can be timed out', () => { @@ -306,7 +328,7 @@ describe.skip('CSOT spec prose tests', () => { }); }); - context('7. GridFS - Download', () => { + context.skip('7. GridFS - Download', () => { /** * This test MUST only be run against server versions 4.4 and higher. * 1. Using `internalClient`, drop and re-create the `db.fs.files` and `db.fs.chunks` collections. @@ -351,96 +373,225 @@ describe.skip('CSOT spec prose tests', () => { }); context('8. Server Selection', () => { - context('serverSelectionTimeoutMS honored if timeoutMS is not set', () => { - /** - * 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. - * - Expect this to fail with a server selection timeout error after no more than 15ms. - */ - }); + context('using sinon timer', function () { + let clock: sinon.SinonFakeTimers; + + beforeEach(function () { + clock = sinon.useFakeTimers(); + }); + + afterEach(function () { + clock.restore(); + }); - context( - "timeoutMS honored for server selection if it's lower than serverSelectionTimeoutMS", - () => { + it('serverSelectionTimeoutMS honored if timeoutMS is not set', 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. + * 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. * - Expect this to fail with a server selection timeout error after no more than 15ms. */ - } - ); - context( - "serverSelectionTimeoutMS honored for server selection if it's lower than timeoutMS", - () => { - /** - * 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. - * - Expect this to fail with a server selection timeout error after no more than 15ms. + /** NOTE: This is the original implementation of this test, but it was flaky, so was + * replaced by the current implementation using sinon fake timers + * ```ts + * client = new MongoClient('mongodb://invalid/?serverSelectionTimeoutMS=10'); + * const admin = client.db('test').admin(); + * const start = performance.now(); + * const maybeError = await admin.ping().then( + * () => null, + * e => e + * ); + * const end = performance.now(); + * + * expect(maybeError).to.be.instanceof(MongoServerSelectionError); + * expect(end - start).to.be.lte(15) + * ``` */ - } - ); + client = new MongoClient('mongodb://invalid/?serverSelectionTimeoutMS=10'); + const admin = client.db('test').admin(); + const maybeError = admin.ping().then( + () => null, + e => e + ); + + await clock.tickAsync(11); + expect(await maybeError).to.be.instanceof(MongoServerSelectionError); + }); + }); + + it("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. + * - Expect this to fail with a server selection timeout error after no more than 15ms. + */ + client = new MongoClient('mongodb://invalid/?timeoutMS=10&serverSelectionTimeoutMS=20'); + const start = now(); + + const maybeError = await client + .db('test') + .admin() + .ping() + .then( + () => null, + e => e + ); + const end = now(); + + expect(maybeError).to.be.instanceof(MongoOperationTimeoutError); + expect(end - start).to.be.lte(15); + }); + + it("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. + * - Expect this to fail with a server selection timeout error after no more than 15ms. + */ + client = new MongoClient('mongodb://invalid/?timeoutMS=20&serverSelectionTimeoutMS=10'); + const start = now(); + const maybeError = await client + .db('test') + .admin() + .ping() + .then( + () => null, + e => e + ); + const end = now(); + + expect(maybeError).to.be.instanceof(MongoOperationTimeoutError); + expect(end - start).to.be.lte(15); + }); - context('serverSelectionTimeoutMS honored for server selection if timeoutMS=0', () => { + it('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. * - Expect this to fail with a server selection timeout error after no more than 15ms. */ + client = new MongoClient('mongodb://invalid/?timeoutMS=0&serverSelectionTimeoutMS=10'); + const start = now(); + const maybeError = await client + .db('test') + .admin() + .ping() + .then( + () => null, + e => e + ); + const end = now(); + + expect(maybeError).to.be.instanceof(MongoOperationTimeoutError); + expect(end - start).to.be.lte(15); }); - context( - "timeoutMS honored for connection handshake commands if it's lower than serverSelectionTimeoutMS", - () => { - /** - * This test MUST only be run if the server version is 4.4 or higher and the URI has authentication fields (i.e. a - * username and password). - * 1. Using `internalClient`, set the following fail point: - * ```js - * { - * configureFailPoint: failCommand, - * mode: { times: 1 }, - * data: { - * failCommands: ["saslContinue"], - * blockConnection: true, - * blockTimeMS: 15 - * } - * } - * ``` - * 1. Create a new MongoClient (referred to as `client`) with `timeoutMS=10` and `serverSelectionTimeoutMS=20`. - * 1. Using `client`, insert the document `{ x: 1 }` into collection `db.coll`. - * - Expect this to fail with a timeout error after no more than 15ms. - */ - } - ); + it.skip("timeoutMS honored for connection handshake commands if it's lower than serverSelectionTimeoutMS", async function () { + /** + * This test MUST only be run if the server version is 4.4 or higher and the URI has authentication fields (i.e. a + * username and password). + * 1. Using `internalClient`, set the following fail point: + * ```js + * { + * configureFailPoint: failCommand, + * mode: { times: 1 }, + * data: { + * failCommands: ["saslContinue"], + * blockConnection: true, + * blockTimeMS: 15 + * } + * } + * ``` + * 1. Create a new MongoClient (referred to as `client`) with `timeoutMS=10` and `serverSelectionTimeoutMS=20`. + * 1. Using `client`, insert the document `{ x: 1 }` into collection `db.coll`. + * - Expect this to fail with a timeout error after no more than 15ms. + */ + await internalClient + .db('db') + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: { times: 1 }, + data: { + failCommands: ['saslContinue'], + blockConnection: true, + blockTimeMS: 15 + } + }); - context( - "serverSelectionTimeoutMS honored for connection handshake commands if it's lower than timeoutMS", - () => { - /** - * This test MUST only be run if the server version is 4.4 or higher and the URI has authentication fields (i.e. a - * username and password). - * 1. Using `internalClient`, set the following fail point: - * ```js - * { - * configureFailPoint: failCommand, - * mode: { times: 1 }, - * data: { - * failCommands: ["saslContinue"], - * blockConnection: true, - * blockTimeMS: 15 - * } - * } - * ``` - * 1. Create a new MongoClient (referred to as `client`) with `timeoutMS=20` and `serverSelectionTimeoutMS=10`. - * 1. Using `client`, insert the document `{ x: 1 }` into collection `db.coll`. - * - Expect this to fail with a timeout error after no more than 15ms. - */ - } - ); + client = this.configuration.newClient({ + serverSelectionTimeoutMS: 20, + timeoutMS: 10 + }); + const start = now(); + const maybeError = await client + .db('db') + .collection('coll') + .insertOne({ x: 1 }) + .then( + () => null, + e => e + ); + const end = now(); + expect(maybeError).to.be.instanceof(MongoOperationTimeoutError); + expect(end - start).to.be.lte(15); + }).skipReason = + 'TODO(DRIVERS-2347): Requires this ticket to be implemented before we can assert on connection CSOT behaviour'; + + it.skip("serverSelectionTimeoutMS honored for connection handshake commands if it's lower than timeoutMS", async function () { + /** + * This test MUST only be run if the server version is 4.4 or higher and the URI has authentication fields (i.e. a + * username and password). + * 1. Using `internalClient`, set the following fail point: + * ```js + * { + * configureFailPoint: failCommand, + * mode: { times: 1 }, + * data: { + * failCommands: ["saslContinue"], + * blockConnection: true, + * blockTimeMS: 15 + * } + * } + * ``` + * 1. Create a new MongoClient (referred to as `client`) with `timeoutMS=20` and `serverSelectionTimeoutMS=10`. + * 1. Using `client`, insert the document `{ x: 1 }` into collection `db.coll`. + * - Expect this to fail with a timeout error after no more than 15ms. + */ + await internalClient + .db('db') + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: { times: 1 }, + data: { + failCommands: ['saslContinue'], + blockConnection: true, + blockTimeMS: 15 + } + }); + + client = this.configuration.newClient({ + serverSelectionTimeoutMS: 10, + timeoutMS: 20 + }); + const start = now(); + const maybeError = await client + .db('db') + .collection('coll') + .insertOne({ x: 1 }) + .then( + () => null, + e => e + ); + const end = now(); + expect(maybeError).to.be.instanceof(MongoOperationTimeoutError); + expect(end - start).to.be.lte(15); + }).skipReason = + 'TODO(DRIVERS-2347): Requires this ticket to be implemented before we can assert on connection CSOT behaviour'; }); - context('9. endSession', () => { + context.skip('9. endSession', () => { /** * This test MUST only be run against replica sets and sharded clusters with server version 4.4 or higher. It MUST be * run three times: once with the timeout specified via the MongoClient `timeoutMS` option, once with the timeout @@ -472,7 +623,7 @@ describe.skip('CSOT spec prose tests', () => { */ }); - context('10. Convenient Transactions', () => { + context.skip('10. Convenient Transactions', () => { /** Tests in this section MUST only run against replica sets and sharded clusters with server versions 4.4 or higher. */ context('timeoutMS is refreshed for abortTransaction if the callback fails', () => { diff --git a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts index cf9c5f736f..c1426d8db1 100644 --- a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts +++ b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts @@ -1,51 +1,105 @@ -/* eslint-disable @typescript-eslint/no-empty-function */ /** * The following tests are described in CSOTs spec prose tests as "unit" tests * The tests enumerated in this section could not be expressed in either spec or prose format. * Drivers SHOULD implement these if it is possible to do so using the driver's existing test infrastructure. */ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { ConnectionPool, type MongoClient, Timeout, Topology } from '../../mongodb'; + // TODO(NODE-5824): Implement CSOT prose tests -describe.skip('CSOT spec unit tests', () => { - context('Operations should ignore waitQueueTimeoutMS if timeoutMS is also set.', () => {}); - - context( - 'If timeoutMS is set for an operation, the remaining timeoutMS value should apply to connection checkout after a server has been selected.', - () => {} - ); - - context( - 'If timeoutMS is not set for an operation, waitQueueTimeoutMS should apply to connection checkout after a server has been selected.', - () => {} - ); - - context( - 'If a new connection is required to execute an operation, min(remaining computedServerSelectionTimeout, connectTimeoutMS) should apply to socket establishment.', - () => {} - ); - - context( - 'For drivers that have control over OCSP behavior, min(remaining computedServerSelectionTimeout, 5 seconds) should apply to HTTP requests against OCSP responders.', - () => {} - ); - - context( - 'If timeoutMS is unset, operations fail after two non-consecutive socket timeouts.', - () => {} - ); - - context( - 'The remaining timeoutMS value should apply to HTTP requests against KMS servers for CSFLE.', - () => {} - ); - - context( - 'The remaining timeoutMS value should apply to commands sent to mongocryptd as part of automatic encryption.', - () => {} - ); - - context( - 'When doing minPoolSize maintenance, connectTimeoutMS is used as the timeout for socket establishment.', - () => {} - ); +describe('CSOT spec unit tests', function () { + let client: MongoClient; + + afterEach(async function () { + sinon.restore(); + await client?.close(); + }); + + context('Server Selection and Connection Checkout', function () { + it('Operations should ignore waitQueueTimeoutMS if timeoutMS is also set.', async function () { + client = this.configuration.newClient({ waitQueueTimeoutMS: 999999, timeoutMS: 10000 }); + sinon.spy(Timeout, 'expires'); + + await client.db('db').collection('collection').insertOne({ x: 1 }); + + expect(Timeout.expires).to.have.been.calledWith(10000); + expect(Timeout.expires).to.not.have.been.calledWith(999999); + }); + + it('If timeoutMS is set for an operation, the remaining timeoutMS value should apply to connection checkout after a server has been selected.', async function () { + client = this.configuration.newClient({ timeoutMS: 1000 }); + // Spy on connection checkout and pull options argument + const checkoutSpy = sinon.spy(ConnectionPool.prototype, 'checkOut'); + const selectServerSpy = sinon.spy(Topology.prototype, 'selectServer'); + const expiresSpy = sinon.spy(Timeout, 'expires'); + + await client.db('db').collection('collection').insertOne({ x: 1 }); + + expect(checkoutSpy).to.have.been.calledOnce; + expect(checkoutSpy.firstCall.args[0].timeout).to.exist; + // Check that we passed through the timeout + expect(checkoutSpy.firstCall.args[0].timeout).to.equal( + selectServerSpy.lastCall.lastArg.timeout + ); + + // Check that no more Timeouts are constructed after we enter checkout + expect(!expiresSpy.calledAfter(checkoutSpy)); + }); + + it('If timeoutMS is not set for an operation, waitQueueTimeoutMS should apply to connection checkout after a server has been selected.', async function () { + client = this.configuration.newClient({ waitQueueTimeoutMS: 123456 }); + + const checkoutSpy = sinon.spy(ConnectionPool.prototype, 'checkOut'); + const selectServerSpy = sinon.spy(Topology.prototype, 'selectServer'); + const expiresSpy = sinon.spy(Timeout, 'expires'); + + await client.db('db').collection('collection').insertOne({ x: 1 }); + expect(checkoutSpy).to.have.been.calledAfter(selectServerSpy); + + expect(expiresSpy).to.have.been.calledWith(123456); + }); + + /* eslint-disable @typescript-eslint/no-empty-function */ + context.skip( + 'If a new connection is required to execute an operation, min(remaining computedServerSelectionTimeout, connectTimeoutMS) should apply to socket establishment.', + () => {} + ).skipReason = + 'TODO(DRIVERS-2347): Requires this ticket to be implemented before we can assert on connection CSOT behaviour'; + + context( + 'For drivers that have control over OCSP behavior, min(remaining computedServerSelectionTimeout, 5 seconds) should apply to HTTP requests against OCSP responders.', + () => {} + ); + }); + + context.skip('Socket timeouts', function () { + context( + 'If timeoutMS is unset, operations fail after two non-consecutive socket timeouts.', + () => {} + ); + }).skipReason = + 'TODO(NODE-5682): Add CSOT support for socket read/write at the connection layer for CRUD APIs'; + + context.skip('Client side encryption', function () { + context( + 'The remaining timeoutMS value should apply to HTTP requests against KMS servers for CSFLE.', + () => {} + ); + + context( + 'The remaining timeoutMS value should apply to commands sent to mongocryptd as part of automatic encryption.', + () => {} + ); + }).skipReason = 'TODO(NODE-5686): Add CSOT support to client side encryption'; + + context.skip('Background Connection Pooling', function () { + context( + 'When doing minPoolSize maintenance, connectTimeoutMS is used as the timeout for socket establishment.', + () => {} + ); + }).skipReason = 'TODO(NODE-6091): Implement CSOT logic for Background Connection Pooling'; + /* eslint-enable @typescript-eslint/no-empty-function */ }); 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 b6a936afbb..5636eb00db 100644 --- a/test/integration/client-side-operations-timeout/node_csot.test.ts +++ b/test/integration/client-side-operations-timeout/node_csot.test.ts @@ -7,7 +7,9 @@ import { type Collection, type Db, type FindCursor, - type MongoClient + LEGACY_HELLO_COMMAND, + type MongoClient, + MongoOperationTimeoutError } from '../../mongodb'; describe('CSOT driver tests', () => { @@ -94,4 +96,75 @@ describe('CSOT driver tests', () => { }); }); }); + + describe('autoconnect', () => { + let client: MongoClient; + + afterEach(async function () { + await client?.close(); + client = undefined; + }); + + describe('when failing autoconnect with timeoutMS defined', () => { + let configClient: MongoClient; + + beforeEach(async function () { + configClient = this.configuration.newClient(); + const result = await configClient + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: 'alwaysOn', + data: { + failCommands: ['ping', 'hello', LEGACY_HELLO_COMMAND], + blockConnection: true, + blockTimeMS: 10 + } + }); + expect(result).to.have.property('ok', 1); + }); + + afterEach(async function () { + const result = await configClient + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: 'off', + data: { + failCommands: ['ping', 'hello', LEGACY_HELLO_COMMAND], + blockConnection: true, + blockTimeMS: 10 + } + }); + expect(result).to.have.property('ok', 1); + await configClient.close(); + }); + + it('throws a MongoOperationTimeoutError', { + metadata: { requires: { mongodb: '>=4.4' } }, + test: async function () { + const commandsStarted = []; + client = this.configuration.newClient(undefined, { timeoutMS: 1, monitorCommands: true }); + + client.on('commandStarted', ev => commandsStarted.push(ev)); + + const maybeError = await client + .db('test') + .collection('test') + .insertOne({ a: 19 }) + .then( + () => null, + e => e + ); + + expect(maybeError).to.exist; + expect(maybeError).to.be.instanceof(MongoOperationTimeoutError); + + expect(commandsStarted).to.have.length(0); // Ensure that we fail before we start the insertOne + } + }); + }); + }); }); diff --git a/test/unit/cmap/connection_pool.test.js b/test/unit/cmap/connection_pool.test.js index 69102e1f15..18048befab 100644 --- a/test/unit/cmap/connection_pool.test.js +++ b/test/unit/cmap/connection_pool.test.js @@ -5,7 +5,7 @@ const { WaitQueueTimeoutError } = require('../../mongodb'); const mock = require('../../tools/mongodb-mock/index'); const sinon = require('sinon'); const { expect } = require('chai'); -const { setImmediate } = require('timers'); +const { setImmediate } = require('timers/promises'); const { ns, isHello } = require('../../mongodb'); const { createTimerSandbox } = require('../timer_sandbox'); const { topologyWithPlaceholderClient } = require('../../tools/utils'); @@ -26,6 +26,9 @@ describe('Connection Pool', function () { options: { extendedMetadata: {} } + }, + s: { + serverSelectionTimeoutMS: 0 } } }; @@ -98,7 +101,7 @@ describe('Connection Pool', function () { pool.checkIn(conn); }); - it('should clear timed out wait queue members if no connections are available', function (done) { + it('should clear timed out wait queue members if no connections are available', async function () { mockMongod.setMessageHandler(request => { const doc = request.document; if (isHello(doc)) { @@ -114,23 +117,15 @@ describe('Connection Pool', function () { pool.ready(); - pool.checkOut().then(conn => { - expect(conn).to.exist; - pool.checkOut().then(expect.fail, err => { - expect(err).to.exist.and.be.instanceOf(WaitQueueTimeoutError); - - // We can only process the wait queue with `checkIn` and `checkOut`, so we - // force the pool here to think there are no available connections, even though - // we are checking the connection back in. This simulates a slow leak where - // incoming requests outpace the ability of the queue to fully process cancelled - // wait queue members - sinon.stub(pool, 'availableConnectionCount').get(() => 0); - pool.checkIn(conn); - - setImmediate(() => expect(pool).property('waitQueueSize').to.equal(0)); - done(); - }); - }, expect.fail); + const conn = await pool.checkOut(); + const err = await pool.checkOut().catch(e => e); + expect(err).to.exist.and.be.instanceOf(WaitQueueTimeoutError); + sinon.stub(pool, 'availableConnectionCount').get(() => 0); + pool.checkIn(conn); + + await setImmediate(); + + expect(pool).property('waitQueueSize').to.equal(0); }); describe('minPoolSize population', function () { diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index c8a1406a00..f1936e2c6d 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -108,6 +108,7 @@ const EXPECTED_EXPORTS = [ 'MongoTailableCursorError', 'MongoTopologyClosedError', 'MongoTransactionError', + 'MongoOperationTimeoutError', 'MongoUnexpectedServerResponseError', 'MongoWriteConcernError', 'WriteConcernErrorResult', From 03554042ba93dbd5c2edf43554bfeffcd5d93743 Mon Sep 17 00:00:00 2001 From: Warren James Date: Mon, 10 Jun 2024 10:46:02 -0400 Subject: [PATCH 17/39] test(NODE-6120): Implement Unified test runner changes for CSOT (#4121) --- test/spec/unified-test-format/Makefile | 37 +++++- .../collectionData-createOptions.yml | 7 +- .../valid-pass/createEntities-operation.json | 74 ++++++++++++ .../valid-pass/createEntities-operation.yml | 38 ++++++ .../valid-pass/entity-cursor-iterateOnce.json | 111 ++++++++++++++++++ .../valid-pass/entity-cursor-iterateOnce.yml | 59 ++++++++++ .../valid-pass/entity-find-cursor.json | 15 ++- .../valid-pass/entity-find-cursor.yml | 6 +- ...ectedEventsForClient-ignoreExtraEvents.yml | 2 +- .../valid-pass/matches-lte-operator.json | 78 ++++++++++++ .../valid-pass/matches-lte-operator.yml | 41 +++++++ .../valid-pass/poc-change-streams.json | 36 ++++++ .../valid-pass/poc-change-streams.yml | 18 +++ .../valid-pass/poc-crud.json | 2 +- .../valid-pass/poc-crud.yml | 2 +- .../valid-pass/poc-sessions.json | 2 +- .../valid-pass/poc-sessions.yml | 3 +- .../poc-transactions-convenient-api.json | 2 +- .../poc-transactions-convenient-api.yml | 2 +- .../poc-transactions-mongos-pin-auto.json | 2 +- .../poc-transactions-mongos-pin-auto.yml | 2 +- .../valid-pass/poc-transactions.json | 6 +- .../valid-pass/poc-transactions.yml | 6 +- test/tools/unified-spec-runner/match.ts | 32 ++++- test/tools/unified-spec-runner/schema.ts | 1 + 25 files changed, 547 insertions(+), 37 deletions(-) create mode 100644 test/spec/unified-test-format/valid-pass/createEntities-operation.json create mode 100644 test/spec/unified-test-format/valid-pass/createEntities-operation.yml create mode 100644 test/spec/unified-test-format/valid-pass/entity-cursor-iterateOnce.json create mode 100644 test/spec/unified-test-format/valid-pass/entity-cursor-iterateOnce.yml create mode 100644 test/spec/unified-test-format/valid-pass/matches-lte-operator.json create mode 100644 test/spec/unified-test-format/valid-pass/matches-lte-operator.yml diff --git a/test/spec/unified-test-format/Makefile b/test/spec/unified-test-format/Makefile index 9711d9eee0..a2b79e3f70 100644 --- a/test/spec/unified-test-format/Makefile +++ b/test/spec/unified-test-format/Makefile @@ -1,8 +1,8 @@ -SCHEMA=../schema-1.5.json +SCHEMA=../schema-1.21.json -.PHONY: all invalid valid-fail valid-pass versioned-api load-balancers gridfs transactions crud collection-management sessions command-monitoring HAS_AJV +.PHONY: all invalid valid-fail valid-pass atlas-data-lake versioned-api load-balancers gridfs transactions transactions-convenient-api crud collection-management read-write-concern retryable-reads retryable-writes sessions command-logging-and-monitoring client-side-operations-timeout HAS_AJV -all: invalid valid-fail valid-pass versioned-api load-balancers gridfs transactions crud collection-management sessions command-monitoring +all: invalid valid-fail valid-pass atlas-data-lake versioned-api load-balancers gridfs transactions transactions-convenient-api change-streams crud collection-management read-write-concern retryable-reads retryable-writes sessions command-logging-and-monitoring client-side-operations-timeout client-side-encryption invalid: HAS_AJV @# Redirect stdout to hide expected validation errors @@ -14,6 +14,9 @@ valid-fail: HAS_AJV valid-pass: HAS_AJV @ajv test -s $(SCHEMA) -d "valid-pass/*.yml" --valid +atlas-data-lake: HAS_AJV + @ajv test -s $(SCHEMA) -d "../../atlas-data-lake-testing/tests/unified/*.yml" --valid + versioned-api: HAS_AJV @ajv test -s $(SCHEMA) -d "../../versioned-api/tests/*.yml" --valid @@ -26,17 +29,39 @@ gridfs: HAS_AJV transactions: HAS_AJV @ajv test -s $(SCHEMA) -d "../../transactions/tests/unified/*.yml" --valid +transactions-convenient-api: HAS_AJV + @ajv test -s $(SCHEMA) -d "../../transactions-convenient-api/tests/unified/*.yml" --valid + +change-streams: HAS_AJV + @ajv test -s $(SCHEMA) -d "../../change-streams/tests/unified/*.yml" --valid + +client-side-operations-timeout: HAS_AJV + @ajv test -s $(SCHEMA) -d "../../client-side-operations-timeout/tests/*.yml" --valid + crud: HAS_AJV @ajv test -s $(SCHEMA) -d "../../crud/tests/unified/*.yml" --valid collection-management: HAS_AJV @ajv test -s $(SCHEMA) -d "../../collection-management/tests/*.yml" --valid +read-write-concern: HAS_AJV + @ajv test -s $(SCHEMA) -d "../../read-write-concern/tests/operation/*.yml" --valid + +retryable-reads: HAS_AJV + @ajv test -s $(SCHEMA) -d "../../retryable-reads/tests/unified/*.yml" --valid + +retryable-writes: HAS_AJV + @ajv test -s $(SCHEMA) -d "../../retryable-writes/tests/unified/*.yml" --valid + sessions: HAS_AJV - @ajv test -s $(SCHEMA) -d "../../sessions/tests/unified/*.yml" --valid + @ajv test -s $(SCHEMA) -d "../../sessions/tests/*.yml" --valid + +command-logging-and-monitoring: HAS_AJV + @ajv test -s $(SCHEMA) -d "../../command-logging-and-monitoring/tests/logging/*.yml" --valid + @ajv test -s $(SCHEMA) -d "../../command-logging-and-monitoring/tests/monitoring/*.yml" --valid -command-monitoring: HAS_AJV - @ajv test -s $(SCHEMA) -d "../../command-monitoring/tests/unified/*.yml" --valid +client-side-encryption: HAS_AJV + @ajv test -s $(SCHEMA) -d "../../client-side-encryption/tests/unified/*.yml" --valid HAS_AJV: @if ! command -v ajv > /dev/null; then \ diff --git a/test/spec/unified-test-format/valid-pass/collectionData-createOptions.yml b/test/spec/unified-test-format/valid-pass/collectionData-createOptions.yml index 3b1c0c3a41..c6afedcfa9 100644 --- a/test/spec/unified-test-format/valid-pass/collectionData-createOptions.yml +++ b/test/spec/unified-test-format/valid-pass/collectionData-createOptions.yml @@ -1,12 +1,9 @@ description: collectionData-createOptions - schemaVersion: "1.9" - runOnRequirements: - minServerVersion: "3.6" # Capped collections cannot be created on serverless instances. serverless: forbid - createEntities: - client: id: &client0 client0 @@ -18,7 +15,6 @@ createEntities: id: &collection0 collection0 database: *database0 collectionName: &collection0Name coll0 - initialData: - collectionName: *collection0Name databaseName: *database0Name @@ -28,7 +24,6 @@ initialData: size: &cappedSize 4096 documents: - { _id: 1, x: 11 } - tests: - description: collection is created with the correct options operations: @@ -39,4 +34,4 @@ tests: - $collStats: { storageStats: {} } - $project: { capped: '$storageStats.capped', maxSize: '$storageStats.maxSize'} expectResult: - - { capped: true, maxSize: *cappedSize } + - { capped: true, maxSize: *cappedSize } \ No newline at end of file diff --git a/test/spec/unified-test-format/valid-pass/createEntities-operation.json b/test/spec/unified-test-format/valid-pass/createEntities-operation.json new file mode 100644 index 0000000000..3fde42919d --- /dev/null +++ b/test/spec/unified-test-format/valid-pass/createEntities-operation.json @@ -0,0 +1,74 @@ +{ + "description": "createEntities-operation", + "schemaVersion": "1.9", + "tests": [ + { + "description": "createEntities operation", + "operations": [ + { + "name": "createEntities", + "object": "testRunner", + "arguments": { + "entities": [ + { + "client": { + "id": "client1", + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database1", + "client": "client1", + "databaseName": "database1" + } + }, + { + "collection": { + "id": "collection1", + "database": "database1", + "collectionName": "coll1" + } + } + ] + } + }, + { + "name": "deleteOne", + "object": "collection1", + "arguments": { + "filter": { + "_id": 1 + } + } + } + ], + "expectEvents": [ + { + "client": "client1", + "events": [ + { + "commandStartedEvent": { + "command": { + "delete": "coll1", + "deletes": [ + { + "q": { + "_id": 1 + }, + "limit": 1 + } + ] + }, + "commandName": "delete", + "databaseName": "database1" + } + } + ] + } + ] + } + ] +} diff --git a/test/spec/unified-test-format/valid-pass/createEntities-operation.yml b/test/spec/unified-test-format/valid-pass/createEntities-operation.yml new file mode 100644 index 0000000000..ee8acd7368 --- /dev/null +++ b/test/spec/unified-test-format/valid-pass/createEntities-operation.yml @@ -0,0 +1,38 @@ +description: createEntities-operation + +# Note: createEntities is not technically in the 1.9 schema but was introduced at the same time. +schemaVersion: "1.9" + +tests: + - description: createEntities operation + operations: + - name: createEntities + object: testRunner + arguments: + entities: + - client: + id: &client1 client1 + observeEvents: [ commandStartedEvent ] + - database: + id: &database1 database1 + client: *client1 + databaseName: &database1Name database1 + - collection: + id: &collection1 collection1 + database: *database1 + collectionName: &collection1Name coll1 + - name: deleteOne + object: *collection1 + arguments: + filter: { _id : 1 } + expectEvents: + - client: *client1 + events: + - commandStartedEvent: + command: + delete: *collection1Name + deletes: + - q: { _id: 1 } + limit: 1 + commandName: delete + databaseName: *database1Name diff --git a/test/spec/unified-test-format/valid-pass/entity-cursor-iterateOnce.json b/test/spec/unified-test-format/valid-pass/entity-cursor-iterateOnce.json new file mode 100644 index 0000000000..b17ae78b94 --- /dev/null +++ b/test/spec/unified-test-format/valid-pass/entity-cursor-iterateOnce.json @@ -0,0 +1,111 @@ +{ + "description": "entity-cursor-iterateOnce", + "schemaVersion": "1.9", + "createEntities": [ + { + "client": { + "id": "client0", + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "database0" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "coll0" + } + } + ], + "initialData": [ + { + "databaseName": "database0", + "collectionName": "coll0", + "documents": [ + { + "_id": 1 + }, + { + "_id": 2 + }, + { + "_id": 3 + } + ] + } + ], + "tests": [ + { + "description": "iterateOnce", + "operations": [ + { + "name": "createFindCursor", + "object": "collection0", + "arguments": { + "filter": {}, + "batchSize": 2 + }, + "saveResultAsEntity": "cursor0" + }, + { + "name": "iterateUntilDocumentOrError", + "object": "cursor0", + "expectResult": { + "_id": 1 + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "cursor0", + "expectResult": { + "_id": 2 + } + }, + { + "name": "iterateOnce", + "object": "cursor0" + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "coll0", + "filter": {}, + "batchSize": 2 + }, + "commandName": "find", + "databaseName": "database0" + } + }, + { + "commandStartedEvent": { + "command": { + "getMore": { + "$$type": [ + "int", + "long" + ] + }, + "collection": "coll0" + }, + "commandName": "getMore" + } + } + ] + } + ] + } + ] +} diff --git a/test/spec/unified-test-format/valid-pass/entity-cursor-iterateOnce.yml b/test/spec/unified-test-format/valid-pass/entity-cursor-iterateOnce.yml new file mode 100644 index 0000000000..508e594a53 --- /dev/null +++ b/test/spec/unified-test-format/valid-pass/entity-cursor-iterateOnce.yml @@ -0,0 +1,59 @@ +description: entity-cursor-iterateOnce + +# Note: iterateOnce is not technically in the 1.9 schema but was introduced at the same time. +schemaVersion: "1.9" + +createEntities: + - client: + id: &client0 client0 + observeEvents: [ commandStartedEvent ] + - database: + id: &database0 database0 + client: *client0 + databaseName: &database0Name database0 + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: &collection0Name coll0 + +initialData: + - databaseName: *database0Name + collectionName: *collection0Name + documents: + - _id: 1 + - _id: 2 + - _id: 3 + +tests: + - description: iterateOnce + operations: + - name: createFindCursor + object: *collection0 + arguments: + filter: {} + batchSize: 2 + saveResultAsEntity: &cursor0 cursor0 + - name: iterateUntilDocumentOrError + object: *cursor0 + expectResult: { _id: 1 } + - name: iterateUntilDocumentOrError + object: *cursor0 + expectResult: { _id: 2 } + # This operation could be iterateUntilDocumentOrError, but we use iterateOne to ensure that drivers support it. + - name: iterateOnce + object: *cursor0 + expectEvents: + - client: *client0 + events: + - commandStartedEvent: + command: + find: *collection0Name + filter: {} + batchSize: 2 + commandName: find + databaseName: *database0Name + - commandStartedEvent: + command: + getMore: { $$type: [ int, long ] } + collection: *collection0Name + commandName: getMore diff --git a/test/spec/unified-test-format/valid-pass/entity-find-cursor.json b/test/spec/unified-test-format/valid-pass/entity-find-cursor.json index 85b8f69d7f..6f955d81f4 100644 --- a/test/spec/unified-test-format/valid-pass/entity-find-cursor.json +++ b/test/spec/unified-test-format/valid-pass/entity-find-cursor.json @@ -109,7 +109,10 @@ "reply": { "cursor": { "id": { - "$$type": "long" + "$$type": [ + "int", + "long" + ] }, "ns": { "$$type": "string" @@ -126,7 +129,10 @@ "commandStartedEvent": { "command": { "getMore": { - "$$type": "long" + "$$type": [ + "int", + "long" + ] }, "collection": "coll0" }, @@ -138,7 +144,10 @@ "reply": { "cursor": { "id": { - "$$type": "long" + "$$type": [ + "int", + "long" + ] }, "ns": { "$$type": "string" diff --git a/test/spec/unified-test-format/valid-pass/entity-find-cursor.yml b/test/spec/unified-test-format/valid-pass/entity-find-cursor.yml index 61c9f8835a..3ecdf6da1d 100644 --- a/test/spec/unified-test-format/valid-pass/entity-find-cursor.yml +++ b/test/spec/unified-test-format/valid-pass/entity-find-cursor.yml @@ -61,19 +61,19 @@ tests: - commandSucceededEvent: reply: cursor: - id: { $$type: long } + id: { $$type: [ int, long ] } ns: { $$type: string } firstBatch: { $$type: array } commandName: find - commandStartedEvent: command: - getMore: { $$type: long } + getMore: { $$type: [ int, long ] } collection: *collection0Name commandName: getMore - commandSucceededEvent: reply: cursor: - id: { $$type: long } + id: { $$type: [ int, long ] } ns: { $$type: string } nextBatch: { $$type: array } commandName: getMore diff --git a/test/spec/unified-test-format/valid-pass/expectedEventsForClient-ignoreExtraEvents.yml b/test/spec/unified-test-format/valid-pass/expectedEventsForClient-ignoreExtraEvents.yml index 162d0e3c04..d6d87094f6 100644 --- a/test/spec/unified-test-format/valid-pass/expectedEventsForClient-ignoreExtraEvents.yml +++ b/test/spec/unified-test-format/valid-pass/expectedEventsForClient-ignoreExtraEvents.yml @@ -75,4 +75,4 @@ tests: insert: *collection0Name documents: - *insertDocument4 - commandName: insert + commandName: insert \ No newline at end of file diff --git a/test/spec/unified-test-format/valid-pass/matches-lte-operator.json b/test/spec/unified-test-format/valid-pass/matches-lte-operator.json new file mode 100644 index 0000000000..4de65c5838 --- /dev/null +++ b/test/spec/unified-test-format/valid-pass/matches-lte-operator.json @@ -0,0 +1,78 @@ +{ + "description": "matches-lte-operator", + "schemaVersion": "1.9", + "createEntities": [ + { + "client": { + "id": "client0", + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "database0Name" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "coll0" + } + } + ], + "initialData": [ + { + "collectionName": "coll0", + "databaseName": "database0Name", + "documents": [] + } + ], + "tests": [ + { + "description": "special lte matching operator", + "operations": [ + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "document": { + "_id": 1, + "y": 1 + } + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "coll0", + "documents": [ + { + "_id": { + "$$lte": 1 + }, + "y": { + "$$lte": 2 + } + } + ] + }, + "commandName": "insert", + "databaseName": "database0Name" + } + } + ] + } + ] + } + ] +} diff --git a/test/spec/unified-test-format/valid-pass/matches-lte-operator.yml b/test/spec/unified-test-format/valid-pass/matches-lte-operator.yml new file mode 100644 index 0000000000..4bec571f02 --- /dev/null +++ b/test/spec/unified-test-format/valid-pass/matches-lte-operator.yml @@ -0,0 +1,41 @@ +description: matches-lte-operator + +# Note: $$lte is not technically in the 1.8 schema but was introduced at the same time. +schemaVersion: "1.9" + +createEntities: + - client: + id: &client0 client0 + observeEvents: [ commandStartedEvent ] + - database: + id: &database0 database0 + client: *client0 + databaseName: &database0Name database0Name + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: &collection0Name coll0 + +initialData: + - collectionName: *collection0Name + databaseName: *database0Name + documents: [] + +tests: + - description: special lte matching operator + operations: + - name: insertOne + object: *collection0 + arguments: + document: { _id : 1, y: 1 } + expectEvents: + - client: *client0 + events: + - commandStartedEvent: + command: + insert: *collection0Name + documents: + # We can make exact assertions here but we use the $$lte operator to ensure drivers support it. + - { _id: { $$lte: 1 }, y: { $$lte: 2 } } + commandName: insert + databaseName: *database0Name diff --git a/test/spec/unified-test-format/valid-pass/poc-change-streams.json b/test/spec/unified-test-format/valid-pass/poc-change-streams.json index 4194005eb4..50f0d06f08 100644 --- a/test/spec/unified-test-format/valid-pass/poc-change-streams.json +++ b/test/spec/unified-test-format/valid-pass/poc-change-streams.json @@ -94,6 +94,42 @@ } ], "tests": [ + { + "description": "saveResultAsEntity is optional for createChangeStream", + "runOnRequirements": [ + { + "minServerVersion": "3.8.0", + "topologies": [ + "replicaset" + ] + } + ], + "operations": [ + { + "name": "createChangeStream", + "object": "client0", + "arguments": { + "pipeline": [] + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "aggregate": 1 + }, + "commandName": "aggregate", + "databaseName": "admin" + } + } + ] + } + ] + }, { "description": "Executing a watch helper on a MongoClient results in notifications for changes to all collections in all databases in the cluster.", "runOnRequirements": [ diff --git a/test/spec/unified-test-format/valid-pass/poc-change-streams.yml b/test/spec/unified-test-format/valid-pass/poc-change-streams.yml index b066cf0b89..a7daafceb7 100644 --- a/test/spec/unified-test-format/valid-pass/poc-change-streams.yml +++ b/test/spec/unified-test-format/valid-pass/poc-change-streams.yml @@ -59,6 +59,24 @@ initialData: documents: [] tests: + - description: "saveResultAsEntity is optional for createChangeStream" + runOnRequirements: + - minServerVersion: "3.8.0" + topologies: [ replicaset ] + operations: + - name: createChangeStream + object: *client0 + arguments: + pipeline: [] + expectEvents: + - client: *client0 + events: + - commandStartedEvent: + command: + aggregate: 1 + commandName: aggregate + databaseName: admin + - description: "Executing a watch helper on a MongoClient results in notifications for changes to all collections in all databases in the cluster." runOnRequirements: - minServerVersion: "3.8.0" diff --git a/test/spec/unified-test-format/valid-pass/poc-crud.json b/test/spec/unified-test-format/valid-pass/poc-crud.json index 0790d9b789..94e4ec5682 100644 --- a/test/spec/unified-test-format/valid-pass/poc-crud.json +++ b/test/spec/unified-test-format/valid-pass/poc-crud.json @@ -322,7 +322,7 @@ "minServerVersion": "4.1.0", "topologies": [ "replicaset", - "sharded-replicaset" + "sharded" ], "serverless": "forbid" } diff --git a/test/spec/unified-test-format/valid-pass/poc-crud.yml b/test/spec/unified-test-format/valid-pass/poc-crud.yml index b7d05d75af..5748c0779f 100644 --- a/test/spec/unified-test-format/valid-pass/poc-crud.yml +++ b/test/spec/unified-test-format/valid-pass/poc-crud.yml @@ -143,7 +143,7 @@ tests: - description: "readConcern majority with out stage" runOnRequirements: - minServerVersion: "4.1.0" - topologies: [ replicaset, sharded-replicaset ] + topologies: [ replicaset, sharded ] serverless: "forbid" operations: - name: aggregate diff --git a/test/spec/unified-test-format/valid-pass/poc-sessions.json b/test/spec/unified-test-format/valid-pass/poc-sessions.json index 75f3489428..117c9e7d00 100644 --- a/test/spec/unified-test-format/valid-pass/poc-sessions.json +++ b/test/spec/unified-test-format/valid-pass/poc-sessions.json @@ -264,7 +264,7 @@ { "minServerVersion": "4.1.8", "topologies": [ - "sharded-replicaset" + "sharded" ] } ], diff --git a/test/spec/unified-test-format/valid-pass/poc-sessions.yml b/test/spec/unified-test-format/valid-pass/poc-sessions.yml index cb16657da3..2090258328 100644 --- a/test/spec/unified-test-format/valid-pass/poc-sessions.yml +++ b/test/spec/unified-test-format/valid-pass/poc-sessions.yml @@ -124,12 +124,11 @@ tests: - description: "Dirty explicit session is discarded" # Original test specified retryWrites=true, but that is now the default. - # Retryable writes will require a sharded-replicaset, though. runOnRequirements: - minServerVersion: "4.0" topologies: [ replicaset ] - minServerVersion: "4.1.8" - topologies: [ sharded-replicaset ] + topologies: [ sharded ] operations: - name: failPoint object: testRunner diff --git a/test/spec/unified-test-format/valid-pass/poc-transactions-convenient-api.json b/test/spec/unified-test-format/valid-pass/poc-transactions-convenient-api.json index 820ed65927..9ab44a9c54 100644 --- a/test/spec/unified-test-format/valid-pass/poc-transactions-convenient-api.json +++ b/test/spec/unified-test-format/valid-pass/poc-transactions-convenient-api.json @@ -11,7 +11,7 @@ { "minServerVersion": "4.1.8", "topologies": [ - "sharded-replicaset" + "sharded" ] } ], diff --git a/test/spec/unified-test-format/valid-pass/poc-transactions-convenient-api.yml b/test/spec/unified-test-format/valid-pass/poc-transactions-convenient-api.yml index 4f981d15dd..94fadda0aa 100644 --- a/test/spec/unified-test-format/valid-pass/poc-transactions-convenient-api.yml +++ b/test/spec/unified-test-format/valid-pass/poc-transactions-convenient-api.yml @@ -6,7 +6,7 @@ runOnRequirements: - minServerVersion: "4.0" topologies: [ replicaset ] - minServerVersion: "4.1.8" - topologies: [ sharded-replicaset ] + topologies: [ sharded ] createEntities: - client: diff --git a/test/spec/unified-test-format/valid-pass/poc-transactions-mongos-pin-auto.json b/test/spec/unified-test-format/valid-pass/poc-transactions-mongos-pin-auto.json index a0b297d59a..de08edec44 100644 --- a/test/spec/unified-test-format/valid-pass/poc-transactions-mongos-pin-auto.json +++ b/test/spec/unified-test-format/valid-pass/poc-transactions-mongos-pin-auto.json @@ -5,7 +5,7 @@ { "minServerVersion": "4.1.8", "topologies": [ - "sharded-replicaset" + "sharded" ] } ], diff --git a/test/spec/unified-test-format/valid-pass/poc-transactions-mongos-pin-auto.yml b/test/spec/unified-test-format/valid-pass/poc-transactions-mongos-pin-auto.yml index 47db7c3188..33cd2a2521 100644 --- a/test/spec/unified-test-format/valid-pass/poc-transactions-mongos-pin-auto.yml +++ b/test/spec/unified-test-format/valid-pass/poc-transactions-mongos-pin-auto.yml @@ -4,7 +4,7 @@ schemaVersion: "1.0" runOnRequirements: - minServerVersion: "4.1.8" - topologies: [ sharded-replicaset ] + topologies: [ sharded ] createEntities: - client: diff --git a/test/spec/unified-test-format/valid-pass/poc-transactions.json b/test/spec/unified-test-format/valid-pass/poc-transactions.json index 0355ca2060..2055a3b705 100644 --- a/test/spec/unified-test-format/valid-pass/poc-transactions.json +++ b/test/spec/unified-test-format/valid-pass/poc-transactions.json @@ -11,7 +11,7 @@ { "minServerVersion": "4.1.8", "topologies": [ - "sharded-replicaset" + "sharded" ] } ], @@ -93,7 +93,7 @@ "minServerVersion": "4.3.4", "topologies": [ "replicaset", - "sharded-replicaset" + "sharded" ] } ], @@ -203,7 +203,7 @@ "minServerVersion": "4.3.4", "topologies": [ "replicaset", - "sharded-replicaset" + "sharded" ] } ], diff --git a/test/spec/unified-test-format/valid-pass/poc-transactions.yml b/test/spec/unified-test-format/valid-pass/poc-transactions.yml index 0a66b9bd7f..8a12c8b39a 100644 --- a/test/spec/unified-test-format/valid-pass/poc-transactions.yml +++ b/test/spec/unified-test-format/valid-pass/poc-transactions.yml @@ -6,7 +6,7 @@ runOnRequirements: - minServerVersion: "4.0" topologies: [ replicaset ] - minServerVersion: "4.1.8" - topologies: [ sharded-replicaset ] + topologies: [ sharded ] createEntities: - client: @@ -51,7 +51,7 @@ tests: - description: "explicitly create collection using create command" runOnRequirements: - minServerVersion: "4.3.4" - topologies: [ replicaset, sharded-replicaset ] + topologies: [ replicaset, sharded ] operations: - name: dropCollection object: *database0 @@ -109,7 +109,7 @@ tests: - description: "create index on a non-existing collection" runOnRequirements: - minServerVersion: "4.3.4" - topologies: [ replicaset, sharded-replicaset ] + topologies: [ replicaset, sharded ] operations: - name: dropCollection object: *database0 diff --git a/test/tools/unified-spec-runner/match.ts b/test/tools/unified-spec-runner/match.ts index f92004c776..7b2668e88a 100644 --- a/test/tools/unified-spec-runner/match.ts +++ b/test/tools/unified-spec-runner/match.ts @@ -24,6 +24,7 @@ import { Long, MongoBulkWriteError, MongoError, + MongoOperationTimeoutError, MongoServerError, ObjectId, type OneOrMore, @@ -97,6 +98,19 @@ export function isMatchAsRootOperator(value: unknown): value is MatchAsRootOpera return typeof value === 'object' && value != null && '$$matchAsRoot' in value; } +export interface LteOperator { + $$lte: number; +} + +export function isLteOperator(value: unknown): value is LteOperator { + return ( + typeof value === 'object' && + value != null && + '$$lte' in value && + typeof value['$$lte'] === 'number' + ); +} + export const SpecialOperatorKeys = [ '$$exists', '$$type', @@ -105,7 +119,8 @@ export const SpecialOperatorKeys = [ '$$matchAsRoot', '$$matchAsDocument', '$$unsetOrMatches', - '$$sessionLsid' + '$$sessionLsid', + '$$lte' ]; export type SpecialOperator = @@ -116,7 +131,8 @@ export type SpecialOperator = | UnsetOrMatchesOperator | SessionLsidOperator | MatchAsDocumentOperator - | MatchAsRootOperator; + | MatchAsRootOperator + | LteOperator; type KeysOfUnion = T extends object ? keyof T : never; export type SpecialOperatorKey = KeysOfUnion; @@ -129,7 +145,8 @@ export function isSpecialOperator(value: unknown): value is SpecialOperator { isUnsetOrMatchesOperator(value) || isSessionLsidOperator(value) || isMatchAsRootOperator(value) || - isMatchAsDocumentOperator(value) + isMatchAsDocumentOperator(value) || + isLteOperator(value) ); } @@ -389,6 +406,9 @@ export function specialCheck( ); resultCheck(actual, expected.$$matchAsRoot as any, entities, path, false); + } else if (isLteOperator(expected)) { + expect(typeof actual).to.equal('number'); + expect(actual).to.be.lte(expected.$$lte); } else { expect.fail(`Unknown special operator: ${JSON.stringify(expected)}`); } @@ -758,6 +778,12 @@ export function expectErrorCheck( } } + 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/schema.ts b/test/tools/unified-spec-runner/schema.ts index 81b8172463..ce722b2e70 100644 --- a/test/tools/unified-spec-runner/schema.ts +++ b/test/tools/unified-spec-runner/schema.ts @@ -386,6 +386,7 @@ export interface StoreEventsAsEntity { } export interface ExpectedError { isError?: true; + isTimeoutError?: boolean; isClientError?: boolean; errorContains?: string; errorCode?: number; From 5ef3d690d4e0282868b55eca2f0bc9e47bb1cfca Mon Sep 17 00:00:00 2001 From: Warren James Date: Fri, 21 Jun 2024 12:06:30 -0400 Subject: [PATCH 18/39] refactor(NODE-6187): refactor to use TimeoutContext abstraction (#4131) --- src/bulk/common.ts | 4 + src/cmap/connection.ts | 4 +- src/cmap/connection_pool.ts | 39 +--- src/index.ts | 18 +- src/operations/aggregate.ts | 5 +- src/operations/bulk_write.ts | 11 +- src/operations/command.ts | 8 +- src/operations/count.ts | 9 +- src/operations/create_collection.ts | 18 +- src/operations/delete.ts | 21 +- src/operations/distinct.ts | 9 +- src/operations/drop.ts | 24 ++- src/operations/estimated_document_count.ts | 9 +- src/operations/execute_operation.ts | 16 +- src/operations/find.ts | 6 +- src/operations/find_and_modify.ts | 9 +- src/operations/get_more.ts | 5 +- src/operations/indexes.ts | 22 +- src/operations/insert.ts | 19 +- src/operations/kill_cursors.ts | 12 +- src/operations/list_collections.ts | 5 +- src/operations/list_databases.ts | 11 +- src/operations/operation.ts | 10 +- src/operations/profiling_level.ts | 9 +- src/operations/remove_user.ts | 9 +- src/operations/rename.ts | 9 +- src/operations/run_command.ts | 17 +- src/operations/search_indexes/create.ts | 12 +- src/operations/search_indexes/drop.ts | 9 +- src/operations/search_indexes/update.ts | 9 +- src/operations/set_profiling_level.ts | 6 +- src/operations/stats.ts | 9 +- src/operations/update.ts | 24 ++- src/operations/validate_collection.ts | 9 +- src/sdam/server.ts | 12 +- src/sdam/topology.ts | 55 +++-- src/timeout.ts | 166 +++++++++++++- ...lient_side_operations_timeout.unit.test.ts | 12 +- .../node_csot.test.ts | 2 +- test/tools/cmap_spec_runner.ts | 12 +- test/unit/cmap/connection_pool.test.js | 22 +- test/unit/error.test.ts | 19 +- test/unit/operations/get_more.test.ts | 2 +- test/unit/sdam/topology.test.ts | 76 +++++-- test/unit/timeout.test.ts | 204 +++++++++++++++++- 45 files changed, 796 insertions(+), 202 deletions(-) diff --git a/src/bulk/common.ts b/src/bulk/common.ts index c133a57d22..9eb6338244 100644 --- a/src/bulk/common.ts +++ b/src/bulk/common.ts @@ -20,6 +20,7 @@ import { makeUpdateStatement, UpdateOperation, type UpdateStatement } from '../o import type { Server } from '../sdam/server'; import type { Topology } from '../sdam/topology'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { applyRetryableWrites, type Callback, @@ -873,6 +874,9 @@ export interface BulkWriteOptions extends CommandOperationOptions { forceServerObjectId?: boolean; /** Map of parameter names and values that can be accessed using $$var (requires MongoDB 5.0). */ let?: Document; + + /** @internal */ + timeoutContext?: TimeoutContext; } const executeCommandsAsync = promisify(executeCommands); diff --git a/src/cmap/connection.ts b/src/cmap/connection.ts index 445967faa5..e88e784b45 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -30,7 +30,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 Timeout } from '../timeout'; +import { type TimeoutContext } from '../timeout'; import { BufferPool, calculateDurationInMs, @@ -97,7 +97,7 @@ export interface CommandOptions extends BSONSerializeOptions { directConnection?: boolean; /** @internal */ - timeout?: Timeout; + timeoutContext?: TimeoutContext; } /** @public */ diff --git a/src/cmap/connection_pool.ts b/src/cmap/connection_pool.ts index 79440db1e0..5369cc155a 100644 --- a/src/cmap/connection_pool.ts +++ b/src/cmap/connection_pool.ts @@ -27,8 +27,8 @@ import { } from '../error'; import { CancellationToken, TypedEventEmitter } from '../mongo_types'; import type { Server } from '../sdam/server'; -import { Timeout, TimeoutError } from '../timeout'; -import { type Callback, csotMin, List, makeCounter, promiseWithResolvers } from '../utils'; +import { type TimeoutContext, TimeoutError } from '../timeout'; +import { type Callback, List, makeCounter, promiseWithResolvers } from '../utils'; import { connect } from './connect'; import { Connection, type ConnectionEvents, type ConnectionOptions } from './connection'; import { @@ -355,41 +355,15 @@ export class ConnectionPool extends TypedEventEmitter { * will be held by the pool. This means that if a connection is checked out it MUST be checked back in or * explicitly destroyed by the new owner. */ - async checkOut(options?: { timeout?: Timeout }): Promise { + async checkOut(options: { timeoutContext: TimeoutContext }): Promise { this.emitAndLog( ConnectionPool.CONNECTION_CHECK_OUT_STARTED, new ConnectionCheckOutStartedEvent(this) ); - const waitQueueTimeoutMS = this.options.waitQueueTimeoutMS; - const serverSelectionTimeoutMS = this[kServer].topology.s.serverSelectionTimeoutMS; - const { promise, resolve, reject } = promiseWithResolvers(); - let timeout: Timeout | null = null; - if (options?.timeout) { - // CSOT enabled - // Determine if we're using the timeout passed in or a new timeout - if (options.timeout.duration > 0 || serverSelectionTimeoutMS > 0) { - // This check determines whether or not Topology.selectServer used the configured - // `timeoutMS` or `serverSelectionTimeoutMS` value for its timeout - if ( - options.timeout.duration === serverSelectionTimeoutMS || - csotMin(options.timeout.duration, serverSelectionTimeoutMS) < serverSelectionTimeoutMS - ) { - // server selection used `timeoutMS`, so we should use the existing timeout as the timeout - // here - timeout = options.timeout; - } else { - // server selection used `serverSelectionTimeoutMS`, so we construct a new timeout with - // the time remaining to ensure that Topology.selectServer and ConnectionPool.checkOut - // cumulatively don't spend more than `serverSelectionTimeoutMS` blocking - timeout = Timeout.expires(serverSelectionTimeoutMS - options.timeout.timeElapsed); - } - } - } else { - timeout = Timeout.expires(waitQueueTimeoutMS); - } + const timeout = options.timeoutContext.connectionCheckoutTimeout; const waitQueueMember: WaitQueueMember = { resolve, @@ -404,6 +378,7 @@ export class ConnectionPool extends TypedEventEmitter { return await (timeout ? Promise.race([promise, timeout]) : promise); } catch (error) { if (TimeoutError.is(error)) { + timeout?.clear(); waitQueueMember[kCancelled] = true; this.emitAndLog( @@ -416,7 +391,7 @@ export class ConnectionPool extends TypedEventEmitter { : 'Timed out while checking out a connection from connection pool', this.address ); - if (options?.timeout) { + if (options.timeoutContext.csotEnabled()) { throw new MongoOperationTimeoutError('Timed out during connection checkout', { cause: timeoutError }); @@ -425,7 +400,7 @@ export class ConnectionPool extends TypedEventEmitter { } throw error; } finally { - if (timeout !== options?.timeout) timeout?.clear(); + if (options.timeoutContext.clearConnectionCheckoutTimeout) timeout?.clear(); } } diff --git a/src/index.ts b/src/index.ts index ba28f50ebe..6ddcc887bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -563,7 +563,13 @@ export type { RTTSampler, ServerMonitoringMode } from './sdam/monitor'; -export type { Server, ServerEvents, ServerOptions, ServerPrivate } from './sdam/server'; +export type { + Server, + ServerCommandOptions, + ServerEvents, + ServerOptions, + ServerPrivate +} from './sdam/server'; export type { ServerDescription, ServerDescriptionOptions, @@ -594,7 +600,15 @@ export type { WithTransactionCallback } from './sessions'; export type { Sort, SortDirection, SortDirectionForCmd, SortForCmd } from './sort'; -export type { Timeout } from './timeout'; +export type { + CSOTTimeoutContext, + CSOTTimeoutContextOptions, + LegacyTimeoutContext, + LegacyTimeoutContextOptions, + Timeout, + TimeoutContext, + TimeoutContextOptions +} from './timeout'; export type { Transaction, TransactionOptions, TxnState } from './transactions'; export type { BufferPool, diff --git a/src/operations/aggregate.ts b/src/operations/aggregate.ts index a5a267ac3e..50494cbba7 100644 --- a/src/operations/aggregate.ts +++ b/src/operations/aggregate.ts @@ -3,6 +3,7 @@ import { CursorResponse, ExplainedCursorResponse } from '../cmap/wire_protocol/r import { MongoInvalidArgumentError } from '../error'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { maxWireVersion, type MongoDBNamespace } from '../utils'; import { WriteConcern } from '../write_concern'; import { type CollationOptions, CommandOperation, type CommandOperationOptions } from './command'; @@ -97,7 +98,8 @@ export class AggregateOperation extends CommandOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { const options: AggregateOptions = this.options; const serverWireVersion = maxWireVersion(server); @@ -142,6 +144,7 @@ export class AggregateOperation extends CommandOperation { server, session, command, + timeoutContext, this.explain ? ExplainedCursorResponse : CursorResponse ); } diff --git a/src/operations/bulk_write.ts b/src/operations/bulk_write.ts index 0a855644f0..55b61ef73b 100644 --- a/src/operations/bulk_write.ts +++ b/src/operations/bulk_write.ts @@ -7,6 +7,7 @@ import type { import type { Collection } from '../collection'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { AbstractOperation, Aspect, defineAspects } from './operation'; /** @internal */ @@ -32,11 +33,17 @@ export class BulkWriteOperation extends AbstractOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { const coll = this.collection; const operations = this.operations; - const options = { ...this.options, ...this.bsonOptions, readPreference: this.readPreference }; + const options = { + ...this.options, + ...this.bsonOptions, + readPreference: this.readPreference, + timeoutContext + }; // Create the bulk operation const bulk: BulkOperationBase = diff --git a/src/operations/command.ts b/src/operations/command.ts index c64b4ae963..5bd80f796d 100644 --- a/src/operations/command.ts +++ b/src/operations/command.ts @@ -7,6 +7,7 @@ import type { ReadPreference } from '../read_preference'; import type { Server } from '../sdam/server'; import { MIN_SECONDARY_WRITE_WIRE_VERSION } from '../sdam/server_selection'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { commandSupportsReadConcern, decorateWithExplain, @@ -112,19 +113,22 @@ export abstract class CommandOperation extends AbstractOperation { server: Server, session: ClientSession | undefined, cmd: Document, + timeoutContext: TimeoutContext, responseType: T | undefined ): Promise>; public async executeCommand( server: Server, session: ClientSession | undefined, - cmd: Document + cmd: Document, + timeoutContext: TimeoutContext ): Promise; async executeCommand( server: Server, session: ClientSession | undefined, cmd: Document, + timeoutContext: TimeoutContext, responseType?: MongoDBResponseConstructor ): Promise { this.server = server; @@ -132,7 +136,7 @@ export abstract class CommandOperation extends AbstractOperation { const options = { ...this.options, ...this.bsonOptions, - timeout: this.timeout, + timeoutContext, readPreference: this.readPreference, session }; diff --git a/src/operations/count.ts b/src/operations/count.ts index 00aae50172..82330a11e7 100644 --- a/src/operations/count.ts +++ b/src/operations/count.ts @@ -2,6 +2,7 @@ import type { Document } from '../bson'; import type { Collection } from '../collection'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import type { MongoDBNamespace } from '../utils'; import { CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects } from './operation'; @@ -36,7 +37,11 @@ export class CountOperation extends CommandOperation { return 'count' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const options = this.options; const cmd: Document = { count: this.collectionName, @@ -59,7 +64,7 @@ export class CountOperation extends CommandOperation { cmd.maxTimeMS = options.maxTimeMS; } - const result = await super.executeCommand(server, session, cmd); + const result = await super.executeCommand(server, session, cmd, timeoutContext); return result ? result.n : 0; } } diff --git a/src/operations/create_collection.ts b/src/operations/create_collection.ts index 8edc7e9a1c..afb2680b9a 100644 --- a/src/operations/create_collection.ts +++ b/src/operations/create_collection.ts @@ -9,6 +9,7 @@ import { MongoCompatibilityError } from '../error'; import type { PkFactory } from '../mongo_client'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { CommandOperation, type CommandOperationOptions } from './command'; import { CreateIndexesOperation } from './indexes'; import { Aspect, defineAspects } from './operation'; @@ -124,7 +125,11 @@ export class CreateCollectionOperation extends CommandOperation { return 'create' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const db = this.db; const name = this.name; const options = this.options; @@ -155,7 +160,7 @@ export class CreateCollectionOperation extends CommandOperation { unique: true } }); - await createOp.executeWithoutEncryptedFieldsCheck(server, session); + await createOp.executeWithoutEncryptedFieldsCheck(server, session, timeoutContext); } if (!options.encryptedFields) { @@ -163,7 +168,7 @@ export class CreateCollectionOperation extends CommandOperation { } } - const coll = await this.executeWithoutEncryptedFieldsCheck(server, session); + const coll = await this.executeWithoutEncryptedFieldsCheck(server, session, timeoutContext); if (encryptedFields) { // Create the required index for queryable encryption support. @@ -173,7 +178,7 @@ export class CreateCollectionOperation extends CommandOperation { { __safeContent__: 1 }, {} ); - await createIndexOp.execute(server, session); + await createIndexOp.execute(server, session, timeoutContext); } return coll; @@ -181,7 +186,8 @@ export class CreateCollectionOperation extends CommandOperation { private async executeWithoutEncryptedFieldsCheck( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { const db = this.db; const name = this.name; @@ -198,7 +204,7 @@ export class CreateCollectionOperation extends CommandOperation { } } // otherwise just execute the command - await super.executeCommand(server, session, cmd); + await super.executeCommand(server, session, cmd, timeoutContext); return new Collection(db, name, options); } } diff --git a/src/operations/delete.ts b/src/operations/delete.ts index f0ef61cb7b..0e93ead36a 100644 --- a/src/operations/delete.ts +++ b/src/operations/delete.ts @@ -4,6 +4,7 @@ import { MongoCompatibilityError, MongoServerError } from '../error'; import { type TODO_NODE_3286 } from '../mongo_types'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { type MongoDBNamespace } from '../utils'; import { type WriteConcernOptions } from '../write_concern'; import { type CollationOptions, CommandOperation, type CommandOperationOptions } from './command'; @@ -67,7 +68,8 @@ export class DeleteOperation extends CommandOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { const options = this.options ?? {}; const ordered = typeof options.ordered === 'boolean' ? options.ordered : true; @@ -95,7 +97,12 @@ export class DeleteOperation extends CommandOperation { } } - const res: TODO_NODE_3286 = await super.executeCommand(server, session, command); + const res: TODO_NODE_3286 = await super.executeCommand( + server, + session, + command, + timeoutContext + ); return res; } } @@ -107,9 +114,10 @@ export class DeleteOneOperation extends DeleteOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { - const res: TODO_NODE_3286 = await super.execute(server, session); + const res: TODO_NODE_3286 = await super.execute(server, session, timeoutContext); if (this.explain) return res; if (res.code) throw new MongoServerError(res); if (res.writeErrors) throw new MongoServerError(res.writeErrors[0]); @@ -127,9 +135,10 @@ export class DeleteManyOperation extends DeleteOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { - const res: TODO_NODE_3286 = await super.execute(server, session); + const res: TODO_NODE_3286 = await super.execute(server, session, timeoutContext); if (this.explain) return res; if (res.code) throw new MongoServerError(res); if (res.writeErrors) throw new MongoServerError(res.writeErrors[0]); diff --git a/src/operations/distinct.ts b/src/operations/distinct.ts index 4fda285d88..51f2a362d8 100644 --- a/src/operations/distinct.ts +++ b/src/operations/distinct.ts @@ -2,6 +2,7 @@ import type { Document } from '../bson'; import type { Collection } from '../collection'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { decorateWithCollation, decorateWithReadConcern } from '../utils'; import { CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects } from './operation'; @@ -42,7 +43,11 @@ export class DistinctOperation extends CommandOperation { return 'distinct' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const coll = this.collection; const key = this.key; const query = this.query; @@ -72,7 +77,7 @@ export class DistinctOperation extends CommandOperation { // Have we specified collation decorateWithCollation(cmd, coll, options); - const result = await super.executeCommand(server, session, cmd); + const result = await super.executeCommand(server, session, cmd, timeoutContext); return this.explain ? result : result.values; } diff --git a/src/operations/drop.ts b/src/operations/drop.ts index 15624d4c07..787bb6e7d0 100644 --- a/src/operations/drop.ts +++ b/src/operations/drop.ts @@ -3,6 +3,7 @@ import type { Db } from '../db'; import { MONGODB_ERROR_CODES, MongoServerError } from '../error'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects } from './operation'; @@ -29,7 +30,11 @@ export class DropCollectionOperation extends CommandOperation { return 'drop' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const db = this.db; const options = this.options; const name = this.name; @@ -57,7 +62,7 @@ export class DropCollectionOperation extends CommandOperation { // Drop auxilliary collections, ignoring potential NamespaceNotFound errors. const dropOp = new DropCollectionOperation(db, collectionName); try { - await dropOp.executeWithoutEncryptedFieldsCheck(server, session); + await dropOp.executeWithoutEncryptedFieldsCheck(server, session, timeoutContext); } catch (err) { if ( !(err instanceof MongoServerError) || @@ -69,14 +74,15 @@ export class DropCollectionOperation extends CommandOperation { } } - return await this.executeWithoutEncryptedFieldsCheck(server, session); + return await this.executeWithoutEncryptedFieldsCheck(server, session, timeoutContext); } private async executeWithoutEncryptedFieldsCheck( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { - await super.executeCommand(server, session, { drop: this.name }); + await super.executeCommand(server, session, { drop: this.name }, timeoutContext); return true; } } @@ -96,8 +102,12 @@ export class DropDatabaseOperation extends CommandOperation { return 'dropDatabase' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { - await super.executeCommand(server, session, { dropDatabase: 1 }); + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { + await super.executeCommand(server, session, { dropDatabase: 1 }, timeoutContext); return true; } } diff --git a/src/operations/estimated_document_count.ts b/src/operations/estimated_document_count.ts index c1d6c38199..5ab5aa4c30 100644 --- a/src/operations/estimated_document_count.ts +++ b/src/operations/estimated_document_count.ts @@ -2,6 +2,7 @@ import type { Document } from '../bson'; import type { Collection } from '../collection'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects } from './operation'; @@ -30,7 +31,11 @@ export class EstimatedDocumentCountOperation extends CommandOperation { return 'count' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const cmd: Document = { count: this.collectionName }; if (typeof this.options.maxTimeMS === 'number') { @@ -43,7 +48,7 @@ export class EstimatedDocumentCountOperation extends CommandOperation { cmd.comment = this.options.comment; } - const response = await super.executeCommand(server, session, cmd); + const response = await super.executeCommand(server, session, cmd, timeoutContext); return response?.n || 0; } diff --git a/src/operations/execute_operation.ts b/src/operations/execute_operation.ts index 97e6045073..39937c8abf 100644 --- a/src/operations/execute_operation.ts +++ b/src/operations/execute_operation.ts @@ -24,7 +24,8 @@ import { } from '../sdam/server_selection'; import type { Topology } from '../sdam/topology'; import type { ClientSession } from '../sessions'; -import { supportsRetryableWrites } from '../utils'; +import { TimeoutContext } from '../timeout'; +import { squashError, supportsRetryableWrites } from '../utils'; import { AbstractOperation, Aspect } from './operation'; const MMAPv1_RETRY_WRITES_ERROR_CODE = MONGODB_ERROR_CODES.IllegalOperation; @@ -57,7 +58,7 @@ type ResultTypeFromOperation = export async function executeOperation< T extends AbstractOperation, TResult = ResultTypeFromOperation ->(client: MongoClient, operation: T): Promise { +>(client: MongoClient, operation: T, timeoutContext?: TimeoutContext): Promise { if (!(operation instanceof AbstractOperation)) { // TODO(NODE-3483): Extend MongoRuntimeError throw new MongoRuntimeError('This method requires a valid operation instance'); @@ -86,6 +87,12 @@ export async function executeOperation< ); } + timeoutContext ??= TimeoutContext.create({ + serverSelectionTimeoutMS: client.s.options.serverSelectionTimeoutMS, + waitQueueTimeoutMS: client.s.options.waitQueueTimeoutMS, + timeoutMS: operation.options.timeoutMS + }); + const readPreference = operation.readPreference ?? ReadPreference.primary; const inTransaction = !!session?.inTransaction(); @@ -109,7 +116,8 @@ export async function executeOperation< return await tryOperation(operation, { topology, session, - readPreference + readPreference, + timeoutContext }); } finally { if (session?.owner != null && session.owner === owner) { @@ -260,7 +268,7 @@ async function tryOperation< } try { - return await operation.execute(server, session); + return await operation.execute(server, session, timeoutContext); } catch (operationError) { if (!(operationError instanceof MongoError)) throw operationError; diff --git a/src/operations/find.ts b/src/operations/find.ts index 0f81f2d61f..5f359324d5 100644 --- a/src/operations/find.ts +++ b/src/operations/find.ts @@ -5,6 +5,7 @@ import { ReadConcern } from '../read_concern'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; import { formatSort, type Sort } from '../sort'; +import { type TimeoutContext } from '../timeout'; import { decorateWithExplain, type MongoDBNamespace, normalizeHintField } from '../utils'; import { type CollationOptions, CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects, type Hint } from './operation'; @@ -98,7 +99,8 @@ export class FindOperation extends CommandOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { this.server = server; @@ -117,7 +119,7 @@ export class FindOperation extends CommandOperation { ...this.bsonOptions, documentsReturnedIn: 'firstBatch', session, - timeout: this.timeout + timeoutContext }, this.explain ? ExplainedCursorResponse : CursorResponse ); diff --git a/src/operations/find_and_modify.ts b/src/operations/find_and_modify.ts index 92b17e93b3..651bcccb62 100644 --- a/src/operations/find_and_modify.ts +++ b/src/operations/find_and_modify.ts @@ -5,6 +5,7 @@ import { ReadPreference } from '../read_preference'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; import { formatSort, type Sort, type SortForCmd } from '../sort'; +import { type TimeoutContext } from '../timeout'; import { decorateWithCollation, hasAtomicOperators, maxWireVersion } from '../utils'; import { type WriteConcern, type WriteConcernSettings } from '../write_concern'; import { CommandOperation, type CommandOperationOptions } from './command'; @@ -180,7 +181,11 @@ export class FindAndModifyOperation extends CommandOperation { return 'findAndModify' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const coll = this.collection; const query = this.query; const options = { ...this.options, ...this.bsonOptions }; @@ -208,7 +213,7 @@ export class FindAndModifyOperation extends CommandOperation { } // Execute the command - const result = await super.executeCommand(server, session, cmd); + const result = await super.executeCommand(server, session, cmd, timeoutContext); return options.includeResultMetadata ? result : (result.value ?? null); } } diff --git a/src/operations/get_more.ts b/src/operations/get_more.ts index aa550721b6..34317d533b 100644 --- a/src/operations/get_more.ts +++ b/src/operations/get_more.ts @@ -3,6 +3,7 @@ import { CursorResponse } from '../cmap/wire_protocol/responses'; import { MongoRuntimeError } from '../error'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { maxWireVersion, type MongoDBNamespace } from '../utils'; import { AbstractOperation, Aspect, defineAspects, type OperationOptions } from './operation'; @@ -58,7 +59,8 @@ export class GetMoreOperation extends AbstractOperation { */ override async execute( server: Server, - _session: ClientSession | undefined + _session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { if (server !== this.server) { throw new MongoRuntimeError('Getmore must run on the same server operation began on'); @@ -97,6 +99,7 @@ export class GetMoreOperation extends AbstractOperation { const commandOptions = { returnFieldSelector: null, documentsReturnedIn: 'nextBatch', + timeoutContext, ...this.options }; diff --git a/src/operations/indexes.ts b/src/operations/indexes.ts index fda3fa80dd..c96a5d7345 100644 --- a/src/operations/indexes.ts +++ b/src/operations/indexes.ts @@ -6,6 +6,7 @@ import { MongoCompatibilityError } from '../error'; import { type OneOrMore } from '../mongo_types'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { isObject, maxWireVersion, type MongoDBNamespace } from '../utils'; import { type CollationOptions, @@ -296,7 +297,11 @@ export class CreateIndexesOperation extends CommandOperation { return 'createIndexes'; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const options = this.options; const indexes = this.indexes; @@ -316,7 +321,7 @@ export class CreateIndexesOperation extends CommandOperation { // collation is set on each index, it should not be defined at the root this.options.collation = undefined; - await super.executeCommand(server, session, cmd); + await super.executeCommand(server, session, cmd, timeoutContext); const indexNames = indexes.map(index => index.name || ''); return indexNames; @@ -344,9 +349,13 @@ export class DropIndexOperation extends CommandOperation { return 'dropIndexes' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const cmd = { dropIndexes: this.collection.collectionName, index: this.indexName }; - return await super.executeCommand(server, session, cmd); + return await super.executeCommand(server, session, cmd, timeoutContext); } } @@ -379,7 +388,8 @@ export class ListIndexesOperation extends CommandOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { const serverWireVersion = maxWireVersion(server); @@ -393,7 +403,7 @@ export class ListIndexesOperation extends CommandOperation { command.comment = this.options.comment; } - return await super.executeCommand(server, session, command, CursorResponse); + return await super.executeCommand(server, session, command, timeoutContext, CursorResponse); } } diff --git a/src/operations/insert.ts b/src/operations/insert.ts index 35a050ed1c..1a40763e31 100644 --- a/src/operations/insert.ts +++ b/src/operations/insert.ts @@ -5,6 +5,7 @@ import { MongoInvalidArgumentError, MongoServerError } from '../error'; import type { InferIdType } from '../mongo_types'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { maybeAddIdToDocuments, type MongoDBNamespace } from '../utils'; import { WriteConcern } from '../write_concern'; import { BulkWriteOperation } from './bulk_write'; @@ -27,7 +28,11 @@ export class InsertOperation extends CommandOperation { return 'insert' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const options = this.options ?? {}; const ordered = typeof options.ordered === 'boolean' ? options.ordered : true; const command: Document = { @@ -46,7 +51,7 @@ export class InsertOperation extends CommandOperation { command.comment = options.comment; } - return await super.executeCommand(server, session, command); + return await super.executeCommand(server, session, command, timeoutContext); } } @@ -73,9 +78,10 @@ export class InsertOneOperation extends InsertOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { - const res = await super.execute(server, session); + const res = await super.execute(server, session, timeoutContext); if (res.code) throw new MongoServerError(res); if (res.writeErrors) { // This should be a WriteError but we can't change it now because of error hierarchy @@ -123,7 +129,8 @@ export class InsertManyOperation extends AbstractOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { const coll = this.collection; const options = { ...this.options, ...this.bsonOptions, readPreference: this.readPreference }; @@ -137,7 +144,7 @@ export class InsertManyOperation extends AbstractOperation { ); try { - const res = await bulkWriteOperation.execute(server, session); + const res = await bulkWriteOperation.execute(server, session, timeoutContext); return { acknowledged: writeConcern?.w !== 0, insertedCount: res.insertedCount, diff --git a/src/operations/kill_cursors.ts b/src/operations/kill_cursors.ts index 356230e9c7..72c6a04b27 100644 --- a/src/operations/kill_cursors.ts +++ b/src/operations/kill_cursors.ts @@ -2,6 +2,7 @@ import type { Long } from '../bson'; import { MongoRuntimeError } from '../error'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { type MongoDBNamespace, squashError } from '../utils'; import { AbstractOperation, Aspect, defineAspects, type OperationOptions } from './operation'; @@ -29,7 +30,11 @@ export class KillCursorsOperation extends AbstractOperation { return 'killCursors' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { if (server !== this.server) { throw new MongoRuntimeError('Killcursor must run on the same server operation began on'); } @@ -46,7 +51,10 @@ export class KillCursorsOperation extends AbstractOperation { cursors: [this.cursorId] }; try { - await server.command(this.ns, killCursorsCommand, { session }); + await server.command(this.ns, killCursorsCommand, { + session, + timeoutContext + }); } catch (error) { // The driver should never emit errors from killCursors, this is spec-ed behavior squashError(error); diff --git a/src/operations/list_collections.ts b/src/operations/list_collections.ts index e94300f120..702db0fe3f 100644 --- a/src/operations/list_collections.ts +++ b/src/operations/list_collections.ts @@ -3,6 +3,7 @@ import { CursorResponse } from '../cmap/wire_protocol/responses'; import type { Db } from '../db'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { maxWireVersion } from '../utils'; import { CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects } from './operation'; @@ -54,12 +55,14 @@ export class ListCollectionsOperation extends CommandOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { return await super.executeCommand( server, session, this.generateCommand(maxWireVersion(server)), + timeoutContext, CursorResponse ); } diff --git a/src/operations/list_databases.ts b/src/operations/list_databases.ts index 5ad9142a1a..bd740d50c6 100644 --- a/src/operations/list_databases.ts +++ b/src/operations/list_databases.ts @@ -3,6 +3,7 @@ import type { Db } from '../db'; import { type TODO_NODE_3286 } from '../mongo_types'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { maxWireVersion, MongoDBNamespace } from '../utils'; import { CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects } from './operation'; @@ -41,7 +42,8 @@ export class ListDatabasesOperation extends CommandOperation { const cmd: Document = { listDatabases: 1 }; @@ -63,7 +65,12 @@ export class ListDatabasesOperation extends CommandOperation); + return await (super.executeCommand( + server, + session, + cmd, + timeoutContext + ) as Promise); } } diff --git a/src/operations/operation.ts b/src/operations/operation.ts index 0599b72b96..97e12871ee 100644 --- a/src/operations/operation.ts +++ b/src/operations/operation.ts @@ -2,7 +2,7 @@ import { type BSONSerializeOptions, type Document, resolveBSONOptions } from '.. import { ReadPreference, type ReadPreferenceLike } from '../read_preference'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; -import { type Timeout } from '../timeout'; +import { type Timeout, type TimeoutContext } from '../timeout'; import type { MongoDBNamespace } from '../utils'; export const Aspect = { @@ -79,15 +79,17 @@ export abstract class AbstractOperation { this.options = options; this.bypassPinningCheck = !!options.bypassPinningCheck; this.trySecondaryWrite = false; - - this.timeoutMS = options.timeoutMS; } /** Must match the first key of the command object sent to the server. Command name should be stateless (should not use 'this' keyword) */ abstract get commandName(): string; - abstract execute(server: Server, session: ClientSession | undefined): Promise; + abstract execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise; hasAspect(aspect: symbol): boolean { const ctor = this.constructor as { aspects?: Set }; diff --git a/src/operations/profiling_level.ts b/src/operations/profiling_level.ts index 383062c2a4..7c860a244b 100644 --- a/src/operations/profiling_level.ts +++ b/src/operations/profiling_level.ts @@ -2,6 +2,7 @@ import type { Db } from '../db'; import { MongoUnexpectedServerResponseError } from '../error'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { CommandOperation, type CommandOperationOptions } from './command'; /** @public */ @@ -20,8 +21,12 @@ export class ProfilingLevelOperation extends CommandOperation { return 'profile' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { - const doc = await super.executeCommand(server, session, { profile: -1 }); + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { + const doc = await super.executeCommand(server, session, { profile: -1 }, timeoutContext); if (doc.ok === 1) { const was = doc.was; if (was === 0) return 'off'; diff --git a/src/operations/remove_user.ts b/src/operations/remove_user.ts index ced8e4e1ca..7f484ba89a 100644 --- a/src/operations/remove_user.ts +++ b/src/operations/remove_user.ts @@ -1,6 +1,7 @@ import type { Db } from '../db'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects } from './operation'; @@ -22,8 +23,12 @@ export class RemoveUserOperation extends CommandOperation { return 'dropUser' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { - await super.executeCommand(server, session, { dropUser: this.username }); + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { + await super.executeCommand(server, session, { dropUser: this.username }, timeoutContext); return true; } } diff --git a/src/operations/rename.ts b/src/operations/rename.ts index a27d4afe45..883be282b6 100644 --- a/src/operations/rename.ts +++ b/src/operations/rename.ts @@ -2,6 +2,7 @@ import type { Document } from '../bson'; import { Collection } from '../collection'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { MongoDBNamespace } from '../utils'; import { CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects } from './operation'; @@ -29,7 +30,11 @@ export class RenameOperation extends CommandOperation { return 'renameCollection' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { // Build the command const renameCollection = this.collection.namespace; const toCollection = this.collection.s.namespace.withCollection(this.newName).toString(); @@ -42,7 +47,7 @@ export class RenameOperation extends CommandOperation { dropTarget: dropTarget }; - await super.executeCommand(server, session, command); + await super.executeCommand(server, session, command, timeoutContext); return new Collection(this.collection.s.db, this.newName, this.collection.s.options); } } diff --git a/src/operations/run_command.ts b/src/operations/run_command.ts index 56462fa884..b91e2d0344 100644 --- a/src/operations/run_command.ts +++ b/src/operations/run_command.ts @@ -5,6 +5,7 @@ import { type TODO_NODE_3286 } from '../mongo_types'; import type { ReadPreferenceLike } from '../read_preference'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { MongoDBNamespace } from '../utils'; import { AbstractOperation } from './operation'; @@ -33,7 +34,11 @@ export class RunCommandOperation extends AbstractOperation { return 'runCommand' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { this.server = server; const res: TODO_NODE_3286 = await server.command( this.ns, @@ -42,7 +47,7 @@ export class RunCommandOperation extends AbstractOperation { ...this.options, readPreference: this.readPreference, session, - timeout: this.timeout + timeoutContext }, this.options.responseType ); @@ -67,13 +72,17 @@ export class RunAdminCommandOperation extends AbstractOperation return 'runCommand' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { this.server = server; const res: TODO_NODE_3286 = await server.command(this.ns, this.command, { ...this.options, readPreference: this.readPreference, session, - timeout: this.timeout + timeoutContext }); return res; } diff --git a/src/operations/search_indexes/create.ts b/src/operations/search_indexes/create.ts index 7e5e55d18d..9661026e3e 100644 --- a/src/operations/search_indexes/create.ts +++ b/src/operations/search_indexes/create.ts @@ -3,6 +3,7 @@ import type { Document } from 'bson'; import type { Collection } from '../../collection'; import type { Server } from '../../sdam/server'; import type { ClientSession } from '../../sessions'; +import { type TimeoutContext } from '../../timeout'; import { AbstractOperation } from '../operation'; /** @@ -32,14 +33,21 @@ export class CreateSearchIndexesOperation extends AbstractOperation { return 'createSearchIndexes' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const namespace = this.collection.fullNamespace; const command = { createSearchIndexes: namespace.collection, indexes: this.descriptions }; - const res = await server.command(namespace, command, { session }); + const res = await server.command(namespace, command, { + session, + timeoutContext + }); const indexesCreated: Array<{ name: string }> = res?.indexesCreated ?? []; return indexesCreated.map(({ name }) => name); diff --git a/src/operations/search_indexes/drop.ts b/src/operations/search_indexes/drop.ts index 4e287cca01..e9ea0ad01c 100644 --- a/src/operations/search_indexes/drop.ts +++ b/src/operations/search_indexes/drop.ts @@ -4,6 +4,7 @@ import type { Collection } from '../../collection'; import { MONGODB_ERROR_CODES, MongoServerError } from '../../error'; import type { Server } from '../../sdam/server'; import type { ClientSession } from '../../sessions'; +import { type TimeoutContext } from '../../timeout'; import { AbstractOperation } from '../operation'; /** @internal */ @@ -19,7 +20,11 @@ export class DropSearchIndexOperation extends AbstractOperation { return 'dropSearchIndex' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const namespace = this.collection.fullNamespace; const command: Document = { @@ -31,7 +36,7 @@ export class DropSearchIndexOperation extends AbstractOperation { } try { - await server.command(namespace, command, { session }); + await server.command(namespace, command, { session, timeoutContext }); } catch (error) { const isNamespaceNotFoundError = error instanceof MongoServerError && error.code === MONGODB_ERROR_CODES.NamespaceNotFound; diff --git a/src/operations/search_indexes/update.ts b/src/operations/search_indexes/update.ts index aad7f93536..e88e777d67 100644 --- a/src/operations/search_indexes/update.ts +++ b/src/operations/search_indexes/update.ts @@ -3,6 +3,7 @@ import type { Document } from 'bson'; import type { Collection } from '../../collection'; import type { Server } from '../../sdam/server'; import type { ClientSession } from '../../sessions'; +import { type TimeoutContext } from '../../timeout'; import { AbstractOperation } from '../operation'; /** @internal */ @@ -19,7 +20,11 @@ export class UpdateSearchIndexOperation extends AbstractOperation { return 'updateSearchIndex' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const namespace = this.collection.fullNamespace; const command = { updateSearchIndex: namespace.collection, @@ -27,7 +32,7 @@ export class UpdateSearchIndexOperation extends AbstractOperation { definition: this.definition }; - await server.command(namespace, command, { session }); + await server.command(namespace, command, { session, timeoutContext }); return; } } diff --git a/src/operations/set_profiling_level.ts b/src/operations/set_profiling_level.ts index 9969b2ea3c..d76473f263 100644 --- a/src/operations/set_profiling_level.ts +++ b/src/operations/set_profiling_level.ts @@ -2,6 +2,7 @@ import type { Db } from '../db'; import { MongoInvalidArgumentError } from '../error'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { enumToString } from '../utils'; import { CommandOperation, type CommandOperationOptions } from './command'; @@ -53,7 +54,8 @@ export class SetProfilingLevelOperation extends CommandOperation override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { const level = this.level; @@ -64,7 +66,7 @@ export class SetProfilingLevelOperation extends CommandOperation } // TODO(NODE-3483): Determine error to put here - await super.executeCommand(server, session, { profile: this.profile }); + await super.executeCommand(server, session, { profile: this.profile }, timeoutContext); return level; } } diff --git a/src/operations/stats.ts b/src/operations/stats.ts index 41c9faf6e2..aafd3bf1ba 100644 --- a/src/operations/stats.ts +++ b/src/operations/stats.ts @@ -2,6 +2,7 @@ import type { Document } from '../bson'; import type { Db } from '../db'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects } from './operation'; @@ -24,13 +25,17 @@ export class DbStatsOperation extends CommandOperation { return 'dbStats' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const command: Document = { dbStats: true }; if (this.options.scale != null) { command.scale = this.options.scale; } - return await super.executeCommand(server, session, command); + return await super.executeCommand(server, session, command, timeoutContext); } } diff --git a/src/operations/update.ts b/src/operations/update.ts index ba0ad6d95f..5b6f396afe 100644 --- a/src/operations/update.ts +++ b/src/operations/update.ts @@ -4,6 +4,7 @@ import { MongoCompatibilityError, MongoInvalidArgumentError, MongoServerError } import type { InferIdType, TODO_NODE_3286 } from '../mongo_types'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { hasAtomicOperators, type MongoDBNamespace } from '../utils'; import { type CollationOptions, CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects, type Hint } from './operation'; @@ -91,7 +92,11 @@ export class UpdateOperation extends CommandOperation { return this.statements.every(op => op.multi == null || op.multi === false); } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const options = this.options ?? {}; const ordered = typeof options.ordered === 'boolean' ? options.ordered : true; const command: Document = { @@ -122,7 +127,7 @@ export class UpdateOperation extends CommandOperation { } } - const res = await super.executeCommand(server, session, command); + const res = await super.executeCommand(server, session, command, timeoutContext); return res; } } @@ -143,9 +148,10 @@ export class UpdateOneOperation extends UpdateOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { - const res: TODO_NODE_3286 = await super.execute(server, session); + const res: TODO_NODE_3286 = await super.execute(server, session, timeoutContext); if (this.explain != null) return res; if (res.code) throw new MongoServerError(res); if (res.writeErrors) throw new MongoServerError(res.writeErrors[0]); @@ -177,9 +183,10 @@ export class UpdateManyOperation extends UpdateOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { - const res: TODO_NODE_3286 = await super.execute(server, session); + const res: TODO_NODE_3286 = await super.execute(server, session, timeoutContext); if (this.explain != null) return res; if (res.code) throw new MongoServerError(res); if (res.writeErrors) throw new MongoServerError(res.writeErrors[0]); @@ -230,9 +237,10 @@ export class ReplaceOneOperation extends UpdateOperation { override async execute( server: Server, - session: ClientSession | undefined + session: ClientSession | undefined, + timeoutContext: TimeoutContext ): Promise { - const res: TODO_NODE_3286 = await super.execute(server, session); + const res: TODO_NODE_3286 = await super.execute(server, session, timeoutContext); if (this.explain != null) return res; if (res.code) throw new MongoServerError(res); if (res.writeErrors) throw new MongoServerError(res.writeErrors[0]); diff --git a/src/operations/validate_collection.ts b/src/operations/validate_collection.ts index 4880a703a7..16ae4cad9e 100644 --- a/src/operations/validate_collection.ts +++ b/src/operations/validate_collection.ts @@ -3,6 +3,7 @@ import type { Document } from '../bson'; import { MongoUnexpectedServerResponseError } from '../error'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { CommandOperation, type CommandOperationOptions } from './command'; /** @public */ @@ -37,10 +38,14 @@ export class ValidateCollectionOperation extends CommandOperation { return 'validate' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { const collectionName = this.collectionName; - const doc = await super.executeCommand(server, session, this.command); + const doc = await super.executeCommand(server, session, this.command, timeoutContext); if (doc.result != null && typeof doc.result !== 'string') throw new MongoUnexpectedServerResponseError('Error with validation data'); if (doc.result != null && doc.result.match(/exception|corrupt/) != null) diff --git a/src/sdam/server.ts b/src/sdam/server.ts index a1b885382e..20cb13423c 100644 --- a/src/sdam/server.ts +++ b/src/sdam/server.ts @@ -40,6 +40,7 @@ import type { ServerApi } from '../mongo_client'; import { TypedEventEmitter } from '../mongo_types'; import type { GetMoreOptions } from '../operations/get_more'; import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; import { isTransactionCommand } from '../transactions'; import { type EventEmitterWithState, @@ -104,6 +105,11 @@ export type ServerEvents = { } & ConnectionPoolEvents & EventEmitterWithState; +/** @internal */ +export type ServerCommandOptions = Omit & { + timeoutContext: TimeoutContext; +}; + /** @internal */ export class Server extends TypedEventEmitter { /** @internal */ @@ -267,20 +273,20 @@ export class Server extends TypedEventEmitter { public async command( ns: MongoDBNamespace, command: Document, - options: CommandOptions | undefined, + options: ServerCommandOptions, responseType: T | undefined ): Promise>; public async command( ns: MongoDBNamespace, command: Document, - options?: CommandOptions + options: ServerCommandOptions ): Promise; public async command( ns: MongoDBNamespace, cmd: Document, - options: CommandOptions, + options: ServerCommandOptions, responseType?: MongoDBResponseConstructor ): Promise { if (ns.db == null || typeof ns === 'string') { diff --git a/src/sdam/topology.ts b/src/sdam/topology.ts index 4c9d71d807..6117b5317c 100644 --- a/src/sdam/topology.ts +++ b/src/sdam/topology.ts @@ -34,11 +34,10 @@ import { MongoLoggableComponent, type MongoLogger, SeverityLevel } from '../mong import { TypedEventEmitter } from '../mongo_types'; import { ReadPreference, type ReadPreferenceLike } from '../read_preference'; import type { ClientSession } from '../sessions'; -import { Timeout, TimeoutError } from '../timeout'; +import { Timeout, TimeoutContext, TimeoutError } from '../timeout'; import type { Transaction } from '../transactions'; import { type Callback, - csotMin, type EventEmitterWithState, HostAddress, List, @@ -179,8 +178,11 @@ export interface SelectServerOptions { session?: ClientSession; operationName: string; previousServer?: ServerDescription; - /** @internal*/ - timeout?: Timeout; + /** + * @internal + * TODO(NODE-5685): Make this required + * */ + timeoutContext?: TimeoutContext; } /** @public */ @@ -458,13 +460,20 @@ export class Topology extends TypedEventEmitter { } } - const timeoutMS = this.client.options.timeoutMS; - const timeout = timeoutMS != null ? Timeout.expires(timeoutMS) : undefined; + 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, + serverSelectionTimeoutMS, + waitQueueTimeoutMS: this.client.s.options.waitQueueTimeoutMS + }); + const selectServerOptions = { operationName: 'ping', - timeout, - ...options + ...options, + timeoutContext }; try { const server = await this.selectServer( @@ -474,7 +483,7 @@ export class Topology extends TypedEventEmitter { const skipPingOnConnect = this.s.options[Symbol.for('@@mdb.skipPingOnConnect')] === true; if (!skipPingOnConnect && server && this.s.credentials) { - await server.command(ns('admin.$cmd'), { ping: 1 }, { timeout }); + await server.command(ns('admin.$cmd'), { ping: 1 }, { timeoutContext }); stateTransition(this, STATE_CONNECTED); this.emit(Topology.OPEN, this); this.emit(Topology.CONNECT, this); @@ -563,24 +572,10 @@ export class Topology extends TypedEventEmitter { new ServerSelectionStartedEvent(selector, this.description, options.operationName) ); } - const serverSelectionTimeoutMS = options.serverSelectionTimeoutMS ?? 0; - let timeout: Timeout | null; - if (options.timeout) { - // CSOT Enabled - if (options.timeout.duration > 0 || serverSelectionTimeoutMS > 0) { - if ( - options.timeout.duration === serverSelectionTimeoutMS || - csotMin(options.timeout.duration, serverSelectionTimeoutMS) < serverSelectionTimeoutMS - ) { - timeout = options.timeout; - } else { - timeout = Timeout.expires(serverSelectionTimeoutMS); - } - } else { - timeout = null; - } - } else { - timeout = Timeout.expires(serverSelectionTimeoutMS); + let timeout; + if (options.timeoutContext) timeout = options.timeoutContext.serverSelectionTimeout; + else { + timeout = Timeout.expires(options.serverSelectionTimeoutMS ?? 0); } const isSharded = this.description.type === TopologyType.Sharded; @@ -604,7 +599,7 @@ export class Topology extends TypedEventEmitter { ) ); } - if (timeout !== options.timeout) timeout?.clear(); + if (options.timeoutContext?.clearServerSelectionTimeout) timeout?.clear(); return transaction.server; } @@ -654,7 +649,7 @@ export class Topology extends TypedEventEmitter { ); } - if (options.timeout) { + if (options.timeoutContext?.csotEnabled()) { throw new MongoOperationTimeoutError('Timed out during server selection', { cause: timeoutError }); @@ -664,7 +659,7 @@ export class Topology extends TypedEventEmitter { // Other server selection error throw error; } finally { - if (timeout !== options.timeout) timeout?.clear(); + if (options.timeoutContext?.clearServerSelectionTimeout) timeout?.clear(); } } /** diff --git a/src/timeout.ts b/src/timeout.ts index 7af1a23f26..3d65992a02 100644 --- a/src/timeout.ts +++ b/src/timeout.ts @@ -1,7 +1,7 @@ import { clearTimeout, setTimeout } from 'timers'; -import { MongoInvalidArgumentError } from './error'; -import { noop } from './utils'; +import { MongoInvalidArgumentError, MongoRuntimeError } from './error'; +import { csotMin, noop } from './utils'; /** @internal */ export class TimeoutError extends Error { @@ -107,3 +107,165 @@ export class Timeout extends Promise { ); } } + +/** @internal */ +export type TimeoutContextOptions = LegacyTimeoutContextOptions | CSOTTimeoutContextOptions; + +/** @internal */ +export type LegacyTimeoutContextOptions = { + serverSelectionTimeoutMS: number; + waitQueueTimeoutMS: number; + socketTimeoutMS?: number; +}; + +/** @internal */ +export type CSOTTimeoutContextOptions = { + timeoutMS: number; + serverSelectionTimeoutMS: number; + socketTimeoutMS?: number; +}; + +function isLegacyTimeoutContextOptions(v: unknown): v is LegacyTimeoutContextOptions { + return ( + v != null && + typeof v === 'object' && + 'serverSelectionTimeoutMS' in v && + typeof v.serverSelectionTimeoutMS === 'number' && + 'waitQueueTimeoutMS' in v && + typeof v.waitQueueTimeoutMS === 'number' + ); +} + +function isCSOTTimeoutContextOptions(v: unknown): v is CSOTTimeoutContextOptions { + return ( + v != null && + typeof v === 'object' && + 'serverSelectionTimeoutMS' in v && + typeof v.serverSelectionTimeoutMS === 'number' && + 'timeoutMS' in v && + typeof v.timeoutMS === 'number' + ); +} + +/** @internal */ +export abstract class TimeoutContext { + static create(options: TimeoutContextOptions): TimeoutContext { + if (isCSOTTimeoutContextOptions(options)) return new CSOTTimeoutContext(options); + else if (isLegacyTimeoutContextOptions(options)) return new LegacyTimeoutContext(options); + else throw new MongoRuntimeError('Unrecognized options'); + } + + abstract get serverSelectionTimeout(): Timeout | null; + + abstract get connectionCheckoutTimeout(): Timeout | null; + + abstract get clearServerSelectionTimeout(): boolean; + + abstract get clearConnectionCheckoutTimeout(): boolean; + + abstract csotEnabled(): this is CSOTTimeoutContext; +} + +/** @internal */ +export class CSOTTimeoutContext extends TimeoutContext { + timeoutMS: number; + serverSelectionTimeoutMS: number; + socketTimeoutMS?: number; + + clearConnectionCheckoutTimeout: boolean; + clearServerSelectionTimeout: boolean; + + private _maxTimeMS?: number; + + private _serverSelectionTimeout?: Timeout | null; + private _connectionCheckoutTimeout?: Timeout | null; + + constructor(options: CSOTTimeoutContextOptions) { + super(); + this.timeoutMS = options.timeoutMS; + + this.serverSelectionTimeoutMS = options.serverSelectionTimeoutMS; + + this.socketTimeoutMS = options.socketTimeoutMS; + + this.clearServerSelectionTimeout = false; + this.clearConnectionCheckoutTimeout = true; + } + + get maxTimeMS(): number { + return this._maxTimeMS ?? -1; + } + + set maxTimeMS(v: number) { + this._maxTimeMS = v; + } + + csotEnabled(): this is CSOTTimeoutContext { + return true; + } + + get serverSelectionTimeout(): Timeout | null { + // check for undefined + if (typeof this._serverSelectionTimeout !== 'object') { + const usingServerSelectionTimeoutMS = + this.serverSelectionTimeoutMS !== 0 && + csotMin(this.timeoutMS, this.serverSelectionTimeoutMS) === this.serverSelectionTimeoutMS; + + if (usingServerSelectionTimeoutMS) { + this._serverSelectionTimeout = Timeout.expires(this.serverSelectionTimeoutMS); + } else { + if (this.timeoutMS > 0) { + this._serverSelectionTimeout = Timeout.expires(this.timeoutMS); + } else { + this._serverSelectionTimeout = null; + } + } + } + + return this._serverSelectionTimeout; + } + + get connectionCheckoutTimeout(): Timeout | null { + if (typeof this._connectionCheckoutTimeout !== 'object') { + if (typeof this._serverSelectionTimeout === 'object') { + // null or Timeout + this._connectionCheckoutTimeout = this._serverSelectionTimeout; + } else { + throw new MongoRuntimeError( + 'Unreachable. If you are seeing this error, please file a ticket on the NODE driver project on Jira' + ); + } + } + return this._connectionCheckoutTimeout; + } +} + +/** @internal */ +export class LegacyTimeoutContext extends TimeoutContext { + options: LegacyTimeoutContextOptions; + clearServerSelectionTimeout: boolean; + clearConnectionCheckoutTimeout: boolean; + + constructor(options: LegacyTimeoutContextOptions) { + super(); + this.options = options; + this.clearServerSelectionTimeout = true; + this.clearConnectionCheckoutTimeout = true; + } + + csotEnabled(): this is CSOTTimeoutContext { + return false; + } + + get serverSelectionTimeout(): Timeout | null { + if (this.options.serverSelectionTimeoutMS != null && this.options.serverSelectionTimeoutMS > 0) + return Timeout.expires(this.options.serverSelectionTimeoutMS); + return null; + } + + get connectionCheckoutTimeout(): Timeout | null { + if (this.options.waitQueueTimeoutMS != null && this.options.waitQueueTimeoutMS > 0) + return Timeout.expires(this.options.waitQueueTimeoutMS); + return null; + } +} diff --git a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts index c1426d8db1..c4989f58d7 100644 --- a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts +++ b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts @@ -33,16 +33,20 @@ describe('CSOT spec unit tests', function () { client = this.configuration.newClient({ timeoutMS: 1000 }); // Spy on connection checkout and pull options argument const checkoutSpy = sinon.spy(ConnectionPool.prototype, 'checkOut'); - const selectServerSpy = sinon.spy(Topology.prototype, 'selectServer'); const expiresSpy = sinon.spy(Timeout, 'expires'); await client.db('db').collection('collection').insertOne({ x: 1 }); expect(checkoutSpy).to.have.been.calledOnce; - expect(checkoutSpy.firstCall.args[0].timeout).to.exist; + const timeoutContext = checkoutSpy.lastCall.args[0].timeoutContext; + expect(timeoutContext).to.exist; // Check that we passed through the timeout - expect(checkoutSpy.firstCall.args[0].timeout).to.equal( - selectServerSpy.lastCall.lastArg.timeout + // @ts-expect-error accessing private properties + expect(timeoutContext._serverSelectionTimeout).to.be.instanceOf(Timeout); + // @ts-expect-error accessing private properties + expect(timeoutContext._serverSelectionTimeout).to.equal( + // @ts-expect-error accessing private properties + timeoutContext._connectionCheckoutTimeout ); // Check that no more Timeouts are constructed after we enter checkout 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 5636eb00db..17d85ba5b2 100644 --- a/test/integration/client-side-operations-timeout/node_csot.test.ts +++ b/test/integration/client-side-operations-timeout/node_csot.test.ts @@ -143,7 +143,7 @@ describe('CSOT driver tests', () => { }); it('throws a MongoOperationTimeoutError', { - metadata: { requires: { mongodb: '>=4.4' } }, + metadata: { requires: { mongodb: '>=4.4', topology: '!load-balanced' } }, test: async function () { const commandsStarted = []; client = this.configuration.newClient(undefined, { timeoutMS: 1, monitorCommands: true }); diff --git a/test/tools/cmap_spec_runner.ts b/test/tools/cmap_spec_runner.ts index f6d7e68bed..9bb2abdb87 100644 --- a/test/tools/cmap_spec_runner.ts +++ b/test/tools/cmap_spec_runner.ts @@ -12,7 +12,8 @@ import { makeClientMetadata, type MongoClient, type Server, - shuffle + shuffle, + TimeoutContext } from '../mongodb'; import { isAnyRequirementSatisfied } from './unified-spec-runner/unified-utils'; import { type FailPoint, sleep } from './utils'; @@ -185,7 +186,14 @@ const compareInputToSpec = (input, expected, message) => { const getTestOpDefinitions = (threadContext: ThreadContext) => ({ checkOut: async function (op) { - const connection: Connection = await ConnectionPool.prototype.checkOut.call(threadContext.pool); + const timeoutContext = TimeoutContext.create({ + serverSelectionTimeoutMS: 0, + waitQueueTimeoutMS: threadContext.pool.options.waitQueueTimeoutMS + }); + const connection: Connection = await ConnectionPool.prototype.checkOut.call( + threadContext.pool, + { timeoutContext } + ); if (op.label != null) { threadContext.connections.set(op.label, connection); } else { diff --git a/test/unit/cmap/connection_pool.test.js b/test/unit/cmap/connection_pool.test.js index 18048befab..1604cd82d8 100644 --- a/test/unit/cmap/connection_pool.test.js +++ b/test/unit/cmap/connection_pool.test.js @@ -10,8 +10,10 @@ const { ns, isHello } = require('../../mongodb'); const { createTimerSandbox } = require('../timer_sandbox'); const { topologyWithPlaceholderClient } = require('../../tools/utils'); const { MongoClientAuthProviders } = require('../../mongodb'); +const { TimeoutContext } = require('../../mongodb'); describe('Connection Pool', function () { + let timeoutContext; let mockMongod; const stubServer = { topology: { @@ -44,6 +46,10 @@ describe('Connection Pool', function () { }) ); + beforeEach(() => { + timeoutContext = TimeoutContext.create({ waitQueueTimeoutMS: 0, serverSelectionTimeoutMS: 0 }); + }); + it('should destroy connections which have been closed', async function () { mockMongod.setMessageHandler(request => { const doc = request.document; @@ -64,8 +70,10 @@ describe('Connection Pool', function () { const events = []; pool.on('connectionClosed', event => events.push(event)); - const conn = await pool.checkOut(); - const error = await conn.command(ns('admin.$cmd'), { ping: 1 }, {}).catch(error => error); + const conn = await pool.checkOut({ timeoutContext }); + const error = await conn + .command(ns('admin.$cmd'), { ping: 1 }, { timeoutContext }) + .catch(error => error); expect(error).to.be.instanceOf(Error); pool.checkIn(conn); @@ -93,7 +101,7 @@ describe('Connection Pool', function () { pool.ready(); - const conn = await pool.checkOut(); + const conn = await pool.checkOut({ timeoutContext }); const maybeError = await conn.command(ns('admin.$cmd'), { ping: 1 }, undefined).catch(e => e); expect(maybeError).to.be.instanceOf(MongoError); expect(maybeError).to.match(/timed out/); @@ -114,11 +122,15 @@ describe('Connection Pool', function () { waitQueueTimeoutMS: 200, hostAddress: mockMongod.hostAddress() }); + const timeoutContext = TimeoutContext.create({ + waitQueueTimeoutMS: 200, + serverSelectionTimeoutMS: 0 + }); pool.ready(); - const conn = await pool.checkOut(); - const err = await pool.checkOut().catch(e => e); + const conn = await pool.checkOut({ timeoutContext }); + const err = await pool.checkOut({ timeoutContext }).catch(e => e); expect(err).to.exist.and.be.instanceOf(WaitQueueTimeoutError); sinon.stub(pool, 'availableConnectionCount').get(() => 0); pool.checkIn(conn); diff --git a/test/unit/error.test.ts b/test/unit/error.test.ts index 6bab40d031..bdc049cbc4 100644 --- a/test/unit/error.test.ts +++ b/test/unit/error.test.ts @@ -28,6 +28,7 @@ import { ns, PoolClosedError as MongoPoolClosedError, setDifference, + TimeoutContext, type TopologyDescription, type TopologyOptions, WaitQueueTimeoutError as MongoWaitQueueTimeoutError @@ -376,11 +377,17 @@ describe('MongoErrors', () => { { replicaSet: 'rs' } as TopologyOptions ); + const timeoutContext = TimeoutContext.create({ + serverSelectionTimeoutMS: 0, + waitQueueTimeoutMS: 0 + }); return replSet .connect() - .then(topology => topology.selectServer('primary', {})) + .then(topology => topology.selectServer('primary', { timeoutContext })) .then(server => - server.command(ns('db1'), Object.assign({}, RAW_USER_WRITE_CONCERN_CMD), {}) + server.command(ns('db1'), Object.assign({}, RAW_USER_WRITE_CONCERN_CMD), { + timeoutContext + }) ) .then( () => expect.fail('expected command to fail'), @@ -419,10 +426,14 @@ describe('MongoErrors', () => { if (err) { return cleanup(err); } + const timeoutContext = TimeoutContext.create({ + serverSelectionTimeoutMS: 0, + waitQueueTimeoutMS: 0 + }); - topology.selectServer('primary', {}).then(server => { + topology.selectServer('primary', { timeoutContext }).then(server => { server - .command(ns('db1'), Object.assign({}, RAW_USER_WRITE_CONCERN_CMD), {}) + .command(ns('db1'), Object.assign({}, RAW_USER_WRITE_CONCERN_CMD), { timeoutContext }) .then(expect.fail, err => { let _err; try { diff --git a/test/unit/operations/get_more.test.ts b/test/unit/operations/get_more.test.ts index f79da44e22..17bc20f6fa 100644 --- a/test/unit/operations/get_more.test.ts +++ b/test/unit/operations/get_more.test.ts @@ -69,7 +69,7 @@ describe('GetMoreOperation', function () { const call = stub.getCall(0); expect(call.args[0]).to.equal(namespace); expect(call.args[1]).to.deep.equal(expectedGetMoreCommand); - expect(call.args[2]).to.deep.equal(opts); + expect(call.args[2]).to.containSubset(opts); }); }); diff --git a/test/unit/sdam/topology.test.ts b/test/unit/sdam/topology.test.ts index e4a34417d5..5264b5d9c4 100644 --- a/test/unit/sdam/topology.test.ts +++ b/test/unit/sdam/topology.test.ts @@ -17,6 +17,7 @@ import { Server, SrvPoller, SrvPollingEvent, + TimeoutContext, Topology, TopologyDescription, TopologyDescriptionChangedEvent, @@ -108,17 +109,28 @@ describe('Topology (unit)', function () { const topology = topologyWithPlaceholderClient(mockServer.hostAddress(), {}); topology.connect().then(() => { - topology.selectServer('primary', {}).then(server => { - server.command(ns('admin.$cmd'), { ping: 1 }, { socketTimeoutMS: 250 }).then( - () => expect.fail('expected command to fail'), - err => { - expect(err).to.exist; - expect(err).to.match(/timed out/); - topology.close(); - done(); - } - ); - }, expect.fail); + const ctx = TimeoutContext.create({ + waitQueueTimeoutMS: 0, + serverSelectionTimeoutMS: 0, + socketTimeoutMS: 250 + }); + topology + .selectServer('primary', { + timeoutContext: ctx + }) + .then(server => { + server + .command(ns('admin.$cmd'), { ping: 1 }, { socketTimeoutMS: 250, timeoutContext: ctx }) + .then( + () => expect.fail('expected command to fail'), + err => { + expect(err).to.exist; + expect(err).to.match(/timed out/); + topology.close(); + done(); + } + ); + }, expect.fail); }, expect.fail); }); }); @@ -217,10 +229,16 @@ describe('Topology (unit)', function () { let poolCleared = false; topology.on('connectionPoolCleared', () => (poolCleared = true)); - const err = await server.command(ns('test.test'), { insert: { a: 42 } }, {}).then( - () => null, - e => e - ); + const timeoutContext = TimeoutContext.create({ + serverSelectionTimeoutMS: 0, + waitQueueTimeoutMS: 0 + }); + const err = await server + .command(ns('test.test'), { insert: { a: 42 } }, { timeoutContext }) + .then( + () => null, + e => e + ); expect(err).to.eql(serverDescription.error); expect(poolCleared).to.be.true; }); @@ -245,11 +263,17 @@ describe('Topology (unit)', function () { let poolCleared = false; topology.on('connectionPoolCleared', () => (poolCleared = true)); + const timeoutContext = TimeoutContext.create({ + serverSelectionTimeoutMS: 0, + waitQueueTimeoutMS: 0 + }); - const err = await server.command(ns('test.test'), { insert: { a: 42 } }, {}).then( - () => null, - e => e - ); + const err = await server + .command(ns('test.test'), { insert: { a: 42 } }, { timeoutContext }) + .then( + () => null, + e => e + ); expect(err).to.eql(serverDescription.error); expect(poolCleared).to.be.false; topology.close(); @@ -269,14 +293,20 @@ describe('Topology (unit)', function () { topology = topologyWithPlaceholderClient(mockServer.hostAddress(), {}); await topology.connect(); + const timeoutContext = TimeoutContext.create({ + waitQueueTimeoutMS: 0, + serverSelectionTimeoutMS: 0 + }); const server = await topology.selectServer('primary', {}); let serverDescription; server.on('descriptionReceived', sd => (serverDescription = sd)); - const err = await server.command(ns('test.test'), { insert: { a: 42 } }, {}).then( - () => null, - e => e - ); + const err = await server + .command(ns('test.test'), { insert: { a: 42 } }, { timeoutContext }) + .then( + () => null, + e => e + ); expect(err).to.eql(serverDescription.error); expect(server.description.type).to.equal('Unknown'); }); diff --git a/test/unit/timeout.test.ts b/test/unit/timeout.test.ts index 3fafc21b35..119d0516a9 100644 --- a/test/unit/timeout.test.ts +++ b/test/unit/timeout.test.ts @@ -1,6 +1,14 @@ import { expect } from 'chai'; -import { MongoInvalidArgumentError, Timeout, TimeoutError } from '../mongodb'; +import { + CSOTTimeoutContext, + LegacyTimeoutContext, + MongoInvalidArgumentError, + MongoRuntimeError, + Timeout, + TimeoutContext, + TimeoutError +} from '../mongodb'; describe('Timeout', function () { let timeout: Timeout; @@ -115,3 +123,197 @@ describe('Timeout', function () { }); }); }); + +describe('TimeoutContext', function () { + describe('TimeoutContext.create', function () { + context('when timeoutMS is a number', function () { + it('returns a CSOTTimeoutContext instance', function () { + const ctx = TimeoutContext.create({ + timeoutMS: 0, + serverSelectionTimeoutMS: 0, + waitQueueTimeoutMS: 0 + }); + + expect(ctx).to.be.instanceOf(CSOTTimeoutContext); + }); + }); + + context('when timeoutMS is undefined', function () { + it('returns a LegacyTimeoutContext instance', function () { + const ctx = TimeoutContext.create({ + serverSelectionTimeoutMS: 0, + waitQueueTimeoutMS: 0 + }); + + expect(ctx).to.be.instanceOf(LegacyTimeoutContext); + }); + }); + }); + + describe('CSOTTimeoutContext', function () { + let ctx: CSOTTimeoutContext; + + describe('get serverSelectionTimeout()', function () { + let timeout: Timeout | null; + + afterEach(() => { + timeout?.clear(); + }); + + context('when timeoutMS is 0 and serverSelectionTimeoutMS is 0', function () { + it('returns null', function () { + ctx = new CSOTTimeoutContext({ + timeoutMS: 0, + serverSelectionTimeoutMS: 0 + }); + + expect(ctx.serverSelectionTimeout).to.be.null; + }); + }); + + context('when timeoutMS is 0 and serverSelectionTimeoutMS is >0', function () { + it('returns a Timeout instance with duration set to serverSelectionTimeoutMS', function () { + ctx = new CSOTTimeoutContext({ + timeoutMS: 0, + serverSelectionTimeoutMS: 10 + }); + + timeout = ctx.serverSelectionTimeout; + expect(timeout).to.be.instanceOf(Timeout); + + expect(timeout.duration).to.equal(ctx.serverSelectionTimeoutMS); + }); + }); + + context( + 'when timeoutMS is >0 serverSelectionTimeoutMS is >0 and timeoutMS > serverSelectionTimeoutMS', + function () { + it('returns a Timeout instance with duration set to serverSelectionTimeoutMS', function () { + ctx = new CSOTTimeoutContext({ + timeoutMS: 15, + serverSelectionTimeoutMS: 10 + }); + + timeout = ctx.serverSelectionTimeout; + expect(timeout).to.exist; + expect(timeout).to.be.instanceOf(Timeout); + expect(timeout.duration).to.equal(ctx.serverSelectionTimeoutMS); + }); + } + ); + + context( + 'when timeoutMS is >0, serverSelectionTimeoutMS is >0 and timeoutMS < serverSelectionTimeoutMS', + function () { + it('returns a Timeout instance with duration set to timeoutMS', function () { + ctx = new CSOTTimeoutContext({ + timeoutMS: 10, + serverSelectionTimeoutMS: 15 + }); + + timeout = ctx.serverSelectionTimeout; + expect(timeout).to.exist; + expect(timeout).to.be.instanceOf(Timeout); + expect(timeout.duration).to.equal(ctx.timeoutMS); + }); + } + ); + }); + + describe('get connectionCheckoutTimeout()', function () { + context('when called before get serverSelectionTimeout()', function () { + it('throws a MongoRuntimeError', function () { + ctx = new CSOTTimeoutContext({ + timeoutMS: 100, + serverSelectionTimeoutMS: 15 + }); + + expect(() => ctx.connectionCheckoutTimeout).to.throw(MongoRuntimeError); + }); + }); + + context('when called after get serverSelectionTimeout()', function () { + let serverSelectionTimeout: Timeout; + let connectionCheckoutTimeout: Timeout; + + afterEach(() => { + serverSelectionTimeout.clear(); + connectionCheckoutTimeout.clear(); + }); + + it('returns same timeout as serverSelectionTimeout', function () { + ctx = new CSOTTimeoutContext({ + timeoutMS: 100, + serverSelectionTimeoutMS: 86 + }); + serverSelectionTimeout = ctx.serverSelectionTimeout; + connectionCheckoutTimeout = ctx.connectionCheckoutTimeout; + + expect(connectionCheckoutTimeout).to.exist; + expect(connectionCheckoutTimeout).to.equal(serverSelectionTimeout); + }); + }); + }); + }); + + describe('LegacyTimeoutContext', function () { + let timeout: Timeout | null; + + afterEach(() => { + timeout?.clear(); + }); + + describe('get serverSelectionTimeout()', function () { + context('when serverSelectionTimeoutMS > 0', function () { + it('returns a Timeout instance with duration set to serverSelectionTimeoutMS', function () { + const ctx = new LegacyTimeoutContext({ + serverSelectionTimeoutMS: 100, + waitQueueTimeoutMS: 10 + }); + + timeout = ctx.serverSelectionTimeout; + expect(timeout).to.be.instanceOf(Timeout); + expect(timeout.duration).to.equal(ctx.options.serverSelectionTimeoutMS); + }); + }); + + context('when serverSelectionTimeoutMS = 0', function () { + it('returns null', function () { + const ctx = new LegacyTimeoutContext({ + serverSelectionTimeoutMS: 0, + waitQueueTimeoutMS: 10 + }); + + timeout = ctx.serverSelectionTimeout; + expect(timeout).to.be.null; + }); + }); + }); + + describe('get connectionCheckoutTimeout()', function () { + context('when waitQueueTimeoutMS > 0', function () { + it('returns a Timeout instance with duration set to waitQueueTimeoutMS', function () { + const ctx = new LegacyTimeoutContext({ + serverSelectionTimeoutMS: 10, + waitQueueTimeoutMS: 20 + }); + timeout = ctx.connectionCheckoutTimeout; + + expect(timeout).to.be.instanceOf(Timeout); + expect(timeout.duration).to.equal(ctx.options.waitQueueTimeoutMS); + }); + }); + + context('when waitQueueTimeoutMS = 0', function () { + it('returns null', function () { + const ctx = new LegacyTimeoutContext({ + serverSelectionTimeoutMS: 10, + waitQueueTimeoutMS: 0 + }); + + expect(ctx.connectionCheckoutTimeout).to.be.null; + }); + }); + }); + }); +}); From 7139b8f322f798f5bad32ebc67783c46ca1119b2 Mon Sep 17 00:00:00 2001 From: Neal Beeken Date: Mon, 22 Jul 2024 11:17:22 -0400 Subject: [PATCH 19/39] refactor(NODE-6230): executeOperation to use iterative retry mechanism (#4157) --- src/cmap/connection_pool.ts | 6 ++++-- src/operations/execute_operation.ts | 27 ++++++++++++++++----------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/cmap/connection_pool.ts b/src/cmap/connection_pool.ts index 5369cc155a..2cd2bcc2c1 100644 --- a/src/cmap/connection_pool.ts +++ b/src/cmap/connection_pool.ts @@ -28,7 +28,7 @@ import { import { CancellationToken, TypedEventEmitter } from '../mongo_types'; import type { Server } from '../sdam/server'; import { type TimeoutContext, TimeoutError } from '../timeout'; -import { type Callback, List, makeCounter, promiseWithResolvers } from '../utils'; +import { type Callback, List, makeCounter, now, promiseWithResolvers } from '../utils'; import { connect } from './connect'; import { Connection, type ConnectionEvents, type ConnectionOptions } from './connection'; import { @@ -356,6 +356,7 @@ export class ConnectionPool extends TypedEventEmitter { * explicitly destroyed by the new owner. */ async checkOut(options: { timeoutContext: TimeoutContext }): Promise { + const checkoutTime = now(); this.emitAndLog( ConnectionPool.CONNECTION_CHECK_OUT_STARTED, new ConnectionCheckOutStartedEvent(this) @@ -367,7 +368,8 @@ export class ConnectionPool extends TypedEventEmitter { const waitQueueMember: WaitQueueMember = { resolve, - reject + reject, + checkoutTime }; this[kWaitQueue].push(waitQueueMember); diff --git a/src/operations/execute_operation.ts b/src/operations/execute_operation.ts index 39937c8abf..efd92f19de 100644 --- a/src/operations/execute_operation.ts +++ b/src/operations/execute_operation.ts @@ -25,7 +25,7 @@ import { import type { Topology } from '../sdam/topology'; import type { ClientSession } from '../sessions'; import { TimeoutContext } from '../timeout'; -import { squashError, supportsRetryableWrites } from '../utils'; +import { supportsRetryableWrites } from '../utils'; import { AbstractOperation, Aspect } from './operation'; const MMAPv1_RETRY_WRITES_ERROR_CODE = MONGODB_ERROR_CODES.IllegalOperation; @@ -87,12 +87,6 @@ export async function executeOperation< ); } - timeoutContext ??= TimeoutContext.create({ - serverSelectionTimeoutMS: client.s.options.serverSelectionTimeoutMS, - waitQueueTimeoutMS: client.s.options.waitQueueTimeoutMS, - timeoutMS: operation.options.timeoutMS - }); - const readPreference = operation.readPreference ?? ReadPreference.primary; const inTransaction = !!session?.inTransaction(); @@ -112,12 +106,18 @@ export async function executeOperation< session.unpin(); } + timeoutContext ??= TimeoutContext.create({ + serverSelectionTimeoutMS: client.s.options.serverSelectionTimeoutMS, + waitQueueTimeoutMS: client.s.options.waitQueueTimeoutMS, + timeoutMS: operation.options.timeoutMS + }); + try { return await tryOperation(operation, { topology, + timeoutContext, session, - readPreference, - timeoutContext + readPreference }); } finally { if (session?.owner != null && session.owner === owner) { @@ -156,6 +156,7 @@ type RetryOptions = { session: ClientSession | undefined; readPreference: ReadPreference; topology: Topology; + timeoutContext: TimeoutContext; }; /** @@ -179,7 +180,10 @@ type RetryOptions = { async function tryOperation< T extends AbstractOperation, TResult = ResultTypeFromOperation ->(operation: T, { topology, session, readPreference }: RetryOptions): Promise { +>( + operation: T, + { topology, timeoutContext, session, readPreference }: RetryOptions +): Promise { let selector: ReadPreference | ServerSelector; if (operation.hasAspect(Aspect.MUST_SELECT_SAME_SERVER)) { @@ -197,7 +201,8 @@ async function tryOperation< let server = await topology.selectServer(selector, { session, - operationName: operation.commandName + operationName: operation.commandName, + timeoutContext }); const hasReadAspect = operation.hasAspect(Aspect.READ_OPERATION); From acfb4fc8c323b71930ad08f6b976b14d92ccb0b2 Mon Sep 17 00:00:00 2001 From: Neal Beeken Date: Fri, 26 Jul 2024 09:55:20 -0400 Subject: [PATCH 20/39] 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\]/ ); }); }); From 4efff95018c4f0f08c3010c18975179b1889ba1e Mon Sep 17 00:00:00 2001 From: Warren James Date: Thu, 1 Aug 2024 16:08:39 -0400 Subject: [PATCH 21/39] feat(NODE-6231): Add CSOT behaviour for retryable reads and writes (#4186) --- src/operations/execute_operation.ts | 9 ++++--- src/timeout.ts | 26 ++++++++++++------- ...lient_side_operations_timeout.spec.test.ts | 13 +++++++++- ...lient_side_operations_timeout.unit.test.ts | 10 +++++-- .../node_csot.test.ts | 5 ---- test/tools/unified-spec-runner/match.ts | 2 ++ 6 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/operations/execute_operation.ts b/src/operations/execute_operation.ts index efd92f19de..c9135fa1c3 100644 --- a/src/operations/execute_operation.ts +++ b/src/operations/execute_operation.ts @@ -227,12 +227,10 @@ async function tryOperation< session.incrementTransactionNumber(); } - // TODO(NODE-6231): implement infinite retry within CSOT timeout here - const maxTries = willRetry ? 2 : 1; + const maxTries = willRetry ? (timeoutContext.csotEnabled() ? Infinity : 2) : 1; let previousOperationError: MongoError | undefined; let previousServer: ServerDescription | undefined; - // TODO(NODE-6231): implement infinite retry within CSOT timeout here for (let tries = 0; tries < maxTries; tries++) { if (previousOperationError) { if (hasWriteAspect && previousOperationError.code === MMAPv1_RETRY_WRITES_ERROR_CODE) { @@ -276,7 +274,6 @@ async function tryOperation< return await operation.execute(server, session, timeoutContext); } catch (operationError) { if (!(operationError instanceof MongoError)) throw operationError; - if ( previousOperationError != null && operationError.hasErrorLabel(MongoErrorLabel.NoWritesPerformed) @@ -285,6 +282,10 @@ async function tryOperation< } previousServer = server.description; previousOperationError = operationError; + + // Reset timeouts + timeoutContext.serverSelectionTimeout?.clear(); + timeoutContext.connectionCheckoutTimeout?.clear(); } } diff --git a/src/timeout.ts b/src/timeout.ts index cc90b8c2e7..297a484b4e 100644 --- a/src/timeout.ts +++ b/src/timeout.ts @@ -39,6 +39,7 @@ export class Timeout extends Promise { public ended: number | null = null; public duration: number; public timedOut = false; + public cleared = false; get remainingTime(): number { if (this.timedOut) return 0; @@ -53,7 +54,6 @@ export class Timeout extends Promise { /** Create a new timeout that expires in `duration` ms */ private constructor(executor: Executor = () => null, duration: number, unref = true) { let reject!: Reject; - if (duration < 0) { throw new MongoInvalidArgumentError('Cannot create a Timeout with a negative duration'); } @@ -86,6 +86,7 @@ export class Timeout extends Promise { clear(): void { clearTimeout(this.id); this.id = undefined; + this.cleared = true; } throwIfExpired(): void { @@ -213,16 +214,20 @@ export class CSOTTimeoutContext extends TimeoutContext { get serverSelectionTimeout(): Timeout | null { // check for undefined - if (typeof this._serverSelectionTimeout !== 'object') { + if (typeof this._serverSelectionTimeout !== 'object' || this._serverSelectionTimeout?.cleared) { + const { remainingTimeMS, serverSelectionTimeoutMS } = this; + if (remainingTimeMS <= 0) + throw new MongoOperationTimeoutError( + `Timed out in server selection after ${this.timeoutMS}ms` + ); const usingServerSelectionTimeoutMS = - this.serverSelectionTimeoutMS !== 0 && - csotMin(this.timeoutMS, this.serverSelectionTimeoutMS) === this.serverSelectionTimeoutMS; - + serverSelectionTimeoutMS !== 0 && + csotMin(remainingTimeMS, serverSelectionTimeoutMS) === serverSelectionTimeoutMS; if (usingServerSelectionTimeoutMS) { - this._serverSelectionTimeout = Timeout.expires(this.serverSelectionTimeoutMS); + this._serverSelectionTimeout = Timeout.expires(serverSelectionTimeoutMS); } else { - if (this.timeoutMS > 0) { - this._serverSelectionTimeout = Timeout.expires(this.timeoutMS); + if (remainingTimeMS > 0 && Number.isFinite(remainingTimeMS)) { + this._serverSelectionTimeout = Timeout.expires(remainingTimeMS); } else { this._serverSelectionTimeout = null; } @@ -233,7 +238,10 @@ export class CSOTTimeoutContext extends TimeoutContext { } get connectionCheckoutTimeout(): Timeout | null { - if (typeof this._connectionCheckoutTimeout !== 'object') { + if ( + typeof this._connectionCheckoutTimeout !== 'object' || + this._connectionCheckoutTimeout?.cleared + ) { if (typeof this._serverSelectionTimeout === 'object') { // null or Timeout this._connectionCheckoutTimeout = this._serverSelectionTimeout; 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 f73f162204..e4c9eb3027 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 @@ -6,7 +6,9 @@ import { runUnifiedSuite } from '../../tools/unified-spec-runner/runner'; const enabled = [ 'override-collection-timeoutMS', 'override-database-timeoutMS', - 'override-operation-timeoutMS' + 'override-operation-timeoutMS', + 'retryability-legacy-timeouts', + 'retryability-timeoutMS' ]; const cursorOperations = [ @@ -18,6 +20,11 @@ const cursorOperations = [ 'listCollectionNames' ]; +const bulkWriteOperations = [ + 'timeoutMS applies to whole operation, not individual attempts - bulkWrite on collection', + 'timeoutMS applies to whole operation, not individual attempts - insertMany on collection' +]; + describe('CSOT spec tests', function () { const specs = loadSpecTests(join('client-side-operations-timeout')); for (const spec of specs) { @@ -30,6 +37,10 @@ describe('CSOT spec tests', function () { // Cursor operation if (test.operations.find(operation => cursorOperations.includes(operation.name))) test.skipReason = 'TODO(NODE-5684): Not working yet'; + + if (bulkWriteOperations.includes(test.description)) + test.skipReason = + 'TODO(NODE-6274): update test runner to check errorResponse field of MongoBulkWriteError in isTimeoutError assertion'; } } runUnifiedSuite(specs); diff --git a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts index c4989f58d7..944d9b9604 100644 --- a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts +++ b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts @@ -7,7 +7,7 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; -import { ConnectionPool, type MongoClient, Timeout, Topology } from '../../mongodb'; +import { ConnectionPool, type MongoClient, Timeout, TimeoutContext, Topology } from '../../mongodb'; // TODO(NODE-5824): Implement CSOT prose tests describe('CSOT spec unit tests', function () { @@ -22,10 +22,16 @@ describe('CSOT spec unit tests', function () { it('Operations should ignore waitQueueTimeoutMS if timeoutMS is also set.', async function () { client = this.configuration.newClient({ waitQueueTimeoutMS: 999999, timeoutMS: 10000 }); sinon.spy(Timeout, 'expires'); + const timeoutContextSpy = sinon.spy(TimeoutContext, 'create'); await client.db('db').collection('collection').insertOne({ x: 1 }); - expect(Timeout.expires).to.have.been.calledWith(10000); + const createCalls = timeoutContextSpy.getCalls().filter( + // @ts-expect-error accessing concrete field + call => call.args[0].timeoutMS === 10000 + ); + + expect(createCalls).to.have.length.greaterThanOrEqual(1); expect(Timeout.expires).to.not.have.been.calledWith(999999); }); 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 0c97b91083..63e2d97dd9 100644 --- a/test/integration/client-side-operations-timeout/node_csot.test.ts +++ b/test/integration/client-side-operations-timeout/node_csot.test.ts @@ -1,6 +1,5 @@ /* Anything javascript specific relating to timeouts */ import { expect } from 'chai'; -import * as sinon from 'sinon'; import { type ClientSession, @@ -13,10 +12,6 @@ import { } from '../../mongodb'; describe('CSOT driver tests', () => { - afterEach(() => { - sinon.restore(); - }); - describe('timeoutMS inheritance', () => { let client: MongoClient; let db: Db; diff --git a/test/tools/unified-spec-runner/match.ts b/test/tools/unified-spec-runner/match.ts index 3e3ba86d0e..90996b9640 100644 --- a/test/tools/unified-spec-runner/match.ts +++ b/test/tools/unified-spec-runner/match.ts @@ -789,6 +789,8 @@ export function expectErrorCheck( expect(error).to.be.instanceof(MongoOperationTimeoutError); } + // TODO(NODE-6274): Check for MongoBulkWriteErrors that have a MongoOperationTimeoutError in their + // errorResponse field if (expected.isTimeoutError === false) { expect(error).to.not.be.instanceof(MongoOperationTimeoutError); } else if (expected.isTimeoutError === true) { From 1997f8179f0e1136091e473d414882c15002b524 Mon Sep 17 00:00:00 2001 From: Neal Beeken Date: Mon, 12 Aug 2024 16:46:06 -0400 Subject: [PATCH 22/39] feat(NODE-6312): add error transformation for server timeouts (#4192) --- src/cmap/connection.ts | 29 ++++ src/cmap/wire_protocol/responses.ts | 36 +++- .../node_csot.test.ts | 163 +++++++++++++++++- 3 files changed, 225 insertions(+), 3 deletions(-) diff --git a/src/cmap/connection.ts b/src/cmap/connection.ts index 3f391bea40..96b8bb26eb 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -16,6 +16,7 @@ import { } from '../constants'; import { MongoCompatibilityError, + MONGODB_ERROR_CODES, MongoMissingDependencyError, MongoNetworkError, MongoNetworkTimeoutError, @@ -538,6 +539,11 @@ export class Connection extends TypedEventEmitter { } if (document.ok === 0) { + if (options.timeoutContext?.csotEnabled() && document.isMaxTimeExpiredError) { + throw new MongoOperationTimeoutError('Server reported a timeout error', { + cause: new MongoServerError((object ??= document.toObject(bsonOptions))) + }); + } throw new MongoServerError((object ??= document.toObject(bsonOptions))); } @@ -611,6 +617,29 @@ export class Connection extends TypedEventEmitter { ): Promise { this.throwIfAborted(); for await (const document of this.sendCommand(ns, command, options, responseType)) { + if (options.timeoutContext?.csotEnabled()) { + if (MongoDBResponse.is(document)) { + // TODO(NODE-5684): test coverage to be added once cursors are enabling CSOT + if (document.isMaxTimeExpiredError) { + throw new MongoOperationTimeoutError('Server reported a timeout error', { + cause: new MongoServerError(document.toObject()) + }); + } + } else { + if ( + (Array.isArray(document?.writeErrors) && + document.writeErrors.some( + error => error?.code === MONGODB_ERROR_CODES.MaxTimeMSExpired + )) || + document?.writeConcernError?.code === MONGODB_ERROR_CODES.MaxTimeMSExpired + ) { + throw new MongoOperationTimeoutError('Server reported a timeout error', { + cause: new MongoServerError(document) + }); + } + } + } + return document; } throw new MongoUnexpectedServerResponseError('Unable to get response from server'); diff --git a/src/cmap/wire_protocol/responses.ts b/src/cmap/wire_protocol/responses.ts index 6c166afd61..12b6878427 100644 --- a/src/cmap/wire_protocol/responses.ts +++ b/src/cmap/wire_protocol/responses.ts @@ -11,7 +11,7 @@ import { pluckBSONSerializeOptions, type Timestamp } from '../../bson'; -import { MongoUnexpectedServerResponseError } from '../../error'; +import { MONGODB_ERROR_CODES, MongoUnexpectedServerResponseError } from '../../error'; import { type ClusterTime } from '../../sdam/common'; import { decorateDecryptionResult, ns } from '../../utils'; import { @@ -111,6 +111,40 @@ export class MongoDBResponse extends OnDemandDocument { // {ok:1} static empty = new MongoDBResponse(new Uint8Array([13, 0, 0, 0, 16, 111, 107, 0, 1, 0, 0, 0, 0])); + /** + * Returns true iff: + * - ok is 0 and the top-level code === 50 + * - ok is 1 and the writeErrors array contains a code === 50 + * - ok is 1 and the writeConcern object contains a code === 50 + */ + get isMaxTimeExpiredError() { + // {ok: 0, code: 50 ... } + const isTopLevel = this.ok === 0 && this.code === MONGODB_ERROR_CODES.MaxTimeMSExpired; + if (isTopLevel) return true; + + if (this.ok === 0) return false; + + // {ok: 1, writeConcernError: {code: 50 ... }} + const isWriteConcern = + this.get('writeConcernError', BSONType.object)?.getNumber('code') === + MONGODB_ERROR_CODES.MaxTimeMSExpired; + if (isWriteConcern) return true; + + const writeErrors = this.get('writeErrors', BSONType.array); + if (writeErrors?.size()) { + for (let i = 0; i < writeErrors.size(); i++) { + const isWriteError = + writeErrors.get(i, BSONType.object)?.getNumber('code') === + MONGODB_ERROR_CODES.MaxTimeMSExpired; + + // {ok: 1, writeErrors: [{code: 50 ... }]} + if (isWriteError) return true; + } + } + + return false; + } + /** * Drivers can safely assume that the `recoveryToken` field is always a BSON document but drivers MUST NOT modify the * contents of the document. 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 63e2d97dd9..d7d4a4ede5 100644 --- a/test/integration/client-side-operations-timeout/node_csot.test.ts +++ b/test/integration/client-side-operations-timeout/node_csot.test.ts @@ -1,17 +1,23 @@ /* Anything javascript specific relating to timeouts */ import { expect } from 'chai'; +import * as semver from 'semver'; +import * as sinon from 'sinon'; import { + BSON, type ClientSession, type Collection, + Connection, type Db, type FindCursor, LEGACY_HELLO_COMMAND, type MongoClient, - MongoOperationTimeoutError + MongoOperationTimeoutError, + MongoServerError } from '../../mongodb'; +import { type FailPoint } from '../../tools/utils'; -describe('CSOT driver tests', () => { +describe('CSOT driver tests', { requires: { mongodb: '>=4.4' } }, () => { describe('timeoutMS inheritance', () => { let client: MongoClient; let db: Db; @@ -161,4 +167,157 @@ describe('CSOT driver tests', () => { }); }); }); + + describe('server-side maxTimeMS errors are transformed', () => { + let client: MongoClient; + let commandsSucceeded; + let commandsFailed; + + beforeEach(async function () { + client = this.configuration.newClient({ timeoutMS: 500_000, monitorCommands: true }); + commandsSucceeded = []; + commandsFailed = []; + client.on('commandSucceeded', event => { + if (event.commandName === 'configureFailPoint') return; + commandsSucceeded.push(event); + }); + client.on('commandFailed', event => commandsFailed.push(event)); + }); + + afterEach(async function () { + await client + .db() + .collection('a') + .drop() + .catch(() => null); + await client.close(); + commandsSucceeded = undefined; + commandsFailed = undefined; + }); + + describe('when a maxTimeExpired error is returned at the top-level', () => { + // {ok: 0, code: 50, codeName: "MaxTimeMSExpired", errmsg: "operation time limit exceeded"} + const failpoint: FailPoint = { + configureFailPoint: 'failCommand', + mode: { times: 1 }, + data: { + failCommands: ['ping'], + errorCode: 50 + } + }; + + beforeEach(async function () { + if (semver.satisfies(this.configuration.version, '>=4.4')) + await client.db('admin').command(failpoint); + else { + this.skipReason = 'Requires server version later than 4.4'; + this.skip(); + } + }); + + afterEach(async function () { + if (semver.satisfies(this.configuration.version, '>=4.4')) + await client.db('admin').command({ ...failpoint, mode: 'off' }); + }); + + it('throws a MongoOperationTimeoutError error and emits command failed', async () => { + const error = await client + .db() + .command({ ping: 1 }) + .catch(error => error); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(error.cause).to.be.instanceOf(MongoServerError); + expect(error.cause).to.have.property('code', 50); + + expect(commandsFailed).to.have.lengthOf(1); + expect(commandsFailed).to.have.nested.property('[0].failure.cause.code', 50); + }); + }); + + describe('when a maxTimeExpired error is returned inside a writeErrors array', () => { + // The server should always return one maxTimeExpiredError at the front of the writeErrors array + // But for the sake of defensive programming we will find any maxTime error in the array. + + beforeEach(async () => { + const writeErrorsReply = BSON.serialize({ + ok: 1, + writeErrors: [ + { code: 2, codeName: 'MaxTimeMSExpired', errmsg: 'operation time limit exceeded' }, + { code: 3, codeName: 'MaxTimeMSExpired', errmsg: 'operation time limit exceeded' }, + { code: 4, codeName: 'MaxTimeMSExpired', errmsg: 'operation time limit exceeded' }, + { code: 50, codeName: 'MaxTimeMSExpired', errmsg: 'operation time limit exceeded' } + ] + }); + const commandSpy = sinon.spy(Connection.prototype, 'command'); + const readManyStub = sinon + // @ts-expect-error: readMany is private + .stub(Connection.prototype, 'readMany') + .callsFake(async function* (...args) { + const realIterator = readManyStub.wrappedMethod.call(this, ...args); + const cmd = commandSpy.lastCall.args.at(1); + if ('giveMeWriteErrors' in cmd) { + await realIterator.next().catch(() => null); // dismiss response + yield { parse: () => writeErrorsReply }; + } else { + yield (await realIterator.next()).value; + } + }); + }); + + afterEach(() => sinon.restore()); + + it('throws a MongoOperationTimeoutError error and emits command succeeded', async () => { + const error = await client + .db('admin') + .command({ giveMeWriteErrors: 1 }) + .catch(error => error); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(error.cause).to.be.instanceOf(MongoServerError); + expect(error.cause).to.have.nested.property('writeErrors[3].code', 50); + + expect(commandsSucceeded).to.have.lengthOf(1); + expect(commandsSucceeded).to.have.nested.property('[0].reply.writeErrors[3].code', 50); + }); + }); + + describe('when a maxTimeExpired error is returned inside a writeConcernError embedded document', () => { + // {ok: 1, writeConcernError: {code: 50, codeName: "MaxTimeMSExpired"}} + const failpoint: FailPoint = { + configureFailPoint: 'failCommand', + mode: { times: 1 }, + data: { + failCommands: ['insert'], + writeConcernError: { code: 50, errmsg: 'times up buster', errorLabels: [] } + } + }; + + beforeEach(async function () { + if (semver.satisfies(this.configuration.version, '>=4.4')) + await client.db('admin').command(failpoint); + else { + this.skipReason = 'Requires server version later than 4.4'; + this.skip(); + } + }); + + afterEach(async function () { + if (semver.satisfies(this.configuration.version, '>=4.4')) + await client.db('admin').command({ ...failpoint, mode: 'off' }); + }); + + it('throws a MongoOperationTimeoutError error and emits command succeeded', async () => { + const error = await client + .db() + .collection('a') + .insertOne({}) + .catch(error => error); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(error.cause).to.be.instanceOf(MongoServerError); + expect(error.cause).to.have.nested.property('writeConcernError.code', 50); + + expect(commandsSucceeded).to.have.lengthOf(1); + expect(commandsSucceeded).to.have.nested.property('[0].reply.writeConcernError.code', 50); + }); + }); + }); }); From cc3ef8f02fc5596fa26193ae7980143d9539ecae Mon Sep 17 00:00:00 2001 From: Neal Beeken Date: Mon, 9 Sep 2024 11:11:11 -0400 Subject: [PATCH 23/39] feat(NODE-6313): add CSOT support to sessions and transactions (#4199) --- package-lock.json | 82 +++--- package.json | 2 +- src/cmap/connection.ts | 7 + src/cmap/wire_protocol/on_data.ts | 15 +- src/collection.ts | 12 +- src/db.ts | 22 +- src/error.ts | 3 + src/operations/execute_operation.ts | 8 +- src/sessions.ts | 255 ++++++++++++------ src/timeout.ts | 49 +++- src/transactions.ts | 7 +- src/utils.ts | 13 +- ...ient_side_operations_timeout.prose.test.ts | 167 +++++++++++- ...lient_side_operations_timeout.spec.test.ts | 18 +- .../node_csot.test.ts | 150 +++++++++++ .../sessions-inherit-timeoutMS.json | 28 +- .../sessions-inherit-timeoutMS.yml | 19 +- ...sessions-override-operation-timeoutMS.json | 32 ++- .../sessions-override-operation-timeoutMS.yml | 23 +- .../sessions-override-timeoutMS.json | 28 +- .../sessions-override-timeoutMS.yml | 19 +- test/tools/unified-spec-runner/entities.ts | 4 + test/tools/unified-spec-runner/match.ts | 19 +- test/tools/unified-spec-runner/operations.ts | 27 +- 24 files changed, 776 insertions(+), 233 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2b07cd361d..1d9cebf509 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,7 +49,7 @@ "mocha": "^10.4.0", "mocha-sinon": "^2.1.2", "mongodb-client-encryption": "^6.1.0", - "mongodb-legacy": "^6.1.0", + "mongodb-legacy": "^6.1.1", "nyc": "^15.1.0", "prettier": "^3.3.3", "semver": "^7.6.3", @@ -6415,10 +6415,46 @@ "node": ">=10" } }, - "node_modules/mongodb": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.8.0.tgz", - "integrity": "sha512-HGQ9NWDle5WvwMnrvUxsFYPd3JEbqD3RgABHBQRuoCEND0qzhsd0iH5ypHsf1eJ+sXmvmyKpP+FLOKY8Il7jMw==", + "node_modules/mongodb-client-encryption": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/mongodb-client-encryption/-/mongodb-client-encryption-6.1.0.tgz", + "integrity": "sha512-Y3Hakre82nXD/pNDUzBjxfgwWSj5E1ar9ZLkqyXDfvirv4huHMbg8Q2qVO/TXlNJuf1B2bzrEDXsTqHKQSQLtw==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.1.2" + }, + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz", + "integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^13.0.0" + } + }, + "node_modules/mongodb-legacy": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/mongodb-legacy/-/mongodb-legacy-6.1.1.tgz", + "integrity": "sha512-u9Cl8UEzdtf7mhWrAEHHhfU0OCqahaOB5midwtyudWIuEz5t18DJFXfqJq3cbEypVfLkfF3zi6rkolKMU9uPjQ==", + "dev": true, + "dependencies": { + "mongodb": "^6.0.0" + }, + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/mongodb-legacy/node_modules/mongodb": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.7.0.tgz", + "integrity": "sha512-TMKyHdtMcO0fYBNORiYdmM25ijsHs+Njs963r4Tro4OQZzqYigAzYQouwWRg4OIaiLRUEGUh/1UAcH5lxdSLIA==", "dev": true, "dependencies": { "@mongodb-js/saslprep": "^1.1.5", @@ -6461,42 +6497,6 @@ } } }, - "node_modules/mongodb-client-encryption": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/mongodb-client-encryption/-/mongodb-client-encryption-6.1.0.tgz", - "integrity": "sha512-Y3Hakre82nXD/pNDUzBjxfgwWSj5E1ar9ZLkqyXDfvirv4huHMbg8Q2qVO/TXlNJuf1B2bzrEDXsTqHKQSQLtw==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "bindings": "^1.5.0", - "node-addon-api": "^4.3.0", - "prebuild-install": "^7.1.2" - }, - "engines": { - "node": ">=16.20.1" - } - }, - "node_modules/mongodb-connection-string-url": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz", - "integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==", - "dependencies": { - "@types/whatwg-url": "^11.0.2", - "whatwg-url": "^13.0.0" - } - }, - "node_modules/mongodb-legacy": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/mongodb-legacy/-/mongodb-legacy-6.1.1.tgz", - "integrity": "sha512-u9Cl8UEzdtf7mhWrAEHHhfU0OCqahaOB5midwtyudWIuEz5t18DJFXfqJq3cbEypVfLkfF3zi6rkolKMU9uPjQ==", - "dev": true, - "dependencies": { - "mongodb": "^6.0.0" - }, - "engines": { - "node": ">=16.20.1" - } - }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", diff --git a/package.json b/package.json index 479356905d..2de0e1811f 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "mocha": "^10.4.0", "mocha-sinon": "^2.1.2", "mongodb-client-encryption": "^6.1.0", - "mongodb-legacy": "^6.1.0", + "mongodb-legacy": "^6.1.1", "nyc": "^15.1.0", "prettier": "^3.3.3", "semver": "^7.6.3", diff --git a/src/cmap/connection.ts b/src/cmap/connection.ts index 96b8bb26eb..eddd2a9223 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -742,6 +742,13 @@ export class Connection extends TypedEventEmitter { return; } } + } catch (readError) { + if (TimeoutError.is(readError)) { + throw new MongoOperationTimeoutError( + `Timed out during socket read (${readError.duration}ms)` + ); + } + throw readError; } finally { this.dataEvents = null; this.throwIfAborted(); diff --git a/src/cmap/wire_protocol/on_data.ts b/src/cmap/wire_protocol/on_data.ts index a32c6b1b48..23fd88e282 100644 --- a/src/cmap/wire_protocol/on_data.ts +++ b/src/cmap/wire_protocol/on_data.ts @@ -1,7 +1,6 @@ import { type EventEmitter } from 'events'; -import { MongoOperationTimeoutError } from '../../error'; -import { type TimeoutContext, TimeoutError } from '../../timeout'; +import { type TimeoutContext } from '../../timeout'; import { List, promiseWithResolvers } from '../../utils'; /** @@ -91,8 +90,11 @@ export function onData( // Adding event handlers emitter.on('data', eventHandler); emitter.on('error', errorHandler); + + const timeoutForSocketRead = timeoutContext?.timeoutForSocketRead; + timeoutForSocketRead?.throwIfExpired(); // eslint-disable-next-line github/no-then - timeoutContext?.timeoutForSocketRead?.then(undefined, errorHandler); + timeoutForSocketRead?.then(undefined, errorHandler); return iterator; @@ -104,12 +106,9 @@ export function onData( function errorHandler(err: Error) { const promise = unconsumedPromises.shift(); - const timeoutError = TimeoutError.is(err) - ? new MongoOperationTimeoutError('Timed out during socket read') - : undefined; - if (promise != null) promise.reject(timeoutError ?? err); - else error = timeoutError ?? err; + if (promise != null) promise.reject(err); + else error = err; void closeHandler(); } diff --git a/src/collection.ts b/src/collection.ts index dbd91371cc..f3a206b0c7 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -470,10 +470,14 @@ export class Collection { // Intentionally, we do not inherit options from parent for this operation. return await executeOperation( this.client, - new RenameOperation(this as TODO_NODE_3286, newName, { - ...options, - readPreference: ReadPreference.PRIMARY - }) as TODO_NODE_3286 + new RenameOperation( + this as TODO_NODE_3286, + newName, + resolveOptions(undefined, { + ...options, + readPreference: ReadPreference.PRIMARY + }) + ) as TODO_NODE_3286 ); } diff --git a/src/db.ts b/src/db.ts index 48501bc497..bd0b5450b8 100644 --- a/src/db.ts +++ b/src/db.ts @@ -275,12 +275,16 @@ export class Db { // Intentionally, we do not inherit options from parent for this operation. return await executeOperation( this.client, - new RunCommandOperation(this, command, { - ...resolveBSONOptions(options), - timeoutMS: options?.timeoutMS ?? this.timeoutMS, - session: options?.session, - readPreference: options?.readPreference - }) + new RunCommandOperation( + this, + command, + resolveOptions(undefined, { + ...resolveBSONOptions(options), + timeoutMS: options?.timeoutMS ?? this.timeoutMS, + session: options?.session, + readPreference: options?.readPreference + }) + ) ); } @@ -385,7 +389,11 @@ export class Db { new RenameOperation( this.collection(fromCollection) as TODO_NODE_3286, toCollection, - { ...options, new_collection: true, readPreference: ReadPreference.primary } + resolveOptions(undefined, { + ...options, + new_collection: true, + readPreference: ReadPreference.primary + }) ) as TODO_NODE_3286 ); } diff --git a/src/error.ts b/src/error.ts index 3f47e07d66..3f803a8c4a 100644 --- a/src/error.ts +++ b/src/error.ts @@ -124,6 +124,9 @@ function isAggregateError(e: unknown): e is Error & { errors: Error[] } { * mongodb-client-encryption has a dependency on this error, it uses the constructor with a string argument */ export class MongoError extends Error { + get [Symbol.toStringTag]() { + return this.name; + } /** @internal */ [kErrorLabels]: Set; /** diff --git a/src/operations/execute_operation.ts b/src/operations/execute_operation.ts index c9135fa1c3..f9d9f9b63b 100644 --- a/src/operations/execute_operation.ts +++ b/src/operations/execute_operation.ts @@ -58,7 +58,7 @@ type ResultTypeFromOperation = export async function executeOperation< T extends AbstractOperation, TResult = ResultTypeFromOperation ->(client: MongoClient, operation: T, timeoutContext?: TimeoutContext): Promise { +>(client: MongoClient, operation: T, timeoutContext?: TimeoutContext | null): Promise { if (!(operation instanceof AbstractOperation)) { // TODO(NODE-3483): Extend MongoRuntimeError throw new MongoRuntimeError('This method requires a valid operation instance'); @@ -81,11 +81,6 @@ export async function executeOperation< } else if (session.client !== client) { throw new MongoInvalidArgumentError('ClientSession must be from the same MongoClient'); } - if (session.explicit && session?.timeoutMS != null && operation.options.timeoutMS != null) { - throw new MongoInvalidArgumentError( - 'Do not specify timeoutMS on operation if already specified on an explicit session' - ); - } const readPreference = operation.readPreference ?? ReadPreference.primary; const inTransaction = !!session?.inTransaction(); @@ -107,6 +102,7 @@ export async function executeOperation< } timeoutContext ??= TimeoutContext.create({ + session, serverSelectionTimeoutMS: client.s.options.serverSelectionTimeoutMS, waitQueueTimeoutMS: client.s.options.waitQueueTimeoutMS, timeoutMS: operation.options.timeoutMS diff --git a/src/sessions.ts b/src/sessions.ts index bad966ed71..bbd1785275 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -29,6 +29,7 @@ import { ReadConcernLevel } from './read_concern'; import { ReadPreference } from './read_preference'; import { type AsyncDisposable, configureResourceManagement } from './resource_management'; import { _advanceClusterTime, type ClusterTime, TopologyType } from './sdam/common'; +import { TimeoutContext } from './timeout'; import { isTransactionCommand, Transaction, @@ -58,8 +59,11 @@ export interface ClientSessionOptions { snapshot?: boolean; /** The default TransactionOptions to use for transactions started on this session. */ defaultTransactionOptions?: TransactionOptions; - /** @internal - * The value of timeoutMS used for CSOT. Used to override client timeoutMS */ + /** + * @public + * An overriding timeoutMS value to use for a client-side timeout. + * If not provided the session uses the timeoutMS specified on the MongoClient. + */ defaultTimeoutMS?: number; /** @internal */ @@ -98,6 +102,9 @@ export interface EndSessionOptions { error?: AnyError; force?: boolean; forceClear?: boolean; + + /** @internal */ + timeoutMS?: number; } /** @@ -115,7 +122,7 @@ export class ClientSession /** @internal */ sessionPool: ServerSessionPool; hasEnded: boolean; - clientOptions?: MongoOptions; + clientOptions: MongoOptions; supports: { causalConsistency: boolean }; clusterTime?: ClusterTime; operationTime?: Timestamp; @@ -137,6 +144,9 @@ export class ClientSession /** @internal */ timeoutMS?: number; + /** @internal */ + public timeoutContext: TimeoutContext | null = null; + /** * Create a client session. * @internal @@ -149,7 +159,7 @@ export class ClientSession client: MongoClient, sessionPool: ServerSessionPool, options: ClientSessionOptions, - clientOptions?: MongoOptions + clientOptions: MongoOptions ) { super(); @@ -269,8 +279,13 @@ export class ClientSession async endSession(options?: EndSessionOptions): Promise { try { if (this.inTransaction()) { - await this.abortTransaction(); + await this.abortTransaction({ ...options, throwTimeout: true }); } + } catch (error) { + // spec indicates that we should ignore all errors for `endSessions` + if (error.name === 'MongoOperationTimeoutError') throw error; + squashError(error); + } finally { if (!this.hasEnded) { const serverSession = this[kServerSession]; if (serverSession != null) { @@ -286,10 +301,6 @@ export class ClientSession this.hasEnded = true; this.emit('ended', this); } - } catch (error) { - // spec indicates that we should ignore all errors for `endSessions` - squashError(error); - } finally { maybeClearPinnedConnection(this, { force: true, ...options }); } } @@ -441,8 +452,10 @@ export class ClientSession /** * Commits the currently active transaction in this session. + * + * @param options - Optional options, can be used to override `defaultTimeoutMS`. */ - async commitTransaction(): Promise { + async commitTransaction(options?: { timeoutMS?: number }): Promise { if (this.transaction.state === TxnState.NO_TRANSACTION) { throw new MongoTransactionError('No transaction started'); } @@ -492,8 +505,25 @@ export class ClientSession bypassPinningCheck: true }); + const timeoutMS = + typeof options?.timeoutMS === 'number' + ? options.timeoutMS + : typeof this.timeoutMS === 'number' + ? this.timeoutMS + : null; + + const timeoutContext = + this.timeoutContext ?? + (typeof timeoutMS === 'number' + ? TimeoutContext.create({ + serverSelectionTimeoutMS: this.clientOptions.serverSelectionTimeoutMS, + socketTimeoutMS: this.clientOptions.socketTimeoutMS, + timeoutMS + }) + : null); + try { - await executeOperation(this.client, operation); + await executeOperation(this.client, operation, timeoutContext); return; } catch (firstCommitError) { if (firstCommitError instanceof MongoError && isRetryableWriteError(firstCommitError)) { @@ -503,7 +533,7 @@ export class ClientSession this.unpin({ force: true }); try { - await executeOperation(this.client, operation); + await executeOperation(this.client, operation, timeoutContext); return; } catch (retryCommitError) { // If the retry failed, we process that error instead of the original @@ -535,8 +565,13 @@ export class ClientSession /** * Aborts the currently active transaction in this session. + * + * @param options - Optional options, can be used to override `defaultTimeoutMS`. */ - async abortTransaction(): Promise { + async abortTransaction(options?: { timeoutMS?: number }): Promise; + /** @internal */ + async abortTransaction(options?: { timeoutMS?: number; throwTimeout?: true }): Promise; + async abortTransaction(options?: { timeoutMS?: number; throwTimeout?: true }): Promise { if (this.transaction.state === TxnState.NO_TRANSACTION) { throw new MongoTransactionError('No transaction started'); } @@ -581,18 +616,45 @@ export class ClientSession bypassPinningCheck: true }); + const timeoutMS = + typeof options?.timeoutMS === 'number' + ? options.timeoutMS + : this.timeoutContext?.csotEnabled() + ? this.timeoutContext.timeoutMS // refresh timeoutMS for abort operation + : typeof this.timeoutMS === 'number' + ? this.timeoutMS + : null; + + const timeoutContext = + timeoutMS != null + ? TimeoutContext.create({ + timeoutMS, + serverSelectionTimeoutMS: this.clientOptions.serverSelectionTimeoutMS, + socketTimeoutMS: this.clientOptions.socketTimeoutMS + }) + : null; + try { - await executeOperation(this.client, operation); + await executeOperation(this.client, operation, timeoutContext); this.unpin(); return; } catch (firstAbortError) { this.unpin(); + if (firstAbortError.name === 'MongoRuntimeError') throw firstAbortError; + if (options?.throwTimeout && firstAbortError.name === 'MongoOperationTimeoutError') { + throw firstAbortError; + } + if (firstAbortError instanceof MongoError && isRetryableWriteError(firstAbortError)) { try { - await executeOperation(this.client, operation); + await executeOperation(this.client, operation, timeoutContext); return; - } catch { + } catch (secondAbortError) { + if (secondAbortError.name === 'MongoRuntimeError') throw secondAbortError; + if (options?.throwTimeout && secondAbortError.name === 'MongoOperationTimeoutError') { + throw secondAbortError; + } // we do not retry the retry } } @@ -647,96 +709,119 @@ export class ClientSession */ async withTransaction( fn: WithTransactionCallback, - options?: TransactionOptions + options?: TransactionOptions & { + /** + * Configures a timeoutMS expiry for the entire withTransactionCallback. + * + * @remarks + * - The remaining timeout will not be applied to callback operations that do not use the ClientSession. + * - Overriding timeoutMS for operations executed using the explicit session inside the provided callback will result in a client-side error. + */ + timeoutMS?: number; + } ): Promise { const MAX_TIMEOUT = 120000; - const startTime = now(); - - let committed = false; - let result: any; - while (!committed) { - this.startTransaction(options); // may throw on error + const timeoutMS = options?.timeoutMS ?? this.timeoutMS ?? null; + this.timeoutContext = + timeoutMS != null + ? TimeoutContext.create({ + timeoutMS, + serverSelectionTimeoutMS: this.clientOptions.serverSelectionTimeoutMS, + socketTimeoutMS: this.clientOptions.socketTimeoutMS + }) + : null; - try { - const promise = fn(this); - if (!isPromiseLike(promise)) { - throw new MongoInvalidArgumentError( - 'Function provided to `withTransaction` must return a Promise' - ); - } + const startTime = this.timeoutContext?.csotEnabled() ? this.timeoutContext.start : now(); - result = await promise; + let committed = false; + let result: any; - if ( - this.transaction.state === TxnState.NO_TRANSACTION || - this.transaction.state === TxnState.TRANSACTION_COMMITTED || - this.transaction.state === TxnState.TRANSACTION_ABORTED - ) { - // Assume callback intentionally ended the transaction - return result; - } - } catch (fnError) { - if (!(fnError instanceof MongoError) || fnError instanceof MongoInvalidArgumentError) { - await this.abortTransaction(); - throw fnError; - } + try { + while (!committed) { + this.startTransaction(options); // may throw on error - if ( - this.transaction.state === TxnState.STARTING_TRANSACTION || - this.transaction.state === TxnState.TRANSACTION_IN_PROGRESS - ) { - await this.abortTransaction(); - } + try { + const promise = fn(this); + if (!isPromiseLike(promise)) { + throw new MongoInvalidArgumentError( + 'Function provided to `withTransaction` must return a Promise' + ); + } - if ( - fnError.hasErrorLabel(MongoErrorLabel.TransientTransactionError) && - now() - startTime < MAX_TIMEOUT - ) { - continue; - } + result = await promise; - throw fnError; - } + if ( + this.transaction.state === TxnState.NO_TRANSACTION || + this.transaction.state === TxnState.TRANSACTION_COMMITTED || + this.transaction.state === TxnState.TRANSACTION_ABORTED + ) { + // Assume callback intentionally ended the transaction + return result; + } + } catch (fnError) { + if (!(fnError instanceof MongoError) || fnError instanceof MongoInvalidArgumentError) { + await this.abortTransaction(); + throw fnError; + } - while (!committed) { - try { - /* - * We will rely on ClientSession.commitTransaction() to - * apply a majority write concern if commitTransaction is - * being retried (see: DRIVERS-601) - */ - await this.commitTransaction(); - committed = true; - } catch (commitError) { - /* - * Note: a maxTimeMS error will have the MaxTimeMSExpired - * code (50) and can be reported as a top-level error or - * inside writeConcernError, ex. - * { ok:0, code: 50, codeName: 'MaxTimeMSExpired' } - * { ok:1, writeConcernError: { code: 50, codeName: 'MaxTimeMSExpired' } } - */ if ( - !isMaxTimeMSExpiredError(commitError) && - commitError.hasErrorLabel(MongoErrorLabel.UnknownTransactionCommitResult) && - now() - startTime < MAX_TIMEOUT + this.transaction.state === TxnState.STARTING_TRANSACTION || + this.transaction.state === TxnState.TRANSACTION_IN_PROGRESS ) { - continue; + await this.abortTransaction(); } if ( - commitError.hasErrorLabel(MongoErrorLabel.TransientTransactionError) && - now() - startTime < MAX_TIMEOUT + fnError.hasErrorLabel(MongoErrorLabel.TransientTransactionError) && + (this.timeoutContext != null || now() - startTime < MAX_TIMEOUT) ) { - break; + continue; } - throw commitError; + throw fnError; + } + + while (!committed) { + try { + /* + * We will rely on ClientSession.commitTransaction() to + * apply a majority write concern if commitTransaction is + * being retried (see: DRIVERS-601) + */ + await this.commitTransaction(); + committed = true; + } catch (commitError) { + /* + * Note: a maxTimeMS error will have the MaxTimeMSExpired + * code (50) and can be reported as a top-level error or + * inside writeConcernError, ex. + * { ok:0, code: 50, codeName: 'MaxTimeMSExpired' } + * { ok:1, writeConcernError: { code: 50, codeName: 'MaxTimeMSExpired' } } + */ + if ( + !isMaxTimeMSExpiredError(commitError) && + commitError.hasErrorLabel(MongoErrorLabel.UnknownTransactionCommitResult) && + (this.timeoutContext != null || now() - startTime < MAX_TIMEOUT) + ) { + continue; + } + + if ( + commitError.hasErrorLabel(MongoErrorLabel.TransientTransactionError) && + (this.timeoutContext != null || now() - startTime < MAX_TIMEOUT) + ) { + break; + } + + throw commitError; + } } } + return result; + } finally { + this.timeoutContext = null; } - - return result; } } diff --git a/src/timeout.ts b/src/timeout.ts index 297a484b4e..f057bdb90b 100644 --- a/src/timeout.ts +++ b/src/timeout.ts @@ -1,16 +1,19 @@ import { clearTimeout, setTimeout } from 'timers'; import { MongoInvalidArgumentError, MongoOperationTimeoutError, MongoRuntimeError } from './error'; +import { type ClientSession } from './sessions'; import { csotMin, noop } from './utils'; /** @internal */ export class TimeoutError extends Error { + duration: number; override get name(): 'TimeoutError' { return 'TimeoutError'; } - constructor(message: string, options?: { cause?: Error }) { + constructor(message: string, options: { cause?: Error; duration: number }) { super(message, options); + this.duration = options.duration; } static is(error: unknown): error is TimeoutError { @@ -52,12 +55,19 @@ export class Timeout extends Promise { } /** Create a new timeout that expires in `duration` ms */ - private constructor(executor: Executor = () => null, duration: number, unref = true) { - let reject!: Reject; + private constructor( + executor: Executor = () => null, + options?: { duration: number; unref?: true; rejection?: Error } + ) { + const duration = options?.duration ?? 0; + const unref = !!options?.unref; + const rejection = options?.rejection; + if (duration < 0) { throw new MongoInvalidArgumentError('Cannot create a Timeout with a negative duration'); } + let reject!: Reject; super((_, promiseReject) => { reject = promiseReject; @@ -67,16 +77,20 @@ export class Timeout extends Promise { this.duration = duration; this.start = Math.trunc(performance.now()); - if (this.duration > 0) { + if (rejection == null && this.duration > 0) { this.id = setTimeout(() => { this.ended = Math.trunc(performance.now()); this.timedOut = true; - reject(new TimeoutError(`Expired after ${duration}ms`)); + reject(new TimeoutError(`Expired after ${duration}ms`, { duration })); }, this.duration); if (typeof this.id.unref === 'function' && unref) { // Ensure we do not keep the Node.js event loop running this.id.unref(); } + } else if (rejection != null) { + this.ended = Math.trunc(performance.now()); + this.timedOut = true; + reject(rejection); } } @@ -90,11 +104,11 @@ export class Timeout extends Promise { } throwIfExpired(): void { - if (this.timedOut) throw new TimeoutError('Timed out'); + if (this.timedOut) throw new TimeoutError('Timed out', { duration: this.duration }); } - public static expires(durationMS: number, unref?: boolean): Timeout { - return new Timeout(undefined, durationMS, unref); + public static expires(duration: number, unref?: true): Timeout { + return new Timeout(undefined, { duration, unref }); } static is(timeout: unknown): timeout is Timeout { @@ -107,10 +121,16 @@ export class Timeout extends Promise { typeof timeout.then === 'function' ); } + + static override reject(rejection?: Error): Timeout { + return new Timeout(undefined, { duration: 0, unref: true, rejection }); + } } /** @internal */ -export type TimeoutContextOptions = LegacyTimeoutContextOptions | CSOTTimeoutContextOptions; +export type TimeoutContextOptions = (LegacyTimeoutContextOptions | CSOTTimeoutContextOptions) & { + session?: ClientSession; +}; /** @internal */ export type LegacyTimeoutContextOptions = { @@ -151,6 +171,7 @@ function isCSOTTimeoutContextOptions(v: unknown): v is CSOTTimeoutContextOptions /** @internal */ export abstract class TimeoutContext { static create(options: TimeoutContextOptions): TimeoutContext { + if (options.session?.timeoutContext != null) return options.session?.timeoutContext; if (isCSOTTimeoutContextOptions(options)) return new CSOTTimeoutContext(options); else if (isLegacyTimeoutContextOptions(options)) return new LegacyTimeoutContext(options); else throw new MongoRuntimeError('Unrecognized options'); @@ -183,7 +204,7 @@ export class CSOTTimeoutContext extends TimeoutContext { private _serverSelectionTimeout?: Timeout | null; private _connectionCheckoutTimeout?: Timeout | null; public minRoundTripTime = 0; - private start: number; + public start: number; constructor(options: CSOTTimeoutContextOptions) { super(); @@ -217,8 +238,8 @@ export class CSOTTimeoutContext extends TimeoutContext { if (typeof this._serverSelectionTimeout !== 'object' || this._serverSelectionTimeout?.cleared) { const { remainingTimeMS, serverSelectionTimeoutMS } = this; if (remainingTimeMS <= 0) - throw new MongoOperationTimeoutError( - `Timed out in server selection after ${this.timeoutMS}ms` + return Timeout.reject( + new MongoOperationTimeoutError(`Timed out in server selection after ${this.timeoutMS}ms`) ); const usingServerSelectionTimeoutMS = serverSelectionTimeoutMS !== 0 && @@ -258,14 +279,14 @@ export class CSOTTimeoutContext extends TimeoutContext { const { remainingTimeMS } = this; if (!Number.isFinite(remainingTimeMS)) return null; if (remainingTimeMS > 0) return Timeout.expires(remainingTimeMS); - throw new MongoOperationTimeoutError('Timed out before socket write'); + return Timeout.reject(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'); + return Timeout.reject(new MongoOperationTimeoutError('Timed out before socket read')); } } diff --git a/src/transactions.ts b/src/transactions.ts index 53dcb84208..db251c82c1 100644 --- a/src/transactions.ts +++ b/src/transactions.ts @@ -60,7 +60,7 @@ const COMMITTED_STATES: Set = new Set([ * Configuration options for a transaction. * @public */ -export interface TransactionOptions extends CommandOperationOptions { +export interface TransactionOptions extends Omit { // TODO(NODE-3344): These options use the proper class forms of these settings, it should accept the basic enum values too /** A default read concern for commands in this transaction */ readConcern?: ReadConcernLike; @@ -68,7 +68,10 @@ export interface TransactionOptions extends CommandOperationOptions { writeConcern?: WriteConcern; /** A default read preference for commands in this transaction */ readPreference?: ReadPreferenceLike; - /** Specifies the maximum amount of time to allow a commit action on a transaction to run in milliseconds */ + /** + * Specifies the maximum amount of time to allow a commit action on a transaction to run in milliseconds + * @deprecated This option is deprecated in favor of `timeoutMS` or `defaultTimeoutMS`. + */ maxCommitTimeMS?: number; } diff --git a/src/utils.ts b/src/utils.ts index ebc0784cb1..04174813c9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -517,6 +517,10 @@ export function hasAtomicOperators(doc: Document | Document[]): boolean { /** * Merge inherited properties from parent into options, prioritizing values from options, * then values from parent. + * + * @param parent - An optional owning class of the operation being run. ex. Db/Collection/MongoClient. + * @param options - The options passed to the operation method. + * * @internal */ export function resolveOptions( @@ -544,9 +548,14 @@ export function resolveOptions( result.readPreference = readPreference; } - const timeoutMS = options?.timeoutMS; + const isConvenientTransaction = session?.explicit && session?.timeoutContext != null; + if (isConvenientTransaction && options?.timeoutMS != null) { + throw new MongoInvalidArgumentError( + 'An operation cannot be given a timeoutMS setting when inside a withTransaction call that has a timeoutMS setting' + ); + } - result.timeoutMS = timeoutMS ?? parent?.timeoutMS; + result.timeoutMS = options?.timeoutMS ?? parent?.timeoutMS; return result; } 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 729bed4219..406aa53ed6 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 @@ -1,6 +1,7 @@ /* Specification prose tests */ import { expect } from 'chai'; +import * as semver from 'semver'; import * as sinon from 'sinon'; import { @@ -9,6 +10,7 @@ import { MongoServerSelectionError, now } from '../../mongodb'; +import { type FailPoint } from '../../tools/utils'; // TODO(NODE-5824): Implement CSOT prose tests describe('CSOT spec prose tests', function () { @@ -595,7 +597,10 @@ describe('CSOT spec prose tests', function () { 'TODO(DRIVERS-2347): Requires this ticket to be implemented before we can assert on connection CSOT behaviour'; }); - context.skip('9. endSession', () => { + describe('9. endSession', () => { + const metadata: MongoDBMetadataUI = { + requires: { mongodb: '>=4.4', topology: ['replicaset', 'sharded'] } + }; /** * This test MUST only be run against replica sets and sharded clusters with server version 4.4 or higher. It MUST be * run three times: once with the timeout specified via the MongoClient `timeoutMS` option, once with the timeout @@ -625,12 +630,92 @@ describe('CSOT spec prose tests', function () { * 1. Using `session`, execute `session.end_session` * - Expect this to fail with a timeout error after no more than 15ms. */ + const failpoint: FailPoint = { + configureFailPoint: 'failCommand', + mode: { times: 1 }, + data: { + failCommands: ['abortTransaction'], + blockConnection: true, + blockTimeMS: 200 + } + }; + + beforeEach(async function () { + const internalClient = this.configuration.newClient(); + // End in-progress transactions otherwise "drop" will hang + await internalClient.db('admin').command({ killAllSessions: [] }); + await internalClient + .db('endSession_db') + .collection('endSession_coll') + .drop() + .catch(() => null); + await internalClient.db('endSession_db').createCollection('endSession_coll'); + await internalClient.db('admin').command(failpoint); + await internalClient.close(); + }); + + let client: MongoClient; + + afterEach(async function () { + const internalClient = this.configuration.newClient(); + await internalClient.db('admin').command({ ...failpoint, mode: 'off' }); + await internalClient.close(); + await client?.close(); + }); + + describe('when timeoutMS is provided to the client', () => { + it('throws a timeout error from endSession', metadata, async function () { + client = this.configuration.newClient({ timeoutMS: 150, monitorCommands: true }); + const coll = client.db('endSession_db').collection('endSession_coll'); + const session = client.startSession(); + session.startTransaction(); + await coll.insertOne({ x: 1 }, { session }); + const start = performance.now(); + const error = await session.endSession().catch(error => error); + const end = performance.now(); + expect(end - start).to.be.within(100, 170); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + }); + }); + + describe('when defaultTimeoutMS is provided to startSession', () => { + it('throws a timeout error from endSession', metadata, async function () { + client = this.configuration.newClient(); + const coll = client.db('endSession_db').collection('endSession_coll'); + const session = client.startSession({ defaultTimeoutMS: 150 }); + session.startTransaction(); + await coll.insertOne({ x: 1 }, { session }); + const start = performance.now(); + const error = await session.endSession().catch(error => error); + const end = performance.now(); + expect(end - start).to.be.within(100, 170); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + }); + }); + + describe('when timeoutMS is provided to endSession', () => { + it('throws a timeout error from endSession', metadata, async function () { + client = this.configuration.newClient(); + const coll = client.db('endSession_db').collection('endSession_coll'); + const session = client.startSession(); + session.startTransaction(); + await coll.insertOne({ x: 1 }, { session }); + const start = performance.now(); + const error = await session.endSession({ timeoutMS: 150 }).catch(error => error); + const end = performance.now(); + expect(end - start).to.be.within(100, 170); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + }); + }); }); - context.skip('10. Convenient Transactions', () => { + describe('10. Convenient Transactions', () => { /** Tests in this section MUST only run against replica sets and sharded clusters with server versions 4.4 or higher. */ + const metadata: MongoDBMetadataUI = { + requires: { topology: ['replicaset', 'sharded'], mongodb: '>=4.4' } + }; - context('timeoutMS is refreshed for abortTransaction if the callback fails', () => { + describe('when an operation fails inside withTransaction callback', () => { /** * 1. Using `internalClient`, drop the `db.coll` collection. * 1. Using `internalClient`, set the following fail point: @@ -641,7 +726,7 @@ describe('CSOT spec prose tests', function () { * data: { * failCommands: ["insert", "abortTransaction"], * blockConnection: true, - * blockTimeMS: 15 + * blockTimeMS: 200 * } * } * ``` @@ -658,6 +743,80 @@ describe('CSOT spec prose tests', function () { * 1. `command_started` and `command_failed` events for an `insert` command. * 1. `command_started` and `command_failed` events for an `abortTransaction` command. */ + + const failpoint: FailPoint = { + configureFailPoint: 'failCommand', + mode: { times: 2 }, + data: { + failCommands: ['insert', 'abortTransaction'], + blockConnection: true, + blockTimeMS: 200 + } + }; + + beforeEach(async function () { + if (!semver.satisfies(this.configuration.version, '>=4.4')) { + this.skipReason = 'Requires server version 4.4+'; + this.skip(); + } + const internalClient = this.configuration.newClient(); + await internalClient + .db('db') + .collection('coll') + .drop() + .catch(() => null); + await internalClient.db('admin').command(failpoint); + await internalClient.close(); + }); + + let client: MongoClient; + + afterEach(async function () { + if (semver.satisfies(this.configuration.version, '>=4.4')) { + const internalClient = this.configuration.newClient(); + await internalClient + .db('admin') + .command({ configureFailPoint: 'failCommand', mode: 'off' }); + await internalClient.close(); + } + await client?.close(); + }); + + it('timeoutMS is refreshed for abortTransaction', metadata, async function () { + if ( + this.configuration.topologyType === 'ReplicaSetWithPrimary' && + semver.satisfies(this.configuration.version, '<=4.4') + ) { + this.skipReason = '4.4 replicaset fail point does not blockConnection for requested time'; + this.skip(); + } + + const commandsFailed = []; + const commandsStarted = []; + + client = this.configuration + .newClient({ timeoutMS: 150, monitorCommands: true }) + .on('commandStarted', e => commandsStarted.push(e.commandName)) + .on('commandFailed', e => commandsFailed.push(e.commandName)); + + const coll = client.db('db').collection('coll'); + + const session = client.startSession(); + + const withTransactionError = await session + .withTransaction(async session => { + await coll.insertOne({ x: 1 }, { session }); + }) + .catch(error => error); + + try { + expect(withTransactionError).to.be.instanceOf(MongoOperationTimeoutError); + expect(commandsStarted, 'commands started').to.deep.equal(['insert', 'abortTransaction']); + expect(commandsFailed, 'commands failed').to.deep.equal(['insert', 'abortTransaction']); + } finally { + await session.endSession(); + } + }); }); }); }); 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 e4c9eb3027..a178cecc5d 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 @@ -1,4 +1,5 @@ import { join } from 'path'; +import * as semver from 'semver'; import { loadSpecTests } from '../../spec'; import { runUnifiedSuite } from '../../tools/unified-spec-runner/runner'; @@ -8,7 +9,10 @@ const enabled = [ 'override-database-timeoutMS', 'override-operation-timeoutMS', 'retryability-legacy-timeouts', - 'retryability-timeoutMS' + 'retryability-timeoutMS', + 'sessions-override-operation-timeoutMS', + 'sessions-override-timeoutMS', + 'sessions-inherit-timeoutMS' ]; const cursorOperations = [ @@ -43,5 +47,15 @@ describe('CSOT spec tests', function () { 'TODO(NODE-6274): update test runner to check errorResponse field of MongoBulkWriteError in isTimeoutError assertion'; } } - runUnifiedSuite(specs); + runUnifiedSuite(specs, (test, configuration) => { + const sessionCSOTTests = ['timeoutMS applied to withTransaction']; + if ( + sessionCSOTTests.includes(test.description) && + configuration.topologyType === 'ReplicaSetWithPrimary' && + semver.satisfies(configuration.version, '<=4.4') + ) { + return '4.4 replicaset fail point does not blockConnection for requested time'; + } + return false; + }); }); 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 d7d4a4ede5..cc767c1d80 100644 --- a/test/integration/client-side-operations-timeout/node_csot.test.ts +++ b/test/integration/client-side-operations-timeout/node_csot.test.ts @@ -12,6 +12,7 @@ import { type FindCursor, LEGACY_HELLO_COMMAND, type MongoClient, + MongoInvalidArgumentError, MongoOperationTimeoutError, MongoServerError } from '../../mongodb'; @@ -320,4 +321,153 @@ describe('CSOT driver tests', { requires: { mongodb: '>=4.4' } }, () => { }); }); }); + + describe('when using an explicit session', () => { + const metadata: MongoDBMetadataUI = { + requires: { topology: ['replicaset'], mongodb: '>=4.4' } + }; + + describe('created for a withTransaction callback', () => { + describe('passing a timeoutMS and a session with a timeoutContext', () => { + let client: MongoClient; + + beforeEach(async function () { + client = this.configuration.newClient({ timeoutMS: 123 }); + }); + + afterEach(async function () { + await client.close(); + }); + + it('throws a validation error from the operation', metadata, async () => { + // Drivers MUST raise a validation error if an explicit session with a timeout is used and + // the timeoutMS option is set at the operation level for operations executed as part of a withTransaction callback. + + const coll = client.db('db').collection('coll'); + + const session = client.startSession(); + + let insertError: Error | null = null; + const withTransactionError = await session + .withTransaction(async session => { + insertError = await coll + .insertOne({ x: 1 }, { session, timeoutMS: 1234 }) + .catch(error => error); + throw insertError; + }) + .catch(error => error); + + expect(insertError).to.be.instanceOf(MongoInvalidArgumentError); + expect(withTransactionError).to.be.instanceOf(MongoInvalidArgumentError); + }); + }); + }); + + describe('created manually', () => { + describe('passing a timeoutMS and a session with an inherited timeoutMS', () => { + let client: MongoClient; + + beforeEach(async function () { + client = this.configuration.newClient({ timeoutMS: 123 }); + }); + + afterEach(async function () { + await client.close(); + }); + + it('does not throw a validation error', metadata, async () => { + const coll = client.db('db').collection('coll'); + const session = client.startSession(); + session.startTransaction(); + await coll.insertOne({ x: 1 }, { session, timeoutMS: 1234 }); + await session.abortTransaction(); // this uses the inherited timeoutMS, not the insert + }); + }); + }); + }); + + describe('Convenient Transactions', () => { + /** Tests in this section MUST only run against replica sets and sharded clusters with server versions 4.4 or higher. */ + const metadata: MongoDBMetadataUI = { + requires: { topology: ['replicaset', 'sharded'], mongodb: '>=5.0' } + }; + + describe('when an operation fails inside withTransaction callback', () => { + const failpoint: FailPoint = { + configureFailPoint: 'failCommand', + mode: { times: 2 }, + data: { + failCommands: ['insert', 'abortTransaction'], + blockConnection: true, + blockTimeMS: 600 + } + }; + + beforeEach(async function () { + if (!semver.satisfies(this.configuration.version, '>=4.4')) { + this.skipReason = 'Requires server version 4.4+'; + this.skip(); + } + const internalClient = this.configuration.newClient(); + await internalClient + .db('db') + .collection('coll') + .drop() + .catch(() => null); + await internalClient.db('admin').command(failpoint); + await internalClient.close(); + }); + + let client: MongoClient; + + afterEach(async function () { + if (semver.satisfies(this.configuration.version, '>=4.4')) { + const internalClient = this.configuration.newClient(); + await internalClient + .db('admin') + .command({ configureFailPoint: 'failCommand', mode: 'off' }); + await internalClient.close(); + } + await client?.close(); + }); + + it( + 'timeoutMS is refreshed for abortTransaction and the timeout error is thrown from the operation', + metadata, + async function () { + const commandsFailed = []; + const commandsStarted = []; + + client = this.configuration + .newClient({ timeoutMS: 500, monitorCommands: true }) + .on('commandStarted', e => commandsStarted.push(e.commandName)) + .on('commandFailed', e => commandsFailed.push(e.commandName)); + + const coll = client.db('db').collection('coll'); + + const session = client.startSession(); + + let insertError: Error | null = null; + const withTransactionError = await session + .withTransaction(async session => { + insertError = await coll.insertOne({ x: 1 }, { session }).catch(error => error); + throw insertError; + }) + .catch(error => error); + + try { + expect(insertError).to.be.instanceOf(MongoOperationTimeoutError); + expect(withTransactionError).to.be.instanceOf(MongoOperationTimeoutError); + expect(commandsStarted, 'commands started').to.deep.equal([ + 'insert', + 'abortTransaction' + ]); + expect(commandsFailed, 'commands failed').to.deep.equal(['insert', 'abortTransaction']); + } finally { + await session.endSession(); + } + } + ); + }); + }); }); diff --git a/test/spec/client-side-operations-timeout/sessions-inherit-timeoutMS.json b/test/spec/client-side-operations-timeout/sessions-inherit-timeoutMS.json index abbc321732..13ea91c794 100644 --- a/test/spec/client-side-operations-timeout/sessions-inherit-timeoutMS.json +++ b/test/spec/client-side-operations-timeout/sessions-inherit-timeoutMS.json @@ -21,7 +21,7 @@ "client": { "id": "client", "uriOptions": { - "timeoutMS": 50 + "timeoutMS": 500 }, "useMultipleMongoses": false, "observeEvents": [ @@ -78,7 +78,7 @@ "commitTransaction" ], "blockConnection": true, - "blockTimeMS": 60 + "blockTimeMS": 600 } } } @@ -165,7 +165,7 @@ "abortTransaction" ], "blockConnection": true, - "blockTimeMS": 60 + "blockTimeMS": 600 } } } @@ -249,7 +249,7 @@ "insert" ], "blockConnection": true, - "blockTimeMS": 60 + "blockTimeMS": 600 } } } @@ -302,6 +302,26 @@ "commandFailedEvent": { "commandName": "insert" } + }, + { + "commandStartedEvent": { + "commandName": "abortTransaction", + "databaseName": "admin", + "command": { + "abortTransaction": 1, + "maxTimeMS": { + "$$type": [ + "int", + "long" + ] + } + } + } + }, + { + "commandFailedEvent": { + "commandName": "abortTransaction" + } } ] } diff --git a/test/spec/client-side-operations-timeout/sessions-inherit-timeoutMS.yml b/test/spec/client-side-operations-timeout/sessions-inherit-timeoutMS.yml index 184ef7eb9e..c79384e5f0 100644 --- a/test/spec/client-side-operations-timeout/sessions-inherit-timeoutMS.yml +++ b/test/spec/client-side-operations-timeout/sessions-inherit-timeoutMS.yml @@ -13,7 +13,7 @@ createEntities: - client: id: &client client uriOptions: - timeoutMS: 50 + timeoutMS: 500 useMultipleMongoses: false observeEvents: - commandStartedEvent @@ -52,7 +52,7 @@ tests: data: failCommands: ["commitTransaction"] blockConnection: true - blockTimeMS: 60 + blockTimeMS: 600 - name: startTransaction object: *session - name: insertOne @@ -95,7 +95,7 @@ tests: data: failCommands: ["abortTransaction"] blockConnection: true - blockTimeMS: 60 + blockTimeMS: 600 - name: startTransaction object: *session - name: insertOne @@ -136,7 +136,7 @@ tests: data: failCommands: ["insert"] blockConnection: true - blockTimeMS: 60 + blockTimeMS: 600 - name: withTransaction object: *session arguments: @@ -153,9 +153,6 @@ tests: expectEvents: - client: *client events: - # Because the insert expects an error and gets an error, it technically succeeds, so withTransaction will - # try to run commitTransaction. This will fail client-side, though, because the timeout has already expired, - # so no command is sent. - commandStartedEvent: commandName: insert databaseName: *databaseName @@ -166,3 +163,11 @@ tests: maxTimeMS: { $$type: ["int", "long"] } - commandFailedEvent: commandName: insert + - commandStartedEvent: + commandName: abortTransaction + databaseName: admin + command: + abortTransaction: 1 + maxTimeMS: { $$type: [ "int", "long" ] } + - commandFailedEvent: + commandName: abortTransaction diff --git a/test/spec/client-side-operations-timeout/sessions-override-operation-timeoutMS.json b/test/spec/client-side-operations-timeout/sessions-override-operation-timeoutMS.json index 0254b184a1..441c698328 100644 --- a/test/spec/client-side-operations-timeout/sessions-override-operation-timeoutMS.json +++ b/test/spec/client-side-operations-timeout/sessions-override-operation-timeoutMS.json @@ -75,7 +75,7 @@ "commitTransaction" ], "blockConnection": true, - "blockTimeMS": 60 + "blockTimeMS": 600 } } } @@ -98,7 +98,7 @@ "name": "commitTransaction", "object": "session", "arguments": { - "timeoutMS": 50 + "timeoutMS": 500 }, "expectError": { "isTimeoutError": true @@ -165,7 +165,7 @@ "abortTransaction" ], "blockConnection": true, - "blockTimeMS": 60 + "blockTimeMS": 600 } } } @@ -188,7 +188,7 @@ "name": "abortTransaction", "object": "session", "arguments": { - "timeoutMS": 50 + "timeoutMS": 500 } } ], @@ -252,7 +252,7 @@ "insert" ], "blockConnection": true, - "blockTimeMS": 60 + "blockTimeMS": 600 } } } @@ -261,7 +261,7 @@ "name": "withTransaction", "object": "session", "arguments": { - "timeoutMS": 50, + "timeoutMS": 500, "callback": [ { "name": "insertOne", @@ -306,6 +306,26 @@ "commandFailedEvent": { "commandName": "insert" } + }, + { + "commandStartedEvent": { + "commandName": "abortTransaction", + "databaseName": "admin", + "command": { + "abortTransaction": 1, + "maxTimeMS": { + "$$type": [ + "int", + "long" + ] + } + } + } + }, + { + "commandFailedEvent": { + "commandName": "abortTransaction" + } } ] } diff --git a/test/spec/client-side-operations-timeout/sessions-override-operation-timeoutMS.yml b/test/spec/client-side-operations-timeout/sessions-override-operation-timeoutMS.yml index 8a80a65720..bee91dc4cb 100644 --- a/test/spec/client-side-operations-timeout/sessions-override-operation-timeoutMS.yml +++ b/test/spec/client-side-operations-timeout/sessions-override-operation-timeoutMS.yml @@ -50,7 +50,7 @@ tests: data: failCommands: ["commitTransaction"] blockConnection: true - blockTimeMS: 60 + blockTimeMS: 600 - name: startTransaction object: *session - name: insertOne @@ -61,7 +61,7 @@ tests: - name: commitTransaction object: *session arguments: - timeoutMS: 50 + timeoutMS: 500 expectError: isTimeoutError: true expectEvents: @@ -95,7 +95,7 @@ tests: data: failCommands: ["abortTransaction"] blockConnection: true - blockTimeMS: 60 + blockTimeMS: 600 - name: startTransaction object: *session - name: insertOne @@ -106,7 +106,7 @@ tests: - name: abortTransaction object: *session arguments: - timeoutMS: 50 + timeoutMS: 500 expectEvents: - client: *client events: @@ -138,11 +138,11 @@ tests: data: failCommands: ["insert"] blockConnection: true - blockTimeMS: 60 + blockTimeMS: 600 - name: withTransaction object: *session arguments: - timeoutMS: 50 + timeoutMS: 500 callback: - name: insertOne object: *collection @@ -156,9 +156,6 @@ tests: expectEvents: - client: *client events: - # Because the insert expects an error and gets an error, it technically succeeds, so withTransaction will - # try to run commitTransaction. This will fail client-side, though, because the timeout has already expired, - # so no command is sent. - commandStartedEvent: commandName: insert databaseName: *databaseName @@ -169,3 +166,11 @@ tests: maxTimeMS: { $$type: ["int", "long"] } - commandFailedEvent: commandName: insert + - commandStartedEvent: + commandName: abortTransaction + databaseName: admin + command: + abortTransaction: 1 + maxTimeMS: { $$type: ["int", "long"] } + - commandFailedEvent: + commandName: abortTransaction diff --git a/test/spec/client-side-operations-timeout/sessions-override-timeoutMS.json b/test/spec/client-side-operations-timeout/sessions-override-timeoutMS.json index c46ae4dd50..d90152e909 100644 --- a/test/spec/client-side-operations-timeout/sessions-override-timeoutMS.json +++ b/test/spec/client-side-operations-timeout/sessions-override-timeoutMS.json @@ -47,7 +47,7 @@ "id": "session", "client": "client", "sessionOptions": { - "defaultTimeoutMS": 50 + "defaultTimeoutMS": 500 } } } @@ -78,7 +78,7 @@ "commitTransaction" ], "blockConnection": true, - "blockTimeMS": 60 + "blockTimeMS": 600 } } } @@ -165,7 +165,7 @@ "abortTransaction" ], "blockConnection": true, - "blockTimeMS": 60 + "blockTimeMS": 600 } } } @@ -249,7 +249,7 @@ "insert" ], "blockConnection": true, - "blockTimeMS": 60 + "blockTimeMS": 600 } } } @@ -302,6 +302,26 @@ "commandFailedEvent": { "commandName": "insert" } + }, + { + "commandStartedEvent": { + "commandName": "abortTransaction", + "databaseName": "admin", + "command": { + "abortTransaction": 1, + "maxTimeMS": { + "$$type": [ + "int", + "long" + ] + } + } + } + }, + { + "commandFailedEvent": { + "commandName": "abortTransaction" + } } ] } diff --git a/test/spec/client-side-operations-timeout/sessions-override-timeoutMS.yml b/test/spec/client-side-operations-timeout/sessions-override-timeoutMS.yml index 61aaab4d97..73aaf9ff2a 100644 --- a/test/spec/client-side-operations-timeout/sessions-override-timeoutMS.yml +++ b/test/spec/client-side-operations-timeout/sessions-override-timeoutMS.yml @@ -29,7 +29,7 @@ createEntities: id: &session session client: *client sessionOptions: - defaultTimeoutMS: 50 + defaultTimeoutMS: 500 initialData: - collectionName: *collectionName @@ -52,7 +52,7 @@ tests: data: failCommands: ["commitTransaction"] blockConnection: true - blockTimeMS: 60 + blockTimeMS: 600 - name: startTransaction object: *session - name: insertOne @@ -95,7 +95,7 @@ tests: data: failCommands: ["abortTransaction"] blockConnection: true - blockTimeMS: 60 + blockTimeMS: 600 - name: startTransaction object: *session - name: insertOne @@ -136,7 +136,7 @@ tests: data: failCommands: ["insert"] blockConnection: true - blockTimeMS: 60 + blockTimeMS: 600 - name: withTransaction object: *session arguments: @@ -153,9 +153,6 @@ tests: expectEvents: - client: *client events: - # Because the insert expects an error and gets an error, it technically succeeds, so withTransaction will - # try to run commitTransaction. This will fail client-side, though, because the timeout has already expired, - # so no command is sent. - commandStartedEvent: commandName: insert databaseName: *databaseName @@ -166,3 +163,11 @@ tests: maxTimeMS: { $$type: ["int", "long"] } - commandFailedEvent: commandName: insert + - commandStartedEvent: + commandName: abortTransaction + databaseName: admin + command: + abortTransaction: 1 + maxTimeMS: { $$type: [ "int", "long" ] } + - commandFailedEvent: + commandName: abortTransaction diff --git a/test/tools/unified-spec-runner/entities.ts b/test/tools/unified-spec-runner/entities.ts index 9f4e20a828..7f90e275dc 100644 --- a/test/tools/unified-spec-runner/entities.ts +++ b/test/tools/unified-spec-runner/entities.ts @@ -619,6 +619,10 @@ export class EntitiesMap extends Map { const options = Object.create(null); + if (entity.session.sessionOptions?.defaultTimeoutMS != null) { + options.defaultTimeoutMS = entity.session.sessionOptions?.defaultTimeoutMS; + } + if (entity.session.sessionOptions?.causalConsistency) { options.causalConsistency = entity.session.sessionOptions?.causalConsistency; } diff --git a/test/tools/unified-spec-runner/match.ts b/test/tools/unified-spec-runner/match.ts index 90996b9640..35c274dfbe 100644 --- a/test/tools/unified-spec-runner/match.ts +++ b/test/tools/unified-spec-runner/match.ts @@ -512,6 +512,13 @@ function compareCommandFailedEvents( } } +function expectInstanceOf any>( + instance: any, + ctor: T +): asserts instance is InstanceType { + expect(instance).to.be.instanceOf(ctor); +} + function compareEvents( actual: CommandEvent[] | CmapEvent[] | SdamEvent[], expected: (ExpectedCommandEvent & ExpectedCmapEvent & ExpectedSdamEvent)[], @@ -526,9 +533,7 @@ function compareEvents( if (expectedEvent.commandStartedEvent) { const path = `${rootPrefix}.commandStartedEvent`; - if (!(actualEvent instanceof CommandStartedEvent)) { - expect.fail(`expected ${path} to be instanceof CommandStartedEvent`); - } + expectInstanceOf(actualEvent, CommandStartedEvent); compareCommandStartedEvents(actualEvent, expectedEvent.commandStartedEvent, entities, path); if (expectedEvent.commandStartedEvent.hasServerConnectionId) { expect(actualEvent).property('serverConnectionId').to.be.a('bigint'); @@ -537,9 +542,7 @@ function compareEvents( } } else if (expectedEvent.commandSucceededEvent) { const path = `${rootPrefix}.commandSucceededEvent`; - if (!(actualEvent instanceof CommandSucceededEvent)) { - expect.fail(`expected ${path} to be instanceof CommandSucceededEvent`); - } + expectInstanceOf(actualEvent, CommandSucceededEvent); compareCommandSucceededEvents( actualEvent, expectedEvent.commandSucceededEvent, @@ -553,9 +556,7 @@ function compareEvents( } } else if (expectedEvent.commandFailedEvent) { const path = `${rootPrefix}.commandFailedEvent`; - if (!(actualEvent instanceof CommandFailedEvent)) { - expect.fail(`expected ${path} to be instanceof CommandFailedEvent`); - } + expectInstanceOf(actualEvent, CommandFailedEvent); compareCommandFailedEvents(actualEvent, expectedEvent.commandFailedEvent, entities, path); if (expectedEvent.commandFailedEvent.hasServerConnectionId) { expect(actualEvent).property('serverConnectionId').to.be.a('bigint'); diff --git a/test/tools/unified-spec-runner/operations.ts b/test/tools/unified-spec-runner/operations.ts index 7a98c7ac97..5b5b704069 100644 --- a/test/tools/unified-spec-runner/operations.ts +++ b/test/tools/unified-spec-runner/operations.ts @@ -19,6 +19,7 @@ import { ServerType, type TopologyDescription, type TopologyType, + type TransactionOptions, WriteConcern } from '../../mongodb'; import { sleep } from '../../tools/utils'; @@ -49,11 +50,6 @@ operations.set('createEntities', async ({ entities, operation, testConfig }) => await EntitiesMap.createEntities(testConfig, null, operation.arguments.entities!, entities); }); -operations.set('abortTransaction', async ({ entities, operation }) => { - const session = entities.getEntity('session', operation.object); - return session.abortTransaction(); -}); - operations.set('aggregate', async ({ entities, operation }) => { const dbOrCollection = entities.get(operation.object) as Db | Collection; if (!(dbOrCollection instanceof Db || dbOrCollection instanceof Collection)) { @@ -241,7 +237,12 @@ operations.set('close', async ({ entities, operation }) => { operations.set('commitTransaction', async ({ entities, operation }) => { const session = entities.getEntity('session', operation.object); - return session.commitTransaction(); + return await session.commitTransaction({ timeoutMS: operation.arguments?.timeoutMS }); +}); + +operations.set('abortTransaction', async ({ entities, operation }) => { + const session = entities.getEntity('session', operation.object); + return await session.abortTransaction({ timeoutMS: operation.arguments?.timeoutMS }); }); operations.set('createChangeStream', async ({ entities, operation }) => { @@ -371,7 +372,7 @@ operations.set('insertOne', async ({ entities, operation }) => { // Looping exposes the fact that we can generate _ids for inserted // documents and we don't want the original operation to get modified // and use the same _id for each insert. - return collection.insertOne({ ...document }, opts); + return await collection.insertOne({ ...document }, opts); }); operations.set('insertMany', async ({ entities, operation }) => { @@ -718,13 +719,17 @@ operations.set('waitForThread', async ({ entities, operation }) => { operations.set('withTransaction', async ({ entities, operation, client, testConfig }) => { const session = entities.getEntity('session', operation.object); - const options = { + const options: TransactionOptions = { readConcern: ReadConcern.fromOptions(operation.arguments), writeConcern: WriteConcern.fromOptions(operation.arguments), readPreference: ReadPreference.fromOptions(operation.arguments), - maxCommitTimeMS: operation.arguments!.maxCommitTimeMS + maxCommitTimeMS: operation.arguments?.maxCommitTimeMS }; + if (typeof operation.arguments?.timeoutMS === 'number') { + options.timeoutMS = operation.arguments.timeoutMS; + } + await session.withTransaction(async () => { for (const callbackOperation of operation.arguments!.callback) { await executeOperationAndCheck(callbackOperation, entities, client, testConfig, true); @@ -945,7 +950,7 @@ export async function executeOperationAndCheck( rethrow = false ): Promise { const opFunc = operations.get(operation.name); - expect(opFunc, `Unknown operation: ${operation.name}`).to.exist; + if (opFunc == null) expect.fail(`Unknown operation: ${operation.name}`); if (operation.arguments && operation.arguments.session) { // The session could need to be either pulled from the entity map or in the case where @@ -959,7 +964,7 @@ export async function executeOperationAndCheck( let result; try { - result = await opFunc!({ entities, operation, client, testConfig }); + result = await opFunc({ entities, operation, client, testConfig }); } catch (error) { if (operation.expectError) { expectErrorCheck(error, operation.expectError, entities); From 38affaea63581f00c0fa16c3ae1e8fb9bea28af2 Mon Sep 17 00:00:00 2001 From: Warren James Date: Thu, 12 Sep 2024 11:35:27 -0400 Subject: [PATCH 24/39] feat(NODE-6304): add CSOT support for non-tailable cursors (#4195) --- src/cmap/connection.ts | 4 +- src/cmap/wire_protocol/on_data.ts | 1 - src/collection.ts | 6 +- src/cursor/abstract_cursor.ts | 146 +++- src/cursor/aggregation_cursor.ts | 20 +- src/cursor/change_stream_cursor.ts | 6 +- src/cursor/find_cursor.ts | 2 +- src/cursor/list_collections_cursor.ts | 2 +- src/cursor/list_indexes_cursor.ts | 2 +- src/cursor/run_command_cursor.ts | 14 +- src/index.ts | 2 +- src/operations/aggregate.ts | 4 + src/operations/execute_operation.ts | 3 +- src/operations/find.ts | 4 + src/operations/indexes.ts | 9 +- src/operations/list_collections.ts | 3 + src/operations/operation.ts | 3 + src/operations/run_command.ts | 2 + src/sessions.ts | 12 +- src/timeout.ts | 27 +- ...ient_side_operations_timeout.prose.test.ts | 84 ++- ...lient_side_operations_timeout.spec.test.ts | 83 ++- .../node_csot.test.ts | 335 ++++++++- .../command-execution.json | 153 ++++ .../client-side-operations-timeout/README.md | 661 ++++++++++++++++++ .../change-streams.json | 20 +- .../change-streams.yml | 30 +- .../close-cursors.json | 12 +- .../close-cursors.yml | 12 +- .../command-execution.json | 2 +- .../command-execution.yml | 5 +- .../convenient-transactions.json | 22 +- .../convenient-transactions.yml | 15 +- .../deprecated-options.json | 2 +- .../deprecated-options.yml | 2 +- .../gridfs-advanced.yml | 2 +- .../non-tailable-cursors.json | 20 +- .../non-tailable-cursors.yml | 32 +- .../retryability-timeoutMS.json | 250 +++++++ .../retryability-timeoutMS.yml | 100 +++ .../tailable-awaitData.json | 14 +- .../tailable-awaitData.yml | 18 +- .../tailable-non-awaitData.json | 10 +- .../tailable-non-awaitData.yml | 12 +- test/tools/unified-spec-runner/operations.ts | 7 +- test/unit/cursor/aggregation_cursor.test.ts | 67 +- 46 files changed, 2008 insertions(+), 234 deletions(-) create mode 100644 test/integration/client-side-operations-timeout/unified-csot-node-specs/command-execution.json create mode 100644 test/spec/client-side-operations-timeout/README.md diff --git a/src/cmap/connection.ts b/src/cmap/connection.ts index eddd2a9223..d0b00b40e1 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -86,6 +86,7 @@ export interface CommandOptions extends BSONSerializeOptions { documentsReturnedIn?: string; noResponse?: boolean; omitReadPreference?: boolean; + omitMaxTimeMS?: boolean; // TODO(NODE-2802): Currently the CommandOptions take a property willRetryWrite which is a hint // from executeOperation that the txnNum should be applied to this command. @@ -419,7 +420,7 @@ export class Connection extends TypedEventEmitter { ...options }; - if (options.timeoutContext?.csotEnabled()) { + if (!options.omitMaxTimeMS && options.timeoutContext?.csotEnabled()) { const { maxTimeMS } = options.timeoutContext; if (maxTimeMS > 0 && Number.isFinite(maxTimeMS)) cmd.maxTimeMS = maxTimeMS; } @@ -619,7 +620,6 @@ export class Connection extends TypedEventEmitter { for await (const document of this.sendCommand(ns, command, options, responseType)) { if (options.timeoutContext?.csotEnabled()) { if (MongoDBResponse.is(document)) { - // TODO(NODE-5684): test coverage to be added once cursors are enabling CSOT if (document.isMaxTimeExpiredError) { throw new MongoOperationTimeoutError('Server reported a timeout error', { cause: new MongoServerError(document.toObject()) diff --git a/src/cmap/wire_protocol/on_data.ts b/src/cmap/wire_protocol/on_data.ts index 23fd88e282..64c636f41f 100644 --- a/src/cmap/wire_protocol/on_data.ts +++ b/src/cmap/wire_protocol/on_data.ts @@ -93,7 +93,6 @@ export function onData( const timeoutForSocketRead = timeoutContext?.timeoutForSocketRead; timeoutForSocketRead?.throwIfExpired(); - // eslint-disable-next-line github/no-then timeoutForSocketRead?.then(undefined, errorHandler); return iterator; diff --git a/src/collection.ts b/src/collection.ts index f3a206b0c7..a73a5276f5 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -11,7 +11,7 @@ import { type ListSearchIndexesOptions } from './cursor/list_search_indexes_cursor'; import type { Db } from './db'; -import { MongoInvalidArgumentError } from './error'; +import { MongoInvalidArgumentError, MongoOperationTimeoutError } from './error'; import type { MongoClient, PkFactory } from './mongo_client'; import type { Filter, @@ -678,7 +678,9 @@ export class Collection { new DropIndexOperation(this as TODO_NODE_3286, '*', resolveOptions(this, options)) ); return true; - } catch { + } catch (error) { + if (error instanceof MongoOperationTimeoutError) throw error; // TODO: Check the spec for index management behaviour/file a drivers ticket for this + // Seems like we should throw all errors return false; } } diff --git a/src/cursor/abstract_cursor.ts b/src/cursor/abstract_cursor.ts index 51206b51a2..d0f386923a 100644 --- a/src/cursor/abstract_cursor.ts +++ b/src/cursor/abstract_cursor.ts @@ -21,6 +21,7 @@ import { ReadPreference, type ReadPreferenceLike } from '../read_preference'; import { type AsyncDisposable, configureResourceManagement } from '../resource_management'; import type { Server } from '../sdam/server'; import { ClientSession, maybeClearPinnedConnection } from '../sessions'; +import { TimeoutContext } from '../timeout'; import { type MongoDBNamespace, squashError } from '../utils'; /** @@ -60,6 +61,17 @@ export interface CursorStreamOptions { /** @public */ export type CursorFlag = (typeof CURSOR_FLAGS)[number]; +/** @public*/ +export const CursorTimeoutMode = Object.freeze({ + ITERATION: 'iteration', + LIFETIME: 'cursorLifetime' +} as const); + +/** @public + * TODO(NODE-5688): Document and release + * */ +export type CursorTimeoutMode = (typeof CursorTimeoutMode)[keyof typeof CursorTimeoutMode]; + /** @public */ export interface AbstractCursorOptions extends BSONSerializeOptions { session?: ClientSession; @@ -105,6 +117,8 @@ export interface AbstractCursorOptions extends BSONSerializeOptions { noCursorTimeout?: boolean; /** @internal TODO(NODE-5688): make this public */ timeoutMS?: number; + /** @internal TODO(NODE-5688): make this public */ + timeoutMode?: CursorTimeoutMode; } /** @internal */ @@ -117,6 +131,8 @@ export type InternalAbstractCursorOptions = Omit { - await this.cleanup(); + async close(options?: { timeoutMS?: number }): Promise { + await this.cleanup(options?.timeoutMS); } /** @@ -658,6 +727,8 @@ export abstract class AbstractCursor< this.cursorId = null; this.documents?.clear(); + this.timeoutContext?.clear(); + this.timeoutContext = undefined; this.isClosed = false; this.isKilled = false; this.initialized = false; @@ -707,7 +778,7 @@ export abstract class AbstractCursor< } ); - return await executeOperation(this.cursorClient, getMoreOperation); + return await executeOperation(this.cursorClient, getMoreOperation, this.timeoutContext); } /** @@ -718,6 +789,12 @@ export abstract class AbstractCursor< * a significant refactor. */ private async cursorInit(): Promise { + if (this.cursorOptions.timeoutMS != null) { + this.timeoutContext = TimeoutContext.create({ + serverSelectionTimeoutMS: this.client.options.serverSelectionTimeoutMS, + timeoutMS: this.cursorOptions.timeoutMS + }); + } try { const state = await this._initialize(this.cursorSession); const response = state.response; @@ -729,7 +806,7 @@ export abstract class AbstractCursor< } catch (error) { // the cursor is now initialized, even if an error occurred this.initialized = true; - await this.cleanup(error); + await this.cleanup(undefined, error); throw error; } @@ -763,6 +840,7 @@ export abstract class AbstractCursor< // otherwise need to call getMore const batchSize = this.cursorOptions.batchSize || 1000; + this.cursorOptions.omitMaxTimeMS = this.cursorOptions.timeoutMS != null; try { const response = await this.getMore(batchSize); @@ -770,7 +848,7 @@ export abstract class AbstractCursor< this.documents = response; } catch (error) { try { - await this.cleanup(error); + await this.cleanup(undefined, error); } catch (error) { // `cleanupCursor` should never throw, squash and throw the original error squashError(error); @@ -791,7 +869,7 @@ export abstract class AbstractCursor< } /** @internal */ - private async cleanup(error?: Error) { + private async cleanup(timeoutMS?: number, error?: Error) { this.isClosed = true; const session = this.cursorSession; try { @@ -806,11 +884,23 @@ export abstract class AbstractCursor< this.isKilled = true; const cursorId = this.cursorId; this.cursorId = Long.ZERO; + let timeoutContext: TimeoutContext | undefined; + if (timeoutMS != null) { + this.timeoutContext?.clear(); + timeoutContext = TimeoutContext.create({ + serverSelectionTimeoutMS: this.client.options.serverSelectionTimeoutMS, + timeoutMS + }); + } else { + this.timeoutContext?.refresh(); + timeoutContext = this.timeoutContext; + } await executeOperation( this.cursorClient, new KillCursorsOperation(cursorId, this.cursorNamespace, this.selectedServer, { session - }) + }), + timeoutContext ); } } catch (error) { diff --git a/src/cursor/aggregation_cursor.ts b/src/cursor/aggregation_cursor.ts index 9762c8a03b..056f28454c 100644 --- a/src/cursor/aggregation_cursor.ts +++ b/src/cursor/aggregation_cursor.ts @@ -1,4 +1,5 @@ import type { Document } from '../bson'; +import { MongoAPIError } from '../error'; import type { ExplainCommandOptions, ExplainVerbosityLike } from '../explain'; import type { MongoClient } from '../mongo_client'; import { AggregateOperation, type AggregateOptions } from '../operations/aggregate'; @@ -9,6 +10,7 @@ import { mergeOptions, type MongoDBNamespace } from '../utils'; import { AbstractCursor, type AbstractCursorOptions, + CursorTimeoutMode, type InitialCursorResponse } from './abstract_cursor'; @@ -38,6 +40,15 @@ export class AggregationCursor extends AbstractCursor { this.pipeline = pipeline; this.aggregateOptions = options; + + const lastStage: Document | undefined = this.pipeline[this.pipeline.length - 1]; + + if ( + this.cursorOptions.timeoutMS != null && + this.cursorOptions.timeoutMode === CursorTimeoutMode.ITERATION && + (lastStage?.$merge != null || lastStage?.$out != null) + ) + throw new MongoAPIError('Cannot use $out or $merge stage with ITERATION timeoutMode'); } clone(): AggregationCursor { @@ -60,7 +71,7 @@ export class AggregationCursor extends AbstractCursor { session }); - const response = await executeOperation(this.client, aggregateOperation); + const response = await executeOperation(this.client, aggregateOperation, this.timeoutContext); return { server: aggregateOperation.server, session, response }; } @@ -95,6 +106,13 @@ export class AggregationCursor extends AbstractCursor { addStage(stage: Document): AggregationCursor; addStage(stage: Document): AggregationCursor { this.throwIfInitialized(); + if ( + this.cursorOptions.timeoutMS != null && + this.cursorOptions.timeoutMode === CursorTimeoutMode.ITERATION && + (stage.$out != null || stage.$merge != null) + ) { + throw new MongoAPIError('Cannot use $out or $merge stage with ITERATION timeoutMode'); + } this.pipeline.push(stage); return this as unknown as AggregationCursor; } diff --git a/src/cursor/change_stream_cursor.ts b/src/cursor/change_stream_cursor.ts index b42ce3e130..13f5867555 100644 --- a/src/cursor/change_stream_cursor.ts +++ b/src/cursor/change_stream_cursor.ts @@ -133,7 +133,11 @@ export class ChangeStreamCursor< session }); - const response = await executeOperation(session.client, aggregateOperation); + const response = await executeOperation( + session.client, + aggregateOperation, + this.timeoutContext + ); const server = aggregateOperation.server; this.maxWireVersion = maxWireVersion(server); diff --git a/src/cursor/find_cursor.ts b/src/cursor/find_cursor.ts index 83a12818bd..96b764dc7f 100644 --- a/src/cursor/find_cursor.ts +++ b/src/cursor/find_cursor.ts @@ -69,7 +69,7 @@ export class FindCursor extends AbstractCursor { session }); - const response = await executeOperation(this.client, findOperation); + const response = await executeOperation(this.client, findOperation, this.timeoutContext); // the response is not a cursor when `explain` is enabled this.numReturned = response.batchSize; diff --git a/src/cursor/list_collections_cursor.ts b/src/cursor/list_collections_cursor.ts index a529709556..9b69de1b93 100644 --- a/src/cursor/list_collections_cursor.ts +++ b/src/cursor/list_collections_cursor.ts @@ -41,7 +41,7 @@ export class ListCollectionsCursor< session }); - const response = await executeOperation(this.parent.client, operation); + const response = await executeOperation(this.parent.client, operation, this.timeoutContext); return { server: operation.server, session, response }; } diff --git a/src/cursor/list_indexes_cursor.ts b/src/cursor/list_indexes_cursor.ts index 799ddf5bdb..0f768f3b69 100644 --- a/src/cursor/list_indexes_cursor.ts +++ b/src/cursor/list_indexes_cursor.ts @@ -30,7 +30,7 @@ export class ListIndexesCursor extends AbstractCursor { session }); - const response = await executeOperation(this.parent.client, operation); + const response = await executeOperation(this.parent.client, operation, this.timeoutContext); return { server: operation.server, session, response }; } diff --git a/src/cursor/run_command_cursor.ts b/src/cursor/run_command_cursor.ts index 78b9826b9b..6b31ce2263 100644 --- a/src/cursor/run_command_cursor.ts +++ b/src/cursor/run_command_cursor.ts @@ -9,12 +9,20 @@ import type { ReadConcernLike } from '../read_concern'; import type { ReadPreferenceLike } from '../read_preference'; import type { ClientSession } from '../sessions'; import { ns } from '../utils'; -import { AbstractCursor, type InitialCursorResponse } from './abstract_cursor'; +import { + AbstractCursor, + type CursorTimeoutMode, + type InitialCursorResponse +} from './abstract_cursor'; /** @public */ export type RunCursorCommandOptions = { readPreference?: ReadPreferenceLike; session?: ClientSession; + /** @internal */ + timeoutMS?: number; + /** @internal */ + timeoutMode?: CursorTimeoutMode; } & BSONSerializeOptions; /** @public */ @@ -105,7 +113,7 @@ export class RunCommandCursor extends AbstractCursor { responseType: CursorResponse }); - const response = await executeOperation(this.client, operation); + const response = await executeOperation(this.client, operation, this.timeoutContext); return { server: operation.server, @@ -123,6 +131,6 @@ export class RunCommandCursor extends AbstractCursor { ...this.getMoreOptions }); - return await executeOperation(this.client, getMoreOperation); + return await executeOperation(this.client, getMoreOperation, this.timeoutContext); } } diff --git a/src/index.ts b/src/index.ts index 6ddcc887bc..822db1ada8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -107,7 +107,7 @@ export { AutoEncryptionLoggerLevel } from './client-side-encryption/auto_encrypt export { GSSAPICanonicalizationValue } from './cmap/auth/gssapi'; export { AuthMechanism } from './cmap/auth/providers'; export { Compressor } from './cmap/wire_protocol/compression'; -export { CURSOR_FLAGS } from './cursor/abstract_cursor'; +export { CURSOR_FLAGS, type CursorTimeoutMode } from './cursor/abstract_cursor'; export { MongoErrorLabel } from './error'; export { ExplainVerbosity } from './explain'; export { ServerApiVersion } from './mongo_client'; diff --git a/src/operations/aggregate.ts b/src/operations/aggregate.ts index 50494cbba7..096fe37271 100644 --- a/src/operations/aggregate.ts +++ b/src/operations/aggregate.ts @@ -1,5 +1,6 @@ import type { Document } from '../bson'; import { CursorResponse, ExplainedCursorResponse } from '../cmap/wire_protocol/responses'; +import { type CursorTimeoutMode } from '../cursor/abstract_cursor'; import { MongoInvalidArgumentError } from '../error'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; @@ -36,6 +37,9 @@ export interface AggregateOptions extends CommandOperationOptions { let?: Document; out?: string; + + /** @internal */ + timeoutMode?: CursorTimeoutMode; } /** @internal */ diff --git a/src/operations/execute_operation.ts b/src/operations/execute_operation.ts index f9d9f9b63b..dd9ba06c51 100644 --- a/src/operations/execute_operation.ts +++ b/src/operations/execute_operation.ts @@ -280,8 +280,7 @@ async function tryOperation< previousOperationError = operationError; // Reset timeouts - timeoutContext.serverSelectionTimeout?.clear(); - timeoutContext.connectionCheckoutTimeout?.clear(); + timeoutContext.clear(); } } diff --git a/src/operations/find.ts b/src/operations/find.ts index 5f359324d5..c39695cc0b 100644 --- a/src/operations/find.ts +++ b/src/operations/find.ts @@ -1,5 +1,6 @@ import type { Document } from '../bson'; import { CursorResponse, ExplainedCursorResponse } from '../cmap/wire_protocol/responses'; +import { type CursorTimeoutMode } from '../cursor/abstract_cursor'; import { MongoInvalidArgumentError } from '../error'; import { ReadConcern } from '../read_concern'; import type { Server } from '../sdam/server'; @@ -64,6 +65,9 @@ export interface FindOptions * @deprecated Starting from MongoDB 4.4 this flag is not needed and will be ignored. */ oplogReplay?: boolean; + + /** @internal*/ + timeoutMode?: CursorTimeoutMode; } /** @internal */ diff --git a/src/operations/indexes.ts b/src/operations/indexes.ts index c96a5d7345..220d438d83 100644 --- a/src/operations/indexes.ts +++ b/src/operations/indexes.ts @@ -1,7 +1,7 @@ import type { Document } from '../bson'; import { CursorResponse } from '../cmap/wire_protocol/responses'; import type { Collection } from '../collection'; -import { type AbstractCursorOptions } from '../cursor/abstract_cursor'; +import { type AbstractCursorOptions, type CursorTimeoutMode } from '../cursor/abstract_cursor'; import { MongoCompatibilityError } from '../error'; import { type OneOrMore } from '../mongo_types'; import type { Server } from '../sdam/server'; @@ -360,7 +360,12 @@ export class DropIndexOperation extends CommandOperation { } /** @public */ -export type ListIndexesOptions = AbstractCursorOptions; +export type ListIndexesOptions = AbstractCursorOptions & { + /** @internal TODO(NODE-5688): make this public */ + timeoutMode?: CursorTimeoutMode; + /** @internal */ + omitMaxTimeMS?: boolean; +}; /** @internal */ export class ListIndexesOperation extends CommandOperation { diff --git a/src/operations/list_collections.ts b/src/operations/list_collections.ts index 702db0fe3f..50df243a3f 100644 --- a/src/operations/list_collections.ts +++ b/src/operations/list_collections.ts @@ -1,5 +1,6 @@ import type { Binary, Document } from '../bson'; import { CursorResponse } from '../cmap/wire_protocol/responses'; +import { type CursorTimeoutMode } from '../cursor/abstract_cursor'; import type { Db } from '../db'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; @@ -16,6 +17,8 @@ export interface ListCollectionsOptions extends Omit { public readonly start: number; public ended: number | null = null; public duration: number; - public timedOut = false; + private timedOut = false; public cleared = false; get remainingTime(): number { @@ -100,6 +100,7 @@ export class Timeout extends Promise { clear(): void { clearTimeout(this.id); this.id = undefined; + this.timedOut = false; this.cleared = true; } @@ -190,6 +191,10 @@ export abstract class TimeoutContext { abstract get timeoutForSocketRead(): Timeout | null; abstract csotEnabled(): this is CSOTTimeoutContext; + + abstract refresh(): void; + + abstract clear(): void; } /** @internal */ @@ -288,6 +293,18 @@ export class CSOTTimeoutContext extends TimeoutContext { if (remainingTimeMS > 0) return Timeout.expires(remainingTimeMS); return Timeout.reject(new MongoOperationTimeoutError('Timed out before socket read')); } + + refresh(): void { + this.start = Math.trunc(performance.now()); + this.minRoundTripTime = 0; + this._serverSelectionTimeout?.clear(); + this._connectionCheckoutTimeout?.clear(); + } + + clear(): void { + this._serverSelectionTimeout?.clear(); + this._connectionCheckoutTimeout?.clear(); + } } /** @internal */ @@ -326,4 +343,12 @@ export class LegacyTimeoutContext extends TimeoutContext { get timeoutForSocketRead(): Timeout | null { return null; } + + refresh(): void { + return; + } + + clear(): void { + return; + } } 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 406aa53ed6..0d36998fd9 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 @@ -4,7 +4,9 @@ import { expect } from 'chai'; import * as semver from 'semver'; import * as sinon from 'sinon'; +import { type CommandStartedEvent } from '../../../mongodb'; import { + type CommandSucceededEvent, MongoClient, MongoOperationTimeoutError, MongoServerSelectionError, @@ -216,12 +218,52 @@ describe('CSOT spec prose tests', function () { }); }); - context.skip('5. Blocking Iteration Methods', () => { + context('5. Blocking Iteration Methods', () => { /** * Tests in this section MUST only be run against server versions 4.4 and higher and only apply to drivers that have a * blocking method for cursor iteration that executes `getMore` commands in a loop until a document is available or an * error occurs. */ + const failpoint: FailPoint = { + configureFailPoint: 'failCommand', + mode: 'alwaysOn', + data: { + failCommands: ['getMore'], + blockConnection: true, + blockTimeMS: 20 + } + }; + let internalClient: MongoClient; + let client: MongoClient; + let commandStarted: CommandStartedEvent[]; + let commandSucceeded: CommandSucceededEvent[]; + + beforeEach(async function () { + internalClient = this.configuration.newClient(); + await internalClient.db('db').dropCollection('coll'); + // Creating capped collection to be able to create tailable find cursor + const coll = await internalClient + .db('db') + .createCollection('coll', { capped: true, size: 1_000_000 }); + await coll.insertOne({ x: 1 }); + await internalClient.db().admin().command(failpoint); + + client = this.configuration.newClient(undefined, { timeoutMS: 20, monitorCommands: true }); + commandStarted = []; + commandSucceeded = []; + + client.on('commandStarted', ev => commandStarted.push(ev)); + client.on('commandSucceeded', ev => commandSucceeded.push(ev)); + }); + + afterEach(async function () { + await internalClient + .db() + .admin() + .command({ ...failpoint, mode: 'off' }); + await internalClient.close(); + await client.close(); + }); context('Tailable cursors', () => { /** @@ -248,6 +290,29 @@ describe('CSOT spec prose tests', function () { * - Expect this to fail with a timeout error. * 1. Verify that a `find` command and two `getMore` commands were executed against the `db.coll` collection during the test. */ + + it.skip('send correct number of finds and getMores', async function () { + const cursor = client + .db('db') + .collection('coll') + .find({}, { tailable: true, awaitData: true }) + .project({ _id: 0 }); + const doc = await cursor.next(); + expect(doc).to.deep.equal({ x: 1 }); + // Check that there are no getMores sent + expect(commandStarted.filter(e => e.command.getMore != null)).to.have.lengthOf(0); + + const maybeError = await cursor.next().then( + () => null, + e => e + ); + + expect(maybeError).to.be.instanceof(MongoOperationTimeoutError); + // Expect 1 find + expect(commandStarted.filter(e => e.command.find != null)).to.have.lengthOf(1); + // Expect 2 getMore + expect(commandStarted.filter(e => e.command.getMore != null)).to.have.lengthOf(2); + }).skipReason = 'TODO(NODE-6305)'; }); context('Change Streams', () => { @@ -272,6 +337,23 @@ describe('CSOT spec prose tests', function () { * - Expect this to fail with a timeout error. * 1. Verify that an `aggregate` command and two `getMore` commands were executed against the `db.coll` collection during the test. */ + it.skip('sends correct number of aggregate and getMores', async function () { + const changeStream = client.db('db').collection('coll').watch(); + const maybeError = await changeStream.next().then( + () => null, + e => e + ); + + expect(maybeError).to.be.instanceof(MongoOperationTimeoutError); + const aggregates = commandStarted + .filter(e => e.command.aggregate != null) + .map(e => e.command); + const getMores = commandStarted.filter(e => e.command.getMore != null).map(e => e.command); + // Expect 1 aggregate + expect(aggregates).to.have.lengthOf(1); + // Expect 1 getMore + expect(getMores).to.have.lengthOf(1); + }).skipReason = 'TODO(NODE-6305)'; }); }); 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 a178cecc5d..99914fa08e 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 @@ -4,49 +4,55 @@ import * as semver from 'semver'; import { loadSpecTests } from '../../spec'; import { runUnifiedSuite } from '../../tools/unified-spec-runner/runner'; -const enabled = [ - 'override-collection-timeoutMS', - 'override-database-timeoutMS', - 'override-operation-timeoutMS', - 'retryability-legacy-timeouts', - 'retryability-timeoutMS', - 'sessions-override-operation-timeoutMS', - 'sessions-override-timeoutMS', - 'sessions-inherit-timeoutMS' -]; +const skippedSpecs = { + bulkWrite: 'TODO(NODE-6274)', + 'change-streams': 'TODO(NODE-6035)', + 'convenient-transactions': 'TODO(NODE-5687)', + 'deprecated-options': 'TODO(NODE-5689)', + 'gridfs-advanced': 'TODO(NODE-6275)', + 'gridfs-delete': 'TODO(NODE-6275)', + 'gridfs-download': 'TODO(NODE-6275)', + 'gridfs-find': 'TODO(NODE-6275)', + 'gridfs-upload': 'TODO(NODE-6275)', + 'tailable-awaitData': 'TODO(NODE-6035)', + 'tailable-non-awaitData': 'TODO(NODE-6035)' +}; -const cursorOperations = [ - 'aggregate', - 'countDocuments', - 'listIndexes', - 'createChangeStream', - 'listCollections', - 'listCollectionNames' -]; - -const bulkWriteOperations = [ - 'timeoutMS applies to whole operation, not individual attempts - bulkWrite on collection', - 'timeoutMS applies to whole operation, not individual attempts - insertMany on collection' -]; +const skippedTests = { + 'timeoutMS can be configured on a MongoClient - insertMany on collection': 'TODO(NODE-6274)', + 'timeoutMS can be configured on a MongoClient - bulkWrite on collection': 'TODO(NODE-6274)', + 'timeoutMS can be configured on a MongoClient - createChangeStream on client': 'TODO(NODE-6305)', + 'timeoutMS applies to whole operation, not individual attempts - createChangeStream on client': + 'TODO(NODE-6305)', + 'Tailable cursor iteration timeoutMS is refreshed for getMore - failure': 'TODO(NODE-6305)', + 'Tailable cursor awaitData iteration timeoutMS is refreshed for getMore - failure': + 'TODO(NODE-6305)', + 'timeoutMS applies to whole operation, not individual attempts - insertMany on collection': + 'TODO(NODE-6274)', + 'timeoutMS applies to whole operation, not individual attempts - bulkWrite on collection': + 'TODO(NODE-6274)', + 'command is not sent if RTT is greater than timeoutMS': 'TODO(DRIVERS-2965)', + 'Non=tailable cursor iteration timeoutMS is refreshed for getMore if timeoutMode is iteration - failure': + 'TODO(DRIVERS-2965)', + 'Non-tailable cursor lifetime remaining timeoutMS applied to getMore if timeoutMode is unset': + 'TODO(DRIVERS-2965)', + 'maxTimeMS value in the command is less than timeoutMS': + 'TODO(DRIVERS-2970): see modified test in unified-csot-node-specs' +}; describe('CSOT spec tests', function () { - const specs = loadSpecTests(join('client-side-operations-timeout')); + const specs = loadSpecTests('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'; + if (skippedSpecs[spec.name] != null) { + test.skipReason = skippedSpecs[spec.name]; + } + if (skippedTests[test.description] != null) { + test.skipReason = skippedTests[test.description]; } - - // Cursor operation - if (test.operations.find(operation => cursorOperations.includes(operation.name))) - test.skipReason = 'TODO(NODE-5684): Not working yet'; - - if (bulkWriteOperations.includes(test.description)) - test.skipReason = - 'TODO(NODE-6274): update test runner to check errorResponse field of MongoBulkWriteError in isTimeoutError assertion'; } } + runUnifiedSuite(specs, (test, configuration) => { const sessionCSOTTests = ['timeoutMS applied to withTransaction']; if ( @@ -59,3 +65,10 @@ describe('CSOT spec tests', function () { return false; }); }); + +describe('CSOT modified spec tests', function () { + const specs = loadSpecTests( + join('..', 'integration', 'client-side-operations-timeout', 'unified-csot-node-specs') + ); + 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 cc767c1d80..f5ada7eef9 100644 --- a/test/integration/client-side-operations-timeout/node_csot.test.ts +++ b/test/integration/client-side-operations-timeout/node_csot.test.ts @@ -1,4 +1,6 @@ /* Anything javascript specific relating to timeouts */ +import { setTimeout } from 'node:timers/promises'; + import { expect } from 'chai'; import * as semver from 'semver'; import * as sinon from 'sinon'; @@ -7,6 +9,9 @@ import { BSON, type ClientSession, type Collection, + type CommandFailedEvent, + type CommandStartedEvent, + type CommandSucceededEvent, Connection, type Db, type FindCursor, @@ -18,7 +23,9 @@ import { } from '../../mongodb'; import { type FailPoint } from '../../tools/utils'; -describe('CSOT driver tests', { requires: { mongodb: '>=4.4' } }, () => { +const metadata = { requires: { mongodb: '>=4.4' } }; + +describe('CSOT driver tests', metadata, () => { describe('timeoutMS inheritance', () => { let client: MongoClient; let db: Db; @@ -171,8 +178,8 @@ describe('CSOT driver tests', { requires: { mongodb: '>=4.4' } }, () => { describe('server-side maxTimeMS errors are transformed', () => { let client: MongoClient; - let commandsSucceeded; - let commandsFailed; + let commandsSucceeded: CommandSucceededEvent[]; + let commandsFailed: CommandFailedEvent[]; beforeEach(async function () { client = this.configuration.newClient({ timeoutMS: 500_000, monitorCommands: true }); @@ -221,18 +228,22 @@ describe('CSOT driver tests', { requires: { mongodb: '>=4.4' } }, () => { await client.db('admin').command({ ...failpoint, mode: 'off' }); }); - it('throws a MongoOperationTimeoutError error and emits command failed', async () => { - const error = await client - .db() - .command({ ping: 1 }) - .catch(error => error); - expect(error).to.be.instanceOf(MongoOperationTimeoutError); - expect(error.cause).to.be.instanceOf(MongoServerError); - expect(error.cause).to.have.property('code', 50); - - expect(commandsFailed).to.have.lengthOf(1); - expect(commandsFailed).to.have.nested.property('[0].failure.cause.code', 50); - }); + it( + 'throws a MongoOperationTimeoutError error and emits command failed', + metadata, + async () => { + const error = await client + .db() + .command({ ping: 1 }) + .catch(error => error); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(error.cause).to.be.instanceOf(MongoServerError); + expect(error.cause).to.have.property('code', 50); + + expect(commandsFailed).to.have.lengthOf(1); + expect(commandsFailed).to.have.nested.property('[0].failure.cause.code', 50); + } + ); }); describe('when a maxTimeExpired error is returned inside a writeErrors array', () => { @@ -267,18 +278,22 @@ describe('CSOT driver tests', { requires: { mongodb: '>=4.4' } }, () => { afterEach(() => sinon.restore()); - it('throws a MongoOperationTimeoutError error and emits command succeeded', async () => { - const error = await client - .db('admin') - .command({ giveMeWriteErrors: 1 }) - .catch(error => error); - expect(error).to.be.instanceOf(MongoOperationTimeoutError); - expect(error.cause).to.be.instanceOf(MongoServerError); - expect(error.cause).to.have.nested.property('writeErrors[3].code', 50); - - expect(commandsSucceeded).to.have.lengthOf(1); - expect(commandsSucceeded).to.have.nested.property('[0].reply.writeErrors[3].code', 50); - }); + it( + 'throws a MongoOperationTimeoutError error and emits command succeeded', + metadata, + async () => { + const error = await client + .db('admin') + .command({ giveMeWriteErrors: 1 }) + .catch(error => error); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(error.cause).to.be.instanceOf(MongoServerError); + expect(error.cause).to.have.nested.property('writeErrors[3].code', 50); + + expect(commandsSucceeded).to.have.lengthOf(1); + expect(commandsSucceeded).to.have.nested.property('[0].reply.writeErrors[3].code', 50); + } + ); }); describe('when a maxTimeExpired error is returned inside a writeConcernError embedded document', () => { @@ -306,22 +321,266 @@ describe('CSOT driver tests', { requires: { mongodb: '>=4.4' } }, () => { await client.db('admin').command({ ...failpoint, mode: 'off' }); }); - it('throws a MongoOperationTimeoutError error and emits command succeeded', async () => { - const error = await client + it( + 'throws a MongoOperationTimeoutError error and emits command succeeded', + metadata, + async () => { + const error = await client + .db() + .collection('a') + .insertOne({}) + .catch(error => error); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(error.cause).to.be.instanceOf(MongoServerError); + expect(error.cause).to.have.nested.property('writeConcernError.code', 50); + + expect(commandsSucceeded).to.have.lengthOf(1); + expect(commandsSucceeded).to.have.nested.property('[0].reply.writeConcernError.code', 50); + } + ); + }); + }); + + describe('Non-Tailable cursors', () => { + let client: MongoClient; + let internalClient: MongoClient; + let commandStarted: CommandStartedEvent[]; + let commandSucceeded: CommandSucceededEvent[]; + const failpoint: FailPoint = { + configureFailPoint: 'failCommand', + mode: 'alwaysOn', + data: { + failCommands: ['find', 'getMore'], + blockConnection: true, + blockTimeMS: 50 + } + }; + + beforeEach(async function () { + internalClient = this.configuration.newClient(); + await internalClient + .db('db') + .dropCollection('coll') + .catch(() => null); + await internalClient + .db('db') + .collection('coll') + .insertMany( + Array.from({ length: 3 }, () => { + return { x: 1 }; + }) + ); + + await internalClient.db().admin().command(failpoint); + + client = this.configuration.newClient(undefined, { monitorCommands: true }); + commandStarted = []; + commandSucceeded = []; + client.on('commandStarted', ev => commandStarted.push(ev)); + client.on('commandSucceeded', ev => commandSucceeded.push(ev)); + }); + + afterEach(async function () { + await internalClient + .db() + .admin() + .command({ ...failpoint, mode: 'off' }); + await internalClient.close(); + await client.close(); + }); + + context('ITERATION mode', () => { + context('when executing an operation', () => { + it( + 'must apply the configured timeoutMS to the initial operation execution', + metadata, + async function () { + const cursor = client + .db('db') + .collection('coll') + .find({}, { batchSize: 3, timeoutMode: 'iteration', timeoutMS: 10 }) + .limit(3); + + const maybeError = await cursor.next().then( + () => null, + e => e + ); + + expect(maybeError).to.be.instanceOf(MongoOperationTimeoutError); + } + ); + + it('refreshes the timeout for any getMores', metadata, async function () { + const cursor = client + .db('db') + .collection('coll') + .find({}, { batchSize: 1, timeoutMode: 'iteration', timeoutMS: 100 }) + .project({ _id: 0 }); + + // Iterating over 3 documents in the collection, each artificially taking ~50 ms due to failpoint. If timeoutMS is not refreshed, then we'd expect to error + for await (const doc of cursor) { + expect(doc).to.deep.equal({ x: 1 }); + } + + const finds = commandSucceeded.filter(ev => ev.commandName === 'find'); + const getMores = commandSucceeded.filter(ev => ev.commandName === 'getMore'); + + expect(finds).to.have.length(1); // Expecting 1 find + expect(getMores).to.have.length(3); // Expecting 3 getMores (including final empty getMore) + }); + + it( + 'does not append a maxTimeMS to the original command or getMores', + metadata, + async function () { + const cursor = client + .db('db') + .collection('coll') + .find({}, { batchSize: 1, timeoutMode: 'iteration', timeoutMS: 100 }) + .project({ _id: 0 }); + await cursor.toArray(); + + expect(commandStarted).to.have.length.gte(3); // Find and 2 getMores + expect( + commandStarted.filter(ev => { + return ( + ev.command.find != null && + ev.command.getMore != null && + ev.command.maxTimeMS != null + ); + }) + ).to.have.lengthOf(0); + } + ); + }); + }); + + context('LIFETIME mode', () => { + let client: MongoClient; + let internalClient: MongoClient; + let commandStarted: CommandStartedEvent[]; + let commandSucceeded: CommandSucceededEvent[]; + const failpoint: FailPoint = { + configureFailPoint: 'failCommand', + mode: 'alwaysOn', + data: { + failCommands: ['find', 'getMore'], + blockConnection: true, + blockTimeMS: 50 + } + }; + + beforeEach(async function () { + internalClient = this.configuration.newClient(); + await internalClient + .db('db') + .dropCollection('coll') + .catch(() => null); + await internalClient + .db('db') + .collection('coll') + .insertMany( + Array.from({ length: 3 }, () => { + return { x: 1 }; + }) + ); + + await internalClient.db().admin().command(failpoint); + + client = this.configuration.newClient(undefined, { monitorCommands: true }); + commandStarted = []; + commandSucceeded = []; + client.on('commandStarted', ev => commandStarted.push(ev)); + client.on('commandSucceeded', ev => commandSucceeded.push(ev)); + }); + + afterEach(async function () { + await internalClient .db() - .collection('a') - .insertOne({}) - .catch(error => error); - expect(error).to.be.instanceOf(MongoOperationTimeoutError); - expect(error.cause).to.be.instanceOf(MongoServerError); - expect(error.cause).to.have.nested.property('writeConcernError.code', 50); - - expect(commandsSucceeded).to.have.lengthOf(1); - expect(commandsSucceeded).to.have.nested.property('[0].reply.writeConcernError.code', 50); + .admin() + .command({ ...failpoint, mode: 'off' }); + await internalClient.close(); + await client.close(); + }); + context('when executing a next call', () => { + context( + 'when there are documents available from previously retrieved batch and timeout has expired', + () => { + it('returns documents without error', metadata, async function () { + const cursor = client + .db('db') + .collection('coll') + .find({}, { timeoutMode: 'cursorLifetime', timeoutMS: 100 }) + .project({ _id: 0 }); + const doc = await cursor.next(); + expect(doc).to.deep.equal({ x: 1 }); + expect(cursor.documents.length).to.be.gt(0); + + await setTimeout(100); + + const docOrErr = await cursor.next().then( + d => d, + e => e + ); + + expect(docOrErr).to.not.be.instanceOf(MongoOperationTimeoutError); + expect(docOrErr).to.be.deep.equal({ x: 1 }); + }); + } + ); + context('when a getMore is required and the timeout has expired', () => { + it('throws a MongoOperationTimeoutError', metadata, async function () { + const cursor = client + .db('db') + .collection('coll') + .find({}, { batchSize: 1, timeoutMode: 'cursorLifetime', timeoutMS: 100 }) + + .project({ _id: 0 }); + + const doc = await cursor.next(); + expect(doc).to.deep.equal({ x: 1 }); + expect(cursor.documents.length).to.equal(0); + + await setTimeout(100); + + const docOrErr = await cursor.next().then( + d => d, + e => e + ); + + expect(docOrErr).to.be.instanceOf(MongoOperationTimeoutError); + }); + }); + + it('does not apply maxTimeMS to a getMore', metadata, async function () { + const cursor = client + .db('db') + .collection('coll') + .find({}, { batchSize: 1, timeoutMode: 'cursorLifetime', timeoutMS: 1000 }) + .project({ _id: 0 }); + + for await (const _doc of cursor) { + // Ignore _doc + } + + const getMores = commandStarted + .filter(ev => ev.command.getMore != null) + .map(ev => ev.command); + expect(getMores.length).to.be.gt(0); + + for (const getMore of getMores) { + expect(getMore.maxTimeMS).to.not.exist; + } + }); }); }); }); + describe.skip('Tailable non-awaitData cursors').skipReason = + 'TODO(NODE-6305): implement CSOT for Tailable cursors'; + describe.skip('Tailable awaitData cursors').skipReason = + 'TODO(NODE-6305): implement CSOT for Tailable cursors'; + describe('when using an explicit session', () => { const metadata: MongoDBMetadataUI = { requires: { topology: ['replicaset'], mongodb: '>=4.4' } diff --git a/test/integration/client-side-operations-timeout/unified-csot-node-specs/command-execution.json b/test/integration/client-side-operations-timeout/unified-csot-node-specs/command-execution.json new file mode 100644 index 0000000000..dd6fcb2cf8 --- /dev/null +++ b/test/integration/client-side-operations-timeout/unified-csot-node-specs/command-execution.json @@ -0,0 +1,153 @@ +{ + "description": "timeoutMS behaves correctly during command execution", + "schemaVersion": "1.9", + "runOnRequirements": [ + { + "minServerVersion": "4.4.7", + "topologies": [ + "single", + "replicaset", + "sharded" + ], + "serverless": "forbid" + } + ], + "createEntities": [ + { + "client": { + "id": "failPointClient", + "useMultipleMongoses": false + } + } + ], + "initialData": [ + { + "collectionName": "coll", + "databaseName": "test", + "documents": [] + }, + { + "collectionName": "timeoutColl", + "databaseName": "test", + "documents": [] + } + ], + "tests": [ + { + "description": "maxTimeMS value in the command is less than timeoutMS", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "failPointClient", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": "alwaysOn", + "data": { + "failCommands": [ + "hello", + "isMaster" + ], + "appName": "reduceMaxTimeMSTest", + "blockConnection": true, + "blockTimeMS": 50 + } + } + } + }, + { + "name": "createEntities", + "object": "testRunner", + "arguments": { + "entities": [ + { + "client": { + "id": "client", + "useMultipleMongoses": false, + "uriOptions": { + "appName": "reduceMaxTimeMSTest", + "w": 1, + "timeoutMS": 500, + "heartbeatFrequencyMS": 500 + }, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database", + "client": "client", + "databaseName": "test" + } + }, + { + "collection": { + "id": "timeoutCollection", + "database": "database", + "collectionName": "timeoutColl" + } + } + ] + } + }, + { + "name": "insertOne", + "object": "timeoutCollection", + "arguments": { + "document": { + "_id": 1 + }, + "timeoutMS": 100000 + } + }, + { + "name": "wait", + "object": "testRunner", + "arguments": { + "ms": 1500 + } + }, + { + "name": "insertOne", + "object": "timeoutCollection", + "arguments": { + "document": { + "_id": 2 + } + } + } + ], + "expectEvents": [ + { + "client": "client", + "events": [ + { + "commandStartedEvent": { + "commandName": "insert", + "databaseName": "test", + "command": { + "insert": "timeoutColl" + } + } + }, + { + "commandStartedEvent": { + "commandName": "insert", + "databaseName": "test", + "command": { + "insert": "timeoutColl", + "maxTimeMS": { + "$$lte": 500 + } + } + } + } + ] + } + ] + } + ] +} diff --git a/test/spec/client-side-operations-timeout/README.md b/test/spec/client-side-operations-timeout/README.md new file mode 100644 index 0000000000..a960c2de21 --- /dev/null +++ b/test/spec/client-side-operations-timeout/README.md @@ -0,0 +1,661 @@ +# Client Side Operations Timeouts Tests + +______________________________________________________________________ + +## Introduction + +This document describes the tests that drivers MUST run to validate the behavior of the timeoutMS option. These tests +are broken up into automated YAML/JSON tests and additional prose tests. + +## Spec Tests + +This directory contains a set of YAML and JSON spec tests. Drivers MUST run these as described in the "Unified Test +Runner" specification. Because the tests introduced in this specification are timing-based, there is a risk that some of +them may intermittently fail without any bugs being present in the driver. As a mitigation, drivers MAY execute these +tests in two new Evergreen tasks that use single-node replica sets: one with only authentication enabled and another +with both authentication and TLS enabled. Drivers that choose to do so SHOULD use the `single-node-auth.json` and +`single-node-auth-ssl.json` files in the `drivers-evergreen-tools` repository to create these clusters. + +## Prose Tests + +There are some tests that cannot be expressed in the unified YAML/JSON format. For each of these tests, drivers MUST +create a MongoClient without the `timeoutMS` option set (referred to as `internalClient`). Any fail points set during a +test MUST be unset using `internalClient` after the test has been executed. All MongoClient instances created for tests +MUST be configured with read/write concern `majority`, read preference `primary`, and command monitoring enabled to +listen for `command_started` events. + +### 1. Multi-batch inserts + +This test MUST only run against standalones on server versions 4.4 and higher. The `insertMany` call takes an +exceedingly long time on replicasets and sharded clusters. Drivers MAY adjust the timeouts used in this test to allow +for differing bulk encoding performance. + +1. Using `internalClient`, drop the `db.coll` collection. + +2. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: { + times: 2 + }, + data: { + failCommands: ["insert"], + blockConnection: true, + blockTimeMS: 1010 + } + } + ``` + +3. Create a new MongoClient (referred to as `client`) with `timeoutMS=2000`. + +4. Using `client`, insert 50 1-megabyte documents in a single `insertMany` call. + + - Expect this to fail with a timeout error. + +5. Verify that two `insert` commands were executed against `db.coll` as part of the `insertMany` call. + +### 2. maxTimeMS is not set for commands sent to mongocryptd + +This test MUST only be run against enterprise server versions 4.2 and higher. + +1. Launch a mongocryptd process on 23000. +2. Create a MongoClient (referred to as `client`) using the URI `mongodb://localhost:23000/?timeoutMS=1000`. +3. Using `client`, execute the `{ ping: 1 }` command against the `admin` database. +4. Verify via command monitoring that the `ping` command sent did not contain a `maxTimeMS` field. + +### 3. ClientEncryption + +Each test under this category MUST only be run against server versions 4.4 and higher. In these tests, `LOCAL_MASTERKEY` +refers to the following base64: + +```javascript +Mng0NCt4ZHVUYUJCa1kxNkVyNUR1QURhZ2h2UzR2d2RrZzh0cFBwM3R6NmdWMDFBMUN3YkQ5aXRRMkhGRGdQV09wOGVNYUMxT2k3NjZKelhaQmRCZGJkTXVyZG9uSjFk +``` + +For each test, perform the following setup: + +1. Using `internalClient`, drop and create the `keyvault.datakeys` collection. + +2. Create a MongoClient (referred to as `keyVaultClient`) with `timeoutMS=10`. + +3. Create a `ClientEncryption` object that wraps `keyVaultClient` (referred to as `clientEncryption`). Configure this + object with `keyVaultNamespace` set to `keyvault.datakeys` and the following KMS providers map: + + ```javascript + { + "local": { "key": } + } + ``` + +#### createDataKey + +1. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: { + times: 1 + }, + data: { + failCommands: ["insert"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +2. Call `clientEncryption.createDataKey()` with the `local` KMS provider. + + - Expect this to fail with a timeout error. + +3. Verify that an `insert` command was executed against to `keyvault.datakeys` as part of the `createDataKey` call. + +#### encrypt + +1. Call `client_encryption.createDataKey()` with the `local` KMS provider. + + - Expect a BSON binary with subtype 4 to be returned, referred to as `datakeyId`. + +2. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: { + times: 1 + }, + data: { + failCommands: ["find"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +3. Call `clientEncryption.encrypt()` with the value `hello`, the algorithm + `AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic`, and the keyId `datakeyId`. + + - Expect this to fail with a timeout error. + +4. Verify that a `find` command was executed against the `keyvault.datakeys` collection as part of the `encrypt` call. + +#### decrypt + +1. Call `clientEncryption.createDataKey()` with the `local` KMS provider. + + - Expect this to return a BSON binary with subtype 4, referred to as `dataKeyId`. + +2. Call `clientEncryption.encrypt()` with the value `hello`, the algorithm + `AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic`, and the keyId `dataKeyId`. + + - Expect this to return a BSON binary with subtype 6, referred to as `encrypted`. + +3. Close and re-create the `keyVaultClient` and `clientEncryption` objects. + +4. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: { + times: 1 + }, + data: { + failCommands: ["find"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +5. Call `clientEncryption.decrypt()` with the value `encrypted`. + + - Expect this to fail with a timeout error. + +6. Verify that a `find` command was executed against the `keyvault.datakeys` collection as part of the `decrypt` call. + +### 4. Background Connection Pooling + +The tests in this section MUST only be run if the server version is 4.4 or higher and the URI has authentication fields +(i.e. a username and password). Each test in this section requires drivers to create a MongoClient and then wait for +some CMAP events to be published. Drivers MUST wait for up to 10 seconds and fail the test if the specified events are +not published within that time. + +#### timeoutMS used for handshake commands + +1. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: { + times: 1 + }, + data: { + failCommands: ["saslContinue"], + blockConnection: true, + blockTimeMS: 15, + appName: "timeoutBackgroundPoolTest" + } + } + ``` + +2. Create a MongoClient (referred to as `client`) configured with the following: + + - `minPoolSize` of 1 + - `timeoutMS` of 10 + - `appName` of `timeoutBackgroundPoolTest` + - CMAP monitor configured to listen for `ConnectionCreatedEvent` and `ConnectionClosedEvent` events. + +3. Wait for a `ConnectionCreatedEvent` and a `ConnectionClosedEvent` to be published. + +#### timeoutMS is refreshed for each handshake command + +1. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: "alwaysOn", + data: { + failCommands: ["hello", "isMaster", "saslContinue"], + blockConnection: true, + blockTimeMS: 15, + appName: "refreshTimeoutBackgroundPoolTest" + } + } + ``` + +2. Create a MongoClient (referred to as `client`) configured with the following: + + - `minPoolSize` of 1 + - `timeoutMS` of 20 + - `appName` of `refreshTimeoutBackgroundPoolTest` + - CMAP monitor configured to listen for `ConnectionCreatedEvent` and `ConnectionReady` events. + +3. Wait for a `ConnectionCreatedEvent` and a `ConnectionReady` to be published. + +### 5. Blocking Iteration Methods + +Tests in this section MUST only be run against server versions 4.4 and higher and only apply to drivers that have a +blocking method for cursor iteration that executes `getMore` commands in a loop until a document is available or an +error occurs. + +#### Tailable cursors + +1. Using `internalClient`, drop the `db.coll` collection. + +2. Using `internalClient`, insert the document `{ x: 1 }` into `db.coll`. + +3. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: "alwaysOn", + data: { + failCommands: ["getMore"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +4. Create a new MongoClient (referred to as `client`) with `timeoutMS=20`. + +5. Using `client`, create a tailable cursor on `db.coll` with `cursorType=tailable`. + + - Expect this to succeed and return a cursor with a non-zero ID. + +6. Call either a blocking or non-blocking iteration method on the cursor. + + - Expect this to succeed and return the document `{ x: 1 }` without sending a `getMore` command. + +7. Call the blocking iteration method on the resulting cursor. + + - Expect this to fail with a timeout error. + +8. Verify that a `find` command and two `getMore` commands were executed against the `db.coll` collection during the + test. + +#### Change Streams + +1. Using `internalClient`, drop the `db.coll` collection. + +2. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: "alwaysOn", + data: { + failCommands: ["getMore"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +3. Create a new MongoClient (referred to as `client`) with `timeoutMS=20`. + +4. Using `client`, use the `watch` helper to create a change stream against `db.coll`. + + - Expect this to succeed and return a change stream with a non-zero ID. + +5. Call the blocking iteration method on the resulting change stream. + + - Expect this to fail with a timeout error. + +6. Verify that an `aggregate` command and two `getMore` commands were executed against the `db.coll` collection during + the test. + +### 6. GridFS - Upload + +Tests in this section MUST only be run against server versions 4.4 and higher. + +#### uploads via openUploadStream can be timed out + +1. Using `internalClient`, drop and re-create the `db.fs.files` and `db.fs.chunks` collections. + +2. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: { times: 1 }, + data: { + failCommands: ["insert"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +3. Create a new MongoClient (referred to as `client`) with `timeoutMS=10`. + +4. Using `client`, create a GridFS bucket (referred to as `bucket`) that wraps the `db` database. + +5. Call `bucket.open_upload_stream()` with the filename `filename` to create an upload stream (referred to as + `uploadStream`). + + - Expect this to succeed and return a non-null stream. + +6. Using `uploadStream`, upload a single `0x12` byte. + +7. Call `uploadStream.close()` to flush the stream and insert chunks. + + - Expect this to fail with a timeout error. + +#### Aborting an upload stream can be timed out + +This test only applies to drivers that provide an API to abort a GridFS upload stream. + +1. Using `internalClient`, drop and re-create the `db.fs.files` and `db.fs.chunks` collections. + +2. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: { times: 1 }, + data: { + failCommands: ["delete"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +3. Create a new MongoClient (referred to as `client`) with `timeoutMS=10`. + +4. Using `client`, create a GridFS bucket (referred to as `bucket`) that wraps the `db` database with + `chunkSizeBytes=2`. + +5. Call `bucket.open_upload_stream()` with the filename `filename` to create an upload stream (referred to as + `uploadStream`). + + - Expect this to succeed and return a non-null stream. + +6. Using `uploadStream`, upload the bytes `[0x01, 0x02, 0x03, 0x04]`. + +7. Call `uploadStream.abort()`. + + - Expect this to fail with a timeout error. + +### 7. GridFS - Download + +This test MUST only be run against server versions 4.4 and higher. + +1. Using `internalClient`, drop and re-create the `db.fs.files` and `db.fs.chunks` collections. + +2. Using `internalClient`, insert the following document into the `db.fs.files` collection: + + ```javascript + { + "_id": { + "$oid": "000000000000000000000005" + }, + "length": 10, + "chunkSize": 4, + "uploadDate": { + "$date": "1970-01-01T00:00:00.000Z" + }, + "md5": "57d83cd477bfb1ccd975ab33d827a92b", + "filename": "length-10", + "contentType": "application/octet-stream", + "aliases": [], + "metadata": {} + } + ``` + +3. Create a new MongoClient (referred to as `client`) with `timeoutMS=10`. + +4. Using `client`, create a GridFS bucket (referred to as `bucket`) that wraps the `db` database. + +5. Call `bucket.open_download_stream` with the id `{ "$oid": "000000000000000000000005" }` to create a download stream + (referred to as `downloadStream`). + + - Expect this to succeed and return a non-null stream. + +6. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: { times: 1 }, + data: { + failCommands: ["find"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +7. Read from the `downloadStream`. + + - Expect this to fail with a timeout error. + +8. Verify that two `find` commands were executed during the read: one against `db.fs.files` and another against + `db.fs.chunks`. + +### 8. Server Selection + +#### serverSelectionTimeoutMS honored if timeoutMS is not set + +1. Create a MongoClient (referred to as `client`) with URI `mongodb://invalid/?serverSelectionTimeoutMS=10`. +2. Using `client`, execute the command `{ ping: 1 }` against the `admin` database. + - Expect this to fail with a server selection timeout error after no more than 15ms. + +#### timeoutMS honored for server selection if it's lower than serverSelectionTimeoutMS + +1. Create a MongoClient (referred to as `client`) with URI + `mongodb://invalid/?timeoutMS=10&serverSelectionTimeoutMS=20`. +2. Using `client`, run the command `{ ping: 1 }` against the `admin` database. + - Expect this to fail with a server selection timeout error after no more than 15ms. + +#### serverSelectionTimeoutMS honored for server selection if it's lower than timeoutMS + +1. Create a MongoClient (referred to as `client`) with URI + `mongodb://invalid/?timeoutMS=20&serverSelectionTimeoutMS=10`. +2. Using `client`, run the command `{ ping: 1 }` against the `admin` database. + - Expect this to fail with a server selection timeout error after no more than 15ms. + +#### serverSelectionTimeoutMS honored for server selection if timeoutMS=0 + +1. Create a MongoClient (referred to as `client`) with URI `mongodb://invalid/?timeoutMS=0&serverSelectionTimeoutMS=10`. +2. Using `client`, run the command `{ ping: 1 }` against the `admin` database. + - Expect this to fail with a server selection timeout error after no more than 15ms. + +#### timeoutMS honored for connection handshake commands if it's lower than serverSelectionTimeoutMS + +This test MUST only be run if the server version is 4.4 or higher and the URI has authentication fields (i.e. a username +and password). + +1. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: failCommand, + mode: { times: 1 }, + data: { + failCommands: ["saslContinue"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +2. Create a new MongoClient (referred to as `client`) with `timeoutMS=10` and `serverSelectionTimeoutMS=20`. + +3. Using `client`, insert the document `{ x: 1 }` into collection `db.coll`. + + - Expect this to fail with a timeout error after no more than 15ms. + +#### serverSelectionTimeoutMS honored for connection handshake commands if it's lower than timeoutMS + +This test MUST only be run if the server version is 4.4 or higher and the URI has authentication fields (i.e. a username +and password). + +1. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: failCommand, + mode: { times: 1 }, + data: { + failCommands: ["saslContinue"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +2. Create a new MongoClient (referred to as `client`) with `timeoutMS=20` and `serverSelectionTimeoutMS=10`. + +3. Using `client`, insert the document `{ x: 1 }` into collection `db.coll`. + + - Expect this to fail with a timeout error after no more than 15ms. + +### 9. endSession + +This test MUST only be run against replica sets and sharded clusters with server version 4.4 or higher. It MUST be run +three times: once with the timeout specified via the MongoClient `timeoutMS` option, once with the timeout specified via +the ClientSession `defaultTimeoutMS` option, and once more with the timeout specified via the `timeoutMS` option for the +`endSession` operation. In all cases, the timeout MUST be set to 10 milliseconds. + +1. Using `internalClient`, drop the `db.coll` collection. + +2. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: failCommand, + mode: { times: 1 }, + data: { + failCommands: ["abortTransaction"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +3. Create a new MongoClient (referred to as `client`) and an explicit ClientSession derived from that MongoClient + (referred to as `session`). + +4. Execute the following code: + + ```typescript + coll = client.database("db").collection("coll") + session.start_transaction() + coll.insert_one({x: 1}, session=session) + ``` + +5. Using `session`, execute `session.end_session` + + - Expect this to fail with a timeout error after no more than 15ms. + +### 10. Convenient Transactions + +Tests in this section MUST only run against replica sets and sharded clusters with server versions 4.4 or higher. + +#### timeoutMS is refreshed for abortTransaction if the callback fails + +1. Using `internalClient`, drop the `db.coll` collection. + +2. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: failCommand, + mode: { times: 2 }, + data: { + failCommands: ["insert", "abortTransaction"], + blockConnection: true, + blockTimeMS: 15 + } + } + ``` + +3. Create a new MongoClient (referred to as `client`) configured with `timeoutMS=10` and an explicit ClientSession + derived from that MongoClient (referred to as `session`). + +4. Using `session`, execute a `withTransaction` operation with the following callback: + + ```typescript + def callback() { + coll = client.database("db").collection("coll") + coll.insert_one({ _id: 1 }, session=session) + } + ``` + +5. Expect the previous `withTransaction` call to fail with a timeout error. + +6. Verify that the following events were published during the `withTransaction` call: + + 1. `command_started` and `command_failed` events for an `insert` command. + 2. `command_started` and `command_failed` events for an `abortTransaction` command. + +### 11. Multi-batch bulkWrites + +This test MUST only run against server versions 8.0+. + +1. Using `internalClient`, drop the `db.coll` collection. + +2. Using `internalClient`, set the following fail point: + + ```javascript + { + configureFailPoint: "failCommand", + mode: { + times: 2 + }, + data: { + failCommands: ["bulkWrite"], + blockConnection: true, + blockTimeMS: 1010 + } + } + ``` + +3. Using `internalClient`, perform a `hello` command and record the `maxBsonObjectSize` and `maxMessageSizeBytes` values + in the response. + +4. Create a new MongoClient (referred to as `client`) with `timeoutMS=2000`. + +5. Create a list of write models (referred to as `models`) with the following write model repeated + (`maxMessageSizeBytes / maxBsonObjectSize + 1`) times: + + ```json + InsertOne { + "namespace": "db.coll", + "document": { "a": "b".repeat(maxBsonObjectSize - 500) } + } + ``` + +6. Call `bulkWrite` on `client` with `models`. + + - Expect this to fail with a timeout error. + +7. Verify that two `bulkWrite` commands were executed as part of the `MongoClient.bulkWrite` call. + +## Unit Tests + +The tests enumerated in this section could not be expressed in either spec or prose format. Drivers SHOULD implement +these if it is possible to do so using the driver's existing test infrastructure. + +- Operations should ignore `waitQueueTimeoutMS` if `timeoutMS` is also set. +- If `timeoutMS` is set for an operation, the remaining `timeoutMS` value should apply to connection checkout after a + server has been selected. +- If `timeoutMS` is not set for an operation, `waitQueueTimeoutMS` should apply to connection checkout after a server + has been selected. +- If a new connection is required to execute an operation, + `min(remaining computedServerSelectionTimeout, connectTimeoutMS)` should apply to socket establishment. +- For drivers that have control over OCSP behavior, `min(remaining computedServerSelectionTimeout, 5 seconds)` should + apply to HTTP requests against OCSP responders. +- If `timeoutMS` is unset, operations fail after two non-consecutive socket timeouts. +- The remaining `timeoutMS` value should apply to HTTP requests against KMS servers for CSFLE. +- The remaining `timeoutMS` value should apply to commands sent to mongocryptd as part of automatic encryption. +- When doing `minPoolSize` maintenance, `connectTimeoutMS` is used as the timeout for socket establishment. diff --git a/test/spec/client-side-operations-timeout/change-streams.json b/test/spec/client-side-operations-timeout/change-streams.json index aef77bb452..8cffb08e26 100644 --- a/test/spec/client-side-operations-timeout/change-streams.json +++ b/test/spec/client-side-operations-timeout/change-streams.json @@ -104,7 +104,7 @@ "aggregate" ], "blockConnection": true, - "blockTimeMS": 55 + "blockTimeMS": 250 } } } @@ -114,7 +114,7 @@ "object": "collection", "arguments": { "pipeline": [], - "timeoutMS": 50 + "timeoutMS": 200 }, "expectError": { "isTimeoutError": true @@ -242,7 +242,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 150 } } } @@ -252,7 +252,7 @@ "object": "collection", "arguments": { "pipeline": [], - "timeoutMS": 20, + "timeoutMS": 200, "batchSize": 2, "maxAwaitTimeMS": 1 }, @@ -310,7 +310,7 @@ "object": "collection", "arguments": { "pipeline": [], - "timeoutMS": 20 + "timeoutMS": 200 }, "saveResultAsEntity": "changeStream" }, @@ -330,7 +330,7 @@ "aggregate" ], "blockConnection": true, - "blockTimeMS": 12, + "blockTimeMS": 120, "errorCode": 7, "errorLabels": [ "ResumableChangeStreamError" @@ -412,7 +412,7 @@ "arguments": { "pipeline": [], "maxAwaitTimeMS": 1, - "timeoutMS": 100 + "timeoutMS": 200 }, "saveResultAsEntity": "changeStream" }, @@ -431,7 +431,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 150 + "blockTimeMS": 250 } } } @@ -534,7 +534,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 250 } } } @@ -544,7 +544,7 @@ "object": "collection", "arguments": { "pipeline": [], - "timeoutMS": 10 + "timeoutMS": 200 }, "saveResultAsEntity": "changeStream" }, diff --git a/test/spec/client-side-operations-timeout/change-streams.yml b/test/spec/client-side-operations-timeout/change-streams.yml index b2a052d01b..c813be035a 100644 --- a/test/spec/client-side-operations-timeout/change-streams.yml +++ b/test/spec/client-side-operations-timeout/change-streams.yml @@ -67,12 +67,12 @@ tests: data: failCommands: ["aggregate"] blockConnection: true - blockTimeMS: 55 + blockTimeMS: 250 - name: createChangeStream object: *collection arguments: pipeline: [] - timeoutMS: 50 + timeoutMS: 200 expectError: isTimeoutError: true expectEvents: @@ -142,12 +142,12 @@ tests: data: failCommands: ["aggregate", "getMore"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 150 - name: createChangeStream object: *collection arguments: pipeline: [] - timeoutMS: 20 + timeoutMS: 200 batchSize: 2 maxAwaitTimeMS: 1 saveResultAsEntity: &changeStream changeStream @@ -171,16 +171,16 @@ tests: maxTimeMS: 1 # The timeout should be applied to the entire resume attempt, not individually to each command. The test creates a - # change stream with timeoutMS=20 which returns an empty initial batch and then sets a fail point to block both - # getMore and aggregate for 12ms each and fail with a resumable error. When the resume attempt happens, the getMore - # and aggregate block for longer than 20ms total, so it times out. + # change stream with timeoutMS=200 which returns an empty initial batch and then sets a fail point to block both + # getMore and aggregate for 120ms each and fail with a resumable error. When the resume attempt happens, the getMore + # and aggregate block for longer than 200ms total, so it times out. - description: "timeoutMS applies to full resume attempt in a next call" operations: - name: createChangeStream object: *collection arguments: pipeline: [] - timeoutMS: 20 + timeoutMS: 200 saveResultAsEntity: &changeStream changeStream - name: failPoint object: testRunner @@ -192,7 +192,7 @@ tests: data: failCommands: ["getMore", "aggregate"] blockConnection: true - blockTimeMS: 12 + blockTimeMS: 120 errorCode: 7 # HostNotFound - resumable but does not require an SDAM state change. # failCommand doesn't correctly add the ResumableChangeStreamError by default. It needs to be specified # manually here so the error is considered resumable. The failGetMoreAfterCursorCheckout fail point @@ -234,9 +234,9 @@ tests: # Specify a short maxAwaitTimeMS because otherwise the getMore on the new cursor will wait for 1000ms and # time out. maxAwaitTimeMS: 1 - timeoutMS: 100 + timeoutMS: 200 saveResultAsEntity: &changeStream changeStream - # Block getMore for 150ms to force the next() call to time out. + # Block getMore for 250ms to force the next() call to time out. - name: failPoint object: testRunner arguments: @@ -247,7 +247,7 @@ tests: data: failCommands: ["getMore"] blockConnection: true - blockTimeMS: 150 + blockTimeMS: 250 # The original aggregate didn't return any events so this should do a getMore and return a timeout error. - name: iterateUntilDocumentOrError object: *changeStream @@ -290,7 +290,7 @@ tests: collection: *collectionName # The timeoutMS value should be refreshed for getMore's. This is a failure test. The createChangeStream operation - # sets timeoutMS=10 and the getMore blocks for 15ms, causing iteration to fail with a timeout error. + # sets timeoutMS=200 and the getMore blocks for 250ms, causing iteration to fail with a timeout error. - description: "timeoutMS is refreshed for getMore - failure" operations: - name: failPoint @@ -303,12 +303,12 @@ tests: data: failCommands: ["getMore"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 250 - name: createChangeStream object: *collection arguments: pipeline: [] - timeoutMS: 10 + timeoutMS: 200 saveResultAsEntity: &changeStream changeStream # The first iteration should do a getMore - name: iterateUntilDocumentOrError diff --git a/test/spec/client-side-operations-timeout/close-cursors.json b/test/spec/client-side-operations-timeout/close-cursors.json index 1361971c4c..79b0de7b6a 100644 --- a/test/spec/client-side-operations-timeout/close-cursors.json +++ b/test/spec/client-side-operations-timeout/close-cursors.json @@ -75,7 +75,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 50 + "blockTimeMS": 250 } } } @@ -86,7 +86,7 @@ "arguments": { "filter": {}, "batchSize": 2, - "timeoutMS": 20 + "timeoutMS": 200 }, "saveResultAsEntity": "cursor" }, @@ -175,7 +175,7 @@ "killCursors" ], "blockConnection": true, - "blockTimeMS": 30 + "blockTimeMS": 250 } } } @@ -186,7 +186,7 @@ "arguments": { "filter": {}, "batchSize": 2, - "timeoutMS": 20 + "timeoutMS": 200 }, "saveResultAsEntity": "cursor" }, @@ -194,7 +194,7 @@ "name": "close", "object": "cursor", "arguments": { - "timeoutMS": 40 + "timeoutMS": 400 } } ], @@ -215,7 +215,7 @@ { "commandStartedEvent": { "command": { - "killCursors": "collection", + "killCursors": "coll", "maxTimeMS": { "$$type": [ "int", diff --git a/test/spec/client-side-operations-timeout/close-cursors.yml b/test/spec/client-side-operations-timeout/close-cursors.yml index db26e79ca3..c4c4ea0acd 100644 --- a/test/spec/client-side-operations-timeout/close-cursors.yml +++ b/test/spec/client-side-operations-timeout/close-cursors.yml @@ -46,13 +46,13 @@ tests: data: failCommands: ["getMore"] blockConnection: true - blockTimeMS: 50 + blockTimeMS: 250 - name: createFindCursor object: *collection arguments: filter: {} batchSize: 2 - timeoutMS: 20 + timeoutMS: 200 saveResultAsEntity: &cursor cursor # Iterate the cursor three times. The third should do a getMore, which should fail with a timeout error. - name: iterateUntilDocumentOrError @@ -99,18 +99,18 @@ tests: data: failCommands: ["killCursors"] blockConnection: true - blockTimeMS: 30 + blockTimeMS: 250 - name: createFindCursor object: *collection arguments: filter: {} batchSize: 2 - timeoutMS: 20 + timeoutMS: 200 saveResultAsEntity: &cursor cursor - name: close object: *cursor arguments: - timeoutMS: 40 + timeoutMS: 400 expectEvents: - client: *client events: @@ -120,7 +120,7 @@ tests: commandName: find - commandStartedEvent: command: - killCursors: *collection + killCursors: *collectionName maxTimeMS: { $$type: ["int", "long"] } commandName: killCursors - commandSucceededEvent: diff --git a/test/spec/client-side-operations-timeout/command-execution.json b/test/spec/client-side-operations-timeout/command-execution.json index b9b306c7fb..aa9c3eb23f 100644 --- a/test/spec/client-side-operations-timeout/command-execution.json +++ b/test/spec/client-side-operations-timeout/command-execution.json @@ -3,7 +3,7 @@ "schemaVersion": "1.9", "runOnRequirements": [ { - "minServerVersion": "4.9", + "minServerVersion": "4.4.7", "topologies": [ "single", "replicaset", diff --git a/test/spec/client-side-operations-timeout/command-execution.yml b/test/spec/client-side-operations-timeout/command-execution.yml index 400a90867a..6ba0585b3c 100644 --- a/test/spec/client-side-operations-timeout/command-execution.yml +++ b/test/spec/client-side-operations-timeout/command-execution.yml @@ -3,9 +3,8 @@ description: "timeoutMS behaves correctly during command execution" schemaVersion: "1.9" runOnRequirements: - # The appName filter cannot be used to set a fail point on connection handshakes until server version 4.9 due to - # SERVER-49220/SERVER-49336. - - minServerVersion: "4.9" + # Require SERVER-49336 for failCommand + appName on the initial handshake. + - minServerVersion: "4.4.7" # Skip load-balanced and serverless which do not support RTT measurements. topologies: [ single, replicaset, sharded ] serverless: forbid diff --git a/test/spec/client-side-operations-timeout/convenient-transactions.json b/test/spec/client-side-operations-timeout/convenient-transactions.json index 07e676d5f5..3868b3026c 100644 --- a/test/spec/client-side-operations-timeout/convenient-transactions.json +++ b/test/spec/client-side-operations-timeout/convenient-transactions.json @@ -21,7 +21,7 @@ "client": { "id": "client", "uriOptions": { - "timeoutMS": 50 + "timeoutMS": 500 }, "useMultipleMongoses": false, "observeEvents": [ @@ -81,6 +81,9 @@ } } ] + }, + "expectError": { + "isClientError": true } } ], @@ -109,7 +112,7 @@ "insert" ], "blockConnection": true, - "blockTimeMS": 30 + "blockTimeMS": 300 } } } @@ -182,6 +185,21 @@ } } } + }, + { + "commandStartedEvent": { + "commandName": "abortTransaction", + "databaseName": "admin", + "command": { + "abortTransaction": 1, + "maxTimeMS": { + "$$type": [ + "int", + "long" + ] + } + } + } } ] } diff --git a/test/spec/client-side-operations-timeout/convenient-transactions.yml b/test/spec/client-side-operations-timeout/convenient-transactions.yml index d79aa4bd05..02d48b8324 100644 --- a/test/spec/client-side-operations-timeout/convenient-transactions.yml +++ b/test/spec/client-side-operations-timeout/convenient-transactions.yml @@ -13,7 +13,7 @@ createEntities: - client: id: &client client uriOptions: - timeoutMS: 50 + timeoutMS: 500 useMultipleMongoses: false observeEvents: - commandStartedEvent @@ -49,6 +49,8 @@ tests: timeoutMS: 100 expectError: isClientError: true + expectError: + isClientError: true expectEvents: # The only operation run fails with a client-side error, so there should be no events for the client. - client: *client @@ -66,7 +68,7 @@ tests: data: failCommands: ["insert"] blockConnection: true - blockTimeMS: 30 + blockTimeMS: 300 - name: withTransaction object: *session arguments: @@ -88,9 +90,6 @@ tests: expectEvents: - client: *client events: - # Because the second insert expects an error and gets an error, it technically succeeds, so withTransaction - # will try to run commitTransaction. This will fail client-side, though, because the timeout has already - # expired, so no command is sent. - commandStartedEvent: commandName: insert databaseName: *databaseName @@ -103,3 +102,9 @@ tests: command: insert: *collectionName maxTimeMS: { $$type: ["int", "long"] } + - commandStartedEvent: + commandName: abortTransaction + databaseName: admin + command: + abortTransaction: 1 + maxTimeMS: { $$type: [ "int", "long" ] } diff --git a/test/spec/client-side-operations-timeout/deprecated-options.json b/test/spec/client-side-operations-timeout/deprecated-options.json index 322e944910..d3e4631ff4 100644 --- a/test/spec/client-side-operations-timeout/deprecated-options.json +++ b/test/spec/client-side-operations-timeout/deprecated-options.json @@ -1,5 +1,5 @@ { - "description": "operations ignore deprected timeout options if timeoutMS is set", + "description": "operations ignore deprecated timeout options if timeoutMS is set", "schemaVersion": "1.9", "runOnRequirements": [ { diff --git a/test/spec/client-side-operations-timeout/deprecated-options.yml b/test/spec/client-side-operations-timeout/deprecated-options.yml index 461ba6ab13..582a8983ae 100644 --- a/test/spec/client-side-operations-timeout/deprecated-options.yml +++ b/test/spec/client-side-operations-timeout/deprecated-options.yml @@ -1,4 +1,4 @@ -description: "operations ignore deprected timeout options if timeoutMS is set" +description: "operations ignore deprecated timeout options if timeoutMS is set" schemaVersion: "1.9" diff --git a/test/spec/client-side-operations-timeout/gridfs-advanced.yml b/test/spec/client-side-operations-timeout/gridfs-advanced.yml index bc788bacc3..f6c37e165b 100644 --- a/test/spec/client-side-operations-timeout/gridfs-advanced.yml +++ b/test/spec/client-side-operations-timeout/gridfs-advanced.yml @@ -119,7 +119,7 @@ tests: update: *filesCollectionName maxTimeMS: { $$type: ["int", "long"] } - # Tests for the "drop" opration. Any tests that might result in multiple commands being sent do not have expectEvents + # Tests for the "drop" operation. Any tests that might result in multiple commands being sent do not have expectEvents # assertions as these assertions reduce test robustness and can cause flaky failures. - description: "timeoutMS can be overridden for drop" diff --git a/test/spec/client-side-operations-timeout/non-tailable-cursors.json b/test/spec/client-side-operations-timeout/non-tailable-cursors.json index 0a5448a6bb..291c6e72aa 100644 --- a/test/spec/client-side-operations-timeout/non-tailable-cursors.json +++ b/test/spec/client-side-operations-timeout/non-tailable-cursors.json @@ -17,7 +17,7 @@ "client": { "id": "client", "uriOptions": { - "timeoutMS": 10 + "timeoutMS": 200 }, "useMultipleMongoses": false, "observeEvents": [ @@ -84,7 +84,7 @@ "find" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 250 } } } @@ -143,7 +143,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 125 } } } @@ -153,7 +153,7 @@ "object": "collection", "arguments": { "filter": {}, - "timeoutMS": 20, + "timeoutMS": 200, "batchSize": 2 }, "expectError": { @@ -221,7 +221,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 150 } } } @@ -232,7 +232,7 @@ "arguments": { "filter": {}, "timeoutMode": "cursorLifetime", - "timeoutMS": 20, + "timeoutMS": 200, "batchSize": 2 }, "expectError": { @@ -299,7 +299,7 @@ "find" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 250 } } } @@ -355,7 +355,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 125 } } } @@ -366,7 +366,7 @@ "arguments": { "filter": {}, "timeoutMode": "iteration", - "timeoutMS": 20, + "timeoutMS": 200, "batchSize": 2 } } @@ -427,7 +427,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 250 } } } diff --git a/test/spec/client-side-operations-timeout/non-tailable-cursors.yml b/test/spec/client-side-operations-timeout/non-tailable-cursors.yml index 8cd953dec4..29037b4c0a 100644 --- a/test/spec/client-side-operations-timeout/non-tailable-cursors.yml +++ b/test/spec/client-side-operations-timeout/non-tailable-cursors.yml @@ -12,7 +12,7 @@ createEntities: - client: id: &client client uriOptions: - timeoutMS: 10 + timeoutMS: 200 useMultipleMongoses: false observeEvents: - commandStartedEvent @@ -53,7 +53,7 @@ tests: data: failCommands: ["find"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 250 - name: find object: *collection arguments: @@ -86,14 +86,14 @@ tests: data: failCommands: ["find", "getMore"] blockConnection: true - blockTimeMS: 15 - # Run a find with timeoutMS=20 and batchSize=1 to force two batches, which will cause a find and a getMore to be - # sent. Both will block for 15ms so together they will go over the timeout. + blockTimeMS: 125 + # Run a find with timeoutMS=200 and batchSize=1 to force two batches, which will cause a find and a getMore to be + # sent. Both will block for 125ms, so together they will go over the timeout. - name: find object: *collection arguments: filter: {} - timeoutMS: 20 + timeoutMS: 200 batchSize: 2 expectError: isTimeoutError: true @@ -127,13 +127,13 @@ tests: data: failCommands: ["find", "getMore"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 150 - name: find object: *collection arguments: filter: {} timeoutMode: cursorLifetime - timeoutMS: 20 + timeoutMS: 200 batchSize: 2 expectError: isTimeoutError: true @@ -168,7 +168,7 @@ tests: data: failCommands: ["find"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 250 - name: find object: *collection arguments: @@ -187,8 +187,8 @@ tests: maxTimeMS: { $$exists: false } # If timeoutMode=ITERATION, timeoutMS applies separately to the initial find and the getMore on the cursor. Neither - # command should have a maxTimeMS field. This is a success test. The "find" is executed with timeoutMS=20 and both - # "find" and "getMore" commands are blocked for 15ms each. Neither exceeds the timeout, so iteration succeeds. + # command should have a maxTimeMS field. This is a success test. The "find" is executed with timeoutMS=200 and both + # "find" and "getMore" commands are blocked for 125ms each. Neither exceeds the timeout, so iteration succeeds. - description: "timeoutMS is refreshed for getMore if timeoutMode is iteration - success" operations: - name: failPoint @@ -201,13 +201,13 @@ tests: data: failCommands: ["find", "getMore"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 125 - name: find object: *collection arguments: filter: {} timeoutMode: iteration - timeoutMS: 20 + timeoutMS: 200 batchSize: 2 expectEvents: - client: *client @@ -227,8 +227,8 @@ tests: maxTimeMS: { $$exists: false } # If timeoutMode=ITERATION, timeoutMS applies separately to the initial find and the getMore on the cursor. Neither - # command should have a maxTimeMS field. This is a failure test. The "find" inherits timeoutMS=10 and "getMore" - # commands are blocked for 15ms, causing iteration to fail with a timeout error. + # command should have a maxTimeMS field. This is a failure test. The "find" inherits timeoutMS=200 and "getMore" + # commands are blocked for 250ms, causing iteration to fail with a timeout error. - description: "timeoutMS is refreshed for getMore if timeoutMode is iteration - failure" operations: - name: failPoint @@ -241,7 +241,7 @@ tests: data: failCommands: ["getMore"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 250 - name: find object: *collection arguments: diff --git a/test/spec/client-side-operations-timeout/retryability-timeoutMS.json b/test/spec/client-side-operations-timeout/retryability-timeoutMS.json index a28dbd2685..9daad260ef 100644 --- a/test/spec/client-side-operations-timeout/retryability-timeoutMS.json +++ b/test/spec/client-side-operations-timeout/retryability-timeoutMS.json @@ -108,6 +108,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - insertOne on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -198,6 +203,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - insertOne on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -327,6 +337,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - insertMany on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -419,6 +434,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - insertMany on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -546,6 +566,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - deleteOne on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -634,6 +659,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - deleteOne on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -760,6 +790,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - replaceOne on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -851,6 +886,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - replaceOne on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -982,6 +1022,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - updateOne on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -1075,6 +1120,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - updateOne on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -1203,6 +1253,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - findOneAndDelete on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -1291,6 +1346,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - findOneAndDelete on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -1417,6 +1477,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - findOneAndReplace on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -1508,6 +1573,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - findOneAndReplace on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -1639,6 +1709,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - findOneAndUpdate on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -1732,6 +1807,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - findOneAndUpdate on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -1868,6 +1948,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - bulkWrite on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -1964,6 +2049,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - bulkWrite on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -2095,6 +2185,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - listDatabases on client", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -2183,6 +2278,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - listDatabases on client", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -2303,6 +2403,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - listDatabaseNames on client", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -2390,6 +2495,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - listDatabaseNames on client", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -2512,6 +2622,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - createChangeStream on client", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -2600,6 +2715,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - createChangeStream on client", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -2730,6 +2850,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - aggregate on database", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -2825,6 +2950,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - aggregate on database", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -2955,6 +3085,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - listCollections on database", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -3043,6 +3178,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - listCollections on database", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -3166,6 +3306,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - listCollectionNames on database", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -3254,6 +3399,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - listCollectionNames on database", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -3377,6 +3527,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - createChangeStream on database", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -3465,6 +3620,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - createChangeStream on database", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -3588,6 +3748,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - aggregate on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -3676,6 +3841,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - aggregate on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -3799,6 +3969,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - count on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -3887,6 +4062,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - count on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -4010,6 +4190,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - countDocuments on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -4098,6 +4283,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - countDocuments on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -4218,6 +4408,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - estimatedDocumentCount on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -4305,6 +4500,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - estimatedDocumentCount on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -4428,6 +4628,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - distinct on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -4517,6 +4722,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - distinct on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -4641,6 +4851,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - find on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -4729,6 +4944,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - find on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -4852,6 +5072,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - findOne on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -4940,6 +5165,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - findOne on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -5060,6 +5290,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - listIndexes on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -5147,6 +5382,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - listIndexes on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -5269,6 +5509,11 @@ }, { "description": "operation is retried multiple times for non-zero timeoutMS - createChangeStream on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", @@ -5357,6 +5602,11 @@ }, { "description": "operation is retried multiple times if timeoutMS is zero - createChangeStream on collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.1" + } + ], "operations": [ { "name": "failPoint", diff --git a/test/spec/client-side-operations-timeout/retryability-timeoutMS.yml b/test/spec/client-side-operations-timeout/retryability-timeoutMS.yml index 039f7ca42e..6f47d6c2e4 100644 --- a/test/spec/client-side-operations-timeout/retryability-timeoutMS.yml +++ b/test/spec/client-side-operations-timeout/retryability-timeoutMS.yml @@ -84,6 +84,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - insertOne on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -125,6 +127,8 @@ tests: insert: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - insertOne on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -191,6 +195,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - insertMany on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -233,6 +239,8 @@ tests: insert: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - insertMany on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -299,6 +307,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - deleteOne on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -340,6 +350,8 @@ tests: delete: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - deleteOne on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -406,6 +418,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - replaceOne on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -448,6 +462,8 @@ tests: update: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - replaceOne on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -515,6 +531,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - updateOne on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -557,6 +575,8 @@ tests: update: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - updateOne on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -623,6 +643,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - findOneAndDelete on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -664,6 +686,8 @@ tests: findAndModify: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - findOneAndDelete on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -730,6 +754,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - findOneAndReplace on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -772,6 +798,8 @@ tests: findAndModify: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - findOneAndReplace on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -839,6 +867,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - findOneAndUpdate on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -881,6 +911,8 @@ tests: findAndModify: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - findOneAndUpdate on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -949,6 +981,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - bulkWrite on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -992,6 +1026,8 @@ tests: insert: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - bulkWrite on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1059,6 +1095,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - listDatabases on client" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1100,6 +1138,8 @@ tests: listDatabases: 1 maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - listDatabases on client" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1163,6 +1203,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - listDatabaseNames on client" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1203,6 +1245,8 @@ tests: listDatabases: 1 maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - listDatabaseNames on client" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1267,6 +1311,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - createChangeStream on client" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1308,6 +1354,8 @@ tests: aggregate: 1 maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - createChangeStream on client" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1373,6 +1421,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - aggregate on database" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1414,6 +1464,8 @@ tests: aggregate: 1 maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - aggregate on database" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1479,6 +1531,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - listCollections on database" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1520,6 +1574,8 @@ tests: listCollections: 1 maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - listCollections on database" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1585,6 +1641,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - listCollectionNames on database" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1626,6 +1684,8 @@ tests: listCollections: 1 maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - listCollectionNames on database" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1691,6 +1751,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - createChangeStream on database" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1732,6 +1794,8 @@ tests: aggregate: 1 maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - createChangeStream on database" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1797,6 +1861,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - aggregate on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1838,6 +1904,8 @@ tests: aggregate: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - aggregate on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1903,6 +1971,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - count on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -1944,6 +2014,8 @@ tests: count: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - count on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2009,6 +2081,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - countDocuments on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2050,6 +2124,8 @@ tests: aggregate: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - countDocuments on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2113,6 +2189,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - estimatedDocumentCount on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2153,6 +2231,8 @@ tests: count: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - estimatedDocumentCount on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2218,6 +2298,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - distinct on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2260,6 +2342,8 @@ tests: distinct: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - distinct on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2326,6 +2410,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - find on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2367,6 +2453,8 @@ tests: find: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - find on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2432,6 +2520,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - findOne on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2473,6 +2563,8 @@ tests: find: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - findOne on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2536,6 +2628,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - listIndexes on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2576,6 +2670,8 @@ tests: listIndexes: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - listIndexes on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2640,6 +2736,8 @@ tests: expectError: isTimeoutError: true - description: "operation is retried multiple times for non-zero timeoutMS - createChangeStream on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner @@ -2681,6 +2779,8 @@ tests: aggregate: *collectionName maxTimeMS: { $$type: ["int", "long"] } - description: "operation is retried multiple times if timeoutMS is zero - createChangeStream on collection" + runOnRequirements: + - minServerVersion: "4.3.1" # failCommand errorLabels option operations: - name: failPoint object: testRunner diff --git a/test/spec/client-side-operations-timeout/tailable-awaitData.json b/test/spec/client-side-operations-timeout/tailable-awaitData.json index 6da85c7783..535fb69243 100644 --- a/test/spec/client-side-operations-timeout/tailable-awaitData.json +++ b/test/spec/client-side-operations-timeout/tailable-awaitData.json @@ -17,7 +17,7 @@ "client": { "id": "client", "uriOptions": { - "timeoutMS": 10 + "timeoutMS": 200 }, "useMultipleMongoses": false, "observeEvents": [ @@ -130,7 +130,7 @@ "find" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 300 } } } @@ -188,7 +188,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 150 } } } @@ -199,7 +199,7 @@ "arguments": { "filter": {}, "cursorType": "tailableAwait", - "timeoutMS": 20, + "timeoutMS": 250, "batchSize": 1 }, "saveResultAsEntity": "tailableCursor" @@ -272,7 +272,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 150 } } } @@ -283,7 +283,7 @@ "arguments": { "filter": {}, "cursorType": "tailableAwait", - "timeoutMS": 20, + "timeoutMS": 250, "batchSize": 1, "maxAwaitTimeMS": 1 }, @@ -354,7 +354,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 250 } } } diff --git a/test/spec/client-side-operations-timeout/tailable-awaitData.yml b/test/spec/client-side-operations-timeout/tailable-awaitData.yml index 422c6fb537..52b9b3b456 100644 --- a/test/spec/client-side-operations-timeout/tailable-awaitData.yml +++ b/test/spec/client-side-operations-timeout/tailable-awaitData.yml @@ -12,7 +12,7 @@ createEntities: - client: id: &client client uriOptions: - timeoutMS: 10 + timeoutMS: 200 useMultipleMongoses: false observeEvents: - commandStartedEvent @@ -83,7 +83,7 @@ tests: data: failCommands: ["find"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 300 - name: find object: *collection arguments: @@ -117,13 +117,13 @@ tests: data: failCommands: ["find", "getMore"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 150 - name: createFindCursor object: *collection arguments: filter: {} cursorType: tailableAwait - timeoutMS: 20 + timeoutMS: 250 batchSize: 1 saveResultAsEntity: &tailableCursor tailableCursor # Iterate twice to force a getMore. The first iteration will return the document from the first batch and the @@ -165,13 +165,13 @@ tests: data: failCommands: ["find", "getMore"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 150 - name: createFindCursor object: *collection arguments: filter: {} cursorType: tailableAwait - timeoutMS: 20 + timeoutMS: 250 batchSize: 1 maxAwaitTimeMS: 1 saveResultAsEntity: &tailableCursor tailableCursor @@ -199,8 +199,8 @@ tests: collection: *collectionName maxTimeMS: 1 - # The timeoutMS value should be refreshed for getMore's. This is a failure test. The find inherits timeoutMS=10 from - # the collection and the getMore blocks for 15ms, causing iteration to fail with a timeout error. + # The timeoutMS value should be refreshed for getMore's. This is a failure test. The find inherits timeoutMS=200 from + # the collection and the getMore blocks for 250ms, causing iteration to fail with a timeout error. - description: "timeoutMS is refreshed for getMore - failure" operations: - name: failPoint @@ -213,7 +213,7 @@ tests: data: failCommands: ["getMore"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 250 - name: createFindCursor object: *collection arguments: diff --git a/test/spec/client-side-operations-timeout/tailable-non-awaitData.json b/test/spec/client-side-operations-timeout/tailable-non-awaitData.json index 34ee660963..e88230e4f7 100644 --- a/test/spec/client-side-operations-timeout/tailable-non-awaitData.json +++ b/test/spec/client-side-operations-timeout/tailable-non-awaitData.json @@ -17,7 +17,7 @@ "client": { "id": "client", "uriOptions": { - "timeoutMS": 10 + "timeoutMS": 200 }, "useMultipleMongoses": false, "observeEvents": [ @@ -94,7 +94,7 @@ "find" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 250 } } } @@ -154,7 +154,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 150 } } } @@ -165,7 +165,7 @@ "arguments": { "filter": {}, "cursorType": "tailable", - "timeoutMS": 20, + "timeoutMS": 200, "batchSize": 1 }, "saveResultAsEntity": "tailableCursor" @@ -239,7 +239,7 @@ "getMore" ], "blockConnection": true, - "blockTimeMS": 15 + "blockTimeMS": 250 } } } diff --git a/test/spec/client-side-operations-timeout/tailable-non-awaitData.yml b/test/spec/client-side-operations-timeout/tailable-non-awaitData.yml index 766b46e658..eb75deaa65 100644 --- a/test/spec/client-side-operations-timeout/tailable-non-awaitData.yml +++ b/test/spec/client-side-operations-timeout/tailable-non-awaitData.yml @@ -12,7 +12,7 @@ createEntities: - client: id: &client client uriOptions: - timeoutMS: 10 + timeoutMS: 200 useMultipleMongoses: false observeEvents: - commandStartedEvent @@ -59,7 +59,7 @@ tests: data: failCommands: ["find"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 250 - name: find object: *collection arguments: @@ -96,13 +96,13 @@ tests: data: failCommands: ["find", "getMore"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 150 - name: createFindCursor object: *collection arguments: filter: {} cursorType: tailable - timeoutMS: 20 + timeoutMS: 200 batchSize: 1 saveResultAsEntity: &tailableCursor tailableCursor # Iterate the cursor twice: the first iteration will return the document from the batch in the find and the @@ -131,7 +131,7 @@ tests: maxTimeMS: { $$exists: false } # The timeoutMS option should apply separately to the initial "find" and each getMore. This is a failure test. The - # find inherits timeoutMS=10 from the collection and the getMore command blocks for 15ms, causing iteration to fail + # find inherits timeoutMS=200 from the collection and the getMore command blocks for 250ms, causing iteration to fail # with a timeout error. - description: "timeoutMS is refreshed for getMore - failure" operations: @@ -145,7 +145,7 @@ tests: data: failCommands: ["getMore"] blockConnection: true - blockTimeMS: 15 + blockTimeMS: 250 - name: createFindCursor object: *collection arguments: diff --git a/test/tools/unified-spec-runner/operations.ts b/test/tools/unified-spec-runner/operations.ts index 5b5b704069..31414fa466 100644 --- a/test/tools/unified-spec-runner/operations.ts +++ b/test/tools/unified-spec-runner/operations.ts @@ -215,7 +215,8 @@ operations.set('close', async ({ entities, operation }) => { /* eslint-disable no-empty */ try { const cursor = entities.getEntity('cursor', operation.object); - await cursor.close(); + const timeoutMS = operation.arguments?.timeoutMS; + await cursor.close({ timeoutMS }); return; } catch {} @@ -787,7 +788,9 @@ operations.set('runCursorCommand', async ({ entities, operation }: OperationFunc const { command, ...opts } = operation.arguments!; const cursor = db.runCursorCommand(command, { readPreference: ReadPreference.fromOptions({ readPreference: opts.readPreference }), - session: opts.session + session: opts.session, + timeoutMode: opts.timeoutMode, + timeoutMS: opts.timeoutMS }); if (!Number.isNaN(+opts.batchSize)) cursor.setBatchSize(+opts.batchSize); diff --git a/test/unit/cursor/aggregation_cursor.test.ts b/test/unit/cursor/aggregation_cursor.test.ts index 32ca4125ff..82ae18745b 100644 --- a/test/unit/cursor/aggregation_cursor.test.ts +++ b/test/unit/cursor/aggregation_cursor.test.ts @@ -1,6 +1,12 @@ import { expect } from 'chai'; -import { type AggregationCursor, MongoClient } from '../../mongodb'; +import { + AggregationCursor, + CursorTimeoutMode, + MongoAPIError, + MongoClient, + ns +} from '../../mongodb'; describe('class AggregationCursor', () => { let client: MongoClient; @@ -126,6 +132,38 @@ describe('class AggregationCursor', () => { }); context('when addStage, bespoke stage methods, or array is used to construct pipeline', () => { + context('when CSOT is enabled', () => { + let aggregationCursor: AggregationCursor; + before(function () { + aggregationCursor = client + .db('test') + .collection('test') + .aggregate([], { timeoutMS: 100, timeoutMode: CursorTimeoutMode.ITERATION }); + }); + + context('when a $out stage is added with .addStage()', () => { + it('throws a MongoAPIError', function () { + expect(() => { + aggregationCursor.addStage({ $out: 'test' }); + }).to.throw(MongoAPIError); + }); + }); + context('when a $merge stage is added with .addStage()', () => { + it('throws a MongoAPIError', function () { + expect(() => { + aggregationCursor.addStage({ $merge: {} }); + }).to.throw(MongoAPIError); + }); + }); + context('when a $out stage is added with .out()', () => { + it('throws a MongoAPIError', function () { + expect(() => { + aggregationCursor.out('test'); + }).to.throw(MongoAPIError); + }); + }); + }); + it('sets deeply identical aggregations pipelines', () => { const collection = client.db().collection('test'); @@ -157,4 +195,31 @@ describe('class AggregationCursor', () => { expect(builderGenericStageCursor.pipeline).to.deep.equal(expectedPipeline); }); }); + + describe('constructor()', () => { + context('when CSOT is enabled', () => { + context('when timeoutMode=ITERATION and a $out stage is provided', function () { + it('throws a MongoAPIError', function () { + expect( + () => + new AggregationCursor(client, ns('db.coll'), [{ $out: 'test' }], { + timeoutMS: 100, + timeoutMode: 'iteration' + }) + ).to.throw(MongoAPIError); + }); + }); + context('when timeoutMode=ITERATION and a $merge stage is provided', function () { + it('throws a MongoAPIError', function () { + expect( + () => + new AggregationCursor(client, ns('db.coll'), [{ $merge: 'test' }], { + timeoutMS: 100, + timeoutMode: 'iteration' + }) + ).to.throw(MongoAPIError); + }); + }); + }); + }); }); From 738188bd27c1e3c77ea994352d0cdf3c76168ac0 Mon Sep 17 00:00:00 2001 From: Neal Beeken Date: Thu, 12 Sep 2024 15:24:39 -0400 Subject: [PATCH 25/39] fix(NODE-6374): MongoOperationTimeoutError inherits MongoRuntimeError (#4237) --- etc/notes/errors.md | 6 +++++- src/error.ts | 21 ++++++++++++++++++--- test/unit/error.test.ts | 20 ++++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/etc/notes/errors.md b/etc/notes/errors.md index d0f8e6b6e9..114bc1b2e2 100644 --- a/etc/notes/errors.md +++ b/etc/notes/errors.md @@ -67,7 +67,7 @@ Children of `MongoError` include: ### `MongoDriverError` This class represents errors which originate in the driver itself or when the user incorrectly uses the driver. This class should **never** be directly instantiated. -Its children are the main classes of errors that most users will interact with: [**`MongoAPIError`**](#MongoAPIError) and [**`MongoRuntimeError`**](#MongoRuntimeError). +Its children are the main classes of errors that most users will interact with: [**`MongoAPIError`**](#MongoAPIError), [**`MongoRuntimeError`**](#MongoRuntimeError) and [**`MongoOperationTimeoutError`**](#MongoOperationTimeoutError). ### `MongoAPIError` @@ -109,6 +109,10 @@ This class should **never** be directly instantiated. | **MongoGridFSChunkError** | Thrown when a malformed or invalid chunk is encountered when reading from a GridFS Stream. | | **MongoUnexpectedServerResponseError** | Thrown when the driver receives a **parsable** response it did not expect from the server. | +### `MongoOperationTimeoutError` + +- TODO(NODE-5688): Add MongoOperationTimeoutError documentation + ### MongoUnexpectedServerResponseError Intended for the scenario where the MongoDB returns an unexpected response in relation to some state the driver is in. diff --git a/src/error.ts b/src/error.ts index 3f803a8c4a..c8420d30b0 100644 --- a/src/error.ts +++ b/src/error.ts @@ -310,7 +310,7 @@ export class MongoAPIError extends MongoDriverError { /** * An error generated when the driver encounters unexpected input - * or reaches an unexpected/invalid internal state + * or reaches an unexpected/invalid internal state. * * @privateRemarks * Should **never** be directly instantiated. @@ -792,9 +792,24 @@ export class MongoUnexpectedServerResponseError extends MongoRuntimeError { } /** - * @internal + * @public + * @category Error + * + * This error is thrown when an operation could not be completed within the specified `timeoutMS`. + * TODO(NODE-5688): expand this documentation. + * + * @example + * ```ts + * try { + * await blogs.insertOne(blogPost, { timeoutMS: 60_000 }) + * } catch (error) { + * if (error instanceof MongoOperationTimeoutError) { + * console.log(`Oh no! writer's block!`, error); + * } + * } + * ``` */ -export class MongoOperationTimeoutError extends MongoRuntimeError { +export class MongoOperationTimeoutError extends MongoDriverError { override get name(): string { return 'MongoOperationTimeoutError'; } diff --git a/test/unit/error.test.ts b/test/unit/error.test.ts index bdc049cbc4..dca792bd38 100644 --- a/test/unit/error.test.ts +++ b/test/unit/error.test.ts @@ -14,12 +14,15 @@ import { LEGACY_NOT_PRIMARY_OR_SECONDARY_ERROR_MESSAGE, LEGACY_NOT_WRITABLE_PRIMARY_ERROR_MESSAGE, MONGODB_ERROR_CODES, + MongoDriverError, MongoError, MongoErrorLabel, MongoMissingDependencyError, MongoNetworkError, MongoNetworkTimeoutError, + MongoOperationTimeoutError, MongoParseError, + MongoRuntimeError, MongoServerError, MongoSystemError, MongoWriteConcernError, @@ -173,6 +176,23 @@ describe('MongoErrors', () => { }); }); + describe('class MongoOperationTimeoutError', () => { + it('has a name property equal to MongoOperationTimeoutError', () => { + const error = new MongoOperationTimeoutError('time out!'); + expect(error).to.have.property('name', 'MongoOperationTimeoutError'); + }); + + it('is instanceof MongoDriverError', () => { + const error = new MongoOperationTimeoutError('time out!'); + expect(error).to.be.instanceOf(MongoDriverError); + }); + + it('is not instanceof MongoRuntimeError', () => { + const error = new MongoOperationTimeoutError('time out!'); + expect(error).to.not.be.instanceOf(MongoRuntimeError); + }); + }); + describe('MongoMissingDependencyError#constructor', () => { context('when options.cause is set', () => { it('attaches the cause property to the instance', () => { From c4a7c2c15759c0c775b71fa49e7cb3ae981142ab Mon Sep 17 00:00:00 2001 From: Warren James Date: Thu, 12 Sep 2024 16:02:50 -0400 Subject: [PATCH 26/39] test: remove empty skipped context blocks (#4238) --- .../client-side-operations-timeout/node_csot.test.ts | 5 ----- 1 file changed, 5 deletions(-) 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 f5ada7eef9..56127cc8ac 100644 --- a/test/integration/client-side-operations-timeout/node_csot.test.ts +++ b/test/integration/client-side-operations-timeout/node_csot.test.ts @@ -576,11 +576,6 @@ describe('CSOT driver tests', metadata, () => { }); }); - describe.skip('Tailable non-awaitData cursors').skipReason = - 'TODO(NODE-6305): implement CSOT for Tailable cursors'; - describe.skip('Tailable awaitData cursors').skipReason = - 'TODO(NODE-6305): implement CSOT for Tailable cursors'; - describe('when using an explicit session', () => { const metadata: MongoDBMetadataUI = { requires: { topology: ['replicaset'], mongodb: '>=4.4' } From 5aa6d4cf019d36fc0f35d445de787c9f65246fba Mon Sep 17 00:00:00 2001 From: Neal Beeken Date: Tue, 17 Sep 2024 13:27:43 -0400 Subject: [PATCH 27/39] feat(NODE-5844): add iscryptd to ServerDescription (#4239) --- src/sdam/server_description.ts | 4 ++ .../server_description.test.ts | 56 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 test/integration/server-discovery-and-monitoring/server_description.test.ts diff --git a/src/sdam/server_description.ts b/src/sdam/server_description.ts index cd32f4968b..d5b67c1808 100644 --- a/src/sdam/server_description.ts +++ b/src/sdam/server_description.ts @@ -69,6 +69,8 @@ export class ServerDescription { setVersion: number | null; electionId: ObjectId | null; logicalSessionTimeoutMinutes: number | null; + /** Indicates server is a mongocryptd instance. */ + iscryptd: boolean; // NOTE: does this belong here? It seems we should gossip the cluster time at the CMAP level $clusterTime?: ClusterTime; @@ -114,6 +116,7 @@ export class ServerDescription { this.primary = hello?.primary ?? null; this.me = hello?.me?.toLowerCase() ?? null; this.$clusterTime = hello?.$clusterTime ?? null; + this.iscryptd = Boolean(hello?.iscryptd); } get hostAddress(): HostAddress { @@ -167,6 +170,7 @@ export class ServerDescription { return ( other != null && + other.iscryptd === this.iscryptd && errorStrictEqual(this.error, other.error) && this.type === other.type && this.minWireVersion === other.minWireVersion && diff --git a/test/integration/server-discovery-and-monitoring/server_description.test.ts b/test/integration/server-discovery-and-monitoring/server_description.test.ts new file mode 100644 index 0000000000..0a3c7eecbf --- /dev/null +++ b/test/integration/server-discovery-and-monitoring/server_description.test.ts @@ -0,0 +1,56 @@ +import { type ChildProcess, spawn } from 'node:child_process'; + +import { expect } from 'chai'; + +import { MongoClient } from '../../mongodb'; + +describe('class ServerDescription', function () { + describe('when connecting to mongocryptd', { requires: { mongodb: '>=4.4' } }, function () { + let client: MongoClient; + const mongocryptdTestPort = '27022'; + let childProcess: ChildProcess; + + beforeEach(async function () { + childProcess = spawn('mongocryptd', ['--port', mongocryptdTestPort, '--ipv6'], { + stdio: 'ignore', + detached: true + }); + + childProcess.on('error', error => console.warn(this.currentTest?.fullTitle(), error)); + client = new MongoClient(`mongodb://localhost:${mongocryptdTestPort}`); + }); + + afterEach(async function () { + await client?.close(); + childProcess.kill('SIGKILL'); + }); + + it('iscryptd is set to true ', async function () { + const descriptions = []; + client.on('serverDescriptionChanged', description => descriptions.push(description)); + const hello = await client.db().command({ hello: true }); + expect(hello).to.have.property('iscryptd', true); + expect(descriptions.at(-1)).to.have.nested.property('newDescription.iscryptd', true); + }); + }); + + describe('when connecting to anything other than mongocryptd', function () { + let client: MongoClient; + + beforeEach(async function () { + client = this.configuration.newClient(); + }); + + afterEach(async function () { + await client?.close(); + }); + + it('iscryptd is set to false ', async function () { + const descriptions = []; + client.on('serverDescriptionChanged', description => descriptions.push(description)); + const hello = await client.db().command({ hello: true }); + expect(hello).to.not.have.property('iscryptd'); + expect(descriptions.at(-1)).to.have.nested.property('newDescription.iscryptd', false); + }); + }); +}); From 17a2fdece6fb36f5fb2d0155c781780416c27f7e Mon Sep 17 00:00:00 2001 From: Warren James Date: Wed, 25 Sep 2024 17:43:12 -0400 Subject: [PATCH 28/39] chore: allow clientBulkWrite to use TimeoutContext (#4251) --- .../client_bulk_write/client_bulk_write.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/operations/client_bulk_write/client_bulk_write.ts b/src/operations/client_bulk_write/client_bulk_write.ts index cb020bde40..b7e6529c3f 100644 --- a/src/operations/client_bulk_write/client_bulk_write.ts +++ b/src/operations/client_bulk_write/client_bulk_write.ts @@ -3,6 +3,7 @@ import { type Document } from 'bson'; import { ClientBulkWriteCursorResponse } from '../../cmap/wire_protocol/responses'; import type { Server } from '../../sdam/server'; import type { ClientSession } from '../../sessions'; +import { type TimeoutContext } from '../../timeout'; import { MongoDBNamespace } from '../../utils'; import { CommandOperation } from '../command'; import { Aspect, defineAspects } from '../operation'; @@ -35,9 +36,16 @@ export class ClientBulkWriteOperation extends CommandOperation { - return await super.executeCommand(server, session, this.command, ClientBulkWriteCursorResponse); + return await super.executeCommand( + server, + session, + this.command, + timeoutContext, + ClientBulkWriteCursorResponse + ); } } From 88ca99042f2d2bc5eb0fa7e26029e18bc673f738 Mon Sep 17 00:00:00 2001 From: Aditi Khare Date: Thu, 26 Sep 2024 17:52:59 -0400 Subject: [PATCH 29/39] half testing lint fix prose test 2 --- src/client-side-encryption/state_machine.ts | 7 +-- src/sdam/server.ts | 4 ++ ...ient_side_operations_timeout.prose.test.ts | 18 +++++-- ...lient_side_operations_timeout.unit.test.ts | 53 +++++++++++++----- .../state_machine.test.ts | 54 +++++++++---------- 5 files changed, 89 insertions(+), 47 deletions(-) diff --git a/src/client-side-encryption/state_machine.ts b/src/client-side-encryption/state_machine.ts index 23e83edf83..ce7bdc483b 100644 --- a/src/client-side-encryption/state_machine.ts +++ b/src/client-side-encryption/state_machine.ts @@ -14,7 +14,7 @@ import { type ProxyOptions } from '../cmap/connection'; import { getSocks, type SocksLib } from '../deps'; import { MongoOperationTimeoutError } from '../error'; import { type MongoClient, type MongoClientOptions } from '../mongo_client'; -import { type CSOTTimeoutContext, Timeout, type TimeoutContext } from '../timeout'; +import { type CSOTTimeoutContext, Timeout, TimeoutError } from '../timeout'; import { BufferPool, MongoDBCollectionNamespace, promiseWithResolvers } from '../utils'; import { autoSelectSocketOptions, type DataKey } from './client_encryption'; import { MongoCryptError } from './errors'; @@ -455,7 +455,8 @@ export class StateMachine { ? Promise.all([willResolveKmsRequest, Timeout.expires(timeoutMS)]) : willResolveKmsRequest); } catch (error) { - if (Timeout.is(error)) throw new MongoOperationTimeoutError('KMS request timed out'); + if (error instanceof TimeoutError) + throw new MongoOperationTimeoutError('KMS request timed out'); throw error; } finally { // There's no need for any more activity on this socket at this point. @@ -595,7 +596,7 @@ export class StateMachine { return client .db(dbName) .collection(collectionName, { readConcern: { level: 'majority' } }) - .find(deserialize(filter), { timeoutMS }) + .find(deserialize(filter), { timeoutMS: timeoutMS != null ? timeoutMS : undefined }) .toArray(); } } diff --git a/src/sdam/server.ts b/src/sdam/server.ts index 20cb13423c..27f4d0808d 100644 --- a/src/sdam/server.ts +++ b/src/sdam/server.ts @@ -311,6 +311,10 @@ export class Server extends TypedEventEmitter { delete finalOptions.readPreference; } + if (this.description.iscryptd) { + finalOptions.omitMaxTimeMS = true; + } + const session = finalOptions.session; let conn = session?.pinnedConnection; 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 9fa625160f..a255c52555 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 @@ -12,7 +12,8 @@ import { MongoClient, MongoOperationTimeoutError, MongoServerSelectionError, - now + now, + squashError } from '../../mongodb'; import { type FailPoint } from '../../tools/utils'; @@ -59,7 +60,7 @@ describe('CSOT spec prose tests', function () { */ }); - context.skip( + context( '2. maxTimeMS is not set for commands sent to mongocryptd', { requires: { mongodb: '>=4.2' } }, () => { @@ -83,24 +84,31 @@ describe('CSOT spec prose tests', function () { }); childProcess.on('error', error => console.warn(this.currentTest?.fullTitle(), error)); - client = new MongoClient(`mongodb://localhost:23000/?timeoutMS=1000`); + client = new MongoClient(`mongodb://localhost:${mongocryptdTestPort}/?timeoutMS=1000`, { + monitorCommands: true + }); }); afterEach(async function () { - await client?.close(); + await client.close(); childProcess.kill('SIGKILL'); + sinon.restore(); }); it('maxTimeMS is not set', async function () { const commandStarted = []; client.on('commandStarted', ev => commandStarted.push(ev)); - await client.db('admin').command({ ping: 1 }); + await client + .db('admin') + .command({ ping: 1 }) + .catch(e => squashError(e)); expect(commandStarted).to.have.lengthOf(1); expect(commandStarted[0].command).to.not.have.property('maxTimeMS'); }); } ); + // TODO(NODE-6391): Add timeoutMS support to Explicit Encryption context.skip('3. ClientEncryption', () => { /** * Each test under this category MUST only be run against server versions 4.4 and higher. In these tests, diff --git a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts index 944d9b9604..454f470683 100644 --- a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts +++ b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts @@ -6,8 +6,19 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; - -import { ConnectionPool, type MongoClient, Timeout, TimeoutContext, Topology } from '../../mongodb'; +import { TLSSocket } from 'tls'; + +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { StateMachine } from '../../../src/client-side-encryption/state_machine'; +import { + ConnectionPool, + type MongoClient, + MongoOperationTimeoutError, + Timeout, + TimeoutContext, + Topology +} from '../../mongodb'; +import { sleep } from '../../tools/utils'; // TODO(NODE-5824): Implement CSOT prose tests describe('CSOT spec unit tests', function () { @@ -93,17 +104,35 @@ describe('CSOT spec unit tests', function () { }).skipReason = 'TODO(NODE-5682): Add CSOT support for socket read/write at the connection layer for CRUD APIs'; - context.skip('Client side encryption', function () { - context( - 'The remaining timeoutMS value should apply to HTTP requests against KMS servers for CSFLE.', - () => {} - ); + context('Client side encryption', function () { + it('The remaining timeoutMS value should apply to HTTP requests against KMS servers for CSFLE.', async function () { + const stateMachine = new StateMachine({} as any); + const request = { + addResponse: _response => {}, + status: { + type: 1, + code: 1, + message: 'notARealStatus' + }, + bytesNeeded: 500, + kmsProvider: 'notRealAgain', + endpoint: 'fake', + message: Buffer.from('foobar') + }; + + const timeoutMS = 100; + sinon.stub(TLSSocket.prototype, 'connect').callsFake(async function (..._args) { + await sleep(200); + return {} as TLSSocket; + }); + const err = await stateMachine.kmsRequest(request, timeoutMS).catch(e => e); + expect(err).to.be.instanceOf(MongoOperationTimeoutError); + expect(err.errmsg).to.equal('KMS request timed out'); + }); - context( - 'The remaining timeoutMS value should apply to commands sent to mongocryptd as part of automatic encryption.', - () => {} - ); - }).skipReason = 'TODO(NODE-5686): Add CSOT support to client side encryption'; + // TODO(NODE-6390): Add timeoutMS support to Auto Encryption + it.skip('The remaining timeoutMS value should apply to commands sent to mongocryptd as part of automatic encryption.', () => {}); + }); context.skip('Background Connection Pooling', function () { context( diff --git a/test/unit/client-side-encryption/state_machine.test.ts b/test/unit/client-side-encryption/state_machine.test.ts index 77f3cf3a82..8245168c72 100644 --- a/test/unit/client-side-encryption/state_machine.test.ts +++ b/test/unit/client-side-encryption/state_machine.test.ts @@ -16,40 +16,40 @@ import { Db } from '../../../src/db'; import { MongoClient } from '../../../src/mongo_client'; import { Int32, Long, serialize } from '../../mongodb'; -describe('StateMachine', function () { - class MockRequest implements MongoCryptKMSRequest { - _bytesNeeded: number; - endpoint = 'some.fake.host.com'; - _kmsProvider = 'aws'; - - constructor( - public _message: Buffer, - bytesNeeded - ) { - this._bytesNeeded = typeof bytesNeeded === 'number' ? bytesNeeded : 1024; - } +class MockRequest implements MongoCryptKMSRequest { + _bytesNeeded: number; + endpoint = 'some.fake.host.com'; + _kmsProvider = 'aws'; + + constructor( + public _message: Buffer, + bytesNeeded + ) { + this._bytesNeeded = typeof bytesNeeded === 'number' ? bytesNeeded : 1024; + } - get message() { - return this._message; - } + get message() { + return this._message; + } - get bytesNeeded() { - return this._bytesNeeded; - } + get bytesNeeded() { + return this._bytesNeeded; + } - get kmsProvider() { - return this._kmsProvider; - } + get kmsProvider() { + return this._kmsProvider; + } - get status() { - return { type: 1, code: 2, message: 'something went wrong' }; - } + get status() { + return { type: 1, code: 2, message: 'something went wrong' }; + } - addResponse(buffer) { - this._bytesNeeded -= buffer.length; - } + addResponse(buffer) { + this._bytesNeeded -= buffer.length; } +} +describe('StateMachine', function () { describe('#markCommand', function () { let runCommandStub; let dbStub; From 2e3a84c340372f1626651c6ea10f5a0a575a17c0 Mon Sep 17 00:00:00 2001 From: Aditi Khare Date: Mon, 30 Sep 2024 10:49:59 -0400 Subject: [PATCH 30/39] revert state machine test changes --- .../state_machine.test.ts | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/test/unit/client-side-encryption/state_machine.test.ts b/test/unit/client-side-encryption/state_machine.test.ts index 8245168c72..77f3cf3a82 100644 --- a/test/unit/client-side-encryption/state_machine.test.ts +++ b/test/unit/client-side-encryption/state_machine.test.ts @@ -16,40 +16,40 @@ import { Db } from '../../../src/db'; import { MongoClient } from '../../../src/mongo_client'; import { Int32, Long, serialize } from '../../mongodb'; -class MockRequest implements MongoCryptKMSRequest { - _bytesNeeded: number; - endpoint = 'some.fake.host.com'; - _kmsProvider = 'aws'; - - constructor( - public _message: Buffer, - bytesNeeded - ) { - this._bytesNeeded = typeof bytesNeeded === 'number' ? bytesNeeded : 1024; - } +describe('StateMachine', function () { + class MockRequest implements MongoCryptKMSRequest { + _bytesNeeded: number; + endpoint = 'some.fake.host.com'; + _kmsProvider = 'aws'; + + constructor( + public _message: Buffer, + bytesNeeded + ) { + this._bytesNeeded = typeof bytesNeeded === 'number' ? bytesNeeded : 1024; + } - get message() { - return this._message; - } + get message() { + return this._message; + } - get bytesNeeded() { - return this._bytesNeeded; - } + get bytesNeeded() { + return this._bytesNeeded; + } - get kmsProvider() { - return this._kmsProvider; - } + get kmsProvider() { + return this._kmsProvider; + } - get status() { - return { type: 1, code: 2, message: 'something went wrong' }; - } + get status() { + return { type: 1, code: 2, message: 'something went wrong' }; + } - addResponse(buffer) { - this._bytesNeeded -= buffer.length; + addResponse(buffer) { + this._bytesNeeded -= buffer.length; + } } -} -describe('StateMachine', function () { describe('#markCommand', function () { let runCommandStub; let dbStub; From e6e9fb456305ee4404e5225cfd31bcc701481e89 Mon Sep 17 00:00:00 2001 From: Aditi Khare Date: Tue, 1 Oct 2024 17:34:20 -0400 Subject: [PATCH 31/39] requested changes --- src/client-side-encryption/state_machine.ts | 56 +++---- ...lient_side_operations_timeout.unit.test.ts | 57 +++++-- .../state_machine.test.ts | 139 +++++++++++++++++- 3 files changed, 204 insertions(+), 48 deletions(-) diff --git a/src/client-side-encryption/state_machine.ts b/src/client-side-encryption/state_machine.ts index ce7bdc483b..eb0e759ece 100644 --- a/src/client-side-encryption/state_machine.ts +++ b/src/client-side-encryption/state_machine.ts @@ -14,7 +14,7 @@ import { type ProxyOptions } from '../cmap/connection'; import { getSocks, type SocksLib } from '../deps'; import { MongoOperationTimeoutError } from '../error'; import { type MongoClient, type MongoClientOptions } from '../mongo_client'; -import { type CSOTTimeoutContext, Timeout, TimeoutError } from '../timeout'; +import { Timeout, type TimeoutContext, TimeoutError } from '../timeout'; import { BufferPool, MongoDBCollectionNamespace, promiseWithResolvers } from '../utils'; import { autoSelectSocketOptions, type DataKey } from './client_encryption'; import { MongoCryptError } from './errors'; @@ -187,7 +187,7 @@ export class StateMachine { async execute( executor: StateMachineExecutable, context: MongoCryptContext, - timeoutContext?: CSOTTimeoutContext + timeoutContext?: TimeoutContext ): Promise { const keyVaultNamespace = executor._keyVaultNamespace; const keyVaultClient = executor._keyVaultClient; @@ -212,7 +212,7 @@ export class StateMachine { metaDataClient, context.ns, filter, - timeoutContext?.csotEnabled() ? timeoutContext.remainingTimeMS : null + timeoutContext ); if (collInfo) { context.addMongoOperationResponse(collInfo); @@ -233,20 +233,9 @@ export class StateMachine { // When we are using the shared library, we don't have a mongocryptd manager. const markedCommand: Uint8Array = mongocryptdManager ? await mongocryptdManager.withRespawn( - this.markCommand.bind( - this, - mongocryptdClient, - context.ns, - command, - timeoutContext?.csotEnabled() ? timeoutContext.remainingTimeMS : null - ) + this.markCommand.bind(this, mongocryptdClient, context.ns, command, timeoutContext) ) - : await this.markCommand( - mongocryptdClient, - context.ns, - command, - timeoutContext?.csotEnabled() ? timeoutContext.remainingTimeMS : null - ); + : await this.markCommand(mongocryptdClient, context.ns, command, timeoutContext); context.addMongoOperationResponse(markedCommand); context.finishMongoOperation(); @@ -259,7 +248,7 @@ export class StateMachine { keyVaultClient, keyVaultNamespace, filter, - timeoutContext?.csotEnabled() ? timeoutContext.remainingTimeMS : null + timeoutContext ); if (keys.length === 0) { @@ -282,12 +271,7 @@ export class StateMachine { } case MONGOCRYPT_CTX_NEED_KMS: { - await Promise.all( - this.requests( - context, - timeoutContext?.csotEnabled() ? timeoutContext.remainingTimeMS : null - ) - ); + await Promise.all(this.requests(context, timeoutContext)); context.finishKMSRequests(); break; } @@ -329,7 +313,7 @@ export class StateMachine { * @param kmsContext - A C++ KMS context returned from the bindings * @returns A promise that resolves when the KMS reply has be fully parsed */ - async kmsRequest(request: MongoCryptKMSRequest, timeoutMS?: number | null): Promise { + async kmsRequest(request: MongoCryptKMSRequest, timeoutContext?: TimeoutContext): Promise { const parsedUrl = request.endpoint.split(':'); const port = parsedUrl[1] != null ? Number.parseInt(parsedUrl[1], 10) : HTTPS_PORT; const socketOptions = autoSelectSocketOptions(this.options.socketOptions || {}); @@ -451,8 +435,8 @@ export class StateMachine { } }); - await (typeof timeoutMS === 'number' - ? Promise.all([willResolveKmsRequest, Timeout.expires(timeoutMS)]) + await (timeoutContext?.csotEnabled() + ? Promise.all([willResolveKmsRequest, Timeout.expires(timeoutContext?.remainingTimeMS)]) : willResolveKmsRequest); } catch (error) { if (error instanceof TimeoutError) @@ -464,13 +448,13 @@ export class StateMachine { } } - *requests(context: MongoCryptContext, timeoutMS?: number | null) { + *requests(context: MongoCryptContext, timeoutContext?: TimeoutContext) { for ( let request = context.nextKMSRequest(); request != null; request = context.nextKMSRequest() ) { - yield this.kmsRequest(request, timeoutMS); + yield this.kmsRequest(request, timeoutContext); } } @@ -531,7 +515,7 @@ export class StateMachine { client: MongoClient, ns: string, filter: Document, - timeoutMS?: number | null + timeoutContext?: TimeoutContext ): Promise { const { db } = MongoDBCollectionNamespace.fromString(ns); @@ -540,9 +524,7 @@ export class StateMachine { .listCollections(filter, { promoteLongs: false, promoteValues: false, - ...(typeof timeoutMS === 'number' - ? { timeoutMS, timeoutMode: 'cursorLifetime' } - : undefined) + timeoutMS: timeoutContext?.csotEnabled() ? timeoutContext?.remainingTimeMS : undefined }) .toArray(); @@ -562,7 +544,7 @@ export class StateMachine { client: MongoClient, ns: string, command: Uint8Array, - timeoutMS?: number | null + timeoutContext?: TimeoutContext ): Promise { const { db } = MongoDBCollectionNamespace.fromString(ns); const bsonOptions = { promoteLongs: false, promoteValues: false }; @@ -570,7 +552,7 @@ export class StateMachine { const response = await client.db(db).command(rawCommand, { ...bsonOptions, - ...(typeof timeoutMS === 'number' ? { timeoutMS, omitMaxTimeMS: true } : undefined) + timeoutMS: timeoutContext?.csotEnabled() ? timeoutContext?.remainingTimeMS : undefined }); return serialize(response, this.bsonOptions); @@ -588,7 +570,7 @@ export class StateMachine { client: MongoClient, keyVaultNamespace: string, filter: Uint8Array, - timeoutMS?: number | null + timeoutContext?: TimeoutContext ): Promise> { const { db: dbName, collection: collectionName } = MongoDBCollectionNamespace.fromString(keyVaultNamespace); @@ -596,7 +578,9 @@ export class StateMachine { return client .db(dbName) .collection(collectionName, { readConcern: { level: 'majority' } }) - .find(deserialize(filter), { timeoutMS: timeoutMS != null ? timeoutMS : undefined }) + .find(deserialize(filter, { allowObjectSmallerThanBufferSize: true }), { + timeoutMS: timeoutContext?.csotEnabled() ? timeoutContext?.remainingTimeMS : undefined + }) .toArray(); } } diff --git a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts index 454f470683..d2bcf9812b 100644 --- a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts +++ b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts @@ -12,6 +12,7 @@ import { TLSSocket } from 'tls'; import { StateMachine } from '../../../src/client-side-encryption/state_machine'; import { ConnectionPool, + CSOTTimeoutContext, type MongoClient, MongoOperationTimeoutError, Timeout, @@ -104,8 +105,8 @@ describe('CSOT spec unit tests', function () { }).skipReason = 'TODO(NODE-5682): Add CSOT support for socket read/write at the connection layer for CRUD APIs'; - context('Client side encryption', function () { - it('The remaining timeoutMS value should apply to HTTP requests against KMS servers for CSFLE.', async function () { + describe('Client side encryption', function () { + describe('KMS requests', function () { const stateMachine = new StateMachine({} as any); const request = { addResponse: _response => {}, @@ -120,14 +121,52 @@ describe('CSOT spec unit tests', function () { message: Buffer.from('foobar') }; - const timeoutMS = 100; - sinon.stub(TLSSocket.prototype, 'connect').callsFake(async function (..._args) { - await sleep(200); - return {} as TLSSocket; + context('when StateMachine.kmsRequest() is passed a `CSOTimeoutContext`', function () { + beforeEach(async function () { + sinon.stub(TLSSocket.prototype, 'connect').callsFake(async function (..._args) { + await sleep(200); + return {} as TLSSocket; + }); + }); + + afterEach(async function () { + sinon.restore(); + }); + + it('the kms request times out through remainingTimeMS', async function () { + const timeoutContext = new CSOTTimeoutContext({ + timeoutMS: 500, + serverSelectionTimeoutMS: 30000 + }); + sleep(300); + const err = await stateMachine.kmsRequest(request, timeoutContext).catch(e => e); + expect(err).to.be.instanceOf(MongoOperationTimeoutError); + expect(err.errmsg).to.equal('KMS request timed out'); + }); + }); + + context('when StateMachine.kmsRequest() is not passed a `CSOTimeoutContext`', function () { + beforeEach(async function () { + sinon.stub(TLSSocket.prototype, 'connect').callsFake(async function (..._args) { + return {} as TLSSocket; + }); + }); + + afterEach(async function () { + sinon.restore(); + }); + + it('the kms request does not timeout within 30 seconds', async function () { + const sleepingFn = async () => { + await sleep(55000); + throw Error('Slept for 30s'); + }; + const err = await Promise.all([stateMachine.kmsRequest(request), sleepingFn()]).catch( + e => e + ); + expect(err.message).to.equal('Slept for 30s'); + }); }); - const err = await stateMachine.kmsRequest(request, timeoutMS).catch(e => e); - expect(err).to.be.instanceOf(MongoOperationTimeoutError); - expect(err.errmsg).to.equal('KMS request timed out'); }); // TODO(NODE-6390): Add timeoutMS support to Auto Encryption diff --git a/test/unit/client-side-encryption/state_machine.test.ts b/test/unit/client-side-encryption/state_machine.test.ts index 77f3cf3a82..a289cd58b3 100644 --- a/test/unit/client-side-encryption/state_machine.test.ts +++ b/test/unit/client-side-encryption/state_machine.test.ts @@ -12,9 +12,17 @@ import * as tls from 'tls'; import { StateMachine } from '../../../src/client-side-encryption/state_machine'; // eslint-disable-next-line @typescript-eslint/no-restricted-imports import { Db } from '../../../src/db'; -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { MongoClient } from '../../../src/mongo_client'; -import { Int32, Long, serialize } from '../../mongodb'; +import { + BSON, + Collection, + CSOTTimeoutContext, + Int32, + Long, + MongoClient, + serialize, + squashError +} from '../../mongodb'; +import { sleep } from '../../tools/utils'; describe('StateMachine', function () { class MockRequest implements MongoCryptKMSRequest { @@ -461,4 +469,129 @@ describe('StateMachine', function () { expect.fail('missed exception'); }); }); + + describe('CSOT', function () { + describe('#fetchKeys', function () { + const stateMachine = new StateMachine({} as any); + const client = new MongoClient('mongodb://localhost:27017'); + let findSpy; + + beforeEach(async function () { + findSpy = sinon.spy(Collection.prototype, 'find'); + }); + + afterEach(async function () { + sinon.restore(); + await client.close(); + }); + + context('when StateMachine.fetchKeys() is passed a `CSOTimeoutContext`', function () { + it('collection.find runs with its timeoutMS property set to remainingTimeMS', async function () { + const timeoutContext = new CSOTTimeoutContext({ + timeoutMS: 500, + serverSelectionTimeoutMS: 30000 + }); + await sleep(300); + await stateMachine + .fetchKeys(client, 'keyVault', BSON.serialize({ a: 1 }), timeoutContext) + .catch(e => squashError(e)); + expect(findSpy.getCalls()[0].args[1].timeoutMS).to.not.be.undefined; + expect(findSpy.getCalls()[0].args[1].timeoutMS).to.be.lessThanOrEqual(200); + }); + }); + + context('when StateMachine.fetchKeys() is not passed a `CSOTimeoutContext`', function () { + it('collection.find runs with an undefined timeoutMS property', async function () { + await stateMachine + .fetchKeys(client, 'keyVault', BSON.serialize({ a: 1 })) + .catch(e => squashError(e)); + expect(findSpy.getCalls()[0].args[1].timeoutMS).to.be.undefined; + }); + }); + }); + + describe('#markCommand', function () { + const stateMachine = new StateMachine({} as any); + const client = new MongoClient('mongodb://localhost:27017'); + let dbCommandSpy; + + beforeEach(async function () { + dbCommandSpy = sinon.spy(Db.prototype, 'command'); + }); + + afterEach(async function () { + sinon.restore(); + await client.close(); + }); + + context('when StateMachine.markCommand() is passed a `CSOTimeoutContext`', function () { + it('db.command runs with its timeoutMS property set to remainingTimeMS', async function () { + const timeoutContext = new CSOTTimeoutContext({ + timeoutMS: 500, + serverSelectionTimeoutMS: 30000 + }); + await sleep(300); + await stateMachine + .markCommand(client, 'keyVault', BSON.serialize({ a: 1 }), timeoutContext) + .catch(e => squashError(e)); + expect(dbCommandSpy.getCalls()[0].args[1].timeoutMS).to.not.be.undefined; + expect(dbCommandSpy.getCalls()[0].args[1].timeoutMS).to.be.lessThanOrEqual(200); + }); + }); + + context('when StateMachine.markCommand() is not passed a `CSOTimeoutContext`', function () { + it('db.command runs with an undefined timeoutMS property', async function () { + await stateMachine + .markCommand(client, 'keyVault', BSON.serialize({ a: 1 })) + .catch(e => squashError(e)); + expect(dbCommandSpy.getCalls()[0].args[1].timeoutMS).to.be.undefined; + }); + }); + }); + + describe('#fetchCollectionInfo', function () { + const stateMachine = new StateMachine({} as any); + const client = new MongoClient('mongodb://localhost:27017'); + let listCollectionsSpy; + + beforeEach(async function () { + listCollectionsSpy = sinon.spy(Db.prototype, 'listCollections'); + }); + + afterEach(async function () { + sinon.restore(); + await client.close(); + }); + + context( + 'when StateMachine.fetchCollectionInfo() is passed a `CSOTimeoutContext`', + function () { + it('listCollections runs with its timeoutMS property set to remainingTimeMS', async function () { + const timeoutContext = new CSOTTimeoutContext({ + timeoutMS: 500, + serverSelectionTimeoutMS: 30000 + }); + await sleep(300); + await stateMachine + .fetchCollectionInfo(client, 'keyVault', BSON.serialize({ a: 1 }), timeoutContext) + .catch(e => squashError(e)); + expect(listCollectionsSpy.getCalls()[0].args[1].timeoutMS).to.not.be.undefined; + expect(listCollectionsSpy.getCalls()[0].args[1].timeoutMS).to.be.lessThanOrEqual(200); + }); + } + ); + + context( + 'when StateMachine.fetchCollectionInfo() is not passed a `CSOTimeoutContext`', + function () { + it('listCollections runs with an undefined timeoutMS property', async function () { + await stateMachine + .fetchCollectionInfo(client, 'keyVault', BSON.serialize({ a: 1 })) + .catch(e => squashError(e)); + expect(listCollectionsSpy.getCalls()[0].args[1].timeoutMS).to.be.undefined; + }); + } + ); + }); + }); }); From 702a03eeb59174ea352f2d64546f32e0cc4c6e84 Mon Sep 17 00:00:00 2001 From: Aditi Khare Date: Tue, 1 Oct 2024 17:43:58 -0400 Subject: [PATCH 32/39] lint fix --- ...ient_side_operations_timeout.prose.test.ts | 19 ------------------- 1 file changed, 19 deletions(-) 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 8ed1a7dc93..0a20a5b15d 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 @@ -109,7 +109,6 @@ describe('CSOT spec prose tests', function () { ); // TODO(NODE-6391): Add timeoutMS support to Explicit Encryption - context.skip('3. ClientEncryption', () => { /** * Each test under this category MUST only be run against server versions 4.4 and higher. In these tests, @@ -570,24 +569,6 @@ describe('CSOT spec prose tests', function () { }).skipReason = 'TODO(NODE-6223): Auto connect performs extra server selection. Explicit connect throws on invalid host name'; - 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. - * - Expect this to fail with a server selection timeout error after no more than 15ms. - */ - client = new MongoClient('mongodb://invalid/?timeoutMS=20&serverSelectionTimeoutMS=10'); - const start = now(); - const maybeError = await client - .db('test') - .admin() - .ping() - .then( - () => null, - e => e - ); - const end = now(); - 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`. From 3b6a23b27cef9d2cb0da00ecd7f1d0f345262880 Mon Sep 17 00:00:00 2001 From: Aditi Khare Date: Tue, 1 Oct 2024 18:31:06 -0400 Subject: [PATCH 33/39] test fix --- src/client-side-encryption/state_machine.ts | 7 +++++-- test/unit/client-side-encryption/state_machine.test.ts | 4 +--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/client-side-encryption/state_machine.ts b/src/client-side-encryption/state_machine.ts index eb0e759ece..994ecf91a9 100644 --- a/src/client-side-encryption/state_machine.ts +++ b/src/client-side-encryption/state_machine.ts @@ -175,6 +175,7 @@ export type StateMachineOptions = { * An internal class that executes across a MongoCryptContext until either * a finishing state or an error is reached. Do not instantiate directly. */ +// TODO(DRIVERS-2671): clarify CSOT behavior for FLE APIs export class StateMachine { constructor( private options: StateMachineOptions, @@ -552,7 +553,9 @@ export class StateMachine { const response = await client.db(db).command(rawCommand, { ...bsonOptions, - timeoutMS: timeoutContext?.csotEnabled() ? timeoutContext?.remainingTimeMS : undefined + ...(timeoutContext?.csotEnabled() + ? { timeoutMS: timeoutContext?.remainingTimeMS } + : undefined) }); return serialize(response, this.bsonOptions); @@ -578,7 +581,7 @@ export class StateMachine { return client .db(dbName) .collection(collectionName, { readConcern: { level: 'majority' } }) - .find(deserialize(filter, { allowObjectSmallerThanBufferSize: true }), { + .find(deserialize(filter), { timeoutMS: timeoutContext?.csotEnabled() ? timeoutContext?.remainingTimeMS : undefined }) .toArray(); diff --git a/test/unit/client-side-encryption/state_machine.test.ts b/test/unit/client-side-encryption/state_machine.test.ts index a289cd58b3..fe9659675a 100644 --- a/test/unit/client-side-encryption/state_machine.test.ts +++ b/test/unit/client-side-encryption/state_machine.test.ts @@ -82,12 +82,10 @@ describe('StateMachine', function () { const options = { promoteLongs: false, promoteValues: false }; const serializedCommand = serialize(command); const stateMachine = new StateMachine({} as any); - // eslint-disable-next-line @typescript-eslint/no-empty-function - const callback = () => {}; context('when executing the command', function () { it('does not promote values', function () { - stateMachine.markCommand(clientStub, 'test.coll', serializedCommand, callback); + stateMachine.markCommand(clientStub, 'test.coll', serializedCommand); expect(runCommandStub.calledWith(command, options)).to.be.true; }); }); From 5560a1bb60262d85e7ad4f8f8925581f06c3c045 Mon Sep 17 00:00:00 2001 From: Aditi Khare Date: Wed, 2 Oct 2024 13:42:39 -0400 Subject: [PATCH 34/39] no negative timeouts --- src/client-side-encryption/state_machine.ts | 17 +++++++++++++++++ src/sdam/server_description.ts | 1 - 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/client-side-encryption/state_machine.ts b/src/client-side-encryption/state_machine.ts index 994ecf91a9..656a8c0991 100644 --- a/src/client-side-encryption/state_machine.ts +++ b/src/client-side-encryption/state_machine.ts @@ -436,6 +436,9 @@ export class StateMachine { } }); + if (timeoutContext?.csotEnabled() && timeoutContext?.remainingTimeMS <= 0) { + throw new MongoOperationTimeoutError('Timed out before KMS request.'); + } await (timeoutContext?.csotEnabled() ? Promise.all([willResolveKmsRequest, Timeout.expires(timeoutContext?.remainingTimeMS)]) : willResolveKmsRequest); @@ -520,6 +523,12 @@ export class StateMachine { ): Promise { const { db } = MongoDBCollectionNamespace.fromString(ns); + if (timeoutContext?.csotEnabled() && timeoutContext?.remainingTimeMS <= 0) { + throw new MongoOperationTimeoutError( + 'Timed out before call to mongocryptd listCollections operation.' + ); + } + const collections = await client .db(db) .listCollections(filter, { @@ -551,6 +560,11 @@ export class StateMachine { const bsonOptions = { promoteLongs: false, promoteValues: false }; const rawCommand = deserialize(command, bsonOptions); + if (timeoutContext?.csotEnabled() && timeoutContext?.remainingTimeMS <= 0) { + throw new MongoOperationTimeoutError( + 'Timed out before call to mongocryptd markings request.' + ); + } const response = await client.db(db).command(rawCommand, { ...bsonOptions, ...(timeoutContext?.csotEnabled() @@ -578,6 +592,9 @@ export class StateMachine { const { db: dbName, collection: collectionName } = MongoDBCollectionNamespace.fromString(keyVaultNamespace); + if (timeoutContext?.csotEnabled() && timeoutContext?.remainingTimeMS <= 0) { + throw new MongoOperationTimeoutError('Timed out before dataKey fetched.'); + } return client .db(dbName) .collection(collectionName, { readConcern: { level: 'majority' } }) diff --git a/src/sdam/server_description.ts b/src/sdam/server_description.ts index 5429843c26..a650c8dba9 100644 --- a/src/sdam/server_description.ts +++ b/src/sdam/server_description.ts @@ -75,7 +75,6 @@ export class ServerDescription { maxWriteBatchSize: number | null; /** The max bson object size. */ maxBsonObjectSize: number | null; - /** Indicates server is a mongocryptd instance. */ iscryptd: boolean; From 601c159acffcdb08916842df5127aec07b3e18d7 Mon Sep 17 00:00:00 2001 From: Aditi Khare Date: Wed, 2 Oct 2024 17:59:43 -0400 Subject: [PATCH 35/39] requested changes --- src/client-side-encryption/state_machine.ts | 20 ++++--------------- ...lient_side_operations_timeout.unit.test.ts | 2 +- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/src/client-side-encryption/state_machine.ts b/src/client-side-encryption/state_machine.ts index 656a8c0991..ae00080edd 100644 --- a/src/client-side-encryption/state_machine.ts +++ b/src/client-side-encryption/state_machine.ts @@ -523,18 +523,13 @@ export class StateMachine { ): Promise { const { db } = MongoDBCollectionNamespace.fromString(ns); - if (timeoutContext?.csotEnabled() && timeoutContext?.remainingTimeMS <= 0) { - throw new MongoOperationTimeoutError( - 'Timed out before call to mongocryptd listCollections operation.' - ); - } - const collections = await client .db(db) .listCollections(filter, { promoteLongs: false, promoteValues: false, - timeoutMS: timeoutContext?.csotEnabled() ? timeoutContext?.remainingTimeMS : undefined + timeoutMS: timeoutContext?.csotEnabled() ? timeoutContext?.remainingTimeMS : undefined, + timeoutMode: 'cursorLifetime' }) .toArray(); @@ -560,11 +555,6 @@ export class StateMachine { const bsonOptions = { promoteLongs: false, promoteValues: false }; const rawCommand = deserialize(command, bsonOptions); - if (timeoutContext?.csotEnabled() && timeoutContext?.remainingTimeMS <= 0) { - throw new MongoOperationTimeoutError( - 'Timed out before call to mongocryptd markings request.' - ); - } const response = await client.db(db).command(rawCommand, { ...bsonOptions, ...(timeoutContext?.csotEnabled() @@ -592,14 +582,12 @@ export class StateMachine { const { db: dbName, collection: collectionName } = MongoDBCollectionNamespace.fromString(keyVaultNamespace); - if (timeoutContext?.csotEnabled() && timeoutContext?.remainingTimeMS <= 0) { - throw new MongoOperationTimeoutError('Timed out before dataKey fetched.'); - } return client .db(dbName) .collection(collectionName, { readConcern: { level: 'majority' } }) .find(deserialize(filter), { - timeoutMS: timeoutContext?.csotEnabled() ? timeoutContext?.remainingTimeMS : undefined + timeoutMS: timeoutContext?.csotEnabled() ? timeoutContext?.remainingTimeMS : undefined, + timeoutMode: 'cursorLifetime' }) .toArray(); } diff --git a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts index d2bcf9812b..329a3b4cb7 100644 --- a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts +++ b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts @@ -158,7 +158,7 @@ describe('CSOT spec unit tests', function () { it('the kms request does not timeout within 30 seconds', async function () { const sleepingFn = async () => { - await sleep(55000); + await sleep(30000); throw Error('Slept for 30s'); }; const err = await Promise.all([stateMachine.kmsRequest(request), sleepingFn()]).catch( From 096f154a3bd7a6f9efdce9cf2a2a77133830f588 Mon Sep 17 00:00:00 2001 From: Aditi Khare Date: Thu, 3 Oct 2024 11:12:43 -0400 Subject: [PATCH 36/39] fix failing tests --- src/client-side-encryption/state_machine.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/client-side-encryption/state_machine.ts b/src/client-side-encryption/state_machine.ts index ae00080edd..8a48ac8fba 100644 --- a/src/client-side-encryption/state_machine.ts +++ b/src/client-side-encryption/state_machine.ts @@ -528,8 +528,9 @@ export class StateMachine { .listCollections(filter, { promoteLongs: false, promoteValues: false, - timeoutMS: timeoutContext?.csotEnabled() ? timeoutContext?.remainingTimeMS : undefined, - timeoutMode: 'cursorLifetime' + ...(timeoutContext?.csotEnabled() + ? { timeoutMS: timeoutContext?.remainingTimeMS, timeoutMode: 'cursorLifetime' } + : {}) }) .toArray(); @@ -585,10 +586,12 @@ export class StateMachine { return client .db(dbName) .collection(collectionName, { readConcern: { level: 'majority' } }) - .find(deserialize(filter), { - timeoutMS: timeoutContext?.csotEnabled() ? timeoutContext?.remainingTimeMS : undefined, - timeoutMode: 'cursorLifetime' - }) + .find( + deserialize(filter), + timeoutContext?.csotEnabled() + ? { timeoutMS: timeoutContext?.remainingTimeMS, timeoutMode: 'cursorLifetime' } + : {} + ) .toArray(); } } From 5aba790e72bb5fd24e5ceafc343611b9f7a32e47 Mon Sep 17 00:00:00 2001 From: Aditi Khare Date: Thu, 3 Oct 2024 16:42:40 -0400 Subject: [PATCH 37/39] requested changes 3 --- src/client-side-encryption/state_machine.ts | 4 --- ...lient_side_operations_timeout.unit.test.ts | 34 +++++++++++++------ 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/client-side-encryption/state_machine.ts b/src/client-side-encryption/state_machine.ts index 8a48ac8fba..f47ee191b5 100644 --- a/src/client-side-encryption/state_machine.ts +++ b/src/client-side-encryption/state_machine.ts @@ -435,10 +435,6 @@ export class StateMachine { resolve(); } }); - - if (timeoutContext?.csotEnabled() && timeoutContext?.remainingTimeMS <= 0) { - throw new MongoOperationTimeoutError('Timed out before KMS request.'); - } await (timeoutContext?.csotEnabled() ? Promise.all([willResolveKmsRequest, Timeout.expires(timeoutContext?.remainingTimeMS)]) : willResolveKmsRequest); diff --git a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts index 329a3b4cb7..7387099a7f 100644 --- a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts +++ b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts @@ -6,7 +6,9 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; +import { setTimeout } from 'timers'; import { TLSSocket } from 'tls'; +import { promisify } from 'util'; // eslint-disable-next-line @typescript-eslint/no-restricted-imports import { StateMachine } from '../../../src/client-side-encryption/state_machine'; @@ -19,7 +21,7 @@ import { TimeoutContext, Topology } from '../../mongodb'; -import { sleep } from '../../tools/utils'; +import { createTimerSandbox } from '../../unit/timer_sandbox'; // TODO(NODE-5824): Implement CSOT prose tests describe('CSOT spec unit tests', function () { @@ -123,10 +125,7 @@ describe('CSOT spec unit tests', function () { context('when StateMachine.kmsRequest() is passed a `CSOTimeoutContext`', function () { beforeEach(async function () { - sinon.stub(TLSSocket.prototype, 'connect').callsFake(async function (..._args) { - await sleep(200); - return {} as TLSSocket; - }); + sinon.stub(TLSSocket.prototype, 'connect').callsFake(function (..._args) {}); }); afterEach(async function () { @@ -138,7 +137,6 @@ describe('CSOT spec unit tests', function () { timeoutMS: 500, serverSelectionTimeoutMS: 30000 }); - sleep(300); const err = await stateMachine.kmsRequest(request, timeoutContext).catch(e => e); expect(err).to.be.instanceOf(MongoOperationTimeoutError); expect(err.errmsg).to.equal('KMS request timed out'); @@ -146,13 +144,26 @@ describe('CSOT spec unit tests', function () { }); context('when StateMachine.kmsRequest() is not passed a `CSOTimeoutContext`', function () { + let clock: sinon.SinonFakeTimers; + let timerSandbox: sinon.SinonSandbox; + + let sleep; + beforeEach(async function () { - sinon.stub(TLSSocket.prototype, 'connect').callsFake(async function (..._args) { - return {} as TLSSocket; + sinon.stub(TLSSocket.prototype, 'connect').callsFake(function (..._args) { + clock.tick(30000); }); + timerSandbox = createTimerSandbox(); + clock = sinon.useFakeTimers(); + sleep = promisify(setTimeout); }); afterEach(async function () { + if (clock) { + timerSandbox.restore(); + clock.restore(); + clock = undefined; + } sinon.restore(); }); @@ -161,9 +172,10 @@ describe('CSOT spec unit tests', function () { await sleep(30000); throw Error('Slept for 30s'); }; - const err = await Promise.all([stateMachine.kmsRequest(request), sleepingFn()]).catch( - e => e - ); + + const err$ = Promise.all([stateMachine.kmsRequest(request), sleepingFn()]).catch(e => e); + clock.tick(30000); + const err = await err$; expect(err.message).to.equal('Slept for 30s'); }); }); From 903e0d06957b9b8c51683020a05ea52c39ff51c9 Mon Sep 17 00:00:00 2001 From: Aditi Khare Date: Thu, 3 Oct 2024 17:12:29 -0400 Subject: [PATCH 38/39] limit flaky tests --- test/unit/client-side-encryption/state_machine.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/unit/client-side-encryption/state_machine.test.ts b/test/unit/client-side-encryption/state_machine.test.ts index fe9659675a..95bb605635 100644 --- a/test/unit/client-side-encryption/state_machine.test.ts +++ b/test/unit/client-side-encryption/state_machine.test.ts @@ -494,7 +494,7 @@ describe('StateMachine', function () { .fetchKeys(client, 'keyVault', BSON.serialize({ a: 1 }), timeoutContext) .catch(e => squashError(e)); expect(findSpy.getCalls()[0].args[1].timeoutMS).to.not.be.undefined; - expect(findSpy.getCalls()[0].args[1].timeoutMS).to.be.lessThanOrEqual(200); + expect(findSpy.getCalls()[0].args[1].timeoutMS).to.be.lessThanOrEqual(205); }); }); @@ -533,7 +533,7 @@ describe('StateMachine', function () { .markCommand(client, 'keyVault', BSON.serialize({ a: 1 }), timeoutContext) .catch(e => squashError(e)); expect(dbCommandSpy.getCalls()[0].args[1].timeoutMS).to.not.be.undefined; - expect(dbCommandSpy.getCalls()[0].args[1].timeoutMS).to.be.lessThanOrEqual(200); + expect(dbCommandSpy.getCalls()[0].args[1].timeoutMS).to.be.lessThanOrEqual(205); }); }); @@ -574,7 +574,7 @@ describe('StateMachine', function () { .fetchCollectionInfo(client, 'keyVault', BSON.serialize({ a: 1 }), timeoutContext) .catch(e => squashError(e)); expect(listCollectionsSpy.getCalls()[0].args[1].timeoutMS).to.not.be.undefined; - expect(listCollectionsSpy.getCalls()[0].args[1].timeoutMS).to.be.lessThanOrEqual(200); + expect(listCollectionsSpy.getCalls()[0].args[1].timeoutMS).to.be.lessThanOrEqual(205); }); } ); From cb12f64f60b9263f24a4fdc23d233fe8505e0ff3 Mon Sep 17 00:00:00 2001 From: Aditi Khare Date: Mon, 7 Oct 2024 10:26:20 -0400 Subject: [PATCH 39/39] lint fix --- .../client_side_operations_timeout.prose.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 6f33af7141..297d60e6c7 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 @@ -17,9 +17,9 @@ import { MongoOperationTimeoutError, MongoServerSelectionError, now, - squashError ObjectId, - promiseWithResolvers + promiseWithResolvers, + squashError } from '../../mongodb'; import { type FailPoint } from '../../tools/utils';