-
Notifications
You must be signed in to change notification settings - Fork 1.8k
/
Copy pathchange_stream.ts
830 lines (722 loc) · 25.7 KB
/
change_stream.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
import Denque = require('denque');
import type { Readable } from 'stream';
import type { Document, Timestamp } from './bson';
import { Collection } from './collection';
import {
AbstractCursor,
AbstractCursorEvents,
AbstractCursorOptions,
CursorStreamOptions
} from './cursor/abstract_cursor';
import { Db } from './db';
import {
AnyError,
isResumableError,
MongoAPIError,
MongoChangeStreamError,
MongoError,
MongoRuntimeError
} from './error';
import { MongoClient } from './mongo_client';
import { InferIdType, Nullable, TypedEventEmitter } from './mongo_types';
import { AggregateOperation, AggregateOptions } from './operations/aggregate';
import type { CollationOptions, OperationParent } from './operations/command';
import { executeOperation, ExecutionResult } from './operations/execute_operation';
import type { ReadPreference } from './read_preference';
import type { Topology } from './sdam/topology';
import type { ClientSession } from './sessions';
import {
calculateDurationInMs,
Callback,
getTopology,
maxWireVersion,
maybePromise,
MongoDBNamespace,
now
} from './utils';
/** @internal */
const kResumeQueue = Symbol('resumeQueue');
/** @internal */
const kCursorStream = Symbol('cursorStream');
/** @internal */
const kClosed = Symbol('closed');
/** @internal */
const kMode = Symbol('mode');
const CHANGE_STREAM_OPTIONS = ['resumeAfter', 'startAfter', 'startAtOperationTime', 'fullDocument'];
const CURSOR_OPTIONS = ['batchSize', 'maxAwaitTimeMS', 'collation', 'readPreference'].concat(
CHANGE_STREAM_OPTIONS
);
const CHANGE_DOMAIN_TYPES = {
COLLECTION: Symbol('Collection'),
DATABASE: Symbol('Database'),
CLUSTER: Symbol('Cluster')
};
const NO_RESUME_TOKEN_ERROR =
'A change stream document has been received that lacks a resume token (_id).';
const NO_CURSOR_ERROR = 'ChangeStream has no cursor';
const CHANGESTREAM_CLOSED_ERROR = 'ChangeStream is closed';
/** @public */
export interface ResumeOptions {
startAtOperationTime?: Timestamp;
batchSize?: number;
maxAwaitTimeMS?: number;
collation?: CollationOptions;
readPreference?: ReadPreference;
}
/**
* Represents the logical starting point for a new or resuming {@link https://docs.mongodb.com/manual/changeStreams/#std-label-change-stream-resume| Change Stream} on the server.
* @public
*/
export type ResumeToken = unknown;
/**
* Represents a specific point in time on a server. Can be retrieved by using {@link Db#command}
* @public
* @remarks
* See {@link https://docs.mongodb.com/manual/reference/method/db.runCommand/#response| Run Command Response}
*/
export type OperationTime = Timestamp;
/** @public */
export interface PipeOptions {
end?: boolean;
}
/**
* Options that can be passed to a ChangeStream. Note that startAfter, resumeAfter, and startAtOperationTime are all mutually exclusive, and the server will error if more than one is specified.
* @public
*/
export interface ChangeStreamOptions extends AggregateOptions {
/** Allowed values: ‘default’, ‘updateLookup’. When set to ‘updateLookup’, the change stream will include both a delta describing the changes to the document, as well as a copy of the entire document that was changed from some time after the change occurred. */
fullDocument?: string;
/** The maximum amount of time for the server to wait on new documents to satisfy a change stream query. */
maxAwaitTimeMS?: number;
/** Allows you to start a changeStream after a specified event. See {@link https://docs.mongodb.com/manual/changeStreams/#resumeafter-for-change-streams|ChangeStream documentation}. */
resumeAfter?: ResumeToken;
/** Similar to resumeAfter, but will allow you to start after an invalidated event. See {@link https://docs.mongodb.com/manual/changeStreams/#startafter-for-change-streams|ChangeStream documentation}. */
startAfter?: ResumeToken;
/** Will start the changeStream after the specified operationTime. */
startAtOperationTime?: OperationTime;
/** The number of documents to return per batch. See {@link https://docs.mongodb.com/manual/reference/command/aggregate|aggregation documentation}. */
batchSize?: number;
}
/** @public */
export interface ChangeStreamDocument<TSchema extends Document = Document> {
/**
* The id functions as an opaque token for use when resuming an interrupted
* change stream.
*/
_id: InferIdType<TSchema>;
/**
* Describes the type of operation represented in this change notification.
*/
operationType:
| 'insert'
| 'update'
| 'replace'
| 'delete'
| 'invalidate'
| 'drop'
| 'dropDatabase'
| 'rename';
/**
* Contains two fields: “db” and “coll” containing the database and
* collection name in which the change happened.
*/
ns: { db: string; coll: string };
/**
* Only present for ops of type ‘insert’, ‘update’, ‘replace’, and
* ‘delete’.
*
* For unsharded collections this contains a single field, _id, with the
* value of the _id of the document updated. For sharded collections,
* this will contain all the components of the shard key in order,
* followed by the _id if the _id isn’t part of the shard key.
*/
documentKey?: InferIdType<TSchema>;
/**
* Only present for ops of type ‘update’.
*
* Contains a description of updated and removed fields in this
* operation.
*/
updateDescription?: UpdateDescription<TSchema>;
/**
* Always present for operations of type ‘insert’ and ‘replace’. Also
* present for operations of type ‘update’ if the user has specified ‘updateLookup’
* in the ‘fullDocument’ arguments to the ‘$changeStream’ stage.
*
* For operations of type ‘insert’ and ‘replace’, this key will contain the
* document being inserted, or the new version of the document that is replacing
* the existing document, respectively.
*
* For operations of type ‘update’, this key will contain a copy of the full
* version of the document from some point after the update occurred. If the
* document was deleted since the updated happened, it will be null.
*/
fullDocument?: TSchema;
}
/** @public */
export interface UpdateDescription<TSchema extends Document = Document> {
/**
* A document containing key:value pairs of names of the fields that were
* changed, and the new value for those fields.
*/
updatedFields: Partial<TSchema>;
/**
* An array of field names that were removed from the document.
*/
removedFields: string[];
}
/** @public */
export type ChangeStreamEvents<TSchema extends Document = Document> = {
resumeTokenChanged(token: ResumeToken): void;
init(response: TSchema): void;
more(response?: TSchema | undefined): void;
response(): void;
end(): void;
error(error: Error): void;
change(change: ChangeStreamDocument<TSchema>): void;
} & AbstractCursorEvents;
/**
* Creates a new Change Stream instance. Normally created using {@link Collection#watch|Collection.watch()}.
* @public
*/
export class ChangeStream<TSchema extends Document = Document> extends TypedEventEmitter<
ChangeStreamEvents<TSchema>
> {
pipeline: Document[];
options: ChangeStreamOptions;
parent: MongoClient | Db | Collection;
namespace: MongoDBNamespace;
type: symbol;
/** @internal */
cursor?: ChangeStreamCursor<TSchema>;
streamOptions?: CursorStreamOptions;
/** @internal */
[kResumeQueue]: Denque<Callback<ChangeStreamCursor<TSchema>>>;
/** @internal */
[kCursorStream]?: Readable;
/** @internal */
[kClosed]: boolean;
/** @internal */
[kMode]: false | 'iterator' | 'emitter';
/** @event */
static readonly RESPONSE = 'response' as const;
/** @event */
static readonly MORE = 'more' as const;
/** @event */
static readonly INIT = 'init' as const;
/** @event */
static readonly CLOSE = 'close' as const;
/**
* Fired for each new matching change in the specified namespace. Attaching a `change`
* event listener to a Change Stream will switch the stream into flowing mode. Data will
* then be passed as soon as it is available.
* @event
*/
static readonly CHANGE = 'change' as const;
/** @event */
static readonly END = 'end' as const;
/** @event */
static readonly ERROR = 'error' as const;
/**
* Emitted each time the change stream stores a new resume token.
* @event
*/
static readonly RESUME_TOKEN_CHANGED = 'resumeTokenChanged' as const;
/**
* @internal
*
* @param parent - The parent object that created this change stream
* @param pipeline - An array of {@link https://docs.mongodb.com/manual/reference/operator/aggregation-pipeline/|aggregation pipeline stages} through which to pass change stream documents
*/
constructor(
parent: OperationParent,
pipeline: Document[] = [],
options: ChangeStreamOptions = {}
) {
super();
this.pipeline = pipeline;
this.options = options;
if (parent instanceof Collection) {
this.type = CHANGE_DOMAIN_TYPES.COLLECTION;
} else if (parent instanceof Db) {
this.type = CHANGE_DOMAIN_TYPES.DATABASE;
} else if (parent instanceof MongoClient) {
this.type = CHANGE_DOMAIN_TYPES.CLUSTER;
} else {
throw new MongoChangeStreamError(
'Parent provided to ChangeStream constructor must be an instance of Collection, Db, or MongoClient'
);
}
this.parent = parent;
this.namespace = parent.s.namespace;
if (!this.options.readPreference && parent.readPreference) {
this.options.readPreference = parent.readPreference;
}
this[kResumeQueue] = new Denque();
// Create contained Change Stream cursor
this.cursor = createChangeStreamCursor(this, options);
this[kClosed] = false;
this[kMode] = false;
// Listen for any `change` listeners being added to ChangeStream
this.on('newListener', eventName => {
if (eventName === 'change' && this.cursor && this.listenerCount('change') === 0) {
streamEvents(this, this.cursor);
}
});
this.on('removeListener', eventName => {
if (eventName === 'change' && this.listenerCount('change') === 0 && this.cursor) {
this[kCursorStream]?.removeAllListeners('data');
}
});
}
/** @internal */
get cursorStream(): Readable | undefined {
return this[kCursorStream];
}
/** The cached resume token that is used to resume after the most recently returned change. */
get resumeToken(): ResumeToken {
return this.cursor?.resumeToken;
}
/** Check if there is any document still available in the Change Stream */
hasNext(): Promise<boolean>;
hasNext(callback: Callback<boolean>): void;
hasNext(callback?: Callback): Promise<boolean> | void {
setIsIterator(this);
return maybePromise(callback, cb => {
getCursor(this, (err, cursor) => {
if (err || !cursor) return cb(err); // failed to resume, raise an error
cursor.hasNext(cb);
});
});
}
/** Get the next available document from the Change Stream. */
next(): Promise<ChangeStreamDocument<TSchema>>;
next(callback: Callback<ChangeStreamDocument<TSchema>>): void;
next(
callback?: Callback<ChangeStreamDocument<TSchema>>
): Promise<ChangeStreamDocument<TSchema>> | void {
setIsIterator(this);
return maybePromise(callback, cb => {
getCursor(this, (err, cursor) => {
if (err || !cursor) return cb(err); // failed to resume, raise an error
cursor.next((error, change) => {
if (error) {
this[kResumeQueue].push(() => this.next(cb));
processError(this, error, cb);
return;
}
processNewChange<TSchema>(this, change, cb);
});
});
});
}
/** Is the cursor closed */
get closed(): boolean {
return this[kClosed] || (this.cursor?.closed ?? false);
}
/** Close the Change Stream */
close(callback?: Callback): Promise<void> | void {
this[kClosed] = true;
return maybePromise(callback, cb => {
if (!this.cursor) {
return cb();
}
const cursor = this.cursor;
return cursor.close(err => {
endStream(this);
this.cursor = undefined;
return cb(err);
});
});
}
/**
* Return a modified Readable stream including a possible transform method.
* @throws MongoDriverError if this.cursor is undefined
*/
stream(options?: CursorStreamOptions): Readable {
this.streamOptions = options;
if (!this.cursor) throw new MongoChangeStreamError(NO_CURSOR_ERROR);
return this.cursor.stream(options);
}
/**
* Try to get the next available document from the Change Stream's cursor or `null` if an empty batch is returned
*/
tryNext(): Promise<Document | null>;
tryNext(callback: Callback<Document | null>): void;
tryNext(callback?: Callback<Document | null>): Promise<Document | null> | void {
setIsIterator(this);
return maybePromise(callback, cb => {
getCursor(this, (err, cursor) => {
if (err || !cursor) return cb(err); // failed to resume, raise an error
return cursor.tryNext(cb);
});
});
}
}
/** @internal */
export interface ChangeStreamCursorOptions extends AbstractCursorOptions {
startAtOperationTime?: OperationTime;
resumeAfter?: ResumeToken;
startAfter?: boolean;
}
/** @internal */
export class ChangeStreamCursor<TSchema extends Document = Document> extends AbstractCursor<
ChangeStreamDocument<TSchema>,
ChangeStreamEvents
> {
_resumeToken: ResumeToken;
startAtOperationTime?: OperationTime;
hasReceived?: boolean;
resumeAfter: ResumeToken;
startAfter: ResumeToken;
options: ChangeStreamCursorOptions;
postBatchResumeToken?: ResumeToken;
pipeline: Document[];
constructor(
topology: Topology,
namespace: MongoDBNamespace,
pipeline: Document[] = [],
options: ChangeStreamCursorOptions = {}
) {
super(topology, namespace, options);
this.pipeline = pipeline;
this.options = options;
this._resumeToken = null;
this.startAtOperationTime = options.startAtOperationTime;
if (options.startAfter) {
this.resumeToken = options.startAfter;
} else if (options.resumeAfter) {
this.resumeToken = options.resumeAfter;
}
}
set resumeToken(token: ResumeToken) {
this._resumeToken = token;
this.emit(ChangeStream.RESUME_TOKEN_CHANGED, token);
}
get resumeToken(): ResumeToken {
return this._resumeToken;
}
get resumeOptions(): ResumeOptions {
const result = {} as ResumeOptions;
for (const optionName of CURSOR_OPTIONS) {
if (Reflect.has(this.options, optionName)) {
Reflect.set(result, optionName, Reflect.get(this.options, optionName));
}
}
if (this.resumeToken || this.startAtOperationTime) {
['resumeAfter', 'startAfter', 'startAtOperationTime'].forEach(key =>
Reflect.deleteProperty(result, key)
);
if (this.resumeToken) {
const resumeKey =
this.options.startAfter && !this.hasReceived ? 'startAfter' : 'resumeAfter';
Reflect.set(result, resumeKey, this.resumeToken);
} else if (this.startAtOperationTime && maxWireVersion(this.server) >= 7) {
result.startAtOperationTime = this.startAtOperationTime;
}
}
return result;
}
cacheResumeToken(resumeToken: ResumeToken): void {
if (this.bufferedCount() === 0 && this.postBatchResumeToken) {
this.resumeToken = this.postBatchResumeToken;
} else {
this.resumeToken = resumeToken;
}
this.hasReceived = true;
}
_processBatch(batchName: string, response?: Document): void {
const cursor = response?.cursor || {};
if (cursor.postBatchResumeToken) {
this.postBatchResumeToken = cursor.postBatchResumeToken;
if (cursor[batchName].length === 0) {
this.resumeToken = cursor.postBatchResumeToken;
}
}
}
clone(): AbstractCursor<ChangeStreamDocument<TSchema>> {
return new ChangeStreamCursor(this.topology, this.namespace, this.pipeline, {
...this.cursorOptions
});
}
_initialize(session: ClientSession, callback: Callback<ExecutionResult>): void {
const aggregateOperation = new AggregateOperation(this.namespace, this.pipeline, {
...this.cursorOptions,
...this.options,
session
});
executeOperation(this.topology, aggregateOperation, (err, response) => {
if (err || response == null) {
return callback(err);
}
const server = aggregateOperation.server;
if (
this.startAtOperationTime == null &&
this.resumeAfter == null &&
this.startAfter == null &&
maxWireVersion(server) >= 7
) {
this.startAtOperationTime = response.operationTime;
}
this._processBatch('firstBatch', response);
this.emit(ChangeStream.INIT, response);
this.emit(ChangeStream.RESPONSE);
// TODO: NODE-2882
callback(undefined, { server, session, response });
});
}
_getMore(batchSize: number, callback: Callback): void {
super._getMore(batchSize, (err, response) => {
if (err) {
return callback(err);
}
this._processBatch('nextBatch', response);
this.emit(ChangeStream.MORE, response);
this.emit(ChangeStream.RESPONSE);
callback(err, response);
});
}
}
const CHANGE_STREAM_EVENTS = [
ChangeStream.RESUME_TOKEN_CHANGED,
ChangeStream.END,
ChangeStream.CLOSE
];
function setIsEmitter<TSchema>(changeStream: ChangeStream<TSchema>): void {
if (changeStream[kMode] === 'iterator') {
// TODO(NODE-3485): Replace with MongoChangeStreamModeError
throw new MongoAPIError(
'ChangeStream cannot be used as an EventEmitter after being used as an iterator'
);
}
changeStream[kMode] = 'emitter';
}
function setIsIterator<TSchema>(changeStream: ChangeStream<TSchema>): void {
if (changeStream[kMode] === 'emitter') {
// TODO(NODE-3485): Replace with MongoChangeStreamModeError
throw new MongoAPIError(
'ChangeStream cannot be used as an iterator after being used as an EventEmitter'
);
}
changeStream[kMode] = 'iterator';
}
/**
* Create a new change stream cursor based on self's configuration
* @internal
*/
function createChangeStreamCursor<TSchema>(
changeStream: ChangeStream<TSchema>,
options: ChangeStreamOptions
): ChangeStreamCursor<TSchema> {
const changeStreamStageOptions: Document = { fullDocument: options.fullDocument || 'default' };
applyKnownOptions(changeStreamStageOptions, options, CHANGE_STREAM_OPTIONS);
if (changeStream.type === CHANGE_DOMAIN_TYPES.CLUSTER) {
changeStreamStageOptions.allChangesForCluster = true;
}
const pipeline = [{ $changeStream: changeStreamStageOptions } as Document].concat(
changeStream.pipeline
);
const cursorOptions = applyKnownOptions({}, options, CURSOR_OPTIONS);
const changeStreamCursor = new ChangeStreamCursor<TSchema>(
getTopology(changeStream.parent),
changeStream.namespace,
pipeline,
cursorOptions
);
for (const event of CHANGE_STREAM_EVENTS) {
changeStreamCursor.on(event, e => changeStream.emit(event, e));
}
if (changeStream.listenerCount(ChangeStream.CHANGE) > 0) {
streamEvents(changeStream, changeStreamCursor);
}
return changeStreamCursor;
}
function applyKnownOptions(target: Document, source: Document, optionNames: string[]) {
optionNames.forEach(name => {
if (source[name]) {
target[name] = source[name];
}
});
return target;
}
interface TopologyWaitOptions {
start?: number;
timeout?: number;
readPreference?: ReadPreference;
}
// This method performs a basic server selection loop, satisfying the requirements of
// ChangeStream resumability until the new SDAM layer can be used.
const SELECTION_TIMEOUT = 30000;
function waitForTopologyConnected(
topology: Topology,
options: TopologyWaitOptions,
callback: Callback
) {
setTimeout(() => {
if (options && options.start == null) {
options.start = now();
}
const start = options.start || now();
const timeout = options.timeout || SELECTION_TIMEOUT;
if (topology.isConnected()) {
return callback();
}
if (calculateDurationInMs(start) > timeout) {
// TODO(NODE-3497): Replace with MongoNetworkTimeoutError
return callback(new MongoRuntimeError('Timed out waiting for connection'));
}
waitForTopologyConnected(topology, options, callback);
}, 500); // this is an arbitrary wait time to allow SDAM to transition
}
function closeWithError<T>(
changeStream: ChangeStream<T>,
error: AnyError,
callback?: Callback
): void {
if (!callback) {
changeStream.emit(ChangeStream.ERROR, error);
}
changeStream.close(() => callback && callback(error));
}
function streamEvents<TSchema>(
changeStream: ChangeStream<TSchema>,
cursor: ChangeStreamCursor<TSchema>
): void {
setIsEmitter(changeStream);
const stream = changeStream[kCursorStream] || cursor.stream();
changeStream[kCursorStream] = stream;
stream.on('data', change => processNewChange(changeStream, change));
stream.on('error', error => processError(changeStream, error));
}
function endStream<TSchema>(changeStream: ChangeStream<TSchema>): void {
const cursorStream = changeStream[kCursorStream];
if (cursorStream) {
['data', 'close', 'end', 'error'].forEach(event => cursorStream.removeAllListeners(event));
cursorStream.destroy();
}
changeStream[kCursorStream] = undefined;
}
function processNewChange<TSchema>(
changeStream: ChangeStream<TSchema>,
change: Nullable<ChangeStreamDocument<TSchema>>,
callback?: Callback<ChangeStreamDocument<TSchema>>
) {
if (changeStream[kClosed]) {
// TODO(NODE-3485): Replace with MongoChangeStreamClosedError
if (callback) callback(new MongoAPIError(CHANGESTREAM_CLOSED_ERROR));
return;
}
// a null change means the cursor has been notified, implicitly closing the change stream
if (change == null) {
// TODO(NODE-3485): Replace with MongoChangeStreamClosedError
return closeWithError(changeStream, new MongoRuntimeError(CHANGESTREAM_CLOSED_ERROR), callback);
}
if (change && !change._id) {
return closeWithError(
changeStream,
new MongoChangeStreamError(NO_RESUME_TOKEN_ERROR),
callback
);
}
// cache the resume token
changeStream.cursor?.cacheResumeToken(change._id);
// wipe the startAtOperationTime if there was one so that there won't be a conflict
// between resumeToken and startAtOperationTime if we need to reconnect the cursor
changeStream.options.startAtOperationTime = undefined;
// Return the change
if (!callback) return changeStream.emit(ChangeStream.CHANGE, change);
return callback(undefined, change);
}
function processError<TSchema>(
changeStream: ChangeStream<TSchema>,
error: AnyError,
callback?: Callback
) {
const cursor = changeStream.cursor;
// If the change stream has been closed explicitly, do not process error.
if (changeStream[kClosed]) {
// TODO(NODE-3485): Replace with MongoChangeStreamClosedError
if (callback) callback(new MongoAPIError(CHANGESTREAM_CLOSED_ERROR));
return;
}
// if the resume succeeds, continue with the new cursor
function resumeWithCursor(newCursor: ChangeStreamCursor<TSchema>) {
changeStream.cursor = newCursor;
processResumeQueue(changeStream);
}
// otherwise, raise an error and close the change stream
function unresumableError(err: AnyError) {
if (!callback) {
changeStream.emit(ChangeStream.ERROR, err);
}
changeStream.close(() => processResumeQueue(changeStream, err));
}
if (cursor && isResumableError(error as MongoError, maxWireVersion(cursor.server))) {
changeStream.cursor = undefined;
// stop listening to all events from old cursor
endStream(changeStream);
// close internal cursor, ignore errors
cursor.close();
const topology = getTopology(changeStream.parent);
waitForTopologyConnected(topology, { readPreference: cursor.readPreference }, err => {
// if the topology can't reconnect, close the stream
if (err) return unresumableError(err);
// create a new cursor, preserving the old cursor's options
const newCursor = createChangeStreamCursor(changeStream, cursor.resumeOptions);
// attempt to continue in emitter mode
if (!callback) return resumeWithCursor(newCursor);
// attempt to continue in iterator mode
newCursor.hasNext(err => {
// if there's an error immediately after resuming, close the stream
if (err) return unresumableError(err);
resumeWithCursor(newCursor);
});
});
return;
}
// if initial error wasn't resumable, raise an error and close the change stream
return closeWithError(changeStream, error, callback);
}
/**
* Safely provides a cursor across resume attempts
*
* @param changeStream - the parent ChangeStream
*/
function getCursor<T>(changeStream: ChangeStream<T>, callback: Callback<ChangeStreamCursor<T>>) {
if (changeStream[kClosed]) {
// TODO(NODE-3485): Replace with MongoChangeStreamClosedError
callback(new MongoAPIError(CHANGESTREAM_CLOSED_ERROR));
return;
}
// if a cursor exists and it is open, return it
if (changeStream.cursor) {
callback(undefined, changeStream.cursor);
return;
}
// no cursor, queue callback until topology reconnects
changeStream[kResumeQueue].push(callback);
}
/**
* Drain the resume queue when a new has become available
*
* @param changeStream - the parent ChangeStream
* @param err - error getting a new cursor
*/
function processResumeQueue<TSchema>(changeStream: ChangeStream<TSchema>, err?: Error) {
while (changeStream[kResumeQueue].length) {
const request = changeStream[kResumeQueue].pop();
if (!request) break; // Should never occur but TS can't use the length check in the while condition
if (!err) {
if (changeStream[kClosed]) {
// TODO(NODE-3485): Replace with MongoChangeStreamClosedError
request(new MongoAPIError(CHANGESTREAM_CLOSED_ERROR));
return;
}
if (!changeStream.cursor) {
request(new MongoChangeStreamError(NO_CURSOR_ERROR));
return;
}
}
request(err, changeStream.cursor);
}
}