Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(NODE-6342): support maxTimeMS for explain commands #4207

Merged
merged 13 commits into from
Sep 16, 2024
4 changes: 2 additions & 2 deletions src/cursor/aggregation_cursor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Document } from '../bson';
import type { ExplainVerbosityLike } from '../explain';
import type { ExplainCommandOptions, ExplainVerbosityLike } from '../explain';
import type { MongoClient } from '../mongo_client';
import { AggregateOperation, type AggregateOptions } from '../operations/aggregate';
import { executeOperation } from '../operations/execute_operation';
Expand Down Expand Up @@ -66,7 +66,7 @@ export class AggregationCursor<TSchema = any> extends AbstractCursor<TSchema> {
}

/** Execute the explain for the cursor */
async explain(verbosity?: ExplainVerbosityLike): Promise<Document> {
async explain(verbosity?: ExplainVerbosityLike | ExplainCommandOptions): Promise<Document> {
return (
await executeOperation(
this.client,
Expand Down
4 changes: 2 additions & 2 deletions src/cursor/find_cursor.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type Document } from '../bson';
import { CursorResponse } from '../cmap/wire_protocol/responses';
import { MongoInvalidArgumentError, MongoTailableCursorError } from '../error';
import { type ExplainVerbosityLike } from '../explain';
import { type ExplainCommandOptions, type ExplainVerbosityLike } from '../explain';
import type { MongoClient } from '../mongo_client';
import type { CollationOptions } from '../operations/command';
import { CountOperation, type CountOptions } from '../operations/count';
Expand Down Expand Up @@ -133,7 +133,7 @@ export class FindCursor<TSchema = any> extends AbstractCursor<TSchema> {
}

/** Execute the explain for the cursor */
async explain(verbosity?: ExplainVerbosityLike): Promise<Document> {
async explain(verbosity?: ExplainVerbosityLike | ExplainCommandOptions): Promise<Document> {
return (
await executeOperation(
this.client,
Expand Down
55 changes: 46 additions & 9 deletions src/explain.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { MongoInvalidArgumentError } from './error';

/** @public */
export const ExplainVerbosity = Object.freeze({
queryPlanner: 'queryPlanner',
Expand All @@ -20,33 +18,72 @@ export type ExplainVerbosity = string;
export type ExplainVerbosityLike = ExplainVerbosity | boolean;

/** @public */
export interface ExplainCommandOptions {
/** The explain verbosity for the command. */
verbosity: ExplainVerbosity;
/** The maxTimeMS setting for the command. */
maxTimeMS?: number;
}

/**
* @public
*
* When set, this configures an explain command. Valid values are boolean (for legacy compatibility,
* see {@link ExplainVerbosityLike}), a string containing the explain verbosity, or an object containing the verbosity and
* an optional maxTimeMS.
*
* Examples of valid usage:
*
* ```typescript
* collection.find({ name: 'john doe' }, { explain: true });
* collection.find({ name: 'john doe' }, { explain: false });
* collection.find({ name: 'john doe' }, { explain: 'queryPlanner' });
* collection.find({ name: 'john doe' }, { explain: { verbosity: 'queryPlanner' } });
* ```
*
* maxTimeMS can be configured to limit the amount of time the server
* spends executing an explain by providing an object:
*
* ```typescript
* // limits the `explain` command to no more than 2 seconds
* collection.find({ name: 'john doe' }, { explain:
* {
* verbosity: 'queryPlanner',
* maxTimeMS: 2000
* }
* });
* ```
nbbeeken marked this conversation as resolved.
Show resolved Hide resolved
*/
export interface ExplainOptions {
/** Specifies the verbosity mode for the explain output. */
explain?: ExplainVerbosityLike;
explain?: ExplainVerbosityLike | ExplainCommandOptions;
}

/** @internal */
export class Explain {
verbosity: ExplainVerbosity;
readonly verbosity: ExplainVerbosity;
readonly maxTimeMS?: number;

constructor(verbosity: ExplainVerbosityLike) {
private constructor(verbosity: ExplainVerbosityLike, maxTimeMS?: number) {
if (typeof verbosity === 'boolean') {
this.verbosity = verbosity
? ExplainVerbosity.allPlansExecution
: ExplainVerbosity.queryPlanner;
} else {
this.verbosity = verbosity;
}

this.maxTimeMS = maxTimeMS;
}

static fromOptions(options?: ExplainOptions): Explain | undefined {
if (options?.explain == null) return;
static fromOptions({ explain }: ExplainOptions = {}): Explain | undefined {
nbbeeken marked this conversation as resolved.
Show resolved Hide resolved
if (explain == null) return;

const explain = options.explain;
if (typeof explain === 'boolean' || typeof explain === 'string') {
return new Explain(explain);
}

throw new MongoInvalidArgumentError('Field "explain" must be a string or a boolean');
const { verbosity, maxTimeMS } = explain;
return new Explain(verbosity, maxTimeMS);
}
}
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,12 @@ export type { RunCursorCommandOptions } from './cursor/run_command_cursor';
export type { DbOptions, DbPrivate } from './db';
export type { Encrypter, EncrypterOptions } from './encrypter';
export type { AnyError, ErrorDescription, MongoNetworkErrorOptions } from './error';
export type { Explain, ExplainOptions, ExplainVerbosityLike } from './explain';
export type {
Explain,
ExplainCommandOptions,
ExplainOptions,
ExplainVerbosityLike
} from './explain';
export type {
GridFSBucketReadStreamOptions,
GridFSBucketReadStreamOptionsWithRevision,
Expand Down
25 changes: 21 additions & 4 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
MongoParseError,
MongoRuntimeError
} from './error';
import type { Explain } from './explain';
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';
Expand Down Expand Up @@ -251,12 +251,29 @@ export function decorateWithReadConcern(
* @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): Document {
if (command.explain) {
export function decorateWithExplain(
command: Document,
explain: Explain
): {
explain: Document;
verbosity: ExplainVerbosity;
maxTimeMS?: number;
} {
type ExplainCommand = ReturnType<typeof decorateWithExplain>;
nbbeeken marked this conversation as resolved.
Show resolved Hide resolved
const isExplainCommand = (doc: Document): doc is ExplainCommand => 'explain' in command;
nbbeeken marked this conversation as resolved.
Show resolved Hide resolved

if (isExplainCommand(command)) {
return command;
}

return { explain: command, verbosity: explain.verbosity };
const { verbosity, maxTimeMS } = explain;
const baseCommand: ExplainCommand = { explain: command, verbosity };

if (typeof maxTimeMS === 'number') {
baseCommand.maxTimeMS = maxTimeMS;
}

return baseCommand;
}

/**
Expand Down
47 changes: 46 additions & 1 deletion test/integration/crud/crud.prose.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { expect } from 'chai';
import { once } from 'events';

import { MongoBulkWriteError, type MongoClient, MongoServerError } from '../../mongodb';
import { type CommandStartedEvent } from '../../../mongodb';
import {
type Collection,
MongoBulkWriteError,
type MongoClient,
MongoServerError
} from '../../mongodb';
import { filterForCommands } from '../shared';

describe('CRUD Prose Spec Tests', () => {
let client: MongoClient;
Expand Down Expand Up @@ -143,4 +150,42 @@ describe('CRUD Prose Spec Tests', () => {
}
});
});

describe('14. `explain` helpers allow users to specify `maxTimeMS`', function () {
let client: MongoClient;
const commands: CommandStartedEvent[] = [];
let collection: Collection;

beforeEach(async function () {
client = this.configuration.newClient({}, { monitorCommands: true });
await client.connect();

await client.db('explain-test').dropDatabase();
collection = await client.db('explain-test').createCollection('collection');

client.on('commandStarted', filterForCommands('explain', commands));
commands.length = 0;
});

afterEach(async function () {
await client.close();
});

it('sets maxTimeMS on explain commands, when specfied', async function () {
nbbeeken marked this conversation as resolved.
Show resolved Hide resolved
await collection
.find(
{ name: 'john doe' },
{
explain: {
maxTimeMS: 2000,
verbosity: 'queryPlanner'
}
}
)
.toArray();

const [{ command }] = commands;
expect(command).to.have.property('maxTimeMS', 2000);
});
});
});
107 changes: 107 additions & 0 deletions test/integration/crud/explain.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect } from 'chai';
import { once } from 'events';
import { test } from 'mocha';

import {
type Collection,
Expand All @@ -8,6 +9,7 @@ import {
type MongoClient,
MongoServerError
} from '../../mongodb';
import { filterForCommands } from '../shared';

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

Expand Down Expand Up @@ -117,6 +119,111 @@ describe('CRUD API explain option', function () {
});
}
}

describe('explain helpers w/ maxTimeMS', function () {
dariakp marked this conversation as resolved.
Show resolved Hide resolved
let client: MongoClient;
const commands: CommandStartedEvent[] = [];
let collection: Collection;

beforeEach(async function () {
client = this.configuration.newClient({}, { monitorCommands: true });
await client.connect();

await client.db('explain-test').dropDatabase();
collection = await client.db('explain-test').createCollection('bar');

client.on('commandStarted', filterForCommands('explain', commands));
commands.length = 0;
});

afterEach(async function () {
await client.close();
});

describe('cursor explain commands', function () {
describe('when maxTimeMS is specified via a cursor explain method, it sets the property on the command', function () {
test('find()', async function () {
await collection
.find({ name: 'john doe' })
.explain({ maxTimeMS: 2000, verbosity: 'queryPlanner' });

const [{ command }] = commands;
expect(command).to.have.property('maxTimeMS', 2000);
});

test('aggregate()', async function () {
await collection
.aggregate([{ $match: { name: 'john doe' } }])
.explain({ maxTimeMS: 2000, verbosity: 'queryPlanner' });

const [{ command }] = commands;
expect(command).to.have.property('maxTimeMS', 2000);
});
});

it('when maxTimeMS is not specified, it is not attached to the explain command', async function () {
await collection.find({ name: 'john doe' }).explain({ verbosity: 'queryPlanner' });

const [{ command }] = commands;
expect(command).not.to.have.property('maxTimeMS');
});

it('when maxTimeMS is specified as an explain option and a command-level option, the explain option takes precedence', async function () {
await collection
.find(
{},
{
maxTimeMS: 1000,
explain: {
verbosity: 'queryPlanner',
maxTimeMS: 2000
}
}
)
.toArray();

const [{ command }] = commands;
expect(command).to.have.property('maxTimeMS', 2000);
});
W-A-James marked this conversation as resolved.
Show resolved Hide resolved
});

describe('regular commands w/ explain', function () {
it('when maxTimeMS is specified as an explain option and a command-level option, the explain option takes precedence', async function () {
await collection.deleteMany(
{},
{
maxTimeMS: 1000,
explain: {
verbosity: 'queryPlanner',
maxTimeMS: 2000
}
}
);

const [{ command }] = commands;
expect(command).to.have.property('maxTimeMS', 2000);
});

describe('when maxTimeMS is specified as an explain option', function () {
it('attaches maxTimeMS to the explain command', async function () {
await collection.deleteMany(
{},
{ explain: { maxTimeMS: 2000, verbosity: 'queryPlanner' } }
);

const [{ command }] = commands;
expect(command).to.have.property('maxTimeMS', 2000);
});
});

it('when maxTimeMS is not specified, it is not attached to the explain command', async function () {
await collection.deleteMany({}, { explain: { verbosity: 'queryPlanner' } });

const [{ command }] = commands;
expect(command).not.to.have.property('maxTimeMS');
});
});
});
});

function explainValueToExpectation(explainValue: boolean | string) {
Expand Down
20 changes: 20 additions & 0 deletions test/integration/shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,26 @@ function dropCollection(dbObj, collectionName, options = {}) {
return dbObj.dropCollection(collectionName, options).catch(ignoreNsNotFound);
}

/**
* Given a set of commands to look for when command monitoring and a destination to store them, returns an event handler
* that collects the specified events.
*
* ```typescript
* const commands = [];
*
* // one command
* client.on('commandStarted', filterForCommands('ping', commands));
* // multiple commands
* client.on('commandStarted', filterForCommands(['ping', 'find'], commands));
* // custom predicate
* client.on('commandStarted', filterForCommands((command) => command.commandName === 'find', commands));
* ```
* @param {string | string[] | (arg0: string) => boolean} commands A set of commands to look for. Either
* a single command name (string), a list of command names (string[]) or a predicate function that
* determines whether or not a command should be kept.
* @param {Array} bag the output for the filtered commands
* @returns a function that collects the specified comment events
*/
W-A-James marked this conversation as resolved.
Show resolved Hide resolved
function filterForCommands(commands, bag) {
if (typeof commands === 'function') {
return function (event) {
Expand Down
Loading