Skip to content

Commit db90293

Browse files
refactor(NODE-5675): refactor server selection and connection checkout to use abort signals for timeout management (#3890)
1 parent 4ff8080 commit db90293

File tree

5 files changed

+138
-57
lines changed

5 files changed

+138
-57
lines changed

src/cmap/connection_pool.ts

Lines changed: 26 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
} from '../error';
2828
import { CancellationToken, TypedEventEmitter } from '../mongo_types';
2929
import type { Server } from '../sdam/server';
30-
import { type Callback, eachAsync, List, makeCounter } from '../utils';
30+
import { type Callback, eachAsync, List, makeCounter, TimeoutController } from '../utils';
3131
import { AUTH_PROVIDERS, connect } from './connect';
3232
import { Connection, type ConnectionEvents, type ConnectionOptions } from './connection';
3333
import {
@@ -101,7 +101,7 @@ export interface ConnectionPoolOptions extends Omit<ConnectionOptions, 'id' | 'g
101101
/** @internal */
102102
export interface WaitQueueMember {
103103
callback: Callback<Connection>;
104-
timer?: NodeJS.Timeout;
104+
timeoutController: TimeoutController;
105105
[kCancelled]?: boolean;
106106
}
107107

@@ -356,27 +356,29 @@ export class ConnectionPool extends TypedEventEmitter<ConnectionPoolEvents> {
356356
new ConnectionCheckOutStartedEvent(this)
357357
);
358358

359-
const waitQueueMember: WaitQueueMember = { callback };
360359
const waitQueueTimeoutMS = this.options.waitQueueTimeoutMS;
361-
if (waitQueueTimeoutMS) {
362-
waitQueueMember.timer = setTimeout(() => {
363-
waitQueueMember[kCancelled] = true;
364-
waitQueueMember.timer = undefined;
365360

366-
this.emitAndLog(
367-
ConnectionPool.CONNECTION_CHECK_OUT_FAILED,
368-
new ConnectionCheckOutFailedEvent(this, 'timeout')
369-
);
370-
waitQueueMember.callback(
371-
new WaitQueueTimeoutError(
372-
this.loadBalanced
373-
? this.waitQueueErrorMetrics()
374-
: 'Timed out while checking out a connection from connection pool',
375-
this.address
376-
)
377-
);
378-
}, waitQueueTimeoutMS);
379-
}
361+
const waitQueueMember: WaitQueueMember = {
362+
callback,
363+
timeoutController: new TimeoutController(waitQueueTimeoutMS)
364+
};
365+
waitQueueMember.timeoutController.signal.addEventListener('abort', () => {
366+
waitQueueMember[kCancelled] = true;
367+
waitQueueMember.timeoutController.clear();
368+
369+
this.emitAndLog(
370+
ConnectionPool.CONNECTION_CHECK_OUT_FAILED,
371+
new ConnectionCheckOutFailedEvent(this, 'timeout')
372+
);
373+
waitQueueMember.callback(
374+
new WaitQueueTimeoutError(
375+
this.loadBalanced
376+
? this.waitQueueErrorMetrics()
377+
: 'Timed out while checking out a connection from connection pool',
378+
this.address
379+
)
380+
);
381+
});
380382

381383
this[kWaitQueue].push(waitQueueMember);
382384
process.nextTick(() => this.processWaitQueue());
@@ -831,9 +833,7 @@ export class ConnectionPool extends TypedEventEmitter<ConnectionPoolEvents> {
831833
ConnectionPool.CONNECTION_CHECK_OUT_FAILED,
832834
new ConnectionCheckOutFailedEvent(this, reason, error)
833835
);
834-
if (waitQueueMember.timer) {
835-
clearTimeout(waitQueueMember.timer);
836-
}
836+
waitQueueMember.timeoutController.clear();
837837
this[kWaitQueue].shift();
838838
waitQueueMember.callback(error);
839839
continue;
@@ -854,9 +854,7 @@ export class ConnectionPool extends TypedEventEmitter<ConnectionPoolEvents> {
854854
ConnectionPool.CONNECTION_CHECKED_OUT,
855855
new ConnectionCheckedOutEvent(this, connection)
856856
);
857-
if (waitQueueMember.timer) {
858-
clearTimeout(waitQueueMember.timer);
859-
}
857+
waitQueueMember.timeoutController.clear();
860858

861859
this[kWaitQueue].shift();
862860
waitQueueMember.callback(undefined, connection);
@@ -893,9 +891,7 @@ export class ConnectionPool extends TypedEventEmitter<ConnectionPoolEvents> {
893891
);
894892
}
895893

