Skip to content

Commit 9c3ade5

Browse files
W-A-Jamesnbbeeken
andauthored
refactor(NODE-5679): introduce timeout abstraction and use for server selection and connection check out (#4078)
Co-authored-by: Neal Beeken <[email protected]>
1 parent af18c53 commit 9c3ade5

File tree

8 files changed

+299
-158
lines changed

8 files changed

+299
-158
lines changed

src/cmap/connection_pool.ts

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,8 @@ import {
2626
} from '../error';
2727
import { CancellationToken, TypedEventEmitter } from '../mongo_types';
2828
import type { Server } from '../sdam/server';
29-
import {
30-
type Callback,
31-
List,
32-
makeCounter,
33-
promiseWithResolvers,
34-
TimeoutController
35-
} from '../utils';
29+
import { Timeout, TimeoutError } from '../timeout';
30+
import { type Callback, List, makeCounter, promiseWithResolvers } from '../utils';
3631
import { connect } from './connect';
3732
import { Connection, type ConnectionEvents, type ConnectionOptions } from './connection';
3833
import {
@@ -107,7 +102,7 @@ export interface ConnectionPoolOptions extends Omit<ConnectionOptions, 'id' | 'g
107102
export interface WaitQueueMember {
108103
resolve: (conn: Connection) => void;
109104
reject: (err: AnyError) => void;
110-
timeoutController: TimeoutController;
105+
timeout: Timeout;
111106
[kCancelled]?: boolean;
112107
}
113108

@@ -368,33 +363,40 @@ export class ConnectionPool extends TypedEventEmitter<ConnectionPoolEvents> {
368363
const waitQueueTimeoutMS = this.options.waitQueueTimeoutMS;
369364

370365
const { promise, resolve, reject } = promiseWithResolvers<Connection>();
366+
367+
const timeout = Timeout.expires(waitQueueTimeoutMS);
368+
371369
const waitQueueMember: WaitQueueMember = {
372370
resolve,
373371
reject,
374-
timeoutController: new TimeoutController(waitQueueTimeoutMS)
372+
timeout
375373
};
376-
waitQueueMember.timeoutController.signal.addEventListener('abort', () => {
377-
waitQueueMember[kCancelled] = true;
378-
waitQueueMember.timeoutController.clear();
379374

380-
this.emitAndLog(
381-
ConnectionPool.CONNECTION_CHECK_OUT_FAILED,
382-
new ConnectionCheckOutFailedEvent(this, 'timeout')
383-
);
384-
waitQueueMember.reject(
385-
new WaitQueueTimeoutError(
375+
this[kWaitQueue].push(waitQueueMember);
376+
process.nextTick(() => this.processWaitQueue());
377+
378+
try {
379+
return await Promise.race([promise, waitQueueMember.timeout]);
380+
} catch (error) {
381+
if (TimeoutError.is(error)) {
382+
waitQueueMember[kCancelled] = true;
383+
384+
waitQueueMember.timeout.clear();
385+
386+
this.emitAndLog(
387+
ConnectionPool.CONNECTION_CHECK_OUT_FAILED,
388+
new ConnectionCheckOutFailedEvent(this, 'timeout')
389+
);
390+
const timeoutError = new WaitQueueTimeoutError(
386391
this.loadBalanced
387392
? this.waitQueueErrorMetrics()
388393
: 'Timed out while checking out a connection from connection pool',
389394
this.address
390-
)
391-
);
392-
});
393-
394-
this[kWaitQueue].push(waitQueueMember);
395-
process.nextTick(() => this.processWaitQueue());
396-
397-
return await promise;
395+
);
396+
throw timeoutError;
397+
}
398+
throw error;
399+
}
398400
}
399401

400402
/**
@@ -758,7 +760,7 @@ export class ConnectionPool extends TypedEventEmitter<ConnectionPoolEvents> {
758760
ConnectionPool.CONNECTION_CHECK_OUT_FAILED,
759761
new ConnectionCheckOutFailedEvent(this, reason, error)
760762
);
761-
waitQueueMember.timeoutController.clear();
763+
waitQueueMember.timeout.clear();
762764
this[kWaitQueue].shift();
763765
waitQueueMember.reject(error);
764766
continue;
@@ -779,7 +781,7 @@ export class ConnectionPool extends TypedEventEmitter<ConnectionPoolEvents> {
779781
ConnectionPool.CONNECTION_CHECKED_OUT,
780782
new ConnectionCheckedOutEvent(this, connection)
781783
);
782-
waitQueueMember.timeoutController.clear();
784+
waitQueueMember.timeout.clear();
783785

784786
this[kWaitQueue].shift();
785787
waitQueueMember.resolve(connection);
@@ -818,7 +820,7 @@ export class ConnectionPool extends TypedEventEmitter<ConnectionPoolEvents> {
818820
waitQueueMember.resolve(connection);
819821
}
820822

821-
waitQueueMember.timeoutController.clear();
823+
waitQueueMember.timeout.clear();
822824
}
823825
process.nextTick(() => this.processWaitQueue());
824826
});

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,7 @@ export type {
547547
WithTransactionCallback
548548
} from './sessions';
549549
export type { Sort, SortDirection, SortDirectionForCmd, SortForCmd } from './sort';
550+
export type { Timeout } from './timeout';
550551
export type { Transaction, TransactionOptions, TxnState } from './transactions';
551552
export type {
552553
BufferPool,
@@ -555,7 +556,6 @@ export type {
555556
HostAddress,
556557
List,
557558
MongoDBCollectionNamespace,
558-
MongoDBNamespace,
559-
TimeoutController
559+
MongoDBNamespace
560560
} from './utils';
561561
export type { W, WriteConcernOptions, WriteConcernSettings } from './write_concern';

src/sdam/topology.ts

Lines changed: 43 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { MongoLoggableComponent, type MongoLogger, SeverityLevel } from '../mong
3333
import { TypedEventEmitter } from '../mongo_types';
3434
import { ReadPreference, type ReadPreferenceLike } from '../read_preference';
3535
import type { ClientSession } from '../sessions';
36+
import { Timeout, TimeoutError } from '../timeout';
3637
import type { Transaction } from '../transactions';
3738
import {
3839
type Callback,
@@ -43,8 +44,7 @@ import {
4344
now,
4445
ns,
4546
promiseWithResolvers,
46-
shuffle,
47-
TimeoutController
47+
shuffle
4848
} from '../utils';
4949
import {
5050
_advanceClusterTime,
@@ -107,7 +107,7 @@ export interface ServerSelectionRequest {
107107
resolve: (server: Server) => void;
108108
reject: (error: MongoError) => void;
109109
[kCancelled]?: boolean;
110-
timeoutController: TimeoutController;
110+
timeout: Timeout;
111111
operationName: string;
112112
waitingLogged: boolean;
113113
previousServer?: ServerDescription;
@@ -178,6 +178,8 @@ export interface SelectServerOptions {
178178
session?: ClientSession;
179179
operationName: string;
180180
previousServer?: ServerDescription;
181+
/** @internal*/
182+
timeout?: Timeout;
181183
}
182184

183185
/** @public */
@@ -580,50 +582,57 @@ export class Topology extends TypedEventEmitter<TopologyEvents> {
580582
}
581583

582584
const { promise: serverPromise, resolve, reject } = promiseWithResolvers<Server>();
585+
const timeout = Timeout.expires(options.serverSelectionTimeoutMS ?? 0);
583586
const waitQueueMember: ServerSelectionRequest = {
584587
serverSelector,
585588
topologyDescription: this.description,
586589
mongoLogger: this.client.mongoLogger,
587590
transaction,
588591
resolve,
589592
reject,
590-
timeoutController: new TimeoutController(options.serverSelectionTimeoutMS),
593+
timeout,
591594
startTime: now(),
592595
operationName: options.operationName,
593596
waitingLogged: false,
594597
previousServer: options.previousServer
595598
};
596599

597-
waitQueueMember.timeoutController.signal.addEventListener('abort', () => {
598-
waitQueueMember[kCancelled] = true;
599-
waitQueueMember.timeoutController.clear();
600-
const timeoutError = new MongoServerSelectionError(
601-
`Server selection timed out after ${options.serverSelectionTimeoutMS} ms`,
602-
this.description
603-
);
604-
if (
605-
this.client.mongoLogger?.willLog(
606-
MongoLoggableComponent.SERVER_SELECTION,
607-
SeverityLevel.DEBUG
608-
)
609-
) {
610-
this.client.mongoLogger?.debug(
611-
MongoLoggableComponent.SERVER_SELECTION,
612-
new ServerSelectionFailedEvent(
613-
selector,
614-
this.description,
615-
timeoutError,
616-
options.operationName
617-
)
618-
);
619-
}
620-
waitQueueMember.reject(timeoutError);
621-
});
622-
623600
this[kWaitQueue].push(waitQueueMember);
624601
processWaitQueue(this);
625602

626-
return await serverPromise;
603+
try {
604+
return await Promise.race([serverPromise, waitQueueMember.timeout]);
605+
} catch (error) {
606+
if (TimeoutError.is(error)) {
607+
// Timeout
608+
waitQueueMember[kCancelled] = true;
609+
timeout.clear();
610+
const timeoutError = new MongoServerSelectionError(
611+
`Server selection timed out after ${options.serverSelectionTimeoutMS} ms`,
612+
this.description
613+
);
614+
if (
615+
this.client.mongoLogger?.willLog(
616+
MongoLoggableComponent.SERVER_SELECTION,
617+
SeverityLevel.DEBUG
618+
)
619+
) {
620+
this.client.mongoLogger?.debug(
621+
MongoLoggableComponent.SERVER_SELECTION,
622+
new ServerSelectionFailedEvent(
623+
selector,
624+
this.description,
625+
timeoutError,
626+
options.operationName
627+
)
628+
);
629+
}
630+
631+
throw timeoutError;
632+
}
633+
// Other server selection error
634+
throw error;
635+
}
627636
}
628637
/**
629638
* Update the internal TopologyDescription with a ServerDescription
@@ -880,7 +889,7 @@ function drainWaitQueue(queue: List<ServerSelectionRequest>, drainError: MongoDr
880889
continue;
881890
}
882891

883-
waitQueueMember.timeoutController.clear();
892+
waitQueueMember.timeout.clear();
884893

885894
if (!waitQueueMember[kCancelled]) {
886895
if (
@@ -935,7 +944,7 @@ function processWaitQueue(topology: Topology) {
935944
)
936945
: serverDescriptions;
937946
} catch (selectorError) {
938-
waitQueueMember.timeoutController.clear();
947+
waitQueueMember.timeout.clear();
939948
if (
940949
topology.client.mongoLogger?.willLog(
941950
MongoLoggableComponent.SERVER_SELECTION,
@@ -1023,7 +1032,7 @@ function processWaitQueue(topology: Topology) {
10231032
transaction.pinServer(selectedServer);
10241033
}
10251034

1026-
waitQueueMember.timeoutController.clear();
1035+
waitQueueMember.timeout.clear();
10271036

10281037
if (
10291038
topology.client.mongoLogger?.willLog(

src/timeout.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { clearTimeout, setTimeout } from 'timers';
2+
3+
import { MongoInvalidArgumentError } from './error';
4+
import { noop } from './utils';
5+
6+
/** @internal */
7+
export class TimeoutError extends Error {
8+
override get name(): 'TimeoutError' {
9+
return 'TimeoutError';
10+
}
11+
12+
constructor(message: string, options?: { cause?: Error }) {
13+
super(message, options);
14+
}
15+
16+
static is(error: unknown): error is TimeoutError {
17+
return (
18+
error != null && typeof error === 'object' && 'name' in error && error.name === 'TimeoutError'
19+
);
20+
}
21+
}
22+
23+
type Executor = ConstructorParameters<typeof Promise<never>>[0];
24+
type Reject = Parameters<ConstructorParameters<typeof Promise<never>>[0]>[1];
25+
/**
26+
* @internal
27+
* This class is an abstraction over timeouts
28+
* The Timeout class can only be in the pending or rejected states. It is guaranteed not to resolve
29+
* if interacted with exclusively through its public API
30+
* */
31+
export class Timeout extends Promise<never> {
32+
get [Symbol.toStringTag](): 'MongoDBTimeout' {
33+
return 'MongoDBTimeout';
34+
}
35+
36+
private timeoutError: TimeoutError;
37+
private id?: NodeJS.Timeout;
38+
39+
public readonly start: number;
40+
public ended: number | null = null;
41+
public duration: number;
42+
public timedOut = false;
43+
44+
/** Create a new timeout that expires in `duration` ms */
45+
private constructor(executor: Executor = () => null, duration: number) {
46+
let reject!: Reject;
47+
48+
if (duration < 0) {
49+
throw new MongoInvalidArgumentError('Cannot create a Timeout with a negative duration');
50+
}
51+
52+
super((_, promiseReject) => {
53+
reject = promiseReject;
54+
55+
executor(noop, promiseReject);
56+
});
57+
58+
// NOTE: Construct timeout error at point of Timeout instantiation to preserve stack traces
59+
this.timeoutError = new TimeoutError(`Expired after ${duration}ms`);
60+
61+
this.duration = duration;
62+
this.start = Math.trunc(performance.now());
63+
64+
if (this.duration > 0) {
65+
this.id = setTimeout(() => {
66+
this.ended = Math.trunc(performance.now());
67+
this.timedOut = true;
68+
reject(this.timeoutError);
69+
}, this.duration);
70+
// Ensure we do not keep the Node.js event loop running
71+
if (typeof this.id.unref === 'function') {
72+
this.id.unref();
73+
}
74+
}
75+
}
76+
77+
/**
78+
* Clears the underlying timeout. This method is idempotent
79+
*/
80+
clear(): void {
81+
clearTimeout(this.id);
82+
this.id = undefined;
83+
}
84+
85+
public static expires(durationMS: number): Timeout {
86+
return new Timeout(undefined, durationMS);
87+
}
88+
89+
static is(timeout: unknown): timeout is Timeout {
90+
return (
91+
typeof timeout === 'object' &&
92+
timeout != null &&
93+
Symbol.toStringTag in timeout &&
94+
timeout[Symbol.toStringTag] === 'MongoDBTimeout' &&
95+
'then' in timeout &&
96+
// eslint-disable-next-line github/no-then
97+
typeof timeout.then === 'function'
98+
);
99+
}
100+
}

0 commit comments

Comments
 (0)