Skip to content

Commit 20396e1

Browse files
authoredSep 16, 2024··
feat(NODE-6342): support maxTimeMS for explain commands (#4207)
1 parent 7b71e1f commit 20396e1

10 files changed

+410
-20
lines changed
 

‎src/cursor/aggregation_cursor.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Document } from '../bson';
2-
import type { ExplainVerbosityLike } from '../explain';
2+
import type { ExplainCommandOptions, ExplainVerbosityLike } from '../explain';
33
import type { MongoClient } from '../mongo_client';
44
import { AggregateOperation, type AggregateOptions } from '../operations/aggregate';
55
import { executeOperation } from '../operations/execute_operation';
@@ -66,7 +66,7 @@ export class AggregationCursor<TSchema = any> extends AbstractCursor<TSchema> {
6666
}
6767

6868
/** Execute the explain for the cursor */
69-
async explain(verbosity?: ExplainVerbosityLike): Promise<Document> {
69+
async explain(verbosity?: ExplainVerbosityLike | ExplainCommandOptions): Promise<Document> {
7070
return (
7171
await executeOperation(
7272
this.client,

‎src/cursor/find_cursor.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { type Document } from '../bson';
22
import { CursorResponse } from '../cmap/wire_protocol/responses';
33
import { MongoInvalidArgumentError, MongoTailableCursorError } from '../error';
4-
import { type ExplainVerbosityLike } from '../explain';
4+
import { type ExplainCommandOptions, type ExplainVerbosityLike } from '../explain';
55
import type { MongoClient } from '../mongo_client';
66
import type { CollationOptions } from '../operations/command';
77
import { CountOperation, type CountOptions } from '../operations/count';
@@ -133,7 +133,7 @@ export class FindCursor<TSchema = any> extends AbstractCursor<TSchema> {
133133
}
134134

135135
/** Execute the explain for the cursor */
136-
async explain(verbosity?: ExplainVerbosityLike): Promise<Document> {
136+
async explain(verbosity?: ExplainVerbosityLike | ExplainCommandOptions): Promise<Document> {
137137
return (
138138
await executeOperation(
139139
this.client,

‎src/explain.ts

+46-9
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { MongoInvalidArgumentError } from './error';
2-
31
/** @public */
42
export const ExplainVerbosity = Object.freeze({
53
queryPlanner: 'queryPlanner',
@@ -19,33 +17,72 @@ export type ExplainVerbosity = string;
1917
export type ExplainVerbosityLike = ExplainVerbosity | boolean;
2018

2119
/** @public */
20+
export interface ExplainCommandOptions {
21+
/** The explain verbosity for the command. */
22+
verbosity: ExplainVerbosity;
23+
/** The maxTimeMS setting for the command. */
24+
maxTimeMS?: number;
25+
}
26+
27+
/**
28+
* @public
29+
*
30+
* When set, this configures an explain command. Valid values are boolean (for legacy compatibility,
31+
* see {@link ExplainVerbosityLike}), a string containing the explain verbosity, or an object containing the verbosity and
32+
* an optional maxTimeMS.
33+
*
34+
* Examples of valid usage:
35+
*
36+
* ```typescript
37+
* collection.find({ name: 'john doe' }, { explain: true });
38+
* collection.find({ name: 'john doe' }, { explain: false });
39+
* collection.find({ name: 'john doe' }, { explain: 'queryPlanner' });
40+
* collection.find({ name: 'john doe' }, { explain: { verbosity: 'queryPlanner' } });
41+
* ```
42+
*
43+
* maxTimeMS can be configured to limit the amount of time the server
44+
* spends executing an explain by providing an object:
45+
*
46+
* ```typescript
47+
* // limits the `explain` command to no more than 2 seconds
48+
* collection.find({ name: 'john doe' }, {
49+
* explain: {
50+
* verbosity: 'queryPlanner',
51+
* maxTimeMS: 2000
52+
* }
53+
* });
54+
* ```
55+
*/
2256
export interface ExplainOptions {
2357
/** Specifies the verbosity mode for the explain output. */
24-
explain?: ExplainVerbosityLike;
58+
explain?: ExplainVerbosityLike | ExplainCommandOptions;
2559
}
2660

2761
/** @internal */
2862
export class Explain {
29-
verbosity: ExplainVerbosity;
63+
readonly verbosity: ExplainVerbosity;
64+
readonly maxTimeMS?: number;
3065

31-
constructor(verbosity: ExplainVerbosityLike) {
66+
private constructor(verbosity: ExplainVerbosityLike, maxTimeMS?: number) {
3267
if (typeof verbosity === 'boolean') {
3368
this.verbosity = verbosity
3469
? ExplainVerbosity.allPlansExecution
3570
: ExplainVerbosity.queryPlanner;
3671
} else {
3772
this.verbosity = verbosity;
3873
}
74+
75+
this.maxTimeMS = maxTimeMS;
3976
}
4077

41-
static fromOptions(options?: ExplainOptions): Explain | undefined {
42-
if (options?.explain == null) return;
78+
static fromOptions({ explain }: ExplainOptions = {}): Explain | undefined {
79+
if (explain == null) return;
4380

44-
const explain = options.explain;
4581
if (typeof explain === 'boolean' || typeof explain === 'string') {
4682
return new Explain(explain);
4783
}
4884

49-
throw new MongoInvalidArgumentError('Field "explain" must be a string or a boolean');
85+
const { verbosity, maxTimeMS } = explain;
86+
return new Explain(verbosity, maxTimeMS);
5087
}
5188
}

‎src/index.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,12 @@ export type { RunCursorCommandOptions } from './cursor/run_command_cursor';
368368
export type { DbOptions, DbPrivate } from './db';
369369
export type { Encrypter, EncrypterOptions } from './encrypter';
370370
export type { AnyError, ErrorDescription, MongoNetworkErrorOptions } from './error';
371-
export type { Explain, ExplainOptions, ExplainVerbosityLike } from './explain';
371+
export type {
372+
Explain,
373+
ExplainCommandOptions,
374+
ExplainOptions,
375+
ExplainVerbosityLike
376+
} from './explain';
372377
export type {
373378
GridFSBucketReadStreamOptions,
374379
GridFSBucketReadStreamOptionsWithRevision,

‎src/utils.ts

+16-5
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
MongoParseError,
2626
MongoRuntimeError
2727
} from './error';
28-
import type { Explain } from './explain';
28+
import type { Explain, ExplainVerbosity } from './explain';
2929
import type { MongoClient } from './mongo_client';
3030
import type { CommandOperationOptions, OperationParent } from './operations/command';
3131
import type { Hint, OperationOptions } from './operations/operation';
@@ -251,12 +251,23 @@ export function decorateWithReadConcern(
251251
* @param command - the command on which to apply the explain
252252
* @param options - the options containing the explain verbosity
253253
*/
254-
export function decorateWithExplain(command: Document, explain: Explain): Document {
255-
if (command.explain) {
256-
return command;
254+
export function decorateWithExplain(
255+
command: Document,
256+
explain: Explain
257+
): {
258+
explain: Document;
259+
verbosity: ExplainVerbosity;
260+
maxTimeMS?: number;
261+
} {
262+
type ExplainCommand = ReturnType<typeof decorateWithExplain>;
263+
const { verbosity, maxTimeMS } = explain;
264+
const baseCommand: ExplainCommand = { explain: command, verbosity };
265+
266+
if (typeof maxTimeMS === 'number') {
267+
baseCommand.maxTimeMS = maxTimeMS;
257268
}
258269

259-
return { explain: command, verbosity: explain.verbosity };
270+
return baseCommand;
260271
}
261272

262273
/**

‎test/integration/crud/crud.prose.test.ts

+46-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { expect } from 'chai';
22
import { once } from 'events';
33

4-
import { MongoBulkWriteError, type MongoClient, MongoServerError } from '../../mongodb';
4+
import { type CommandStartedEvent } from '../../../mongodb';
5+
import {
6+
type Collection,
7+
MongoBulkWriteError,
8+
type MongoClient,
9+
MongoServerError
10+
} from '../../mongodb';
11+
import { filterForCommands } from '../shared';
512

613
describe('CRUD Prose Spec Tests', () => {
714
let client: MongoClient;
@@ -143,4 +150,42 @@ describe('CRUD Prose Spec Tests', () => {
143150
}
144151
});
145152
});
153+
154+
describe('14. `explain` helpers allow users to specify `maxTimeMS`', function () {
155+
let client: MongoClient;
156+
const commands: CommandStartedEvent[] = [];
157+
let collection: Collection;
158+
159+
beforeEach(async function () {
160+
client = this.configuration.newClient({}, { monitorCommands: true });
161+
await client.connect();
162+
163+
await client.db('explain-test').dropDatabase();
164+
collection = await client.db('explain-test').createCollection('collection');
165+
166+
client.on('commandStarted', filterForCommands('explain', commands));
167+
commands.length = 0;
168+
});
169+
170+
afterEach(async function () {
171+
await client.close();
172+
});
173+
174+
it('sets maxTimeMS on explain commands, when specified', async function () {
175+
await collection
176+
.find(
177+
{ name: 'john doe' },
178+
{
179+
explain: {
180+
maxTimeMS: 2000,
181+
verbosity: 'queryPlanner'
182+
}
183+
}
184+
)
185+
.toArray();
186+
187+
const [{ command }] = commands;
188+
expect(command).to.have.property('maxTimeMS', 2000);
189+
});
190+
});
146191
});

‎test/integration/crud/explain.test.ts

+179
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
type MongoClient,
99
MongoServerError
1010
} from '../../mongodb';
11+
import { filterForCommands } from '../shared';
1112

1213
const explain = [true, false, 'queryPlanner', 'allPlansExecution', 'executionStats', 'invalid'];
1314

@@ -117,6 +118,184 @@ describe('CRUD API explain option', function () {
117118
});
118119
}
119120
}
121+
122+
describe('explain helpers w/ maxTimeMS', function () {
123+
let client: MongoClient;
124+
const commands: CommandStartedEvent[] = [];
125+
let collection: Collection;
126+
127+
beforeEach(async function () {
128+
client = this.configuration.newClient({}, { monitorCommands: true });
129+
await client.connect();
130+
131+
await client.db('explain-test').dropDatabase();
132+
collection = await client.db('explain-test').createCollection('bar');
133+
134+
client.on('commandStarted', filterForCommands('explain', commands));
135+
commands.length = 0;
136+
});
137+
138+
afterEach(async function () {
139+
await client.close();
140+
});
141+
142+
describe('maxTimeMS provided to explain, not to command', function () {
143+
describe('cursor commands', function () {
144+
describe('options API', function () {
145+
beforeEach(async function () {
146+
await collection
147+
.find({}, { explain: { maxTimeMS: 1000, verbosity: 'queryPlanner' } })
148+
.toArray();
149+
});
150+
151+
it('attaches maxTimeMS to the explain command', expectOnExplain(1000));
152+
153+
it('does not attach maxTimeMS to the find command', expectNotOnCommand());
154+
});
155+
156+
describe('fluent API', function () {
157+
beforeEach(async function () {
158+
await collection.find({}).explain({ maxTimeMS: 1000, verbosity: 'queryPlanner' });
159+
});
160+
161+
it('attaches maxTimeMS to the explain command', expectOnExplain(1000));
162+
163+
it('does not attach maxTimeMS to the find command', expectNotOnCommand());
164+
});
165+
});
166+
167+
describe('non-cursor commands', function () {
168+
beforeEach(async function () {
169+
await collection.deleteMany(
170+
{},
171+
{ explain: { maxTimeMS: 1000, verbosity: 'queryPlanner' } }
172+
);
173+
});
174+
175+
it('attaches maxTimeMS to the explain command', expectOnExplain(1000));
176+
177+
it('does not attach maxTimeMS to the explained command', expectNotOnCommand());
178+
});
179+
});
180+
181+
describe('maxTimeMS provided to command, not explain', function () {
182+
describe('cursor commands', function () {
183+
describe('options API', function () {
184+
beforeEach(async function () {
185+
await collection
186+
.find({}, { maxTimeMS: 1000, explain: { verbosity: 'queryPlanner' } })
187+
.toArray();
188+
});
189+
190+
it('does not attach maxTimeMS to the explain command', expectNotOnExplain());
191+
192+
it('attaches maxTimeMS to the find command', expectOnCommand(1000));
193+
});
194+
195+
describe('fluent API', function () {
196+
beforeEach(async function () {
197+
await collection.find({}, { maxTimeMS: 1000 }).explain({ verbosity: 'queryPlanner' });
198+
});
199+
200+
it('does not attach maxTimeMS to the explain command', expectNotOnExplain());
201+
202+
it('attaches maxTimeMS to the find command', expectOnCommand(1000));
203+
});
204+
});
205+
206+
describe('non-cursor commands', function () {
207+
beforeEach(async function () {
208+
await collection.deleteMany(
209+
{},
210+
{ maxTimeMS: 1000, explain: { verbosity: 'queryPlanner' } }
211+
);
212+
});
213+
214+
it('does nto attach maxTimeMS to the explain command', expectNotOnExplain());
215+
216+
it('attaches maxTimeMS to the explained command', expectOnCommand(1000));
217+
});
218+
});
219+
220+
describe('maxTimeMS specified in command options and explain options', function () {
221+
describe('cursor commands', function () {
222+
describe('options API', function () {
223+
beforeEach(async function () {
224+
await collection
225+
.find(
226+
{},
227+
{ maxTimeMS: 1000, explain: { maxTimeMS: 2000, verbosity: 'queryPlanner' } }
228+
)
229+
.toArray();
230+
});
231+
232+
it('attaches maxTimeMS from the explain options to explain', expectOnExplain(2000));
233+
234+
it('attaches maxTimeMS from the find options to the find command', expectOnCommand(1000));
235+
});
236+
237+
describe('fluent API', function () {
238+
beforeEach(async function () {
239+
await collection
240+
.find({}, { maxTimeMS: 1000 })
241+
.explain({ maxTimeMS: 2000, verbosity: 'queryPlanner' });
242+
});
243+
244+
it('attaches maxTimeMS from the explain options to explain', expectOnExplain(2000));
245+
246+
it('attaches maxTimeMS from the find options to the find command', expectOnCommand(1000));
247+
});
248+
});
249+
250+
describe('non-cursor commands', function () {
251+
beforeEach(async function () {
252+
await collection.deleteMany(
253+
{},
254+
{ maxTimeMS: 1000, explain: { maxTimeMS: 2000, verbosity: 'queryPlanner' } }
255+
);
256+
});
257+
258+
it('attaches maxTimeMS to the explain command', expectOnExplain(2000));
259+
260+
it('attaches maxTimeMS to the explained command', expectOnCommand(1000));
261+
});
262+
});
263+
264+
function expectOnExplain(value: number) {
265+
return function () {
266+
const [{ command }] = commands;
267+
expect(command).to.have.property('maxTimeMS', value);
268+
};
269+
}
270+
271+
function expectNotOnExplain() {
272+
return function () {
273+
const [{ command }] = commands;
274+
expect(command).not.to.have.property('maxTimeMS');
275+
};
276+
}
277+
278+
function expectOnCommand(value: number) {
279+
return function () {
280+
const [
281+
{
282+
command: { explain }
283+
}
284+
] = commands;
285+
expect(explain).to.have.property('maxTimeMS', value);
286+
};
287+
}
288+
function expectNotOnCommand() {
289+
return function () {
290+
const [
291+
{
292+
command: { explain }
293+
}
294+
] = commands;
295+
expect(explain).not.to.have.property('maxTimeMS');
296+
};
297+
}
298+
});
120299
});
121300

122301
function explainValueToExpectation(explainValue: boolean | string) {

‎test/integration/shared.js

+20
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,26 @@ function dropCollection(dbObj, collectionName, options = {}) {
4242
return dbObj.dropCollection(collectionName, options).catch(ignoreNsNotFound);
4343
}
4444

45+
/**
46+
* Given a set of commands to look for when command monitoring and a destination to store them, returns an event handler
47+
* that collects the specified events.
48+
*
49+
* ```typescript
50+
* const commands = [];
51+
*
52+
* // one command
53+
* client.on('commandStarted', filterForCommands('ping', commands));
54+
* // multiple commands
55+
* client.on('commandStarted', filterForCommands(['ping', 'find'], commands));
56+
* // custom predicate
57+
* client.on('commandStarted', filterForCommands((command) => command.commandName === 'find', commands));
58+
* ```
59+
* @param {string | string[] | (arg0: string) => boolean} commands A set of commands to look for. Either
60+
* a single command name (string), a list of command names (string[]) or a predicate function that
61+
* determines whether or not a command should be kept.
62+
* @param {Array} bag the output for the filtered commands
63+
* @returns a function that collects the specified comment events
64+
*/
4565
function filterForCommands(commands, bag) {
4666
if (typeof commands === 'function') {
4767
return function (event) {

‎test/unit/explain.test.ts

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { expect } from 'chai';
2+
import { it } from 'mocha';
3+
4+
import { Explain, ExplainVerbosity } from '../mongodb';
5+
6+
describe('class Explain {}', function () {
7+
describe('static .fromOptions()', function () {
8+
it('when no options are provided, it returns undefined', function () {
9+
expect(Explain.fromOptions()).to.be.undefined;
10+
});
11+
12+
it('explain=true constructs an allPlansExecution explain', function () {
13+
const explain = Explain.fromOptions({ explain: true });
14+
expect(explain).to.have.property('verbosity', ExplainVerbosity.allPlansExecution);
15+
expect(explain).to.have.property('maxTimeMS').to.be.undefined;
16+
});
17+
18+
it('explain=false constructs an allPlansExecution explain', function () {
19+
const explain = Explain.fromOptions({ explain: false });
20+
expect(explain).to.have.property('verbosity', ExplainVerbosity.queryPlanner);
21+
expect(explain).to.have.property('maxTimeMS').to.be.undefined;
22+
});
23+
24+
it('explain=<type string> constructs an explain with verbosity set to the string', function () {
25+
const explain = Explain.fromOptions({ explain: 'some random string' });
26+
expect(explain).to.have.property('verbosity', 'some random string');
27+
expect(explain).to.have.property('maxTimeMS').to.be.undefined;
28+
});
29+
30+
describe('when explain is an object', function () {
31+
it('uses the verbosity from the object', function () {
32+
const explain = Explain.fromOptions({
33+
explain: {
34+
verbosity: 'some random string'
35+
}
36+
});
37+
expect(explain).to.have.property('verbosity', 'some random string');
38+
expect(explain).to.have.property('maxTimeMS').to.be.undefined;
39+
});
40+
41+
it('when a maxTimeMS is provided, it constructs an explain with the maxTImeMS value', function () {
42+
const explain = Explain.fromOptions({
43+
explain: {
44+
verbosity: 'some random string',
45+
maxTimeMS: 2000
46+
}
47+
});
48+
expect(explain).to.have.property('verbosity', 'some random string');
49+
expect(explain).to.have.property('maxTimeMS', 2000);
50+
});
51+
});
52+
});
53+
});

‎test/unit/utils.test.ts

+40
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
BufferPool,
55
ByteUtils,
66
compareObjectId,
7+
decorateWithExplain,
8+
Explain,
79
HostAddress,
810
hostMatchesWildcards,
911
isHello,
@@ -1003,4 +1005,42 @@ describe('driver utils', function () {
10031005
describe('when given an object that does not respond to Symbol.toStringTag', () =>
10041006
it('returns false', () => expect(isUint8Array(Object.create(null))).to.be.false));
10051007
});
1008+
1009+
describe('decorateWithExplain()', function () {
1010+
it('when the command is a valid explain command, the command is still wrapped', function () {
1011+
const command = { explain: { hello: 'world' } };
1012+
const result = decorateWithExplain(command, Explain.fromOptions({ explain: true }));
1013+
1014+
expect(result).to.deep.equal({ explain: command, verbosity: 'allPlansExecution' });
1015+
});
1016+
1017+
it('when the options have a maxTimeMS, it is attached to the explain command', function () {
1018+
const command = { ping: 1 };
1019+
const result = decorateWithExplain(
1020+
command,
1021+
Explain.fromOptions({
1022+
explain: { verbosity: 'queryPlanner', maxTimeMS: 1000 }
1023+
})
1024+
);
1025+
expect(result).to.deep.equal({
1026+
explain: { ping: 1 },
1027+
verbosity: 'queryPlanner',
1028+
maxTimeMS: 1000
1029+
});
1030+
});
1031+
1032+
it('when the options have do not have a maxTimeMS, it is not attached to the explain command', function () {
1033+
const command = { ping: 1 };
1034+
const result = decorateWithExplain(
1035+
command,
1036+
Explain.fromOptions({
1037+
explain: { verbosity: 'queryPlanner' }
1038+
})
1039+
);
1040+
expect(result).to.deep.equal({
1041+
explain: { ping: 1 },
1042+
verbosity: 'queryPlanner'
1043+
});
1044+
});
1045+
});
10061046
});

0 commit comments

Comments
 (0)
Please sign in to comment.