Skip to content

Commit 3591368

Browse files
aditi-khare-mongoDBW-A-Jamesnbbeekenbaileympearson
committed
feat(NODE-6389): add support for timeoutMS in StateMachine.execute() (#4243)
Co-authored-by: Warren James <[email protected]> Co-authored-by: Neal Beeken <[email protected]> Co-authored-by: Bailey Pearson <[email protected]>
1 parent 27fc6a9 commit 3591368

File tree

5 files changed

+371
-55
lines changed

5 files changed

+371
-55
lines changed

src/client-side-encryption/state_machine.ts

+62-26
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import {
1212
} from '../bson';
1313
import { type ProxyOptions } from '../cmap/connection';
1414
import { getSocks, type SocksLib } from '../deps';
15+
import { MongoOperationTimeoutError } from '../error';
1516
import { type MongoClient, type MongoClientOptions } from '../mongo_client';
17+
import { Timeout, type TimeoutContext, TimeoutError } from '../timeout';
1618
import { BufferPool, MongoDBCollectionNamespace, promiseWithResolvers } from '../utils';
1719
import { autoSelectSocketOptions, type DataKey } from './client_encryption';
1820
import { MongoCryptError } from './errors';
@@ -173,6 +175,7 @@ export type StateMachineOptions = {
173175
* An internal class that executes across a MongoCryptContext until either
174176
* a finishing state or an error is reached. Do not instantiate directly.
175177
*/
178+
// TODO(DRIVERS-2671): clarify CSOT behavior for FLE APIs
176179
export class StateMachine {
177180
constructor(
178181
private options: StateMachineOptions,
@@ -182,7 +185,11 @@ export class StateMachine {
182185
/**
183186
* Executes the state machine according to the specification
184187
*/
185-
async execute(executor: StateMachineExecutable, context: MongoCryptContext): Promise<Uint8Array> {
188+
async execute(
189+
executor: StateMachineExecutable,
190+
context: MongoCryptContext,
191+
timeoutContext?: TimeoutContext
192+
): Promise<Uint8Array> {
186193
const keyVaultNamespace = executor._keyVaultNamespace;
187194
const keyVaultClient = executor._keyVaultClient;
188195
const metaDataClient = executor._metaDataClient;
@@ -201,8 +208,13 @@ export class StateMachine {
201208
'unreachable state machine state: entered MONGOCRYPT_CTX_NEED_MONGO_COLLINFO but metadata client is undefined'
202209
);
203210
}
204-
const collInfo = await this.fetchCollectionInfo(metaDataClient, context.ns, filter);
205211

212+
const collInfo = await this.fetchCollectionInfo(
213+
metaDataClient,
214+
context.ns,
215+
filter,
216+
timeoutContext
217+
);
206218
if (collInfo) {
207219
context.addMongoOperationResponse(collInfo);
208220
}
@@ -222,9 +234,9 @@ export class StateMachine {
222234
// When we are using the shared library, we don't have a mongocryptd manager.
223235
const markedCommand: Uint8Array = mongocryptdManager
224236
? await mongocryptdManager.withRespawn(
225-
this.markCommand.bind(this, mongocryptdClient, context.ns, command)
237+
this.markCommand.bind(this, mongocryptdClient, context.ns, command, timeoutContext)
226238
)
227-
: await this.markCommand(mongocryptdClient, context.ns, command);
239+
: await this.markCommand(mongocryptdClient, context.ns, command, timeoutContext);
228240

229241
context.addMongoOperationResponse(markedCommand);
230242
context.finishMongoOperation();
@@ -233,7 +245,12 @@ export class StateMachine {
233245

234246
case MONGOCRYPT_CTX_NEED_MONGO_KEYS: {
235247
const filter = context.nextMongoOperation();
236-
const keys = await this.fetchKeys(keyVaultClient, keyVaultNamespace, filter);
248+
const keys = await this.fetchKeys(
249+
keyVaultClient,
250+
keyVaultNamespace,
251+
filter,
252+
timeoutContext
253+
);
237254

238255
if (keys.length === 0) {
239256
// See docs on EMPTY_V
@@ -255,9 +272,7 @@ export class StateMachine {
255272
}
256273

257274
case MONGOCRYPT_CTX_NEED_KMS: {
258-
const requests = Array.from(this.requests(context));
259-
await Promise.all(requests);
260-
275+
await Promise.all(this.requests(context, timeoutContext));
261276
context.finishKMSRequests();
262277
break;
263278
}
@@ -299,7 +314,7 @@ export class StateMachine {
299314
* @param kmsContext - A C++ KMS context returned from the bindings
300315
* @returns A promise that resolves when the KMS reply has be fully parsed
301316
*/
302-
async kmsRequest(request: MongoCryptKMSRequest): Promise<void> {
317+
async kmsRequest(request: MongoCryptKMSRequest, timeoutContext?: TimeoutContext): Promise<void> {
303318
const parsedUrl = request.endpoint.split(':');
304319
const port = parsedUrl[1] != null ? Number.parseInt(parsedUrl[1], 10) : HTTPS_PORT;
305320
const socketOptions = autoSelectSocketOptions(this.options.socketOptions || {});
@@ -329,10 +344,6 @@ export class StateMachine {
329344
}
330345
}
331346

332-
function ontimeout() {
333-
return new MongoCryptError('KMS request timed out');
334-
}
335-
336347
function onerror(cause: Error) {
337348
return new MongoCryptError('KMS request failed', { cause });
338349
}
@@ -364,7 +375,6 @@ export class StateMachine {
364375
resolve: resolveOnNetSocketConnect
365376
} = promiseWithResolvers<void>();
366377
netSocket
367-
.once('timeout', () => rejectOnNetSocketError(ontimeout()))
368378
.once('error', err => rejectOnNetSocketError(onerror(err)))
369379
.once('close', () => rejectOnNetSocketError(onclose()))
370380
.once('connect', () => resolveOnNetSocketConnect());
@@ -410,8 +420,8 @@ export class StateMachine {
410420
reject: rejectOnTlsSocketError,
411421
resolve
412422
} = promiseWithResolvers<void>();
423+
413424
socket
414-
.once('timeout', () => rejectOnTlsSocketError(ontimeout()))
415425
.once('error', err => rejectOnTlsSocketError(onerror(err)))
416426
.once('close', () => rejectOnTlsSocketError(onclose()))
417427
.on('data', data => {
@@ -425,20 +435,26 @@ export class StateMachine {
425435
resolve();
426436
}
427437
});
428-
await willResolveKmsRequest;
438+
await (timeoutContext?.csotEnabled()
439+
? Promise.all([willResolveKmsRequest, Timeout.expires(timeoutContext?.remainingTimeMS)])
440+
: willResolveKmsRequest);
441+
} catch (error) {
442+
if (error instanceof TimeoutError)
443+
throw new MongoOperationTimeoutError('KMS request timed out');
444+
throw error;
429445
} finally {
430446
// There's no need for any more activity on this socket at this point.
431447
destroySockets();
432448
}
433449
}
434450

435-
*requests(context: MongoCryptContext) {
451+
*requests(context: MongoCryptContext, timeoutContext?: TimeoutContext) {
436452
for (
437453
let request = context.nextKMSRequest();
438454
request != null;
439455
request = context.nextKMSRequest()
440456
) {
441-
yield this.kmsRequest(request);
457+
yield this.kmsRequest(request, timeoutContext);
442458
}
443459
}
444460

@@ -498,15 +514,19 @@ export class StateMachine {
498514
async fetchCollectionInfo(
499515
client: MongoClient,
500516
ns: string,
501-
filter: Document
517+
filter: Document,
518+
timeoutContext?: TimeoutContext
502519
): Promise<Uint8Array | null> {
503520
const { db } = MongoDBCollectionNamespace.fromString(ns);
504521

505522
const collections = await client
506523
.db(db)
507524
.listCollections(filter, {
508525
promoteLongs: false,
509-
promoteValues: false
526+
promoteValues: false,
527+
...(timeoutContext?.csotEnabled()
528+
? { timeoutMS: timeoutContext?.remainingTimeMS, timeoutMode: 'cursorLifetime' }
529+
: {})
510530
})
511531
.toArray();
512532

@@ -522,12 +542,22 @@ export class StateMachine {
522542
* @param command - The command to execute.
523543
* @param callback - Invoked with the serialized and marked bson command, or with an error
524544
*/
525-
async markCommand(client: MongoClient, ns: string, command: Uint8Array): Promise<Uint8Array> {
526-
const options = { promoteLongs: false, promoteValues: false };
545+
async markCommand(
546+
client: MongoClient,
547+
ns: string,
548+
command: Uint8Array,
549+
timeoutContext?: TimeoutContext
550+
): Promise<Uint8Array> {
527551
const { db } = MongoDBCollectionNamespace.fromString(ns);
528-
const rawCommand = deserialize(command, options);
552+
const bsonOptions = { promoteLongs: false, promoteValues: false };
553+
const rawCommand = deserialize(command, bsonOptions);
529554

530-
const response = await client.db(db).command(rawCommand, options);
555+
const response = await client.db(db).command(rawCommand, {
556+
...bsonOptions,
557+
...(timeoutContext?.csotEnabled()
558+
? { timeoutMS: timeoutContext?.remainingTimeMS }
559+
: undefined)
560+
});
531561

532562
return serialize(response, this.bsonOptions);
533563
}
@@ -543,15 +573,21 @@ export class StateMachine {
543573
fetchKeys(
544574
client: MongoClient,
545575
keyVaultNamespace: string,
546-
filter: Uint8Array
576+
filter: Uint8Array,
577+
timeoutContext?: TimeoutContext
547578
): Promise<Array<DataKey>> {
548579
const { db: dbName, collection: collectionName } =
549580
MongoDBCollectionNamespace.fromString(keyVaultNamespace);
550581

551582
return client
552583
.db(dbName)
553584
.collection<DataKey>(collectionName, { readConcern: { level: 'majority' } })
554-
.find(deserialize(filter))
585+
.find(
586+
deserialize(filter),
587+
timeoutContext?.csotEnabled()
588+
? { timeoutMS: timeoutContext?.remainingTimeMS, timeoutMode: 'cursorLifetime' }
589+
: {}
590+
)
555591
.toArray();
556592
}
557593
}

src/sdam/server.ts

+4
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,10 @@ export class Server extends TypedEventEmitter<ServerEvents> {
311311
delete finalOptions.readPreference;
312312
}
313313

314+
if (this.description.iscryptd) {
315+
finalOptions.omitMaxTimeMS = true;
316+
}
317+
314318
const session = finalOptions.session;
315319
let conn = session?.pinnedConnection;
316320

test/integration/client-side-operations-timeout/client_side_operations_timeout.prose.test.ts

+76-11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
/* Specification prose tests */
22

3+
import { type ChildProcess, spawn } from 'node:child_process';
4+
35
import { expect } from 'chai';
46
import * as semver from 'semver';
57
import * as sinon from 'sinon';
@@ -16,7 +18,8 @@ import {
1618
MongoServerSelectionError,
1719
now,
1820
ObjectId,
19-
promiseWithResolvers
21+
promiseWithResolvers,
22+
squashError
2023
} from '../../mongodb';
2124
import { type FailPoint } from '../../tools/utils';
2225

@@ -103,17 +106,55 @@ describe('CSOT spec prose tests', function () {
103106
});
104107
});
105108

106-
context.skip('2. maxTimeMS is not set for commands sent to mongocryptd', () => {
107-
/**
108-
* This test MUST only be run against enterprise server versions 4.2 and higher.
109-
*
110-
* 1. Launch a mongocryptd process on 23000.
111-
* 1. Create a MongoClient (referred to as `client`) using the URI `mongodb://localhost:23000/?timeoutMS=1000`.
112-
* 1. Using `client`, execute the `{ ping: 1 }` command against the `admin` database.
113-
* 1. Verify via command monitoring that the `ping` command sent did not contain a `maxTimeMS` field.
114-
*/
115-
});
109+
context(
110+
'2. maxTimeMS is not set for commands sent to mongocryptd',
111+
{ requires: { mongodb: '>=4.2' } },
112+
() => {
113+
/**
114+
* This test MUST only be run against enterprise server versions 4.2 and higher.
115+
*
116+
* 1. Launch a mongocryptd process on 23000.
117+
* 1. Create a MongoClient (referred to as `client`) using the URI `mongodb://localhost:23000/?timeoutMS=1000`.
118+
* 1. Using `client`, execute the `{ ping: 1 }` command against the `admin` database.
119+
* 1. Verify via command monitoring that the `ping` command sent did not contain a `maxTimeMS` field.
120+
*/
121+
122+
let client: MongoClient;
123+
const mongocryptdTestPort = '23000';
124+
let childProcess: ChildProcess;
125+
126+
beforeEach(async function () {
127+
childProcess = spawn('mongocryptd', ['--port', mongocryptdTestPort, '--ipv6'], {
128+
stdio: 'ignore',
129+
detached: true
130+
});
131+
132+
childProcess.on('error', error => console.warn(this.currentTest?.fullTitle(), error));
133+
client = new MongoClient(`mongodb://localhost:${mongocryptdTestPort}/?timeoutMS=1000`, {
134+
monitorCommands: true
135+
});
136+
});
137+
138+
afterEach(async function () {
139+
await client.close();
140+
childProcess.kill('SIGKILL');
141+
sinon.restore();
142+
});
143+
144+
it('maxTimeMS is not set', async function () {
145+
const commandStarted = [];
146+
client.on('commandStarted', ev => commandStarted.push(ev));
147+
await client
148+
.db('admin')
149+
.command({ ping: 1 })
150+
.catch(e => squashError(e));
151+
expect(commandStarted).to.have.lengthOf(1);
152+
expect(commandStarted[0].command).to.not.have.property('maxTimeMS');
153+
});
154+
}
155+
);
116156

157+
// TODO(NODE-6391): Add timeoutMS support to Explicit Encryption
117158
context.skip('3. ClientEncryption', () => {
118159
/**
119160
* Each test under this category MUST only be run against server versions 4.4 and higher. In these tests,
@@ -720,6 +761,30 @@ describe('CSOT spec prose tests', function () {
720761
'TODO(NODE-6223): Auto connect performs extra server selection. Explicit connect throws on invalid host name';
721762
});
722763

764+
it.skip("timeoutMS honored for server selection if it's lower than serverSelectionTimeoutMS", async function () {
765+
/**
766+
* 1. Create a MongoClient (referred to as `client`) with URI `mongodb://invalid/?timeoutMS=10&serverSelectionTimeoutMS=20`.
767+
* 1. Using `client`, run the command `{ ping: 1 }` against the `admin` database.
768+
* - Expect this to fail with a server selection timeout error after no more than 15ms.
769+
*/
770+
client = new MongoClient('mongodb://invalid/?timeoutMS=10&serverSelectionTimeoutMS=20');
771+
const start = now();
772+
773+
const maybeError = await client
774+
.db('test')
775+
.admin()
776+
.ping()
777+
.then(
778+
() => null,
779+
e => e
780+
);
781+
const end = now();
782+
783+
expect(maybeError).to.be.instanceof(MongoOperationTimeoutError);
784+
expect(end - start).to.be.lte(15);
785+
}).skipReason =
786+
'TODO(NODE-6223): Auto connect performs extra server selection. Explicit connect throws on invalid host name';
787+
723788
it.skip("timeoutMS honored for server selection if it's lower than serverSelectionTimeoutMS", async function () {
724789
/**
725790
* 1. Create a MongoClient (referred to as `client`) with URI `mongodb://invalid/?timeoutMS=10&serverSelectionTimeoutMS=20`.

0 commit comments

Comments
 (0)