Skip to content

Commit 61564cd

Browse files
committed
feat(NODE-6090): Implement CSOT logic for connection checkout and server selection
1 parent f790cc1 commit 61564cd

21 files changed

+575
-176
lines changed

src/admin.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ export class Admin {
7878
new RunAdminCommandOperation(command, {
7979
...resolveBSONOptions(options),
8080
session: options?.session,
81-
readPreference: options?.readPreference
81+
readPreference: options?.readPreference,
82+
timeoutMS: options?.timeoutMS ?? this.s.db.timeoutMS
8283
})
8384
);
8485
}

src/cmap/connection.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { type CancellationToken, TypedEventEmitter } from '../mongo_types';
2929
import { ReadPreference, type ReadPreferenceLike } from '../read_preference';
3030
import { ServerType } from '../sdam/common';
3131
import { applySession, type ClientSession, updateSessionFromResponse } from '../sessions';
32+
import { type Timeout } from '../timeout';
3233
import {
3334
BufferPool,
3435
calculateDurationInMs,
@@ -92,6 +93,9 @@ export interface CommandOptions extends BSONSerializeOptions {
9293
writeConcern?: WriteConcern;
9394

9495
directConnection?: boolean;
96+
97+
/** @internal */
98+
timeout?: Timeout;
9599
}
96100

97101
/** @public */

src/cmap/connection_pool.ts

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@ import {
2121
MongoInvalidArgumentError,
2222
MongoMissingCredentialsError,
2323
MongoNetworkError,
24+
MongoOperationTimeoutError,
2425
MongoRuntimeError,
2526
MongoServerError
2627
} from '../error';
2728
import { CancellationToken, TypedEventEmitter } from '../mongo_types';
2829
import type { Server } from '../sdam/server';
2930
import { Timeout, TimeoutError } from '../timeout';
30-
import { type Callback, List, makeCounter, promiseWithResolvers } from '../utils';
31+
import { type Callback, csotMin, List, makeCounter, promiseWithResolvers } from '../utils';
3132
import { connect } from './connect';
3233
import { Connection, type ConnectionEvents, type ConnectionOptions } from './connection';
3334
import {
@@ -102,7 +103,6 @@ export interface ConnectionPoolOptions extends Omit<ConnectionOptions, 'id' | 'g
102103
export interface WaitQueueMember {
103104
resolve: (conn: Connection) => void;
104105
reject: (err: AnyError) => void;
105-
timeout: Timeout;
106106
[kCancelled]?: boolean;
107107
}
108108

@@ -354,35 +354,57 @@ export class ConnectionPool extends TypedEventEmitter<ConnectionPoolEvents> {
354354
* will be held by the pool. This means that if a connection is checked out it MUST be checked back in or
355355
* explicitly destroyed by the new owner.
356356
*/
357-
async checkOut(): Promise<Connection> {
357+
async checkOut(options?: { timeout?: Timeout }): Promise<Connection> {
358358
this.emitAndLog(
359359
ConnectionPool.CONNECTION_CHECK_OUT_STARTED,
360360
new ConnectionCheckOutStartedEvent(this)
361361
);
362362

363363
const waitQueueTimeoutMS = this.options.waitQueueTimeoutMS;
364+
const serverSelectionTimeoutMS = this[kServer].topology.s.serverSelectionTimeoutMS;
364365

365366
const { promise, resolve, reject } = promiseWithResolvers<Connection>();
366367

367-
const timeout = Timeout.expires(waitQueueTimeoutMS);
368+
let timeout: Timeout | null = null;
369+
if (options?.timeout) {
370+
// CSOT enabled
371+
// Determine if we're using the timeout passed in or a new timeout
372+
if (options.timeout.duration > 0 || serverSelectionTimeoutMS > 0) {
373+
// This check determines whether or not Topology.selectServer used the configured
374+
// `timeoutMS` or `serverSelectionTimeoutMS` value for its timeout
375+
if (
376+
options.timeout.duration === serverSelectionTimeoutMS ||
377+
csotMin(options.timeout.duration, serverSelectionTimeoutMS) < serverSelectionTimeoutMS
378+
) {
379+
// server selection used `timeoutMS`, so we should use the existing timeout as the timeout
380+
// here
381+
timeout = options.timeout;
382+
} else {
383+
// server selection used `serverSelectionTimeoutMS`, so we construct a new timeout with
384+
// the time remaining to ensure that Topology.selectServer and ConnectionPool.checkOut
385+
// cumulatively don't spend more than `serverSelectionTimeoutMS` blocking
386+
timeout = Timeout.expires(serverSelectionTimeoutMS - options.timeout.timeElapsed);
387+
}
388+
}
389+
} else {
390+
timeout = Timeout.expires(waitQueueTimeoutMS);
391+
}
368392

369393
const waitQueueMember: WaitQueueMember = {
370394
resolve,
371-
reject,
372-
timeout
395+
reject
373396
};
374397

375398
this[kWaitQueue].push(waitQueueMember);
376399
process.nextTick(() => this.processWaitQueue());
377400

378401
try {
379-
return await Promise.race([promise, waitQueueMember.timeout]);
402+
timeout?.throwIfExpired();
403+
return await (timeout ? Promise.race([promise, timeout]) : promise);
380404
} catch (error) {
381405
if (TimeoutError.is(error)) {
382406
waitQueueMember[kCancelled] = true;
383407

384-
waitQueueMember.timeout.clear();
385-
386408
this.emitAndLog(
387409
ConnectionPool.CONNECTION_CHECK_OUT_FAILED,
388410
new ConnectionCheckOutFailedEvent(this, 'timeout')
@@ -393,9 +415,16 @@ export class ConnectionPool extends TypedEventEmitter<ConnectionPoolEvents> {
393415
: 'Timed out while checking out a connection from connection pool',
394416
this.address
395417
);
418+
if (options?.timeout) {
419+
throw new MongoOperationTimeoutError('Timed out during connection checkout', {
420+
cause: timeoutError
421+
});
422+
}
396423
throw timeoutError;
397424
}
398425
throw error;
426+
} finally {
427+
if (timeout !== options?.timeout) timeout?.clear();
399428
}
400429
}
401430

@@ -761,7 +790,6 @@ export class ConnectionPool extends TypedEventEmitter<ConnectionPoolEvents> {
761790
ConnectionPool.CONNECTION_CHECK_OUT_FAILED,
762791
new ConnectionCheckOutFailedEvent(this, reason, error)
763792
);
764-
waitQueueMember.timeout.clear();
765793
this[kWaitQueue].shift();
766794
waitQueueMember.reject(error);
767795
continue;
@@ -782,7 +810,6 @@ export class ConnectionPool extends TypedEventEmitter<ConnectionPoolEvents> {
782810
ConnectionPool.CONNECTION_CHECKED_OUT,
783811
new ConnectionCheckedOutEvent(this, connection)
784812
);
785-
waitQueueMember.timeout.clear();
786813

787814
this[kWaitQueue].shift();
788815
waitQueueMember.resolve(connection);
@@ -820,8 +847,6 @@ export class ConnectionPool extends TypedEventEmitter<ConnectionPoolEvents> {
820847
);
821848
waitQueueMember.resolve(connection);
822849
}
823-
824-
waitQueueMember.timeout.clear();
825850
}
826851
process.nextTick(() => this.processWaitQueue());
827852
});

src/collection.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,11 @@ export class Collection<TSchema extends Document = Document> {
254254
this.s.collectionHint = normalizeHintField(v);
255255
}
256256

257+
/** @internal */
258+
get timeoutMS(): number | undefined {
259+
return this.s.options.timeoutMS;
260+
}
261+
257262
/**
258263
* Inserts a single document into MongoDB. If documents passed in do not contain the **_id** field,
259264
* one will be added to each of the documents missing it by the driver, mutating the document. This behavior

src/db.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,11 @@ export class Db {
222222
return this.s.namespace.toString();
223223
}
224224

225+
/** @internal */
226+
get timeoutMS(): number | undefined {
227+
return this.s.options?.timeoutMS;
228+
}
229+
225230
/**
226231
* Create a new collection on a server with the specified options. Use this to create capped collections.
227232
* More information about command options available at https://www.mongodb.com/docs/manual/reference/command/create/
@@ -272,6 +277,7 @@ export class Db {
272277
this.client,
273278
new RunCommandOperation(this, command, {
274279
...resolveBSONOptions(options),
280+
timeoutMS: options?.timeoutMS,
275281
session: options?.session,
276282
readPreference: options?.readPreference
277283
})

src/error.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,15 @@ export class MongoUnexpectedServerResponseError extends MongoRuntimeError {
759759
}
760760
}
761761

762+
/**
763+
* @internal
764+
*/
765+
export class MongoOperationTimeoutError extends MongoRuntimeError {
766+
override get name(): string {
767+
return 'MongoOperationTimeoutError';
768+
}
769+
}
770+
762771
/**
763772
* An error thrown when the user attempts to add options to a cursor that has already been
764773
* initialized

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export {
6363
MongoNetworkTimeoutError,
6464
MongoNotConnectedError,
6565
MongoOIDCError,
66+
MongoOperationTimeoutError,
6667
MongoParseError,
6768
MongoRuntimeError,
6869
MongoServerClosedError,

src/operations/command.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export interface OperationParent {
6464
writeConcern?: WriteConcern;
6565
readPreference?: ReadPreference;
6666
bsonOptions?: BSONSerializeOptions;
67+
timeoutMS?: number;
6768
}
6869

6970
/** @internal */
@@ -117,6 +118,7 @@ export abstract class CommandOperation<T> extends AbstractOperation<T> {
117118
const options = {
118119
...this.options,
119120
...this.bsonOptions,
121+
timeout: this.timeout,
120122
readPreference: this.readPreference,
121123
session
122124
};

src/operations/execute_operation.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from '../sdam/server_selection';
2828
import type { Topology } from '../sdam/topology';
2929
import type { ClientSession } from '../sessions';
30+
import { Timeout } from '../timeout';
3031
import { squashError, supportsRetryableWrites } from '../utils';
3132
import { AbstractOperation, Aspect } from './operation';
3233

@@ -152,9 +153,13 @@ export async function executeOperation<
152153
selector = readPreference;
153154
}
154155

156+
const timeout = operation.timeoutMS != null ? Timeout.expires(operation.timeoutMS) : undefined;
157+
operation.timeout = timeout;
158+
155159
const server = await topology.selectServer(selector, {
156160
session,
157-
operationName: operation.commandName
161+
operationName: operation.commandName,
162+
timeout
158163
});
159164

160165
if (session == null) {
@@ -265,6 +270,7 @@ async function retryOperation<
265270
// select a new server, and attempt to retry the operation
266271
const server = await topology.selectServer(selector, {
267272
session,
273+
timeout: operation.timeout,
268274
operationName: operation.commandName,
269275
previousServer
270276
});

src/operations/find.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@ export class FindOperation extends CommandOperation<Document> {
112112
...this.options,
113113
...this.bsonOptions,
114114
documentsReturnedIn: 'firstBatch',
115-
session
115+
session,
116+
timeout: this.timeout
116117
},
117118
undefined
118119
);

src/operations/operation.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { type BSONSerializeOptions, type Document, resolveBSONOptions } from '..
22
import { ReadPreference, type ReadPreferenceLike } from '../read_preference';
33
import type { Server } from '../sdam/server';
44
import type { ClientSession } from '../sessions';
5+
import { type Timeout } from '../timeout';
56
import type { MongoDBNamespace } from '../utils';
67

78
export const Aspect = {
@@ -61,6 +62,11 @@ export abstract class AbstractOperation<TResult = any> {
6162

6263
options: OperationOptions;
6364

65+
/** @internal */
66+
timeout?: Timeout;
67+
/** @internal */
68+
timeoutMS?: number;
69+
6470
[kSession]: ClientSession | undefined;
6571

6672
constructor(options: OperationOptions = {}) {
@@ -76,6 +82,8 @@ export abstract class AbstractOperation<TResult = any> {
7682
this.options = options;
7783
this.bypassPinningCheck = !!options.bypassPinningCheck;
7884
this.trySecondaryWrite = false;
85+
86+
this.timeoutMS = options.timeoutMS;
7987
}
8088

8189
/** Must match the first key of the command object sent to the server.

src/operations/run_command.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export type RunCommandOptions = {
1313
session?: ClientSession;
1414
/** The read preference */
1515
readPreference?: ReadPreferenceLike;
16+
/** @internal */
17+
timeoutMS?: number;
1618
} & BSONSerializeOptions;
1719

1820
/** @internal */
@@ -31,6 +33,7 @@ export class RunCommandOperation<T = Document> extends AbstractOperation<T> {
3133
const res: TODO_NODE_3286 = await server.command(this.ns, this.command, {
3234
...this.options,
3335
readPreference: this.readPreference,
36+
timeout: this.timeout,
3437
session
3538
});
3639
return res;
@@ -58,7 +61,8 @@ export class RunAdminCommandOperation<T = Document> extends AbstractOperation<T>
5861
const res: TODO_NODE_3286 = await server.command(this.ns, this.command, {
5962
...this.options,
6063
readPreference: this.readPreference,
61-
session
64+
session,
65+
timeout: this.timeout
6266
});
6367
return res;
6468
}

src/sdam/server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ export class Server extends TypedEventEmitter<ServerEvents> {
310310
this.incrementOperationCount();
311311
if (conn == null) {
312312
try {
313-
conn = await this.pool.checkOut();
313+
conn = await this.pool.checkOut(options);
314314
if (this.loadBalanced && isPinnableCommand(cmd, session)) {
315315
session?.pin(conn);
316316
}
@@ -333,6 +333,7 @@ export class Server extends TypedEventEmitter<ServerEvents> {
333333
operationError.code === MONGODB_ERROR_CODES.Reauthenticate
334334
) {
335335
await this.pool.reauthenticate(conn);
336+
// TODO(NODE-5682): Implement CSOT support for socket read/write at the connection layer
336337
try {
337338
return await conn.command(ns, cmd, finalOptions, responseType);
338339
} catch (commandError) {

0 commit comments

Comments
 (0)