Skip to content

Commit 8a9c108

Browse files
authored
fix(ChangeStream): whitelist change stream resumable errors
- Changes which errors are considered resumable on change streams, adding support for the new ResumableChangeStreamError label. - Removes ElectionInProgress (216) from ResumableChangeStreamError. - Updates ChangeStream prose tests which described startAfter behavior for unsupported server versions. - Fixes use of startAfter/resumeAfter when resuming from an invalidate event. Implement prose tests #17 and #18. NODE-2478 NODE-2522
1 parent c5d60fc commit 8a9c108

16 files changed

+6088
-714
lines changed

.travis.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ language: node_js
44
branches:
55
only:
66
- master
7-
- next
7+
- 3.6
88

99
before_install:
1010
# we have to intstall mongo-orchestration ourselves to get around permissions issues in subshells

lib/change_stream.js

+34-26
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,9 @@ class ChangeStreamCursor extends Cursor {
288288
['resumeAfter', 'startAfter', 'startAtOperationTime'].forEach(key => delete result[key]);
289289

290290
if (this.resumeToken) {
291-
result.resumeAfter = this.resumeToken;
291+
const resumeKey =
292+
this.options.startAfter && !this.hasReceived ? 'startAfter' : 'resumeAfter';
293+
result[resumeKey] = this.resumeToken;
292294
} else if (this.startAtOperationTime && maxWireVersion(this.server) >= 7) {
293295
result.startAtOperationTime = this.startAtOperationTime;
294296
}
@@ -297,6 +299,26 @@ class ChangeStreamCursor extends Cursor {
297299
return result;
298300
}
299301

302+
cacheResumeToken(resumeToken) {
303+
if (this.bufferedCount() === 0 && this.cursorState.postBatchResumeToken) {
304+
this.resumeToken = this.cursorState.postBatchResumeToken;
305+
} else {
306+
this.resumeToken = resumeToken;
307+
}
308+
this.hasReceived = true;
309+
}
310+
311+
_processBatch(batchName, response) {
312+
const cursor = response.cursor;
313+
if (cursor.postBatchResumeToken) {
314+
this.cursorState.postBatchResumeToken = cursor.postBatchResumeToken;
315+
316+
if (cursor[batchName].length === 0) {
317+
this.resumeToken = cursor.postBatchResumeToken;
318+
}
319+
}
320+
}
321+
300322
_initializeCursor(callback) {
301323
super._initializeCursor((err, result) => {
302324
if (err) {
@@ -315,15 +337,9 @@ class ChangeStreamCursor extends Cursor {
315337
this.startAtOperationTime = response.operationTime;
316338
}
317339

318-
const cursor = response.cursor;
319-
if (cursor.postBatchResumeToken) {
320-
this.cursorState.postBatchResumeToken = cursor.postBatchResumeToken;
321-
322-
if (cursor.firstBatch.length === 0) {
323-
this.resumeToken = cursor.postBatchResumeToken;
324-
}
325-
}
340+
this._processBatch('firstBatch', response);
326341

342+
this.emit('init', result);
327343
this.emit('response');
328344
callback(err, result);
329345
});
@@ -336,15 +352,9 @@ class ChangeStreamCursor extends Cursor {
336352
return;
337353
}
338354

339-
const cursor = response.cursor;
340-
if (cursor.postBatchResumeToken) {
341-
this.cursorState.postBatchResumeToken = cursor.postBatchResumeToken;
342-
343-
if (cursor.nextBatch.length === 0) {
344-
this.resumeToken = cursor.postBatchResumeToken;
345-
}
346-
}
355+
this._processBatch('nextBatch', response);
347356

357+
this.emit('more', response);
348358
this.emit('response');
349359
callback(err, response);
350360
});
@@ -367,6 +377,7 @@ function createChangeStreamCursor(self, options) {
367377

368378
const pipeline = [{ $changeStream: changeStreamStageOptions }].concat(self.pipeline);
369379
const cursorOptions = applyKnownOptions({}, options, CURSOR_OPTIONS);
380+
370381
const changeStreamCursor = new ChangeStreamCursor(
371382
self.topology,
372383
new AggregateOperation(self.parent, pipeline, options),
@@ -465,9 +476,10 @@ function processNewChange(args) {
465476
const change = args.change;
466477
const callback = args.callback;
467478
const eventEmitter = args.eventEmitter || false;
479+
const cursor = changeStream.cursor;
468480

469-
// If the changeStream is closed, then it should not process a change.
470-
if (changeStream.isClosed()) {
481+
// If the cursor is null, then it should not process a change.
482+
if (cursor == null) {
471483
// We do not error in the eventEmitter case.
472484
if (eventEmitter) {
473485
return;
@@ -479,12 +491,12 @@ function processNewChange(args) {
479491
: changeStream.promiseLibrary.reject(error);
480492
}
481493

482-
const cursor = changeStream.cursor;
483494
const topology = changeStream.topology;
484495
const options = changeStream.cursor.options;
496+
const wireVersion = maxWireVersion(cursor.server);
485497

486498
if (error) {
487-
if (isResumableError(error) && !changeStream.attemptingResume) {
499+
if (isResumableError(error, wireVersion) && !changeStream.attemptingResume) {
488500
changeStream.attemptingResume = true;
489501

490502
// stop listening to all events from old cursor
@@ -550,11 +562,7 @@ function processNewChange(args) {
550562
}
551563

552564
// cache the resume token
553-
if (cursor.bufferedCount() === 0 && cursor.cursorState.postBatchResumeToken) {
554-
cursor.resumeToken = cursor.cursorState.postBatchResumeToken;
555-
} else {
556-
cursor.resumeToken = change._id;
557-
}
565+
cursor.cacheResumeToken(change._id);
558566

559567
// wipe the startAtOperationTime if there was one so that there won't be a conflict
560568
// between resumeToken and startAtOperationTime if we need to reconnect the cursor

lib/core/utils.js

+13-11
Original file line numberDiff line numberDiff line change
@@ -83,22 +83,24 @@ function retrieveEJSON() {
8383
* @param {(Topology|Server)} topologyOrServer
8484
*/
8585
function maxWireVersion(topologyOrServer) {
86-
if (topologyOrServer.ismaster) {
87-
return topologyOrServer.ismaster.maxWireVersion;
88-
}
86+
if (topologyOrServer) {
87+
if (topologyOrServer.ismaster) {
88+
return topologyOrServer.ismaster.maxWireVersion;
89+
}
8990

90-
if (typeof topologyOrServer.lastIsMaster === 'function') {
91-
const lastIsMaster = topologyOrServer.lastIsMaster();
92-
if (lastIsMaster) {
93-
return lastIsMaster.maxWireVersion;
91+
if (typeof topologyOrServer.lastIsMaster === 'function') {
92+
const lastIsMaster = topologyOrServer.lastIsMaster();
93+
if (lastIsMaster) {
94+
return lastIsMaster.maxWireVersion;
95+
}
9496
}
95-
}
9697

97-
if (topologyOrServer.description) {
98-
return topologyOrServer.description.maxWireVersion;
98+
if (topologyOrServer.description) {
99+
return topologyOrServer.description.maxWireVersion;
100+
}
99101
}
100102

101-
return null;
103+
return 0;
102104
}
103105

104106
/*

lib/error.js

+29-16
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,34 @@ const GET_MORE_NON_RESUMABLE_CODES = new Set([
99
11601 // Interrupted
1010
]);
1111

12-
// From spec@https://github.com./mongodb/specifications/blob/7a2e93d85935ee4b1046a8d2ad3514c657dc74fa/source/change-streams/change-streams.rst#resumable-error:
13-
//
14-
// An error is considered resumable if it meets any of the following criteria:
15-
// - any error encountered which is not a server error (e.g. a timeout error or network error)
16-
// - any server error response from a getMore command excluding those containing the error label
17-
// NonRetryableChangeStreamError and those containing the following error codes:
18-
// - Interrupted: 11601
19-
// - CappedPositionLost: 136
20-
// - CursorKilled: 237
21-
//
22-
// An error on an aggregate command is not a resumable error. Only errors on a getMore command may be considered resumable errors.
12+
// From spec@https://github.com./mongodb/specifications/blob/f93d78191f3db2898a59013a7ed5650352ef6da8/source/change-streams/change-streams.rst#resumable-error
13+
const GET_MORE_RESUMABLE_CODES = new Set([
14+
6, // HostUnreachable
15+
7, // HostNotFound
16+
89, // NetworkTimeout
17+
91, // ShutdownInProgress
18+
189, // PrimarySteppedDown
19+
262, // ExceededTimeLimit
20+
9001, // SocketException
21+
10107, // NotMaster
22+
11600, // InterruptedAtShutdown
23+
11602, // InterruptedDueToReplStateChange
24+
13435, // NotMasterNoSlaveOk
25+
13436, // NotMasterOrSecondary
26+
63, // StaleShardVersion
27+
150, // StaleEpoch
28+
13388, // StaleConfig
29+
234, // RetryChangeStream
30+
133 // FailedToSatisfyReadPreference
31+
]);
2332

2433
function isGetMoreError(error) {
2534
if (error[mongoErrorContextSymbol]) {
2635
return error[mongoErrorContextSymbol].isGetMore;
2736
}
2837
}
2938

30-
function isResumableError(error) {
39+
function isResumableError(error, wireVersion) {
3140
if (!isGetMoreError(error)) {
3241
return false;
3342
}
@@ -36,10 +45,14 @@ function isResumableError(error) {
3645
return true;
3746
}
3847

39-
return !(
40-
GET_MORE_NON_RESUMABLE_CODES.has(error.code) ||
41-
error.hasErrorLabel('NonRetryableChangeStreamError')
48+
if (wireVersion >= 9) {
49+
return error.hasErrorLabel('ResumableChangeStreamError');
50+
}
51+
52+
return (
53+
GET_MORE_RESUMABLE_CODES.has(error.code) &&
54+
!error.hasErrorLabel('NonResumableChangeStreamError')
4255
);
4356
}
4457

45-
module.exports = { GET_MORE_NON_RESUMABLE_CODES, isResumableError };
58+
module.exports = { GET_MORE_NON_RESUMABLE_CODES, GET_MORE_RESUMABLE_CODES, isResumableError };

0 commit comments

Comments
 (0)