Skip to content

Commit 0ed7cbf

Browse files
authored
fix(NODE-3810): delay timeout errors by one event loop tick (#3180)
1 parent 4e2f9bf commit 0ed7cbf

File tree

7 files changed

+411
-260
lines changed

7 files changed

+411
-260
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,4 @@ etc/docs/build
7777
!docs/*/lib
7878
!docs/**/*.png
7979
!docs/**/*.css
80-
!docs/**/*.js
80+
!docs/**/*.js

src/cmap/commands.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,7 @@ export class KillCursor {
458458
}
459459
}
460460

461+
/** @internal */
461462
export interface MessageHeader {
462463
length: number;
463464
requestId: number;
@@ -466,6 +467,7 @@ export interface MessageHeader {
466467
fromCompressed?: boolean;
467468
}
468469

470+
/** @internal */
469471
export interface OpResponseOptions extends BSONSerializeOptions {
470472
raw?: boolean;
471473
documentsReturnedIn?: string | null;
@@ -640,6 +642,7 @@ const OPTS_CHECKSUM_PRESENT = 1;
640642
const OPTS_MORE_TO_COME = 2;
641643
const OPTS_EXHAUST_ALLOWED = 1 << 16;
642644

645+
/** @internal */
643646
export interface OpMsgOptions {
644647
requestId: number;
645648
serializeFunctions: boolean;

src/cmap/connection.ts

Lines changed: 128 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ const kHello = Symbol('hello');
7575
const kAutoEncrypter = Symbol('autoEncrypter');
7676
/** @internal */
7777
const kFullResult = Symbol('fullResult');
78+
/** @internal */
79+
const kDelayedTimeoutId = Symbol('delayedTimeoutId');
7880

7981
/** @internal */
8082
export interface QueryOptions extends BSONSerializeOptions {
@@ -199,6 +201,9 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
199201
lastHelloMS?: number;
200202
serverApi?: ServerApi;
201203
helloOk?: boolean;
204+
205+
/**@internal */
206+
[kDelayedTimeoutId]: NodeJS.Timeout | null;
202207
/** @internal */
203208
[kDescription]: StreamDescription;
204209
/** @internal */
@@ -253,19 +258,21 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
253258
...options,
254259
maxBsonMessageSize: this.hello?.maxBsonMessageSize
255260
});
256-
this[kMessageStream].on('message', messageHandler(this));
257261
this[kStream] = stream;
258-
stream.on('error', () => {
262+
263+
this[kDelayedTimeoutId] = null;
264+
265+
this[kMessageStream].on('message', message => this.onMessage(message));
266+
this[kMessageStream].on('error', error => this.onError(error));
267+
this[kStream].on('close', () => this.onClose());
268+
this[kStream].on('timeout', () => this.onTimeout());
269+
this[kStream].on('error', () => {
259270
/* ignore errors, listen to `close` instead */
260271
});
261272

262-
this[kMessageStream].on('error', error => this.handleIssue({ destroy: error }));
263-
stream.on('close', () => this.handleIssue({ isClose: true }));
264-
stream.on('timeout', () => this.handleIssue({ isTimeout: true, destroy: true }));
265-
266273
// hook the message stream up to the passed in stream
267-
stream.pipe(this[kMessageStream]);
268-
this[kMessageStream].pipe(stream);
274+
this[kStream].pipe(this[kMessageStream]);
275+
this[kMessageStream].pipe(this[kStream]);
269276
}
270277

271278
get description(): StreamDescription {
@@ -317,40 +324,133 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
317324
this[kLastUseTime] = now();
318325
}
319326

320-
handleIssue(issue: { isTimeout?: boolean; isClose?: boolean; destroy?: boolean | Error }): void {
327+
onError(error: Error) {
321328
if (this.closed) {
322329
return;
323330
}
324331

325-
if (issue.destroy) {
326-
this[kStream].destroy(typeof issue.destroy === 'boolean' ? undefined : issue.destroy);
332+
this[kStream].destroy(error);
333+
334+
this.closed = true;
335+
336+
for (const op of this[kQueue].values()) {
337+
op.cb(error);
338+
}
339+
340+
this[kQueue].clear();
341+
this.emit(Connection.CLOSE);
342+
}
343+
344+
onClose() {
345+
if (this.closed) {
346+
return;
327347
}
328348

329349
this.closed = true;
330350

331-
for (const [, op] of this[kQueue]) {
332-
if (issue.isTimeout) {
333-
op.cb(
334-
new MongoNetworkTimeoutError(`connection ${this.id} to ${this.address} timed out`, {
335-
beforeHandshake: this.hello == null
336-
})
337-
);
338-
} else if (issue.isClose) {
339-
op.cb(new MongoNetworkError(`connection ${this.id} to ${this.address} closed`));
340-
} else {
341-
op.cb(typeof issue.destroy === 'boolean' ? undefined : issue.destroy);
342-
}
351+
const message = `connection ${this.id} to ${this.address} closed`;
352+
for (const op of this[kQueue].values()) {
353+
op.cb(new MongoNetworkError(message));
343354
}
344355

345356
this[kQueue].clear();
346357
this.emit(Connection.CLOSE);
347358
}
348359

349-
destroy(): void;
350-
destroy(callback: Callback): void;
351-
destroy(options: DestroyOptions): void;
352-
destroy(options: DestroyOptions, callback: Callback): void;
353-
destroy(options?: DestroyOptions | Callback, callback?: Callback): void {
360+
onTimeout() {
361+
if (this.closed) {
362+
return;
363+
}
364+
365+
this[kDelayedTimeoutId] = setTimeout(() => {
366+
this[kStream].destroy();
367+
368+
this.closed = true;
369+
370+
const message = `connection ${this.id} to ${this.address} timed out`;
371+
const beforeHandshake = this.hello == null;
372+
for (const op of this[kQueue].values()) {
373+
op.cb(new MongoNetworkTimeoutError(message, { beforeHandshake }));
374+
}
375+
376+
this[kQueue].clear();
377+
this.emit(Connection.CLOSE);
378+
}, 1).unref(); // No need for this timer to hold the event loop open
379+
}
380+
381+
onMessage(message: BinMsg | Response) {
382+
const delayedTimeoutId = this[kDelayedTimeoutId];
383+
if (delayedTimeoutId != null) {
384+
clearTimeout(delayedTimeoutId);
385+
this[kDelayedTimeoutId] = null;
386+
}
387+
388+
// always emit the message, in case we are streaming
389+
this.emit('message', message);
390+
const operationDescription = this[kQueue].get(message.responseTo);
391+
if (!operationDescription) {
392+
return;
393+
}
394+
395+
const callback = operationDescription.cb;
396+
397+
// SERVER-45775: For exhaust responses we should be able to use the same requestId to
398+
// track response, however the server currently synthetically produces remote requests
399+
// making the `responseTo` change on each response
400+
this[kQueue].delete(message.responseTo);
401+
if ('moreToCome' in message && message.moreToCome) {
402+
// requeue the callback for next synthetic request
403+
this[kQueue].set(message.requestId, operationDescription);
404+
} else if (operationDescription.socketTimeoutOverride) {
405+
this[kStream].setTimeout(this.socketTimeoutMS);
406+
}
407+
408+
try {
409+
// Pass in the entire description because it has BSON parsing options
410+
message.parse(operationDescription);
411+
} catch (err) {
412+
// If this error is generated by our own code, it will already have the correct class applied
413+
// if it is not, then it is coming from a catastrophic data parse failure or the BSON library
414+
// in either case, it should not be wrapped
415+
callback(err);
416+
return;
417+
}
418+
419+
if (message.documents[0]) {
420+
const document: Document = message.documents[0];
421+
const session = operationDescription.session;
422+
if (session) {
423+
updateSessionFromResponse(session, document);
424+
}
425+
426+
if (document.$clusterTime) {
427+
this[kClusterTime] = document.$clusterTime;
428+
this.emit(Connection.CLUSTER_TIME_RECEIVED, document.$clusterTime);
429+
}
430+
431+
if (operationDescription.command) {
432+
if (document.writeConcernError) {
433+
callback(new MongoWriteConcernError(document.writeConcernError, document));
434+
return;
435+
}
436+
437+
if (document.ok === 0 || document.$err || document.errmsg || document.code) {
438+
callback(new MongoServerError(document));
439+
return;
440+
}
441+
} else {
442+
// Pre 3.2 support
443+
if (document.ok === 0 || document.$err || document.errmsg) {
444+
callback(new MongoServerError(document));
445+
return;
446+
}
447+
}
448+
}
449+
450+
callback(undefined, operationDescription.fullResult ? message : message.documents[0]);
451+
}
452+
453+
destroy(options?: DestroyOptions, callback?: Callback): void {
354454
if (typeof options === 'function') {
355455
callback = options;
356456
options = { force: false };
@@ -387,7 +487,6 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
387487
});
388488
}
389489

390-
/** @internal */
391490
command(
392491
ns: MongoDBNamespace,
393492
cmd: Document,
@@ -464,7 +563,6 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
464563
}
465564
}
466565

467-
/** @internal */
468566
query(ns: MongoDBNamespace, cmd: Document, options: QueryOptions, callback: Callback): void {
469567
const isExplain = cmd.$explain != null;
470568
const readPreference = options.readPreference ?? ReadPreference.primary;
@@ -537,7 +635,6 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
537635
);
538636
}
539637

540-
/** @internal */
541638
getMore(
542639
ns: MongoDBNamespace,
543640
cursorId: Long,
@@ -599,7 +696,6 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
599696
this.command(ns, getMoreCmd, commandOptions, callback);
600697
}
601698

602-
/** @internal */
603699
killCursors(
604700
ns: MongoDBNamespace,
605701
cursorIds: Long[],
@@ -719,74 +815,6 @@ function supportsOpMsg(conn: Connection) {
719815
return maxWireVersion(conn) >= 6 && !description.__nodejs_mock_server__;
720816
}
721817

722-
function messageHandler(conn: Connection) {
723-
return function messageHandler(message: BinMsg | Response) {
724-
// always emit the message, in case we are streaming
725-
conn.emit('message', message);
726-
const operationDescription = conn[kQueue].get(message.responseTo);
727-
if (!operationDescription) {
728-
return;
729-
}
730-
731-
const callback = operationDescription.cb;
732-
733-
// SERVER-45775: For exhaust responses we should be able to use the same requestId to
734-
// track response, however the server currently synthetically produces remote requests
735-
// making the `responseTo` change on each response
736-
conn[kQueue].delete(message.responseTo);
737-
if ('moreToCome' in message && message.moreToCome) {
738-
// requeue the callback for next synthetic request
739-
conn[kQueue].set(message.requestId, operationDescription);
740-
} else if (operationDescription.socketTimeoutOverride) {
741-
conn[kStream].setTimeout(conn.socketTimeoutMS);
742-
}
743-
744-
try {
745-
// Pass in the entire description because it has BSON parsing options
746-
message.parse(operationDescription);
747-
} catch (err) {
748-
// If this error is generated by our own code, it will already have the correct class applied
749-
// if it is not, then it is coming from a catastrophic data parse failure or the BSON library
750-
// in either case, it should not be wrapped
751-
callback(err);
752-
return;
753-
}
754-
755-
if (message.documents[0]) {
756-
const document: Document = message.documents[0];
757-
const session = operationDescription.session;
758-
if (session) {
759-
updateSessionFromResponse(session, document);
760-
}
761-
762-
if (document.$clusterTime) {
763-
conn[kClusterTime] = document.$clusterTime;
764-
conn.emit(Connection.CLUSTER_TIME_RECEIVED, document.$clusterTime);
765-
}
766-
767-
if (operationDescription.command) {
768-
if (document.writeConcernError) {
769-
callback(new MongoWriteConcernError(document.writeConcernError, document));
770-
return;
771-
}
772-
773-
if (document.ok === 0 || document.$err || document.errmsg || document.code) {
774-
callback(new MongoServerError(document));
775-
return;
776-
}
777-
} else {
778-
// Pre 3.2 support
779-
if (document.ok === 0 || document.$err || document.errmsg) {
780-
callback(new MongoServerError(document));
781-
return;
782-
}
783-
}
784-
}
785-
786-
callback(undefined, operationDescription.fullResult ? message : message.documents[0]);
787-
};
788-
}
789-
790818
function streamIdentifier(stream: Stream, options: ConnectionOptions): string {
791819
if (options.proxyHost) {
792820
// If proxy options are specified, the properties of `stream` itself

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,12 +186,17 @@ export type {
186186
MongoCredentialsOptions
187187
} from './cmap/auth/mongo_credentials';
188188
export type {
189+
BinMsg,
189190
GetMore,
190191
KillCursor,
192+
MessageHeader,
191193
Msg,
192194
OpGetMoreOptions,
195+
OpMsgOptions,
193196
OpQueryOptions,
197+
OpResponseOptions,
194198
Query,
199+
Response,
195200
WriteProtocolMessageType
196201
} from './cmap/commands';
197202
export type { LEGAL_TCP_SOCKET_OPTIONS, LEGAL_TLS_SOCKET_OPTIONS, Stream } from './cmap/connect';

test/tools/runner/hooks/configuration.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,14 @@ async function initializeFilters(client) {
6666
}
6767

6868
const testSkipBeforeEachHook = async function () {
69-
// `metadata` always exists, `requires` is optional
70-
const requires = this.currentTest.metadata.requires;
69+
const metadata = this.currentTest.metadata;
7170

72-
if (requires && Object.keys(requires).length > 0) {
71+
if (metadata && metadata.requires && Object.keys(metadata.requires).length > 0) {
7372
const failedFilter = filters.find(filter => !filter.filter(this.currentTest));
7473

7574
if (failedFilter) {
7675
const filterName = failedFilter.constructor.name;
77-
const metadataString = inspect(requires, {
76+
const metadataString = inspect(metadata.requires, {
7877
colors: true,
7978
compact: true,
8079
depth: 10,

0 commit comments

Comments
 (0)