From 583024c1de2fdd7f4a800533730aaad5a18818be Mon Sep 17 00:00:00 2001 From: Bailey Pearson Date: Mon, 21 Oct 2024 05:25:07 -0600 Subject: [PATCH] feat(NODE-6421): add support for timeoutMS to explain helpers (#4268) --- package-lock.json | 9 +- package.json | 2 +- src/cursor/aggregation_cursor.ts | 40 ++- src/cursor/find_cursor.ts | 45 +++- src/explain.ts | 85 ++++++ src/index.ts | 2 + src/operations/command.ts | 15 +- src/operations/find.ts | 9 +- src/utils.ts | 27 -- test/integration/crud/explain.test.ts | 369 ++++++++++++++++++++++++++ test/tools/runner/config.ts | 20 +- test/tools/utils.ts | 23 +- test/unit/explain.test.ts | 40 ++- test/unit/index.test.ts | 1 + 14 files changed, 625 insertions(+), 62 deletions(-) diff --git a/package-lock.json b/package-lock.json index 728deda4932..c67887fe6cd 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.2", + "mongodb-legacy": "^6.1.3", "nyc": "^15.1.0", "prettier": "^3.3.3", "semver": "^7.6.3", @@ -6440,11 +6440,10 @@ } }, "node_modules/mongodb-legacy": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/mongodb-legacy/-/mongodb-legacy-6.1.2.tgz", - "integrity": "sha512-oj+LLtvhhi8XuAQ8dll2BVjrnKxOo/7ylyQu0LsKmzyGcbrvzcyvFUOLC6rPhuJPOvnezh3MZ3/Sk9Tl1jpUpg==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/mongodb-legacy/-/mongodb-legacy-6.1.3.tgz", + "integrity": "sha512-XJ2PIbVEHUUF4/SyH00dfeprfeLOdWiHcKq8At+JoEZeTue+IAG39G2ixRwClnI7roPb/46K8IF713v9dgQ8rg==", "dev": true, - "license": "Apache-2.0", "dependencies": { "mongodb": "^6.0.0" }, diff --git a/package.json b/package.json index 3339b1df34d..5e76162eb81 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.2", + "mongodb-legacy": "^6.1.3", "nyc": "^15.1.0", "prettier": "^3.3.3", "semver": "^7.6.3", diff --git a/src/cursor/aggregation_cursor.ts b/src/cursor/aggregation_cursor.ts index 056f28454ce..db7bd20b5fa 100644 --- a/src/cursor/aggregation_cursor.ts +++ b/src/cursor/aggregation_cursor.ts @@ -1,6 +1,12 @@ import type { Document } from '../bson'; import { MongoAPIError } from '../error'; -import type { ExplainCommandOptions, ExplainVerbosityLike } from '../explain'; +import { + Explain, + ExplainableCursor, + type ExplainCommandOptions, + type ExplainVerbosityLike, + validateExplainTimeoutOptions +} from '../explain'; import type { MongoClient } from '../mongo_client'; import { AggregateOperation, type AggregateOptions } from '../operations/aggregate'; import { executeOperation } from '../operations/execute_operation'; @@ -8,7 +14,6 @@ import type { ClientSession } from '../sessions'; import type { Sort } from '../sort'; import { mergeOptions, type MongoDBNamespace } from '../utils'; import { - AbstractCursor, type AbstractCursorOptions, CursorTimeoutMode, type InitialCursorResponse @@ -24,7 +29,7 @@ export interface AggregationCursorOptions extends AbstractCursorOptions, Aggrega * or higher stream * @public */ -export class AggregationCursor extends AbstractCursor { +export class AggregationCursor extends ExplainableCursor { public readonly pipeline: Document[]; /** @internal */ private aggregateOptions: AggregateOptions; @@ -65,11 +70,20 @@ export class AggregationCursor extends AbstractCursor { /** @internal */ async _initialize(session: ClientSession): Promise { - const aggregateOperation = new AggregateOperation(this.namespace, this.pipeline, { + const options = { ...this.aggregateOptions, ...this.cursorOptions, session - }); + }; + try { + validateExplainTimeoutOptions(options, Explain.fromOptions(options)); + } catch { + throw new MongoAPIError( + 'timeoutMS cannot be used with explain when explain is specified in aggregateOptions' + ); + } + + const aggregateOperation = new AggregateOperation(this.namespace, this.pipeline, options); const response = await executeOperation(this.client, aggregateOperation, this.timeoutContext); @@ -77,14 +91,26 @@ export class AggregationCursor extends AbstractCursor { } /** Execute the explain for the cursor */ - async explain(verbosity?: ExplainVerbosityLike | ExplainCommandOptions): Promise { + async explain(): Promise; + async explain(verbosity: ExplainVerbosityLike | ExplainCommandOptions): Promise; + async explain(options: { timeoutMS?: number }): Promise; + async explain( + verbosity: ExplainVerbosityLike | ExplainCommandOptions, + options: { timeoutMS?: number } + ): Promise; + async explain( + verbosity?: ExplainVerbosityLike | ExplainCommandOptions | { timeoutMS?: number }, + options?: { timeoutMS?: number } + ): Promise { + const { explain, timeout } = this.resolveExplainTimeoutOptions(verbosity, options); return ( await executeOperation( this.client, new AggregateOperation(this.namespace, this.pipeline, { ...this.aggregateOptions, // NOTE: order matters here, we may need to refine this ...this.cursorOptions, - explain: verbosity ?? true + ...timeout, + explain: explain ?? true }) ) ).shift(this.deserializationOptions); diff --git a/src/cursor/find_cursor.ts b/src/cursor/find_cursor.ts index 96b764dc7ff..469c27628a5 100644 --- a/src/cursor/find_cursor.ts +++ b/src/cursor/find_cursor.ts @@ -1,7 +1,13 @@ import { type Document } from '../bson'; import { CursorResponse } from '../cmap/wire_protocol/responses'; -import { MongoInvalidArgumentError, MongoTailableCursorError } from '../error'; -import { type ExplainCommandOptions, type ExplainVerbosityLike } from '../explain'; +import { MongoAPIError, MongoInvalidArgumentError, MongoTailableCursorError } from '../error'; +import { + Explain, + ExplainableCursor, + type ExplainCommandOptions, + type ExplainVerbosityLike, + validateExplainTimeoutOptions +} from '../explain'; import type { MongoClient } from '../mongo_client'; import type { CollationOptions } from '../operations/command'; import { CountOperation, type CountOptions } from '../operations/count'; @@ -11,7 +17,7 @@ import type { Hint } from '../operations/operation'; import type { ClientSession } from '../sessions'; import { formatSort, type Sort, type SortDirection } from '../sort'; import { emitWarningOnce, mergeOptions, type MongoDBNamespace, squashError } from '../utils'; -import { AbstractCursor, type InitialCursorResponse } from './abstract_cursor'; +import { type InitialCursorResponse } from './abstract_cursor'; /** @public Flags allowed for cursor */ export const FLAGS = [ @@ -24,7 +30,7 @@ export const FLAGS = [ ] as const; /** @public */ -export class FindCursor extends AbstractCursor { +export class FindCursor extends ExplainableCursor { /** @internal */ private cursorFilter: Document; /** @internal */ @@ -63,11 +69,21 @@ export class FindCursor extends AbstractCursor { /** @internal */ async _initialize(session: ClientSession): Promise { - const findOperation = new FindOperation(this.namespace, this.cursorFilter, { + const options = { ...this.findOptions, // NOTE: order matters here, we may need to refine this ...this.cursorOptions, session - }); + }; + + try { + validateExplainTimeoutOptions(options, Explain.fromOptions(options)); + } catch { + throw new MongoAPIError( + 'timeoutMS cannot be used with explain when explain is specified in findOptions' + ); + } + + const findOperation = new FindOperation(this.namespace, this.cursorFilter, options); const response = await executeOperation(this.client, findOperation, this.timeoutContext); @@ -133,14 +149,27 @@ export class FindCursor extends AbstractCursor { } /** Execute the explain for the cursor */ - async explain(verbosity?: ExplainVerbosityLike | ExplainCommandOptions): Promise { + async explain(): Promise; + async explain(verbosity: ExplainVerbosityLike | ExplainCommandOptions): Promise; + async explain(options: { timeoutMS?: number }): Promise; + async explain( + verbosity: ExplainVerbosityLike | ExplainCommandOptions, + options: { timeoutMS?: number } + ): Promise; + async explain( + verbosity?: ExplainVerbosityLike | ExplainCommandOptions | { timeoutMS?: number }, + options?: { timeoutMS?: number } + ): Promise { + const { explain, timeout } = this.resolveExplainTimeoutOptions(verbosity, options); + return ( await executeOperation( this.client, new FindOperation(this.namespace, this.cursorFilter, { ...this.findOptions, // NOTE: order matters here, we may need to refine this ...this.cursorOptions, - explain: verbosity ?? true + ...timeout, + explain: explain ?? true }) ) ).shift(this.deserializationOptions); diff --git a/src/explain.ts b/src/explain.ts index 51f591efd47..670bea53041 100644 --- a/src/explain.ts +++ b/src/explain.ts @@ -1,3 +1,7 @@ +import { type Document } from './bson'; +import { AbstractCursor } from './cursor/abstract_cursor'; +import { MongoAPIError } from './error'; + /** @public */ export const ExplainVerbosity = Object.freeze({ queryPlanner: 'queryPlanner', @@ -86,3 +90,84 @@ export class Explain { return new Explain(verbosity, maxTimeMS); } } + +export function validateExplainTimeoutOptions(options: Document, explain?: Explain) { + const { maxTimeMS, timeoutMS } = options; + if (timeoutMS != null && (maxTimeMS != null || explain?.maxTimeMS != null)) { + throw new MongoAPIError('Cannot use maxTimeMS with timeoutMS for explain commands.'); + } +} + +/** + * Applies an explain to a given command. + * @internal + * + * @param command - the command on which to apply the explain + * @param options - the options containing the explain verbosity + */ +export function decorateWithExplain( + command: Document, + explain: Explain +): { + explain: Document; + verbosity: ExplainVerbosity; + maxTimeMS?: number; +} { + type ExplainCommand = ReturnType; + const { verbosity, maxTimeMS } = explain; + const baseCommand: ExplainCommand = { explain: command, verbosity }; + + if (typeof maxTimeMS === 'number') { + baseCommand.maxTimeMS = maxTimeMS; + } + + return baseCommand; +} + +/** + * @public + * + * A base class for any cursors that have `explain()` methods. + */ +export abstract class ExplainableCursor extends AbstractCursor { + /** Execute the explain for the cursor */ + abstract explain(): Promise; + abstract explain(verbosity: ExplainVerbosityLike | ExplainCommandOptions): Promise; + abstract explain(options: { timeoutMS?: number }): Promise; + abstract explain( + verbosity: ExplainVerbosityLike | ExplainCommandOptions, + options: { timeoutMS?: number } + ): Promise; + abstract explain( + verbosity?: ExplainVerbosityLike | ExplainCommandOptions | { timeoutMS?: number }, + options?: { timeoutMS?: number } + ): Promise; + + protected resolveExplainTimeoutOptions( + verbosity?: ExplainVerbosityLike | ExplainCommandOptions | { timeoutMS?: number }, + options?: { timeoutMS?: number } + ): { timeout?: { timeoutMS?: number }; explain?: ExplainVerbosityLike | ExplainCommandOptions } { + let explain: ExplainVerbosityLike | ExplainCommandOptions | undefined; + let timeout: { timeoutMS?: number } | undefined; + + if (verbosity == null && options == null) { + explain = undefined; + timeout = undefined; + } else if (verbosity != null && options == null) { + explain = + typeof verbosity !== 'object' + ? verbosity + : 'verbosity' in verbosity + ? verbosity + : undefined; + + timeout = typeof verbosity === 'object' && 'timeoutMS' in verbosity ? verbosity : undefined; + } else { + // @ts-expect-error TS isn't smart enough to determine that if both options are provided, the first is explain options + explain = verbosity; + timeout = options; + } + + return { timeout, explain }; + } +} diff --git a/src/index.ts b/src/index.ts index 419ddc2e692..65f9ec7ccb7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { ListCollectionsCursor } from './cursor/list_collections_cursor'; import { ListIndexesCursor } from './cursor/list_indexes_cursor'; import type { RunCommandCursor } from './cursor/run_command_cursor'; import { Db } from './db'; +import { ExplainableCursor } from './explain'; import { GridFSBucket } from './gridfs'; import { GridFSBucketReadStream } from './gridfs/download'; import { GridFSBucketWriteStream } from './gridfs/upload'; @@ -91,6 +92,7 @@ export { ClientSession, Collection, Db, + ExplainableCursor, FindCursor, GridFSBucket, GridFSBucketReadStream, diff --git a/src/operations/command.ts b/src/operations/command.ts index 5bd80f796d1..bcd3919017b 100644 --- a/src/operations/command.ts +++ b/src/operations/command.ts @@ -1,19 +1,19 @@ import type { BSONSerializeOptions, Document } from '../bson'; import { type MongoDBResponseConstructor } from '../cmap/wire_protocol/responses'; import { MongoInvalidArgumentError } from '../error'; -import { Explain, type ExplainOptions } from '../explain'; +import { + decorateWithExplain, + Explain, + type ExplainOptions, + validateExplainTimeoutOptions +} from '../explain'; import { ReadConcern } from '../read_concern'; 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, - maxWireVersion, - MongoDBNamespace -} from '../utils'; +import { commandSupportsReadConcern, maxWireVersion, MongoDBNamespace } from '../utils'; import { WriteConcern, type WriteConcernOptions } from '../write_concern'; import type { ReadConcernLike } from './../read_concern'; import { AbstractOperation, Aspect, type OperationOptions } from './operation'; @@ -97,6 +97,7 @@ export abstract class CommandOperation extends AbstractOperation { if (this.hasAspect(Aspect.EXPLAINABLE)) { this.explain = Explain.fromOptions(options); + validateExplainTimeoutOptions(this.options, this.explain); } else if (options?.explain != null) { throw new MongoInvalidArgumentError(`Option "explain" is not supported on this command`); } diff --git a/src/operations/find.ts b/src/operations/find.ts index 10453d141da..1775ea6e07f 100644 --- a/src/operations/find.ts +++ b/src/operations/find.ts @@ -2,13 +2,17 @@ import type { Document } from '../bson'; import { CursorResponse, ExplainedCursorResponse } from '../cmap/wire_protocol/responses'; import { type AbstractCursorOptions, type CursorTimeoutMode } from '../cursor/abstract_cursor'; import { MongoInvalidArgumentError } from '../error'; -import { type ExplainOptions } from '../explain'; +import { + decorateWithExplain, + type ExplainOptions, + validateExplainTimeoutOptions +} from '../explain'; 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 MongoDBNamespace, normalizeHintField } from '../utils'; import { type CollationOptions, CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects, type Hint } from './operation'; @@ -119,6 +123,7 @@ export class FindOperation extends CommandOperation { let findCommand = makeFindCommand(this.ns, this.filter, options); if (this.explain) { + validateExplainTimeoutOptions(this.options, this.explain); findCommand = decorateWithExplain(findCommand, this.explain); } diff --git a/src/utils.ts b/src/utils.ts index 03829c9e12e..c70f682c761 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -26,7 +26,6 @@ import { MongoParseError, MongoRuntimeError } from './error'; -import type { Explain, ExplainVerbosity } from './explain'; import type { MongoClient } from './mongo_client'; import type { CommandOperationOptions, OperationParent } from './operations/command'; import type { Hint, OperationOptions } from './operations/operation'; @@ -246,32 +245,6 @@ export function decorateWithReadConcern( } } -/** - * Applies an explain to a given command. - * @internal - * - * @param command - the command on which to apply the explain - * @param options - the options containing the explain verbosity - */ -export function decorateWithExplain( - command: Document, - explain: Explain -): { - explain: Document; - verbosity: ExplainVerbosity; - maxTimeMS?: number; -} { - type ExplainCommand = ReturnType; - const { verbosity, maxTimeMS } = explain; - const baseCommand: ExplainCommand = { explain: command, verbosity }; - - if (typeof maxTimeMS === 'number') { - baseCommand.maxTimeMS = maxTimeMS; - } - - return baseCommand; -} - /** * @internal */ diff --git a/test/integration/crud/explain.test.ts b/test/integration/crud/explain.test.ts index 44fe381303a..c7a9a3025f9 100644 --- a/test/integration/crud/explain.test.ts +++ b/test/integration/crud/explain.test.ts @@ -5,9 +5,12 @@ import { type Collection, type CommandStartedEvent, type Db, + type Document, type MongoClient, + MongoOperationTimeoutError, MongoServerError } from '../../mongodb'; +import { clearFailPoint, configureFailPoint, measureDuration } from '../../tools/utils'; import { filterForCommands } from '../shared'; const explain = [true, false, 'queryPlanner', 'allPlansExecution', 'executionStats', 'invalid']; @@ -296,6 +299,372 @@ describe('CRUD API explain option', function () { }; } }); + + describe('explain with timeoutMS', function () { + let client: MongoClient; + type ExplainStartedEvent = CommandStartedEvent & { + command: { explain: Document & { maxTimeMS?: number }; maxTimeMS?: number }; + }; + const commands: ExplainStartedEvent[] = []; + + afterEach(async function () { + await clearFailPoint( + this.configuration, + this.configuration.url({ useMultipleMongoses: false }) + ); + }); + + beforeEach(async function () { + const uri = this.configuration.url({ useMultipleMongoses: false }); + await configureFailPoint( + this.configuration, + { + configureFailPoint: 'failCommand', + mode: 'alwaysOn', + data: { + failCommands: ['explain'], + blockConnection: true, + blockTimeMS: 2000 + } + }, + this.configuration.url({ useMultipleMongoses: false }) + ); + + client = this.configuration.newClient(uri, { monitorCommands: true }); + client.on('commandStarted', filterForCommands('explain', commands)); + await client.connect(); + }); + + afterEach(async function () { + await client?.close(); + commands.length = 0; + }); + + describe('Explain helpers respect timeoutMS', function () { + describe('when a cursor api is being explained', function () { + describe('when timeoutMS is provided', function () { + it( + 'the explain command times out after timeoutMS', + { requires: { mongodb: '>=4.4' } }, + async function () { + const cursor = client.db('foo').collection('bar').find({}, { timeoutMS: 1000 }); + const { duration, result } = await measureDuration(() => + cursor.explain({ verbosity: 'queryPlanner' }).catch(e => e) + ); + + expect(result).to.be.instanceOf(MongoOperationTimeoutError); + expect(duration).to.be.within(1000 - 100, 1000 + 100); + } + ); + + it( + 'the explain command has the calculated maxTimeMS value attached', + { requires: { mongodb: '>=4.4' } }, + async function () { + const cursor = client.db('foo').collection('bar').find({}, { timeoutMS: 1000 }); + const timeout = await cursor.explain({ verbosity: 'queryPlanner' }).catch(e => e); + expect(timeout).to.be.instanceOf(MongoOperationTimeoutError); + + const [ + { + command: { maxTimeMS } + } + ] = commands; + + expect(maxTimeMS).to.be.a('number'); + } + ); + + it( + 'the explained command does not have a maxTimeMS value attached', + { requires: { mongodb: '>=4.4' } }, + async function () { + const cursor = client.db('foo').collection('bar').find({}, { timeoutMS: 1000 }); + const timeout = await cursor.explain({ verbosity: 'queryPlanner' }).catch(e => e); + expect(timeout).to.be.instanceOf(MongoOperationTimeoutError); + + const [ + { + command: { + explain: { maxTimeMS } + } + } + ] = commands; + + expect(maxTimeMS).not.to.exist; + } + ); + }); + + describe('when timeoutMS and maxTimeMS are both provided', function () { + it( + 'an error is thrown indicating incompatibility of those options', + { requires: { mongodb: '>=4.4' } }, + async function () { + const cursor = client.db('foo').collection('bar').find({}, { timeoutMS: 1000 }); + const error = await cursor + .explain({ verbosity: 'queryPlanner', maxTimeMS: 1000 }) + .catch(e => e); + expect(error).to.match(/Cannot use maxTimeMS with timeoutMS for explain commands/); + } + ); + }); + }); + + describe('when a non-cursor api is being explained', function () { + describe('when timeoutMS is provided', function () { + it( + 'the explain command times out after timeoutMS', + { requires: { mongodb: '>=4.4' } }, + async function () { + const { duration, result } = await measureDuration(() => + client + .db('foo') + .collection('bar') + .deleteMany( + {}, + { + timeoutMS: 1000, + explain: { verbosity: 'queryPlanner' } + } + ) + .catch(e => e) + ); + + expect(result).to.be.instanceOf(MongoOperationTimeoutError); + expect(duration).to.be.within(1000 - 100, 1000 + 100); + } + ); + + it( + 'the explain command has the calculated maxTimeMS value attached', + { requires: { mongodb: '>=4.4' } }, + async function () { + const timeout = await client + .db('foo') + .collection('bar') + .deleteMany( + {}, + { + timeoutMS: 1000, + explain: { verbosity: 'queryPlanner' } + } + ) + .catch(e => e); + + expect(timeout).to.be.instanceOf(MongoOperationTimeoutError); + + const [ + { + command: { maxTimeMS } + } + ] = commands; + + expect(maxTimeMS).to.be.a('number'); + } + ); + + it( + 'the explained command does not have a maxTimeMS value attached', + { requires: { mongodb: '>=4.4' } }, + async function () { + const timeout = await client + .db('foo') + .collection('bar') + .deleteMany( + {}, + { + timeoutMS: 1000, + explain: { verbosity: 'queryPlanner' } + } + ) + .catch(e => e); + + expect(timeout).to.be.instanceOf(MongoOperationTimeoutError); + + const [ + { + command: { + explain: { maxTimeMS } + } + } + ] = commands; + + expect(maxTimeMS).not.to.exist; + } + ); + }); + + describe('when timeoutMS and maxTimeMS are both provided', function () { + it( + 'an error is thrown indicating incompatibility of those options', + { requires: { mongodb: '>=4.4' } }, + async function () { + const error = await client + .db('foo') + .collection('bar') + .deleteMany( + {}, + { + timeoutMS: 1000, + explain: { verbosity: 'queryPlanner', maxTimeMS: 1000 } + } + ) + .catch(e => e); + + expect(error).to.match(/Cannot use maxTimeMS with timeoutMS for explain commands/); + } + ); + }); + }); + + describe('when find({}, { explain: ...}) is used with timeoutMS', function () { + it( + 'an error is thrown indicating that explain is not supported with timeoutMS for this API', + { requires: { mongodb: '>=4.4' } }, + async function () { + const error = await client + .db('foo') + .collection('bar') + .find( + {}, + { + timeoutMS: 1000, + explain: { verbosity: 'queryPlanner', maxTimeMS: 1000 } + } + ) + .toArray() + .catch(e => e); + + expect(error).to.match( + /timeoutMS cannot be used with explain when explain is specified in findOptions/ + ); + } + ); + }); + + describe('when aggregate({}, { explain: ...}) is used with timeoutMS', function () { + it( + 'an error is thrown indicating that explain is not supported with timeoutMS for this API', + { requires: { mongodb: '>=4.4' } }, + async function () { + const error = await client + .db('foo') + .collection('bar') + .aggregate([], { + timeoutMS: 1000, + explain: { verbosity: 'queryPlanner', maxTimeMS: 1000 } + }) + .toArray() + .catch(e => e); + + expect(error).to.match( + /timeoutMS cannot be used with explain when explain is specified in aggregateOptions/ + ); + } + ); + }); + }); + + describe('fluent api timeoutMS precedence and inheritance', function () { + describe('find({}, { timeoutMS }).explain()', function () { + it( + 'respects the timeoutMS from the find options', + { requires: { mongodb: '>=4.4' } }, + async function () { + const cursor = client.db('foo').collection('bar').find({}, { timeoutMS: 800 }); + const { duration, result: error } = await measureDuration(() => + cursor.explain({ verbosity: 'queryPlanner' }).catch(e => e) + ); + + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(duration).to.be.within(800 - 100, 800 + 100); + } + ); + }); + + describe('find().explain({}, { timeoutMS })', function () { + it( + 'respects the timeoutMS from the explain helper', + { requires: { mongodb: '>=4.4' } }, + async function () { + const cursor = client.db('foo').collection('bar').find(); + const { duration, result: error } = await measureDuration(() => + cursor.explain({ verbosity: 'queryPlanner' }, { timeoutMS: 800 }).catch(e => e) + ); + + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(duration).to.be.within(800 - 100, 800 + 100); + } + ); + }); + + describe('find({}, { timeoutMS} ).explain({}, { timeoutMS })', function () { + it( + 'the timeoutMS from the explain helper has precedence', + { requires: { mongodb: '>=4.4' } }, + async function () { + const cursor = client.db('foo').collection('bar').find({}, { timeoutMS: 100 }); + const { duration, result: error } = await measureDuration(() => + cursor.explain({ verbosity: 'queryPlanner' }, { timeoutMS: 800 }).catch(e => e) + ); + + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(duration).to.be.within(800 - 100, 800 + 100); + } + ); + }); + + describe('aggregate([], { timeoutMS }).explain()', function () { + it( + 'respects the timeoutMS from the find options', + { requires: { mongodb: '>=4.4' } }, + async function () { + const cursor = client.db('foo').collection('bar').aggregate([], { timeoutMS: 800 }); + const { duration, result: error } = await measureDuration(() => + cursor.explain({ verbosity: 'queryPlanner' }).catch(e => e) + ); + + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(duration).to.be.within(800 - 100, 800 + 100); + } + ); + }); + + describe('aggregate([], { timeoutMS })', function () { + it( + 'respects the timeoutMS from the explain helper', + { requires: { mongodb: '>=4.4' } }, + async function () { + const cursor = client.db('foo').collection('bar').aggregate(); + + const { duration, result: error } = await measureDuration(() => + cursor.explain({ verbosity: 'queryPlanner' }, { timeoutMS: 800 }).catch(e => e) + ); + + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(duration).to.be.within(800 - 100, 800 + 100); + } + ); + }); + + describe('aggregate([], { timeoutMS} ).explain({}, { timeoutMS })', function () { + it( + 'the timeoutMS from the explain helper has precedence', + { requires: { mongodb: '>=4.4' } }, + async function () { + const cursor = client.db('foo').collection('bar').aggregate([], { timeoutMS: 100 }); + const { duration, result: error } = await measureDuration(() => + cursor.explain({ verbosity: 'queryPlanner' }, { timeoutMS: 800 }).catch(e => e) + ); + + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(duration).to.be.within(800 - 100, 800 + 100); + } + ); + }); + }); + }); }); function explainValueToExpectation(explainValue: boolean | string) { diff --git a/test/tools/runner/config.ts b/test/tools/runner/config.ts index 16024638fba..af596980c3f 100644 --- a/test/tools/runner/config.ts +++ b/test/tools/runner/config.ts @@ -199,7 +199,7 @@ export class TestConfiguration { } newClient(urlOrQueryOptions?: string | Record, serverOptions?: MongoClientOptions) { - serverOptions = Object.assign({}, getEnvironmentalOptions(), serverOptions); + serverOptions = Object.assign({}, getEnvironmentalOptions(), serverOptions); // Support MongoClient constructor form (url, options) for `newClient`. if (typeof urlOrQueryOptions === 'string') { @@ -294,7 +294,23 @@ export class TestConfiguration { * * @param options - overrides and settings for URI generation */ - url(options?: UrlOptions) { + url( + options?: UrlOptions & { + useMultipleMongoses?: boolean; + db?: string; + replicaSet?: string; + proxyURIParams?: ProxyParams; + username?: string; + password?: string; + auth?: { + username?: string; + password?: string; + }; + authSource?: string; + authMechanism?: string; + authMechanismProperties?: Record; + } + ) { options = { db: this.options.db, replicaSet: this.options.replicaSet, diff --git a/test/tools/utils.ts b/test/tools/utils.ts index 38c0da6c092..cd79bb2d4c2 100644 --- a/test/tools/utils.ts +++ b/test/tools/utils.ts @@ -15,6 +15,7 @@ import { type Document, type HostAddress, MongoClient, + now, OP_MSG, Topology, type TopologyOptions @@ -616,8 +617,8 @@ export async function configureFailPoint( } } -export async function clearFailPoint(configuration: TestConfiguration, uri = configuration.url()) { - const utilClient = configuration.newClient(uri); +export async function clearFailPoint(configuration: TestConfiguration, url = configuration.url()) { + const utilClient = configuration.newClient(url); await utilClient.connect(); try { @@ -669,3 +670,21 @@ export async function makeMultiResponseBatchModelArray( return models; } + +/** + * A utility to measure the duration of an async function. This is intended to be used for CSOT + * testing, where we expect to timeout within a certain threshold and want to measure the duration + * of that operation. + */ +export async function measureDuration(f: () => Promise): Promise<{ + duration: number; + result: T | Error; +}> { + const start = now(); + const result = await f().catch(e => e); + const end = now(); + return { + duration: end - start, + result + }; +} diff --git a/test/unit/explain.test.ts b/test/unit/explain.test.ts index 8d71197a81a..282a6fe7c8e 100644 --- a/test/unit/explain.test.ts +++ b/test/unit/explain.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import { it } from 'mocha'; -import { Explain, ExplainVerbosity } from '../mongodb'; +import { Explain, ExplainVerbosity, FindCursor, MongoClient, MongoDBNamespace } from '../mongodb'; describe('class Explain {}', function () { describe('static .fromOptions()', function () { @@ -50,4 +50,42 @@ describe('class Explain {}', function () { }); }); }); + + describe('parseTimeoutOptions()', function () { + const cursor = new FindCursor( + new MongoClient('mongodb://localhost:27027'), + MongoDBNamespace.fromString('foo.bar'), + {}, + {} + ); + + it('parseTimeoutOptions()', function () { + const { timeout, explain } = cursor.resolveExplainTimeoutOptions(); + expect(timeout).to.be.undefined; + expect(explain).to.be.undefined; + }); + + it('parseTimeoutOptions()', function () { + const { timeout, explain } = cursor.resolveExplainTimeoutOptions({ timeoutMS: 1_000 }); + expect(timeout).to.deep.equal({ timeoutMS: 1_000 }); + expect(explain).to.be.undefined; + }); + + it('parseTimeoutOptions()', function () { + const { timeout, explain } = cursor.resolveExplainTimeoutOptions({ + verbosity: 'queryPlanner' + }); + expect(timeout).to.be.undefined; + expect(explain).to.deep.equal({ verbosity: 'queryPlanner' }); + }); + + it('parseTimeoutOptions()', function () { + const { timeout, explain } = cursor.resolveExplainTimeoutOptions( + { verbosity: 'queryPlanner' }, + { timeoutMS: 1_000 } + ); + expect(timeout).to.deep.equal({ timeoutMS: 1_000 }); + expect(explain).to.deep.equal({ verbosity: 'queryPlanner' }); + }); + }); }); diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index a1e8f22e37d..a76aff98d91 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -54,6 +54,7 @@ const EXPECTED_EXPORTS = [ 'Decimal128', 'Double', 'ExplainVerbosity', + 'ExplainableCursor', 'FindCursor', 'GridFSBucket', 'GridFSBucketReadStream',