896-
if (waitQueueMember.timer) {
897-
clearTimeout(waitQueueMember.timer);
898-
}
894+
waitQueueMember.timeoutController.clear();
899895
waitQueueMember.callback(err, connection);
900896
}
901897
process.nextTick(() => this.processWaitQueue());

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,7 @@ export type {
524524
HostAddress,
525525
List,
526526
MongoDBCollectionNamespace,
527-
MongoDBNamespace
527+
MongoDBNamespace,
528+
TimeoutController
528529
} from './utils';
529530
export type { W, WriteConcernOptions, WriteConcernSettings } from './write_concern';

src/sdam/topology.ts

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { clearTimeout, setTimeout } from 'timers';
21
import { promisify } from 'util';
32

43
import type { BSONSerializeOptions, Document } from '../bson';
@@ -43,7 +42,8 @@ import {
4342
List,
4443
makeStateMachine,
4544
ns,
46-
shuffle
45+
shuffle,
46+
TimeoutController
4747
} from '../utils';
4848
import {
4949
_advanceClusterTime,
@@ -94,8 +94,8 @@ export interface ServerSelectionRequest {
9494
serverSelector: ServerSelector;
9595
transaction?: Transaction;
9696
callback: ServerSelectionCallback;
97-
timer?: NodeJS.Timeout;
9897
[kCancelled]?: boolean;
98+
timeoutController: TimeoutController;
9999
}
100100

101101
/** @internal */
@@ -556,22 +556,20 @@ export class Topology extends TypedEventEmitter<TopologyEvents> {
556556
const waitQueueMember: ServerSelectionRequest = {
557557
serverSelector,
558558
transaction,
559-
callback
559+
callback,
560+
timeoutController: new TimeoutController(options.serverSelectionTimeoutMS)
560561
};
561562

562-
const serverSelectionTimeoutMS = options.serverSelectionTimeoutMS;
563-
if (serverSelectionTimeoutMS) {
564-
waitQueueMember.timer = setTimeout(() => {
565-
waitQueueMember[kCancelled] = true;
566-
waitQueueMember.timer = undefined;
567-
const timeoutError = new MongoServerSelectionError(
568-
`Server selection timed out after ${serverSelectionTimeoutMS} ms`,
569-
this.description
570-
);
563+
waitQueueMember.timeoutController.signal.addEventListener('abort', () => {
564+
waitQueueMember[kCancelled] = true;
565+
waitQueueMember.timeoutController.clear();
566+
const timeoutError = new MongoServerSelectionError(
567+
`Server selection timed out after ${options.serverSelectionTimeoutMS} ms`,
568+
this.description
569+
);
571570

572-
waitQueueMember.callback(timeoutError);
573-
}, serverSelectionTimeoutMS);
574-
}
571+
waitQueueMember.callback(timeoutError);
572+
});
575573

576574
this[kWaitQueue].push(waitQueueMember);
577575
processWaitQueue(this);
@@ -842,9 +840,7 @@ function drainWaitQueue(queue: List<ServerSelectionRequest>, err?: MongoDriverEr
842840
continue;
843841
}
844842

845-
if (waitQueueMember.timer) {
846-
clearTimeout(waitQueueMember.timer);
847-
}
843+
waitQueueMember.timeoutController.clear();
848844

849845
if (!waitQueueMember[kCancelled]) {
850846
waitQueueMember.callback(err);
@@ -878,9 +874,7 @@ function processWaitQueue(topology: Topology) {
878874
? serverSelector(topology.description, serverDescriptions)
879875
: serverDescriptions;
880876
} catch (e) {
881-
if (waitQueueMember.timer) {
882-
clearTimeout(waitQueueMember.timer);
883-
}
877+
waitQueueMember.timeoutController.clear();
884878

885879
waitQueueMember.callback(e);
886880
continue;
@@ -917,9 +911,7 @@ function processWaitQueue(topology: Topology) {
917911
transaction.pinServer(selectedServer);
918912
}
919913

920-
if (waitQueueMember.timer) {
921-
clearTimeout(waitQueueMember.timer);
922-
}
914+
waitQueueMember.timeoutController.clear();
923915

924916
waitQueueMember.callback(undefined, selectedServer);
925917
}

src/utils.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as crypto from 'crypto';
22
import type { SrvRecord } from 'dns';
33
import * as http from 'http';
4+
import { clearTimeout, setTimeout } from 'timers';
45
import * as url from 'url';
56
import { URL } from 'url';
67

@@ -1254,3 +1255,30 @@ export async function request(
12541255
req.end();
12551256
});
12561257
}
1258+
1259+
/**
1260+
* A custom AbortController that aborts after a specified timeout.
1261+
*
1262+
* If `timeout` is undefined or \<=0, the abort controller never aborts.
1263+
*
1264+
* This class provides two benefits over the built-in AbortSignal.timeout() method.
1265+
* - This class provides a mechanism for cancelling the timeout
1266+
* - This class supports infinite timeouts by interpreting a timeout of 0 as infinite. This is
1267+
* consistent with existing timeout options in the Node driver (serverSelectionTimeoutMS, for example).
1268+
* @internal
1269+
*/
1270+
export class TimeoutController extends AbortController {
1271+
constructor(
1272+
timeout = 0,
1273+
private timeoutId = timeout > 0 ? setTimeout(() => this.abort(), timeout) : null
1274+
) {
1275+
super();
1276+
}
1277+
1278+
clear() {
1279+
if (this.timeoutId != null) {
1280+
clearTimeout(this.timeoutId);
1281+
}
1282+
this.timeoutId = null;
1283+
}
1284+
}

test/unit/utils.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { expect } from 'chai';
2+
import * as sinon from 'sinon';
23

34
import {
45
BufferPool,
@@ -16,8 +17,10 @@ import {
1617
MongoDBNamespace,
1718
MongoRuntimeError,
1819
ObjectId,
19-
shuffle
20+
shuffle,
21+
TimeoutController
2022
} from '../mongodb';
23+
import { createTimerSandbox } from './timer_sandbox';
2124

2225
describe('driver utils', function () {
2326
describe('.hostMatchesWildcards', function () {
@@ -1101,4 +1104,65 @@ describe('driver utils', function () {
11011104
});
11021105
});
11031106
});
1107+
1108+
describe('class TimeoutController', () => {
1109+
let timerSandbox, clock, spy;
1110+
1111+
beforeEach(function () {
1112+
timerSandbox = createTimerSandbox();
1113+
clock = sinon.useFakeTimers();
1114+
spy = sinon.spy();
1115+
});
1116+
1117+
afterEach(function () {
1118+
clock.restore();
1119+
timerSandbox.restore();
1120+
});
1121+
1122+
describe('constructor', () => {
1123+
it('when no timeout is provided, it creates an infinite timeout', () => {
1124+
const controller = new TimeoutController();
1125+
// @ts-expect-error Accessing a private field on TimeoutController
1126+
expect(controller.timeoutId).to.be.null;
1127+
});
1128+
1129+
it('when timeout is 0, it creates an infinite timeout', () => {
1130+
const controller = new TimeoutController(0);
1131+
// @ts-expect-error Accessing a private field on TimeoutController
1132+
expect(controller.timeoutId).to.be.null;
1133+
});
1134+
1135+
it('when timeout <0, it creates an infinite timeout', () => {
1136+
const controller = new TimeoutController(-5);
1137+
// @ts-expect-error Accessing a private field on TimeoutController
1138+
expect(controller.timeoutId).to.be.null;
1139+
});
1140+
1141+
context('when timeout > 0', () => {
1142+
let timeoutController: TimeoutController;
1143+
1144+
beforeEach(function () {
1145+
timeoutController = new TimeoutController(3000);
1146+
timeoutController.signal.addEventListener('abort', spy);
1147+
});
1148+
1149+
afterEach(function () {
1150+
timeoutController.clear();
1151+
});
1152+
1153+
it('it creates a timeout', () => {
1154+
// @ts-expect-error Accessing a private field on TimeoutController
1155+
expect(timeoutController.timeoutId).not.to.be.null;
1156+
});
1157+
1158+
it('times out after `timeout` milliseconds', () => {
1159+
expect(spy, 'spy was called after creation').not.to.have.been.called;
1160+
clock.tick(2999);
1161+
expect(spy, 'spy was called before 3000ms has expired').not.to.have.been.called;
1162+
clock.tick(1);
1163+
expect(spy, 'spy was not called after 3000ms').to.have.been.called;
1164+
});
1165+
});
1166+
});
1167+
});
11041168
});

0 commit comments

Comments
 (0)