diff --git a/.evergreen/run-serverless-tests.sh b/.evergreen/run-serverless-tests.sh index 86b9800fbe..35e45c8328 100755 --- a/.evergreen/run-serverless-tests.sh +++ b/.evergreen/run-serverless-tests.sh @@ -25,4 +25,5 @@ npx mocha \ test/integration/transactions/transactions.test.ts \ test/integration/versioned-api/versioned_api.spec.test.js \ test/integration/load-balancers/load_balancers.spec.test.js \ - test/integration/client-side-encryption/client_side_encryption.spec.test.ts + test/integration/client-side-encryption/client_side_encryption.spec.test.ts \ + test/integration/run-command/run_command.spec.test.ts diff --git a/src/cursor/abstract_cursor.ts b/src/cursor/abstract_cursor.ts index 3cc8fdf6c2..4c5cb83742 100644 --- a/src/cursor/abstract_cursor.ts +++ b/src/cursor/abstract_cursor.ts @@ -608,7 +608,7 @@ export abstract class AbstractCursor< abstract clone(): AbstractCursor; /** @internal */ - abstract _initialize( + protected abstract _initialize( session: ClientSession | undefined, callback: Callback ): void; diff --git a/src/cursor/run_command_cursor.ts b/src/cursor/run_command_cursor.ts new file mode 100644 index 0000000000..95dcef4983 --- /dev/null +++ b/src/cursor/run_command_cursor.ts @@ -0,0 +1,140 @@ +import type { BSONSerializeOptions, Document, Long } from '../bson'; +import type { Db } from '../db'; +import { MongoAPIError, MongoUnexpectedServerResponseError } from '../error'; +import { executeOperation, ExecutionResult } from '../operations/execute_operation'; +import { GetMoreOperation } from '../operations/get_more'; +import { RunCommandOperation } from '../operations/run_command'; +import type { ReadConcernLike } from '../read_concern'; +import type { ReadPreferenceLike } from '../read_preference'; +import type { ClientSession } from '../sessions'; +import { Callback, ns } from '../utils'; +import { AbstractCursor } from './abstract_cursor'; + +/** @public */ +export type RunCursorCommandOptions = { + readPreference?: ReadPreferenceLike; + session?: ClientSession; +} & BSONSerializeOptions; + +/** @internal */ +type RunCursorCommandResponse = { + cursor: { id: bigint | Long | number; ns: string; firstBatch: Document[] }; + ok: 1; +}; + +/** @public */ +export class RunCommandCursor extends AbstractCursor { + public readonly command: Readonly>; + public readonly getMoreOptions: { + comment?: any; + maxAwaitTimeMS?: number; + batchSize?: number; + } = {}; + + /** + * Controls the `getMore.comment` field + * @param comment - any BSON value + */ + public setComment(comment: any): this { + this.getMoreOptions.comment = comment; + return this; + } + + /** + * Controls the `getMore.maxTimeMS` field. Only valid when cursor is tailable await + * @param maxTimeMS - the number of milliseconds to wait for new data + */ + public setMaxTimeMS(maxTimeMS: number): this { + this.getMoreOptions.maxAwaitTimeMS = maxTimeMS; + return this; + } + + /** + * Controls the `getMore.batchSize` field + * @param maxTimeMS - the number documents to return in the `nextBatch` + */ + public setBatchSize(batchSize: number): this { + this.getMoreOptions.batchSize = batchSize; + return this; + } + + /** Unsupported for RunCommandCursor */ + public override clone(): never { + throw new MongoAPIError('Clone not supported, create a new cursor with db.runCursorCommand'); + } + + /** Unsupported for RunCommandCursor: readConcern must be configured directly on command document */ + public override withReadConcern(_: ReadConcernLike): never { + throw new MongoAPIError( + 'RunCommandCursor does not support readConcern it must be attached to the command being run' + ); + } + + /** Unsupported for RunCommandCursor: various cursor flags must be configured directly on command document */ + public override addCursorFlag(_: string, __: boolean): never { + throw new MongoAPIError( + 'RunCommandCursor does not support cursor flags, they must be attached to the command being run' + ); + } + + /** Unsupported for RunCommandCursor: maxTimeMS must be configured directly on command document */ + public override maxTimeMS(_: number): never { + throw new MongoAPIError( + 'maxTimeMS must be configured on the command document directly, to configure getMore.maxTimeMS use cursor.setMaxTimeMS()' + ); + } + + /** Unsupported for RunCommandCursor: batchSize must be configured directly on command document */ + public override batchSize(_: number): never { + throw new MongoAPIError( + 'batchSize must be configured on the command document directly, to configure getMore.batchSize use cursor.setBatchSize()' + ); + } + + /** @internal */ + private db: Db; + + /** @internal */ + constructor(db: Db, command: Document, options: RunCursorCommandOptions = {}) { + super(db.s.client, ns(db.namespace), options); + this.db = db; + this.command = Object.freeze({ ...command }); + } + + /** @internal */ + protected _initialize(session: ClientSession, callback: Callback) { + const operation = new RunCommandOperation(this.db, this.command, { + ...this.cursorOptions, + session: session, + readPreference: this.cursorOptions.readPreference + }); + executeOperation(this.client, operation).then( + response => { + if (response.cursor == null) { + callback( + new MongoUnexpectedServerResponseError('Expected server to respond with cursor') + ); + return; + } + callback(undefined, { + server: operation.server, + session, + response + }); + }, + err => callback(err) + ); + } + + /** @internal */ + override _getMore(_batchSize: number, callback: Callback) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const getMoreOperation = new GetMoreOperation(this.namespace, this.id!, this.server!, { + ...this.cursorOptions, + session: this.session, + ...this.getMoreOptions + }); + + executeOperation(this.client, getMoreOperation, callback); + } +} diff --git a/src/db.ts b/src/db.ts index 4d3ddeba24..548d183094 100644 --- a/src/db.ts +++ b/src/db.ts @@ -5,6 +5,7 @@ import { Collection, CollectionOptions } from './collection'; import * as CONSTANTS from './constants'; import { AggregationCursor } from './cursor/aggregation_cursor'; import { ListCollectionsCursor } from './cursor/list_collections_cursor'; +import { RunCommandCursor, type RunCursorCommandOptions } from './cursor/run_command_cursor'; import { MongoAPIError, MongoInvalidArgumentError } from './error'; import type { MongoClient, PkFactory } from './mongo_client'; import type { TODO_NODE_3286 } from './mongo_types'; @@ -523,6 +524,19 @@ export class Db { return new ChangeStream(this, pipeline, resolveOptions(this, options)); } + + /** + * A low level cursor API providing basic driver functionality: + * - ClientSession management + * - ReadPreference for server selection + * - Running getMores automatically when a local batch is exhausted + * + * @param command - The command that will start a cursor on the server. + * @param options - Configurations for running the command, bson options will apply to getMores + */ + runCursorCommand(command: Document, options?: RunCursorCommandOptions): RunCommandCursor { + return new RunCommandCursor(this, command, options); + } } // TODO(NODE-3484): Refactor into MongoDBNamespace diff --git a/src/index.ts b/src/index.ts index e3b1950cfb..7917e23539 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { AggregationCursor } from './cursor/aggregation_cursor'; import { FindCursor } from './cursor/find_cursor'; 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 { GridFSBucket } from './gridfs'; import { GridFSBucketReadStream } from './gridfs/download'; @@ -87,6 +88,7 @@ export { ListIndexesCursor, MongoClient, OrderedBulkOperation, + RunCommandCursor, UnorderedBulkOperation }; @@ -275,6 +277,7 @@ export type { ChangeStreamAggregateRawResult, ChangeStreamCursorOptions } from './cursor/change_stream_cursor'; +export type { RunCursorCommandOptions } from './cursor/run_command_cursor'; export type { DbOptions, DbPrivate } from './db'; export type { AutoEncrypter, AutoEncryptionOptions, AutoEncryptionTlsOptions } from './deps'; export type { Encrypter, EncrypterOptions } from './encrypter'; diff --git a/test/integration/client-side-operations-timeout/.gitkeep b/test/integration/client-side-operations-timeout/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/integration/run-command/run_command.spec.test.ts b/test/integration/run-command/run_command.spec.test.ts index c2ca5e91b5..a4a8a522d5 100644 --- a/test/integration/run-command/run_command.spec.test.ts +++ b/test/integration/run-command/run_command.spec.test.ts @@ -2,5 +2,10 @@ import { loadSpecTests } from '../../spec'; import { runUnifiedSuite } from '../../tools/unified-spec-runner/runner'; describe('RunCommand spec', () => { - runUnifiedSuite(loadSpecTests('run-command')); + runUnifiedSuite(loadSpecTests('run-command'), test => { + if (test.description === 'does not attach $readPreference to given command on standalone') { + return 'TODO(NODE-5263): Do not send $readPreference to standalone servers'; + } + return false; + }); }); diff --git a/test/integration/run-command/run_cursor_command.test.ts b/test/integration/run-command/run_cursor_command.test.ts new file mode 100644 index 0000000000..d90640c15d --- /dev/null +++ b/test/integration/run-command/run_cursor_command.test.ts @@ -0,0 +1,50 @@ +import { expect } from 'chai'; + +import { Db, MongoClient } from '../../mongodb'; + +describe('runCursorCommand API', () => { + let client: MongoClient; + let db: Db; + + beforeEach(async function () { + client = this.configuration.newClient({}, { monitorCommands: true }); + db = client.db(); + await db.dropDatabase().catch(() => null); + await db + .collection<{ _id: number }>('collection') + .insertMany([{ _id: 0 }, { _id: 1 }, { _id: 2 }]); + }); + + afterEach(async function () { + await client.close(); + }); + + it('returns each document only once across multiple iterators', async () => { + const cursor = db.runCursorCommand({ find: 'collection', filter: {}, batchSize: 1 }); + cursor.setBatchSize(1); + + const a = cursor[Symbol.asyncIterator](); + const b = cursor[Symbol.asyncIterator](); + + // Interleaving calls to A and B + const results = [ + await a.next(), // find, first doc + await b.next(), // getMore, second doc + + await a.next(), // getMore, third doc + await b.next(), // getMore, no doc & exhausted id, a.k.a. done + + await a.next(), // done + await b.next() // done + ]; + + expect(results).to.deep.equal([ + { value: { _id: 0 }, done: false }, + { value: { _id: 1 }, done: false }, + { value: { _id: 2 }, done: false }, + { value: undefined, done: true }, + { value: undefined, done: true }, + { value: undefined, done: true } + ]); + }); +}); diff --git a/test/mongodb.ts b/test/mongodb.ts index 18327be63b..85ce656c81 100644 --- a/test/mongodb.ts +++ b/test/mongodb.ts @@ -138,6 +138,7 @@ export * from '../src/cursor/change_stream_cursor'; export * from '../src/cursor/find_cursor'; export * from '../src/cursor/list_collections_cursor'; export * from '../src/cursor/list_indexes_cursor'; +export * from '../src/cursor/run_command_cursor'; export * from '../src/db'; export * from '../src/deps'; export * from '../src/encrypter'; diff --git a/test/spec/client-side-operations-timeout/runCursorCommand.json b/test/spec/client-side-operations-timeout/runCursorCommand.json new file mode 100644 index 0000000000..5fc0be3399 --- /dev/null +++ b/test/spec/client-side-operations-timeout/runCursorCommand.json @@ -0,0 +1,583 @@ +{ + "description": "runCursorCommand", + "schemaVersion": "1.9", + "runOnRequirements": [ + { + "minServerVersion": "4.4" + } + ], + "createEntities": [ + { + "client": { + "id": "failPointClient", + "useMultipleMongoses": false + } + }, + { + "client": { + "id": "commandClient", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent", + "commandSucceededEvent" + ] + } + }, + { + "client": { + "id": "client", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ], + "ignoreCommandMonitoringEvents": [ + "killCursors" + ] + } + }, + { + "database": { + "id": "commandDb", + "client": "commandClient", + "databaseName": "commandDb" + } + }, + { + "database": { + "id": "db", + "client": "client", + "databaseName": "db" + } + }, + { + "collection": { + "id": "collection", + "database": "db", + "collectionName": "collection" + } + } + ], + "initialData": [ + { + "collectionName": "collection", + "databaseName": "db", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + }, + { + "_id": 4, + "x": 44 + }, + { + "_id": 5, + "x": 55 + } + ] + } + ], + "tests": [ + { + "description": "errors if timeoutMode is set without timeoutMS", + "operations": [ + { + "name": "runCursorCommand", + "object": "db", + "arguments": { + "commandName": "find", + "command": { + "find": "collection" + }, + "timeoutMode": "cursorLifetime" + }, + "expectError": { + "isClientError": true + } + } + ] + }, + { + "description": "error if timeoutMode is cursorLifetime and cursorType is tailableAwait", + "operations": [ + { + "name": "runCursorCommand", + "object": "db", + "arguments": { + "commandName": "find", + "command": { + "find": "collection" + }, + "timeoutMode": "cursorLifetime", + "cursorType": "tailableAwait" + }, + "expectError": { + "isClientError": true + } + } + ] + }, + { + "description": "Non-tailable cursor lifetime remaining timeoutMS applied to getMore if timeoutMode is unset", + "runOnRequirements": [ + { + "serverless": "forbid" + } + ], + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "failPointClient", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 2 + }, + "data": { + "failCommands": [ + "find", + "getMore" + ], + "blockConnection": true, + "blockTimeMS": 60 + } + } + } + }, + { + "name": "runCursorCommand", + "object": "db", + "arguments": { + "commandName": "find", + "timeoutMS": 100, + "command": { + "find": "collection", + "batchSize": 2 + } + }, + "expectError": { + "isTimeoutError": true + } + } + ], + "expectEvents": [ + { + "client": "client", + "events": [ + { + "commandStartedEvent": { + "commandName": "find", + "command": { + "find": "collection", + "maxTimeMS": { + "$$type": [ + "int", + "long" + ] + } + } + } + }, + { + "commandStartedEvent": { + "commandName": "getMore", + "command": { + "getMore": { + "$$type": [ + "int", + "long" + ] + }, + "collection": "collection", + "maxTimeMS": { + "$$exists": true + } + } + } + } + ] + } + ] + }, + { + "description": "Non=tailable cursor iteration timeoutMS is refreshed for getMore if timeoutMode is iteration - failure", + "runOnRequirements": [ + { + "serverless": "forbid" + } + ], + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "failPointClient", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "getMore" + ], + "blockConnection": true, + "blockTimeMS": 60 + } + } + } + }, + { + "name": "runCursorCommand", + "object": "db", + "arguments": { + "commandName": "find", + "command": { + "find": "collection", + "batchSize": 2 + }, + "timeoutMode": "iteration", + "timeoutMS": 100, + "batchSize": 2 + }, + "expectError": { + "isTimeoutError": true + } + } + ], + "expectEvents": [ + { + "client": "client", + "events": [ + { + "commandStartedEvent": { + "commandName": "find", + "databaseName": "db", + "command": { + "find": "collection", + "maxTimeMS": { + "$$exists": false + } + } + } + }, + { + "commandStartedEvent": { + "commandName": "getMore", + "databaseName": "db", + "command": { + "getMore": { + "$$type": [ + "int", + "long" + ] + }, + "collection": "collection", + "maxTimeMS": { + "$$exists": false + } + } + } + } + ] + } + ] + }, + { + "description": "Tailable cursor iteration timeoutMS is refreshed for getMore - failure", + "runOnRequirements": [ + { + "serverless": "forbid" + } + ], + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "failPointClient", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "getMore" + ], + "blockConnection": true, + "blockTimeMS": 60 + } + } + } + }, + { + "name": "dropCollection", + "object": "db", + "arguments": { + "collection": "cappedCollection" + } + }, + { + "name": "createCollection", + "object": "db", + "arguments": { + "collection": "cappedCollection", + "capped": true, + "size": 4096, + "max": 3 + }, + "saveResultAsEntity": "cappedCollection" + }, + { + "name": "insertMany", + "object": "cappedCollection", + "arguments": { + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + } + ] + } + }, + { + "name": "createCommandCursor", + "object": "db", + "arguments": { + "commandName": "find", + "command": { + "find": "cappedCollection", + "batchSize": 1, + "tailable": true + }, + "timeoutMode": "iteration", + "timeoutMS": 100, + "batchSize": 1, + "cursorType": "tailable" + }, + "saveResultAsEntity": "tailableCursor" + }, + { + "name": "iterateUntilDocumentOrError", + "object": "tailableCursor" + }, + { + "name": "iterateUntilDocumentOrError", + "object": "tailableCursor", + "expectError": { + "isTimeoutError": true + } + } + ], + "expectEvents": [ + { + "client": "client", + "events": [ + { + "commandStartedEvent": { + "commandName": "drop" + } + }, + { + "commandStartedEvent": { + "commandName": "create" + } + }, + { + "commandStartedEvent": { + "commandName": "insert" + } + }, + { + "commandStartedEvent": { + "commandName": "find", + "databaseName": "db", + "command": { + "find": "cappedCollection", + "tailable": true, + "awaitData": { + "$$exists": false + }, + "maxTimeMS": { + "$$exists": false + } + } + } + }, + { + "commandStartedEvent": { + "commandName": "getMore", + "databaseName": "db", + "command": { + "getMore": { + "$$type": [ + "int", + "long" + ] + }, + "collection": "cappedCollection", + "maxTimeMS": { + "$$exists": false + } + } + } + } + ] + } + ] + }, + { + "description": "Tailable cursor awaitData iteration timeoutMS is refreshed for getMore - failure", + "runOnRequirements": [ + { + "serverless": "forbid" + } + ], + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "failPointClient", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "getMore" + ], + "blockConnection": true, + "blockTimeMS": 60 + } + } + } + }, + { + "name": "dropCollection", + "object": "db", + "arguments": { + "collection": "cappedCollection" + } + }, + { + "name": "createCollection", + "object": "db", + "arguments": { + "collection": "cappedCollection", + "capped": true, + "size": 4096, + "max": 3 + }, + "saveResultAsEntity": "cappedCollection" + }, + { + "name": "insertMany", + "object": "cappedCollection", + "arguments": { + "documents": [ + { + "foo": "bar" + }, + { + "fizz": "buzz" + } + ] + } + }, + { + "name": "createCommandCursor", + "object": "db", + "arguments": { + "command": { + "find": "cappedCollection", + "tailable": true, + "awaitData": true + }, + "cursorType": "tailableAwait", + "batchSize": 1 + }, + "saveResultAsEntity": "tailableCursor" + }, + { + "name": "iterateUntilDocumentOrError", + "object": "tailableCursor" + }, + { + "name": "iterateUntilDocumentOrError", + "object": "tailableCursor", + "expectError": { + "isTimeoutError": true + } + } + ], + "expectEvents": [ + { + "client": "client", + "events": [ + { + "commandStartedEvent": { + "commandName": "drop" + } + }, + { + "commandStartedEvent": { + "commandName": "create" + } + }, + { + "commandStartedEvent": { + "commandName": "insert" + } + }, + { + "commandStartedEvent": { + "commandName": "find", + "databaseName": "db", + "command": { + "find": "cappedCollection", + "tailable": true, + "awaitData": true, + "maxTimeMS": { + "$$exists": true + } + } + } + }, + { + "commandStartedEvent": { + "commandName": "getMore", + "databaseName": "db", + "command": { + "getMore": { + "$$type": [ + "int", + "long" + ] + }, + "collection": "cappedCollection" + } + } + } + ] + } + ] + } + ] +} diff --git a/test/spec/client-side-operations-timeout/runCursorCommand.yml b/test/spec/client-side-operations-timeout/runCursorCommand.yml new file mode 100644 index 0000000000..16a648e028 --- /dev/null +++ b/test/spec/client-side-operations-timeout/runCursorCommand.yml @@ -0,0 +1,304 @@ +description: runCursorCommand + +schemaVersion: '1.9' + +runOnRequirements: + - minServerVersion: "4.4" + +createEntities: + - client: + id: &failPointClient failPointClient + useMultipleMongoses: false + - client: + id: &commandClient commandClient + useMultipleMongoses: false + observeEvents: [commandStartedEvent, commandSucceededEvent] + - client: + id: &client client + useMultipleMongoses: false + observeEvents: [commandStartedEvent] + ignoreCommandMonitoringEvents: [killCursors] + - database: # For tests that need success event assertions + id: &commandDb commandDb + client: *commandClient + databaseName: *commandDb + - database: + id: &db db + client: *client + databaseName: *db + - collection: + id: &collection collection + database: *db + collectionName: *collection + +initialData: + - collectionName: *collection + databaseName: *db + documents: &documents + - { _id: 1, x: 11 } + - { _id: 2, x: 22 } + - { _id: 3, x: 33 } + - { _id: 4, x: 44 } + - { _id: 5, x: 55 } + +tests: + - description: errors if timeoutMode is set without timeoutMS + operations: + - name: runCursorCommand + object: *db + arguments: + commandName: find + command: { find: *collection } + timeoutMode: cursorLifetime + expectError: + isClientError: true + + - description: error if timeoutMode is cursorLifetime and cursorType is tailableAwait + operations: + - name: runCursorCommand + object: *db + arguments: + commandName: find + command: { find: *collection } + timeoutMode: cursorLifetime + cursorType: tailableAwait + expectError: + isClientError: true + + # If timeoutMode is unset, it should default to CURSOR_LIFETIME and the time remaining after the find succeeds should be applied to the getMore + - description: Non-tailable cursor lifetime remaining timeoutMS applied to getMore if timeoutMode is unset + runOnRequirements: + - serverless: forbid + operations: + # Block find/getMore for 15ms. + - name: failPoint + object: testRunner + arguments: + client: *failPointClient + failPoint: + configureFailPoint: failCommand + mode: { times: 2 } + data: + failCommands: [find, getMore] + blockConnection: true + blockTimeMS: 60 + # Run a find with timeoutMS less than double our failPoint blockTimeMS and + # batchSize less than the total document count will cause a find and a getMore to be sent. + # Both will block for 60ms so together they will go over the timeout. + - name: runCursorCommand + object: *db + arguments: + commandName: find + timeoutMS: 100 + command: { find: *collection, batchSize: 2 } + expectError: + isTimeoutError: true + expectEvents: + - client: *client + events: + - commandStartedEvent: + commandName: find + command: + find: *collection + maxTimeMS: { $$type: [int, long] } + - commandStartedEvent: + commandName: getMore + command: + getMore: { $$type: [int, long] } + collection: *collection + maxTimeMS: { $$exists: true } + + # 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=100 and "getMore" + # commands are blocked for 60ms, causing iteration to fail with a timeout error. + - description: Non=tailable cursor iteration timeoutMS is refreshed for getMore if timeoutMode is iteration - failure + runOnRequirements: + - serverless: forbid + operations: + - name: failPoint + object: testRunner + arguments: + client: *failPointClient + failPoint: + configureFailPoint: failCommand + mode: { times: 1 } + data: + failCommands: ["getMore"] + blockConnection: true + blockTimeMS: 60 + - name: runCursorCommand + object: *db + arguments: + commandName: find + command: { find: *collection, batchSize: 2 } + timeoutMode: iteration + timeoutMS: 100 + batchSize: 2 + expectError: + isTimeoutError: true + expectEvents: + - client: *client + events: + - commandStartedEvent: + commandName: find + databaseName: *db + command: + find: *collection + maxTimeMS: { $$exists: false } + - commandStartedEvent: + commandName: getMore + databaseName: *db + command: + getMore: { $$type: ["int", "long"] } + collection: *collection + 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=100 from the collection and the getMore command blocks for 60ms, causing iteration to fail + # with a timeout error. + - description: Tailable cursor iteration timeoutMS is refreshed for getMore - failure + runOnRequirements: + - serverless: forbid + operations: + - name: failPoint + object: testRunner + arguments: + client: *failPointClient + failPoint: + configureFailPoint: failCommand + mode: { times: 1 } + data: + failCommands: ["getMore"] + blockConnection: true + blockTimeMS: 60 + - name: dropCollection + object: *db + arguments: + collection: &cappedCollection cappedCollection + - name: createCollection + object: *db + arguments: + collection: *cappedCollection + capped: true + size: 4096 + max: 3 + saveResultAsEntity: *cappedCollection + - name: insertMany + object: *cappedCollection + arguments: + documents: + - { _id: 1, x: 11 } + - { _id: 2, x: 22 } + - name: createCommandCursor + object: *db + arguments: + commandName: find + command: { find: *cappedCollection, batchSize: 1, tailable: true } + timeoutMode: iteration + timeoutMS: 100 + batchSize: 1 + cursorType: tailable + saveResultAsEntity: &tailableCursor tailableCursor + # Iterate the cursor twice: the first iteration will return the document from the batch in the find and the + # second will do a getMore. + - name: iterateUntilDocumentOrError + object: *tailableCursor + - name: iterateUntilDocumentOrError + object: *tailableCursor + expectError: + isTimeoutError: true + expectEvents: + - client: *client + events: + - commandStartedEvent: + commandName: drop + - commandStartedEvent: + commandName: create + - commandStartedEvent: + commandName: insert + - commandStartedEvent: + commandName: find + databaseName: *db + command: + find: *cappedCollection + tailable: true + awaitData: { $$exists: false } + maxTimeMS: { $$exists: false } + - commandStartedEvent: + commandName: getMore + databaseName: *db + command: + getMore: { $$type: ["int", "long"] } + collection: *cappedCollection + maxTimeMS: { $$exists: false } + + # 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. + - description: Tailable cursor awaitData iteration timeoutMS is refreshed for getMore - failure + runOnRequirements: + - serverless: forbid + operations: + - name: failPoint + object: testRunner + arguments: + client: *failPointClient + failPoint: + configureFailPoint: failCommand + mode: { times: 1 } + data: + failCommands: ["getMore"] + blockConnection: true + blockTimeMS: 60 + - name: dropCollection + object: *db + arguments: + collection: &cappedCollection cappedCollection + - name: createCollection + object: *db + arguments: + collection: *cappedCollection + capped: true + size: 4096 + max: 3 + saveResultAsEntity: *cappedCollection + - name: insertMany + object: *cappedCollection + arguments: + documents: [ { foo: bar }, { fizz: buzz } ] + - name: createCommandCursor + object: *db + arguments: + command: { find: *cappedCollection, tailable: true, awaitData: true } + cursorType: tailableAwait + batchSize: 1 + saveResultAsEntity: &tailableCursor tailableCursor + # Iterate twice to force a getMore. + - name: iterateUntilDocumentOrError + object: *tailableCursor + - name: iterateUntilDocumentOrError + object: *tailableCursor + expectError: + isTimeoutError: true + expectEvents: + - client: *client + events: + - commandStartedEvent: + commandName: drop + - commandStartedEvent: + commandName: create + - commandStartedEvent: + commandName: insert + - commandStartedEvent: + commandName: find + databaseName: *db + command: + find: *cappedCollection + tailable: true + awaitData: true + maxTimeMS: { $$exists: true } + - commandStartedEvent: + commandName: getMore + databaseName: *db + command: + getMore: { $$type: ["int", "long"] } + collection: *cappedCollection diff --git a/test/spec/run-command/runCommand.json b/test/spec/run-command/runCommand.json index 0ae0e9d66e..007e514bd7 100644 --- a/test/spec/run-command/runCommand.json +++ b/test/spec/run-command/runCommand.json @@ -123,6 +123,68 @@ } ] }, + { + "description": "always gossips the $clusterTime on the sent command", + "runOnRequirements": [ + { + "topologies": [ + "replicaset", + "sharded" + ] + } + ], + "operations": [ + { + "name": "runCommand", + "object": "db", + "arguments": { + "commandName": "ping", + "command": { + "ping": 1 + } + }, + "expectResult": { + "ok": 1 + } + }, + { + "name": "runCommand", + "object": "db", + "arguments": { + "commandName": "ping", + "command": { + "ping": 1 + } + }, + "expectResult": { + "ok": 1 + } + } + ], + "expectEvents": [ + { + "client": "client", + "events": [ + { + "commandStartedEvent": { + "commandName": "ping" + } + }, + { + "commandStartedEvent": { + "command": { + "ping": 1, + "$clusterTime": { + "$$exists": true + } + }, + "commandName": "ping" + } + } + ] + } + ] + }, { "description": "attaches the provided session lsid to given command", "operations": [ @@ -163,6 +225,16 @@ }, { "description": "attaches the provided $readPreference to given command", + "runOnRequirements": [ + { + "topologies": [ + "replicaset", + "sharded-replicaset", + "load-balanced", + "sharded" + ] + } + ], "operations": [ { "name": "runCommand", @@ -201,6 +273,93 @@ } ] }, + { + "description": "does not attach $readPreference to given command on standalone", + "runOnRequirements": [ + { + "topologies": [ + "single" + ] + } + ], + "operations": [ + { + "name": "runCommand", + "object": "db", + "arguments": { + "commandName": "ping", + "command": { + "ping": 1 + }, + "readPreference": { + "mode": "nearest" + } + }, + "expectResult": { + "ok": 1 + } + } + ], + "expectEvents": [ + { + "client": "client", + "events": [ + { + "commandStartedEvent": { + "command": { + "ping": 1, + "$readPreference": { + "$$exists": false + }, + "$db": "db" + }, + "commandName": "ping" + } + } + ] + } + ] + }, + { + "description": "does not attach primary $readPreference to given command", + "operations": [ + { + "name": "runCommand", + "object": "db", + "arguments": { + "commandName": "ping", + "command": { + "ping": 1 + }, + "readPreference": { + "mode": "primary" + } + }, + "expectResult": { + "ok": 1 + } + } + ], + "expectEvents": [ + { + "client": "client", + "events": [ + { + "commandStartedEvent": { + "command": { + "ping": 1, + "$readPreference": { + "$$exists": false + }, + "$db": "db" + }, + "commandName": "ping" + } + } + ] + } + ] + }, { "description": "does not inherit readConcern specified at the db level", "operations": [ @@ -249,7 +408,7 @@ "insert": "collection", "documents": [ { - "_id": 1 + "foo": "bar" } ], "ordered": true @@ -355,7 +514,7 @@ "insert": "collection", "documents": [ { - "_id": 2 + "foo": "transaction" } ], "ordered": true @@ -383,7 +542,7 @@ "insert": "collection", "documents": [ { - "_id": 2 + "foo": "transaction" } ], "ordered": true, diff --git a/test/spec/run-command/runCommand.yml b/test/spec/run-command/runCommand.yml index 3c5f231361..eaa12eff23 100644 --- a/test/spec/run-command/runCommand.yml +++ b/test/spec/run-command/runCommand.yml @@ -67,6 +67,36 @@ tests: $readPreference: { $$exists: false } commandName: ping + - description: always gossips the $clusterTime on the sent command + runOnRequirements: + # Only replicasets and sharded clusters have a $clusterTime + - topologies: [ replicaset, sharded ] + operations: + # We have to run one command to obtain a clusterTime to gossip + - name: runCommand + object: *db + arguments: + commandName: ping + command: { ping: 1 } + expectResult: { ok: 1 } + - name: runCommand + object: *db + arguments: + commandName: ping + command: { ping: 1 } + expectResult: { ok: 1 } + expectEvents: + - client: *client + events: + - commandStartedEvent: + commandName: ping + # Only check the shape of the second ping which should have the $clusterTime received from the first operation + - commandStartedEvent: + command: + ping: 1 + $clusterTime: { $$exists: true } + commandName: ping + - description: attaches the provided session lsid to given command operations: - name: runCommand @@ -87,6 +117,9 @@ tests: commandName: ping - description: attaches the provided $readPreference to given command + runOnRequirements: + # Exclude single topology, which is most likely a standalone server + - topologies: [ replicaset, sharded-replicaset, load-balanced, sharded ] operations: - name: runCommand object: *db @@ -105,6 +138,50 @@ tests: $db: *db commandName: ping + - description: does not attach $readPreference to given command on standalone + runOnRequirements: + # This test assumes that the single topology contains a standalone server; + # however, it is possible for a single topology to contain a direct + # connection to another server type. + # See: https://github.com/mongodb/specifications/blob/master/source/server-selection/server-selection.rst#topology-type-single + - topologies: [ single ] + operations: + - name: runCommand + object: *db + arguments: + commandName: ping + command: { ping: 1 } + readPreference: { mode: 'nearest' } + expectResult: { ok: 1 } + expectEvents: + - client: *client + events: + - commandStartedEvent: + command: + ping: 1 + $readPreference: { $$exists: false } + $db: *db + commandName: ping + + - description: does not attach primary $readPreference to given command + operations: + - name: runCommand + object: *db + arguments: + commandName: ping + command: { ping: 1 } + readPreference: { mode: 'primary' } + expectResult: { ok: 1 } + expectEvents: + - client: *client + events: + - commandStartedEvent: + command: + ping: 1 + $readPreference: { $$exists: false } + $db: *db + commandName: ping + - description: does not inherit readConcern specified at the db level operations: - name: runCommand @@ -133,7 +210,7 @@ tests: commandName: insert command: insert: *collection - documents: [ { _id: 1 } ] + documents: [ { foo: 'bar' } ] ordered: true expectResult: { ok: 1 } expectEvents: @@ -186,7 +263,7 @@ tests: commandName: insert command: insert: *collection - documents: [ { _id: 2 } ] + documents: [ { foo: 'transaction' } ] ordered: true expectResult: { $$unsetOrMatches: { insertedId: { $$unsetOrMatches: 1 } } } expectEvents: @@ -195,7 +272,7 @@ tests: - commandStartedEvent: command: insert: *collection - documents: [ { _id: 2 } ] + documents: [ { foo: 'transaction' } ] ordered: true lsid: { $$sessionLsid: *session } txnNumber: 1 diff --git a/test/spec/run-command/runCursorCommand.json b/test/spec/run-command/runCursorCommand.json new file mode 100644 index 0000000000..4f1ec8a01a --- /dev/null +++ b/test/spec/run-command/runCursorCommand.json @@ -0,0 +1,877 @@ +{ + "description": "runCursorCommand", + "schemaVersion": "1.9", + "createEntities": [ + { + "client": { + "id": "client", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent", + "connectionReadyEvent", + "connectionCheckedOutEvent", + "connectionCheckedInEvent" + ] + } + }, + { + "session": { + "id": "session", + "client": "client" + } + }, + { + "database": { + "id": "db", + "client": "client", + "databaseName": "db" + } + }, + { + "collection": { + "id": "collection", + "database": "db", + "collectionName": "collection" + } + } + ], + "initialData": [ + { + "collectionName": "collection", + "databaseName": "db", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + }, + { + "_id": 4, + "x": 44 + }, + { + "_id": 5, + "x": 55 + } + ] + } + ], + "tests": [ + { + "description": "successfully executes checkMetadataConsistency cursor creating command", + "runOnRequirements": [ + { + "minServerVersion": "7.0", + "topologies": [ + "sharded" + ] + } + ], + "operations": [ + { + "name": "runCursorCommand", + "object": "db", + "arguments": { + "commandName": "checkMetadataConsistency", + "command": { + "checkMetadataConsistency": 1 + } + } + } + ], + "expectEvents": [ + { + "client": "client", + "eventType": "command", + "events": [ + { + "commandStartedEvent": { + "command": { + "checkMetadataConsistency": 1, + "$db": "db", + "lsid": { + "$$exists": true + } + }, + "commandName": "checkMetadataConsistency" + } + } + ] + } + ] + }, + { + "description": "errors if the command response is not a cursor", + "operations": [ + { + "name": "createCommandCursor", + "object": "db", + "arguments": { + "commandName": "ping", + "command": { + "ping": 1 + } + }, + "expectError": { + "isClientError": true + } + } + ] + }, + { + "description": "creates an implicit session that is reused across getMores", + "operations": [ + { + "name": "runCursorCommand", + "object": "db", + "arguments": { + "commandName": "find", + "command": { + "find": "collection", + "batchSize": 2 + } + }, + "expectResult": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + }, + { + "_id": 4, + "x": 44 + }, + { + "_id": 5, + "x": 55 + } + ] + }, + { + "name": "assertSameLsidOnLastTwoCommands", + "object": "testRunner", + "arguments": { + "client": "client" + } + } + ], + "expectEvents": [ + { + "client": "client", + "eventType": "command", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "collection", + "batchSize": 2, + "$db": "db", + "lsid": { + "$$exists": true + } + }, + "commandName": "find" + } + }, + { + "commandStartedEvent": { + "command": { + "getMore": { + "$$type": [ + "int", + "long" + ] + }, + "collection": "collection", + "$db": "db", + "lsid": { + "$$exists": true + } + }, + "commandName": "getMore" + } + } + ] + } + ] + }, + { + "description": "accepts an explicit session that is reused across getMores", + "operations": [ + { + "name": "runCursorCommand", + "object": "db", + "arguments": { + "commandName": "find", + "session": "session", + "command": { + "find": "collection", + "batchSize": 2 + } + }, + "expectResult": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + }, + { + "_id": 4, + "x": 44 + }, + { + "_id": 5, + "x": 55 + } + ] + }, + { + "name": "assertSameLsidOnLastTwoCommands", + "object": "testRunner", + "arguments": { + "client": "client" + } + } + ], + "expectEvents": [ + { + "client": "client", + "eventType": "command", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "collection", + "batchSize": 2, + "$db": "db", + "lsid": { + "$$sessionLsid": "session" + } + }, + "commandName": "find" + } + }, + { + "commandStartedEvent": { + "command": { + "getMore": { + "$$type": [ + "int", + "long" + ] + }, + "collection": "collection", + "$db": "db", + "lsid": { + "$$sessionLsid": "session" + } + }, + "commandName": "getMore" + } + } + ] + } + ] + }, + { + "description": "returns pinned connections to the pool when the cursor is exhausted", + "runOnRequirements": [ + { + "topologies": [ + "load-balanced" + ] + } + ], + "operations": [ + { + "name": "createCommandCursor", + "object": "db", + "arguments": { + "commandName": "find", + "batchSize": 2, + "session": "session", + "command": { + "find": "collection", + "batchSize": 2 + } + }, + "saveResultAsEntity": "cursor" + }, + { + "name": "assertNumberConnectionsCheckedOut", + "object": "testRunner", + "arguments": { + "client": "client", + "connections": 1 + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "cursor", + "expectResult": { + "_id": 1, + "x": 11 + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "cursor", + "expectResult": { + "_id": 2, + "x": 22 + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "cursor", + "expectResult": { + "_id": 3, + "x": 33 + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "cursor", + "expectResult": { + "_id": 4, + "x": 44 + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "cursor", + "expectResult": { + "_id": 5, + "x": 55 + } + }, + { + "name": "assertNumberConnectionsCheckedOut", + "object": "testRunner", + "arguments": { + "client": "client", + "connections": 0 + } + } + ], + "expectEvents": [ + { + "client": "client", + "eventType": "command", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "collection", + "batchSize": 2, + "$db": "db", + "lsid": { + "$$sessionLsid": "session" + } + }, + "commandName": "find" + } + }, + { + "commandStartedEvent": { + "command": { + "getMore": { + "$$type": [ + "int", + "long" + ] + }, + "collection": "collection", + "$db": "db", + "lsid": { + "$$sessionLsid": "session" + } + }, + "commandName": "getMore" + } + }, + { + "commandStartedEvent": { + "command": { + "getMore": { + "$$type": [ + "int", + "long" + ] + }, + "collection": "collection", + "$db": "db", + "lsid": { + "$$sessionLsid": "session" + } + }, + "commandName": "getMore" + } + } + ] + }, + { + "client": "client", + "eventType": "cmap", + "events": [ + { + "connectionReadyEvent": {} + }, + { + "connectionCheckedOutEvent": {} + }, + { + "connectionCheckedInEvent": {} + } + ] + } + ] + }, + { + "description": "returns pinned connections to the pool when the cursor is closed", + "runOnRequirements": [ + { + "topologies": [ + "load-balanced" + ] + } + ], + "operations": [ + { + "name": "createCommandCursor", + "object": "db", + "arguments": { + "commandName": "find", + "command": { + "find": "collection", + "batchSize": 2 + } + }, + "saveResultAsEntity": "cursor" + }, + { + "name": "assertNumberConnectionsCheckedOut", + "object": "testRunner", + "arguments": { + "client": "client", + "connections": 1 + } + }, + { + "name": "close", + "object": "cursor" + }, + { + "name": "assertNumberConnectionsCheckedOut", + "object": "testRunner", + "arguments": { + "client": "client", + "connections": 0 + } + } + ] + }, + { + "description": "supports configuring getMore batchSize", + "operations": [ + { + "name": "runCursorCommand", + "object": "db", + "arguments": { + "commandName": "find", + "batchSize": 5, + "command": { + "find": "collection", + "batchSize": 1 + } + }, + "expectResult": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + }, + { + "_id": 4, + "x": 44 + }, + { + "_id": 5, + "x": 55 + } + ] + } + ], + "expectEvents": [ + { + "client": "client", + "eventType": "command", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "collection", + "batchSize": 1, + "$db": "db", + "lsid": { + "$$exists": true + } + }, + "commandName": "find" + } + }, + { + "commandStartedEvent": { + "command": { + "getMore": { + "$$type": [ + "int", + "long" + ] + }, + "collection": "collection", + "batchSize": 5, + "$db": "db", + "lsid": { + "$$exists": true + } + }, + "commandName": "getMore" + } + } + ] + } + ] + }, + { + "description": "supports configuring getMore maxTimeMS", + "operations": [ + { + "name": "runCursorCommand", + "object": "db", + "arguments": { + "commandName": "find", + "maxTimeMS": 300, + "command": { + "find": "collection", + "maxTimeMS": 200, + "batchSize": 1 + } + }, + "ignoreResultAndError": true + } + ], + "expectEvents": [ + { + "client": "client", + "eventType": "command", + "ignoreExtraEvents": true, + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "collection", + "maxTimeMS": 200, + "batchSize": 1, + "$db": "db", + "lsid": { + "$$exists": true + } + }, + "commandName": "find" + } + }, + { + "commandStartedEvent": { + "command": { + "getMore": { + "$$type": [ + "int", + "long" + ] + }, + "collection": "collection", + "$db": "db", + "maxTimeMS": 300, + "lsid": { + "$$exists": true + } + }, + "commandName": "getMore" + } + } + ] + } + ] + }, + { + "description": "supports configuring getMore comment", + "runOnRequirements": [ + { + "minServerVersion": "4.4" + } + ], + "operations": [ + { + "name": "runCursorCommand", + "object": "db", + "arguments": { + "commandName": "find", + "comment": { + "hello": "getMore" + }, + "command": { + "find": "collection", + "batchSize": 1, + "comment": { + "hello": "find" + } + } + }, + "expectResult": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + }, + { + "_id": 4, + "x": 44 + }, + { + "_id": 5, + "x": 55 + } + ] + } + ], + "expectEvents": [ + { + "client": "client", + "eventType": "command", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "collection", + "batchSize": 1, + "comment": { + "hello": "find" + }, + "$db": "db", + "lsid": { + "$$exists": true + } + }, + "commandName": "find" + } + }, + { + "commandStartedEvent": { + "command": { + "getMore": { + "$$type": [ + "int", + "long" + ] + }, + "collection": "collection", + "comment": { + "hello": "getMore" + }, + "$db": "db", + "lsid": { + "$$exists": true + } + }, + "commandName": "getMore" + } + } + ] + } + ] + }, + { + "description": "does not close the cursor when receiving an empty batch", + "runOnRequirements": [ + { + "serverless": "forbid" + } + ], + "operations": [ + { + "name": "dropCollection", + "object": "db", + "arguments": { + "collection": "cappedCollection" + } + }, + { + "name": "createCollection", + "object": "db", + "arguments": { + "collection": "cappedCollection", + "capped": true, + "size": 4096, + "max": 3 + }, + "saveResultAsEntity": "cappedCollection" + }, + { + "name": "insertMany", + "object": "cappedCollection", + "arguments": { + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + } + ] + } + }, + { + "name": "createCommandCursor", + "object": "db", + "arguments": { + "cursorType": "tailable", + "commandName": "find", + "batchSize": 2, + "command": { + "find": "cappedCollection", + "tailable": true + } + }, + "saveResultAsEntity": "cursor" + }, + { + "name": "iterateOnce", + "object": "cursor" + }, + { + "name": "iterateOnce", + "object": "cursor" + }, + { + "name": "iterateOnce", + "object": "cursor" + }, + { + "name": "close", + "object": "cursor" + } + ], + "expectEvents": [ + { + "client": "client", + "eventType": "command", + "events": [ + { + "commandStartedEvent": { + "command": { + "drop": "cappedCollection" + }, + "commandName": "drop" + } + }, + { + "commandStartedEvent": { + "command": { + "create": "cappedCollection" + }, + "commandName": "create" + } + }, + { + "commandStartedEvent": { + "command": { + "insert": "cappedCollection" + }, + "commandName": "insert" + } + }, + { + "commandStartedEvent": { + "command": { + "find": "cappedCollection", + "$db": "db", + "lsid": { + "$$exists": true + } + }, + "commandName": "find" + } + }, + { + "commandStartedEvent": { + "command": { + "getMore": { + "$$type": [ + "int", + "long" + ] + }, + "collection": "cappedCollection", + "$db": "db", + "lsid": { + "$$exists": true + } + }, + "commandName": "getMore" + } + }, + { + "commandStartedEvent": { + "command": { + "killCursors": "cappedCollection", + "cursors": { + "$$type": "array" + } + }, + "commandName": "killCursors" + } + } + ] + } + ] + } + ] +} diff --git a/test/spec/run-command/runCursorCommand.yml b/test/spec/run-command/runCursorCommand.yml new file mode 100644 index 0000000000..1f9bf532c3 --- /dev/null +++ b/test/spec/run-command/runCursorCommand.yml @@ -0,0 +1,391 @@ +description: runCursorCommand + +schemaVersion: '1.9' + +createEntities: + - client: + id: &client client + useMultipleMongoses: false + observeEvents: [commandStartedEvent, connectionReadyEvent, connectionCheckedOutEvent, connectionCheckedInEvent] + - session: + id: &session session + client: *client + - database: + id: &db db + client: *client + databaseName: *db + - collection: + id: &collection collection + database: *db + collectionName: *collection + +initialData: + - collectionName: collection + databaseName: *db + documents: &documents + - { _id: 1, x: 11 } + - { _id: 2, x: 22 } + - { _id: 3, x: 33 } + - { _id: 4, x: 44 } + - { _id: 5, x: 55 } + +tests: + # This is what this API was invented to do. + - description: successfully executes checkMetadataConsistency cursor creating command + runOnRequirements: + - minServerVersion: '7.0' + topologies: [sharded] + operations: + - name: runCursorCommand + object: *db + arguments: + commandName: checkMetadataConsistency + command: { checkMetadataConsistency: 1 } + expectEvents: + - client: *client + eventType: command + events: + - commandStartedEvent: + command: + checkMetadataConsistency: 1 + $db: *db + lsid: { $$exists: true } + commandName: checkMetadataConsistency + + - description: errors if the command response is not a cursor + operations: + - name: createCommandCursor + object: *db + arguments: + commandName: ping + command: { ping: 1 } + expectError: + isClientError: true + + + # Driver Sessions + - description: creates an implicit session that is reused across getMores + operations: + - name: runCursorCommand + object: *db + arguments: + commandName: find + command: { find: *collection, batchSize: 2 } + expectResult: *documents + - name: assertSameLsidOnLastTwoCommands + object: testRunner + arguments: + client: *client + expectEvents: + - client: *client + eventType: command + events: + - commandStartedEvent: + command: + find: *collection + batchSize: 2 + $db: *db + lsid: { $$exists: true } + commandName: find + - commandStartedEvent: + command: + getMore: { $$type: [int, long] } + collection: *collection + $db: *db + lsid: { $$exists: true } + commandName: getMore + + - description: accepts an explicit session that is reused across getMores + operations: + - name: runCursorCommand + object: *db + arguments: + commandName: find + session: *session + command: { find: *collection, batchSize: 2 } + expectResult: *documents + - name: assertSameLsidOnLastTwoCommands + object: testRunner + arguments: + client: *client + expectEvents: + - client: *client + eventType: command + events: + - commandStartedEvent: + command: + find: *collection + batchSize: 2 + $db: *db + lsid: { $$sessionLsid: *session } + commandName: find + - commandStartedEvent: + command: + getMore: { $$type: [int, long] } + collection: *collection + $db: *db + lsid: { $$sessionLsid: *session } + commandName: getMore + + # Load Balancers + - description: returns pinned connections to the pool when the cursor is exhausted + runOnRequirements: + - topologies: [ load-balanced ] + operations: + - name: createCommandCursor + object: *db + arguments: + commandName: find + batchSize: 2 + session: *session + command: { find: *collection, batchSize: 2 } + saveResultAsEntity: &cursor cursor + - name: assertNumberConnectionsCheckedOut + object: testRunner + arguments: + client: *client + connections: 1 + - name: iterateUntilDocumentOrError + object: *cursor + expectResult: { _id: 1, x: 11 } + - name: iterateUntilDocumentOrError + object: *cursor + expectResult: { _id: 2, x: 22 } + - name: iterateUntilDocumentOrError + object: *cursor + expectResult: { _id: 3, x: 33 } + - name: iterateUntilDocumentOrError + object: *cursor + expectResult: { _id: 4, x: 44 } + - name: iterateUntilDocumentOrError + object: *cursor + expectResult: { _id: 5, x: 55 } + - name: assertNumberConnectionsCheckedOut + object: testRunner + arguments: + client: *client + connections: 0 + expectEvents: + - client: *client + eventType: command + events: + - commandStartedEvent: + command: + find: *collection + batchSize: 2 + $db: *db + lsid: { $$sessionLsid: *session } + commandName: find # 2 documents + - commandStartedEvent: + command: + getMore: { $$type: [int, long] } + collection: *collection + $db: *db + lsid: { $$sessionLsid: *session } + commandName: getMore # 2 documents + - commandStartedEvent: + command: + getMore: { $$type: [int, long] } + collection: *collection + $db: *db + lsid: { $$sessionLsid: *session } + commandName: getMore # 1 document + # Total documents: 5 + - client: *client + eventType: cmap + events: + - connectionReadyEvent: {} + - connectionCheckedOutEvent: {} + - connectionCheckedInEvent: {} + + - description: returns pinned connections to the pool when the cursor is closed + runOnRequirements: + - topologies: [ load-balanced ] + operations: + - name: createCommandCursor + object: *db + arguments: + commandName: find + command: { find: *collection, batchSize: 2 } + saveResultAsEntity: *cursor + - name: assertNumberConnectionsCheckedOut + object: testRunner + arguments: + client: *client + connections: 1 + - name: close + object: *cursor + - name: assertNumberConnectionsCheckedOut + object: testRunner + arguments: + client: *client + connections: 0 + + # Iterating the Cursor / Executing GetMores + - description: supports configuring getMore batchSize + operations: + - name: runCursorCommand + object: *db + arguments: + commandName: find + batchSize: 5 + command: { find: *collection, batchSize: 1 } + expectResult: *documents + expectEvents: + - client: *client + eventType: command + events: + - commandStartedEvent: + command: + find: *collection + batchSize: 1 + $db: *db + lsid: { $$exists: true } + commandName: find + - commandStartedEvent: + command: + getMore: { $$type: [int, long] } + collection: *collection + batchSize: 5 + $db: *db + lsid: { $$exists: true } + commandName: getMore + + - description: supports configuring getMore maxTimeMS + operations: + - name: runCursorCommand + object: *db + arguments: + commandName: find + maxTimeMS: 300 + command: { find: *collection, maxTimeMS: 200, batchSize: 1 } + ignoreResultAndError: true + expectEvents: + - client: *client + eventType: command + # The getMore should receive an error here because we do not have the right kind of cursor + # So drivers should run a killCursors, but neither the error nor the killCursors command is relevant to this test + ignoreExtraEvents: true + events: + - commandStartedEvent: + command: + find: *collection + maxTimeMS: 200 + batchSize: 1 + $db: *db + lsid: { $$exists: true } + commandName: find + - commandStartedEvent: + command: + getMore: { $$type: [int, long] } + collection: *collection + $db: *db + maxTimeMS: 300 + lsid: { $$exists: true } + commandName: getMore + + - description: supports configuring getMore comment + runOnRequirements: + - minServerVersion: '4.4' + operations: + - name: runCursorCommand + object: *db + arguments: + commandName: find + comment: { hello: 'getMore' } + command: { find: *collection, batchSize: 1, comment: { hello: 'find' } } + expectResult: *documents + expectEvents: + - client: *client + eventType: command + events: + - commandStartedEvent: + command: + find: *collection + batchSize: 1 + comment: { hello: 'find' } + $db: *db + lsid: { $$exists: true } + commandName: find + - commandStartedEvent: + command: + getMore: { $$type: [int, long] } + collection: *collection + comment: { hello: 'getMore' } + $db: *db + lsid: { $$exists: true } + commandName: getMore + + # Tailable cursor + - description: does not close the cursor when receiving an empty batch + runOnRequirements: + - serverless: forbid + operations: + - name: dropCollection + object: *db + arguments: + collection: &cappedCollection cappedCollection + - name: createCollection + object: *db + arguments: + collection: *cappedCollection + capped: true + size: 4096 + max: 3 + saveResultAsEntity: *cappedCollection + - name: insertMany + object: *cappedCollection + arguments: + documents: + - { _id: 1, x: 11 } + - { _id: 2, x: 22 } + - name: createCommandCursor + object: *db + arguments: + cursorType: tailable + commandName: find + batchSize: 2 + command: { find: *cappedCollection, tailable: true } + saveResultAsEntity: &cursor cursor + - name: iterateOnce + object: *cursor + - name: iterateOnce + object: *cursor + - name: iterateOnce + object: *cursor + - name: close + object: *cursor + expectEvents: + - client: *client + eventType: command + events: + - commandStartedEvent: + command: + drop: *cappedCollection + commandName: drop + - commandStartedEvent: + command: + create: *cappedCollection + commandName: create + - commandStartedEvent: + command: + insert: *cappedCollection + commandName: insert + - commandStartedEvent: + command: + find: *cappedCollection + $db: *db + lsid: { $$exists: true } + commandName: find + - commandStartedEvent: + command: + getMore: { $$type: [int, long] } + collection: *cappedCollection + $db: *db + lsid: { $$exists: true } + commandName: getMore + - commandStartedEvent: + command: + killCursors: *cappedCollection + cursors: { $$type: array } + commandName: killCursors diff --git a/test/spec/unified-test-format/valid-pass/entity-commandCursor.json b/test/spec/unified-test-format/valid-pass/entity-commandCursor.json new file mode 100644 index 0000000000..72b74b4a9a --- /dev/null +++ b/test/spec/unified-test-format/valid-pass/entity-commandCursor.json @@ -0,0 +1,278 @@ +{ + "description": "entity-commandCursor", + "schemaVersion": "1.3", + "createEntities": [ + { + "client": { + "id": "client", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "db", + "client": "client", + "databaseName": "db" + } + }, + { + "collection": { + "id": "collection", + "database": "db", + "collectionName": "collection" + } + } + ], + "initialData": [ + { + "collectionName": "collection", + "databaseName": "db", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + }, + { + "_id": 4, + "x": 44 + }, + { + "_id": 5, + "x": 55 + } + ] + } + ], + "tests": [ + { + "description": "runCursorCommand creates and exhausts cursor by running getMores", + "operations": [ + { + "name": "runCursorCommand", + "object": "db", + "arguments": { + "commandName": "find", + "batchSize": 2, + "command": { + "find": "collection", + "filter": {}, + "batchSize": 2 + } + }, + "expectResult": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + }, + { + "_id": 4, + "x": 44 + }, + { + "_id": 5, + "x": 55 + } + ] + } + ], + "expectEvents": [ + { + "client": "client", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "collection", + "filter": {}, + "batchSize": 2, + "$db": "db", + "lsid": { + "$$exists": true + } + }, + "commandName": "find" + } + }, + { + "commandStartedEvent": { + "command": { + "getMore": { + "$$type": [ + "int", + "long" + ] + }, + "collection": "collection", + "$db": "db", + "lsid": { + "$$exists": true + } + }, + "commandName": "getMore" + } + }, + { + "commandStartedEvent": { + "command": { + "getMore": { + "$$type": [ + "int", + "long" + ] + }, + "collection": "collection", + "$db": "db", + "lsid": { + "$$exists": true + } + }, + "commandName": "getMore" + } + } + ] + } + ] + }, + { + "description": "createCommandCursor creates a cursor and stores it as an entity that can be iterated one document at a time", + "operations": [ + { + "name": "createCommandCursor", + "object": "db", + "arguments": { + "commandName": "find", + "batchSize": 2, + "command": { + "find": "collection", + "filter": {}, + "batchSize": 2 + } + }, + "saveResultAsEntity": "myRunCommandCursor" + }, + { + "name": "iterateUntilDocumentOrError", + "object": "myRunCommandCursor", + "expectResult": { + "_id": 1, + "x": 11 + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "myRunCommandCursor", + "expectResult": { + "_id": 2, + "x": 22 + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "myRunCommandCursor", + "expectResult": { + "_id": 3, + "x": 33 + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "myRunCommandCursor", + "expectResult": { + "_id": 4, + "x": 44 + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "myRunCommandCursor", + "expectResult": { + "_id": 5, + "x": 55 + } + } + ] + }, + { + "description": "createCommandCursor's cursor can be closed and will perform a killCursors operation", + "operations": [ + { + "name": "createCommandCursor", + "object": "db", + "arguments": { + "commandName": "find", + "batchSize": 2, + "command": { + "find": "collection", + "filter": {}, + "batchSize": 2 + } + }, + "saveResultAsEntity": "myRunCommandCursor" + }, + { + "name": "iterateUntilDocumentOrError", + "object": "myRunCommandCursor", + "expectResult": { + "_id": 1, + "x": 11 + } + }, + { + "name": "close", + "object": "myRunCommandCursor" + } + ], + "expectEvents": [ + { + "client": "client", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "collection", + "filter": {}, + "batchSize": 2, + "$db": "db", + "lsid": { + "$$exists": true + } + }, + "commandName": "find" + } + }, + { + "commandStartedEvent": { + "command": { + "killCursors": "collection", + "cursors": { + "$$type": "array" + } + }, + "commandName": "killCursors" + } + } + ] + } + ] + } + ] +} diff --git a/test/spec/unified-test-format/valid-pass/entity-commandCursor.yml b/test/spec/unified-test-format/valid-pass/entity-commandCursor.yml new file mode 100644 index 0000000000..3becf2095a --- /dev/null +++ b/test/spec/unified-test-format/valid-pass/entity-commandCursor.yml @@ -0,0 +1,115 @@ +description: entity-commandCursor +schemaVersion: '1.3' +createEntities: + - client: + id: &client client + useMultipleMongoses: false + observeEvents: [commandStartedEvent] + - database: + id: &db db + client: *client + databaseName: *db + - collection: + id: &collection collection + database: *db + collectionName: *collection +initialData: + - collectionName: collection + databaseName: *db + documents: &documents + - { _id: 1, x: 11 } + - { _id: 2, x: 22 } + - { _id: 3, x: 33 } + - { _id: 4, x: 44 } + - { _id: 5, x: 55 } +tests: + - description: runCursorCommand creates and exhausts cursor by running getMores + operations: + - name: runCursorCommand + object: *db + arguments: + commandName: find + batchSize: 2 + command: { find: *collection, filter: {}, batchSize: 2 } + expectResult: *documents + expectEvents: + - client: *client + events: + - commandStartedEvent: + command: + find: *collection + filter: {} + batchSize: 2 + $db: *db + lsid: { $$exists: true } + commandName: find + - commandStartedEvent: + command: + getMore: { $$type: [int, long] } + collection: *collection + $db: *db + lsid: { $$exists: true } + commandName: getMore + - commandStartedEvent: + command: + getMore: { $$type: [int, long] } + collection: *collection + $db: *db + lsid: { $$exists: true } + commandName: getMore + + - description: createCommandCursor creates a cursor and stores it as an entity that can be iterated one document at a time + operations: + - name: createCommandCursor + object: *db + arguments: + commandName: find + batchSize: 2 + command: { find: *collection, filter: {}, batchSize: 2 } + saveResultAsEntity: &myRunCommandCursor myRunCommandCursor + - name: iterateUntilDocumentOrError + object: *myRunCommandCursor + expectResult: { _id: 1, x: 11 } + - name: iterateUntilDocumentOrError + object: *myRunCommandCursor + expectResult: { _id: 2, x: 22 } + - name: iterateUntilDocumentOrError + object: *myRunCommandCursor + expectResult: { _id: 3, x: 33 } + - name: iterateUntilDocumentOrError + object: *myRunCommandCursor + expectResult: { _id: 4, x: 44 } + - name: iterateUntilDocumentOrError + object: *myRunCommandCursor + expectResult: { _id: 5, x: 55 } + + - description: createCommandCursor's cursor can be closed and will perform a killCursors operation + operations: + - name: createCommandCursor + object: *db + arguments: + commandName: find + batchSize: 2 + command: { find: *collection, filter: {}, batchSize: 2 } + saveResultAsEntity: myRunCommandCursor + - name: iterateUntilDocumentOrError + object: *myRunCommandCursor + expectResult: { _id: 1, x: 11 } + - name: close + object: *myRunCommandCursor + expectEvents: + - client: *client + events: + - commandStartedEvent: + command: + find: *collection + filter: {} + batchSize: 2 + $db: *db + lsid: { $$exists: true } + commandName: find + - commandStartedEvent: + command: + killCursors: *collection + cursors: { $$type: array } + commandName: killCursors diff --git a/test/tools/unified-spec-runner/entities.ts b/test/tools/unified-spec-runner/entities.ts index 83c689b6fb..ffc1d41221 100644 --- a/test/tools/unified-spec-runner/entities.ts +++ b/test/tools/unified-spec-runner/entities.ts @@ -417,6 +417,16 @@ export class EntitiesMap extends Map { return new EntitiesMap(Array.from(this.entries()).filter(([, e]) => e instanceof ctor)); } + getChangeStreamOrCursor(key: string): UnifiedChangeStream | AbstractCursor { + try { + const cs = this.getEntity('stream', key); + return cs; + } catch { + const cursor = this.getEntity('cursor', key); + return cursor; + } + } + getEntity(type: 'client', key: string, assertExists?: boolean): UnifiedMongoClient; getEntity(type: 'db', key: string, assertExists?: boolean): Db; getEntity(type: 'collection', key: string, assertExists?: boolean): Collection; diff --git a/test/tools/unified-spec-runner/operations.ts b/test/tools/unified-spec-runner/operations.ts index a2b4e555c8..3118a51889 100644 --- a/test/tools/unified-spec-runner/operations.ts +++ b/test/tools/unified-spec-runner/operations.ts @@ -21,9 +21,9 @@ import { } from '../../mongodb'; import { getSymbolFrom, sleep } from '../../tools/utils'; import { TestConfiguration } from '../runner/config'; -import { EntitiesMap, UnifiedChangeStream } from './entities'; +import { EntitiesMap } from './entities'; import { expectErrorCheck, resultCheck } from './match'; -import type { ExpectedEvent, ExpectedLogMessage, OperationDescription } from './schema'; +import type { ExpectedEvent, OperationDescription } from './schema'; import { getMatchingEventCount, translateOptions } from './unified-utils'; interface OperationFunctionParams { @@ -350,26 +350,13 @@ operations.set('insertMany', async ({ entities, operation }) => { }); operations.set('iterateUntilDocumentOrError', async ({ entities, operation }) => { - function getChangeStream(): UnifiedChangeStream | null { - try { - const changeStream = entities.getEntity('stream', operation.object); - return changeStream; - } catch (e) { - return null; - } - } - - const changeStream = getChangeStream(); - if (changeStream == null) { - // iterateUntilDocumentOrError is used for changes streams and regular cursors. - // we have no other way to distinguish which scenario we are testing when we run an - // iterateUntilDocumentOrError operation, so we first try to get the changeStream and - // if that fails, we know we need to get a cursor - const cursor = entities.getEntity('cursor', operation.object); - return await cursor.next(); - } + const iterable = entities.getChangeStreamOrCursor(operation.object); + return iterable.next(); +}); - return await changeStream.next(); +operations.set('iterateOnce', async ({ entities, operation }) => { + const iterable = entities.getChangeStreamOrCursor(operation.object); + return iterable.tryNext(); }); operations.set('listCollections', async ({ entities, operation }) => { @@ -659,6 +646,40 @@ operations.set('runCommand', async ({ entities, operation }: OperationFunctionPa return db.command(command, options); }); +operations.set('runCursorCommand', async ({ entities, operation }: OperationFunctionParams) => { + const db = entities.getEntity('db', operation.object); + const { command, ...opts } = operation.arguments!; + const cursor = db.runCursorCommand(command, { + readPreference: ReadPreference.fromOptions({ readPreference: opts.readPreference }), + session: opts.session + }); + + if (!Number.isNaN(+opts.batchSize)) cursor.setBatchSize(+opts.batchSize); + if (!Number.isNaN(+opts.maxTimeMS)) cursor.setMaxTimeMS(+opts.maxTimeMS); + if (opts.comment !== undefined) cursor.setComment(opts.comment); + + return cursor.toArray(); +}); + +operations.set('createCommandCursor', async ({ entities, operation }: OperationFunctionParams) => { + const collection = entities.getEntity('db', operation.object); + const { command, ...opts } = operation.arguments!; + const cursor = collection.runCursorCommand(command, { + readPreference: ReadPreference.fromOptions({ readPreference: opts.readPreference }), + session: opts.session + }); + + if (!Number.isNaN(+opts.batchSize)) cursor.setBatchSize(+opts.batchSize); + if (!Number.isNaN(+opts.maxTimeMS)) cursor.setMaxTimeMS(+opts.maxTimeMS); + if (opts.comment !== undefined) cursor.setComment(opts.comment); + + // The spec dictates that we create the cursor and force the find command + // to execute, but the first document must still be returned for the first iteration. + await cursor.hasNext(); + + return cursor; +}); + operations.set('updateMany', async ({ entities, operation }) => { const collection = entities.getEntity('collection', operation.object); const { filter, update, ...options } = operation.arguments!; @@ -742,8 +763,7 @@ export async function executeOperationAndCheck( expect(opFunc, `Unknown operation: ${operation.name}`).to.exist; if (operation.arguments?.session) { - const session = entities.getEntity('session', operation.arguments.session, false); - operation.arguments.session = session; + operation.arguments.session = entities.getEntity('session', operation.arguments.session); } let result; diff --git a/test/unit/cursor/run_command_cursor.test.ts b/test/unit/cursor/run_command_cursor.test.ts new file mode 100644 index 0000000000..d09966059d --- /dev/null +++ b/test/unit/cursor/run_command_cursor.test.ts @@ -0,0 +1,86 @@ +import { expect } from 'chai'; + +import { MongoAPIError, MongoClient } from '../../mongodb'; + +describe('class RunCommandCursor', () => { + let client: MongoClient; + beforeEach(async function () { + client = new MongoClient('mongodb://iLoveJavascript'); + }); + + context('constructor()', () => { + it('freezes and stores the command on the cursor instance', () => { + const cursor = client.db().runCursorCommand({ a: 1 }); + expect(cursor).to.have.property('command').that.is.frozen; + }); + + it('creates getMoreOptions property with no defaults', () => { + const cursor = client.db().runCursorCommand({ a: 1 }); + expect(cursor).to.have.property('getMoreOptions').that.deep.equals({}); + }); + }); + + context('setComment()', () => { + it('stores the comment value in getMoreOptions', () => { + const cursor = client.db().runCursorCommand({ a: 1 }); + cursor.setComment('iLoveJS'); + expect(cursor).to.have.nested.property('getMoreOptions.comment', 'iLoveJS'); + }); + }); + + context('setMaxTimeMS()', () => { + it('stores the maxTimeMS value in getMoreOptions.maxAwaitTimeMS', () => { + const cursor = client.db().runCursorCommand({ a: 1 }); + cursor.setMaxTimeMS(2); + expect(cursor).to.have.nested.property('getMoreOptions.maxAwaitTimeMS', 2); + }); + + it('does not validate maxTimeMS type', () => { + const cursor = client.db().runCursorCommand({ a: 1 }); + // @ts-expect-error: testing for incorrect type + cursor.setMaxTimeMS('abc'); + expect(cursor).to.have.nested.property('getMoreOptions.maxAwaitTimeMS', 'abc'); + }); + }); + + context('setBatchSize()', () => { + it('stores the batchSize value in getMoreOptions', () => { + const cursor = client.db().runCursorCommand({ a: 1 }); + cursor.setBatchSize(2); + expect(cursor).to.have.nested.property('getMoreOptions.batchSize', 2); + }); + + it('does not validate batchSize type', () => { + const cursor = client.db().runCursorCommand({ a: 1 }); + // @ts-expect-error: testing for incorrect type + cursor.setBatchSize('abc'); + expect(cursor).to.have.nested.property('getMoreOptions.batchSize', 'abc'); + }); + }); + + context('Non applicable AbstractCursor methods', () => { + it('withReadConcern throws', () => { + expect(() => + client.db().runCursorCommand({ a: 1 }).withReadConcern({ level: 'local' }) + ).to.throw(MongoAPIError); + }); + + it('addCursorFlag throws', () => { + expect(() => client.db().runCursorCommand({ a: 1 }).addCursorFlag('tailable', true)).to.throw( + MongoAPIError + ); + }); + + it('maxTimeMS throws', () => { + expect(() => client.db().runCursorCommand({ a: 1 }).maxTimeMS(2)).to.throw(MongoAPIError); + }); + + it('batchSize throws', () => { + expect(() => client.db().runCursorCommand({ a: 1 }).batchSize(2)).to.throw(MongoAPIError); + }); + + it('clone throws', () => { + expect(() => client.db().runCursorCommand({ a: 1 }).clone()).to.throw(MongoAPIError); + }); + }); +});