@powersync/service-module-mongodb 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/dist/api/MongoRouteAPIAdapter.d.ts +2 -2
- package/dist/api/MongoRouteAPIAdapter.js +12 -21
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +19 -37
- package/dist/replication/ChangeStream.js +157 -362
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.js +1 -1
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/ChangeStreamReplicator.js +1 -1
- package/dist/replication/ChangeStreamReplicator.js.map +1 -1
- package/dist/replication/MongoRelation.d.ts +1 -1
- package/dist/replication/MongoRelation.js +41 -21
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/MongoSnapshotter.d.ts +80 -0
- package/dist/replication/MongoSnapshotter.js +585 -0
- package/dist/replication/MongoSnapshotter.js.map +1 -0
- package/dist/types/types.d.ts +2 -2
- package/package.json +11 -21
- package/src/api/MongoRouteAPIAdapter.ts +14 -22
- package/src/replication/ChangeStream.ts +173 -439
- package/src/replication/ChangeStreamReplicationJob.ts +1 -1
- package/src/replication/ChangeStreamReplicator.ts +1 -1
- package/src/replication/MongoRelation.ts +51 -25
- package/src/replication/MongoSnapshotter.ts +719 -0
- package/test/src/change_stream.test.ts +214 -20
- package/test/src/change_stream_utils.ts +41 -34
- package/test/src/checkpoint_retry.test.ts +131 -0
- package/test/src/resume.test.ts +2 -2
- package/test/src/resuming_snapshots.test.ts +10 -6
- package/test/src/util.ts +2 -1
- package/tsconfig.json +1 -4
- package/tsconfig.scripts.json +1 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
SourceTable,
|
|
19
19
|
storage
|
|
20
20
|
} from '@powersync/service-core';
|
|
21
|
-
import {
|
|
21
|
+
import { HydratedSyncConfig } from '@powersync/service-sync-rules';
|
|
22
22
|
import { ReplicationMetric } from '@powersync/service-types';
|
|
23
23
|
import { performance } from 'node:perf_hooks';
|
|
24
24
|
import { MongoLSN } from '../common/MongoLSN.js';
|
|
@@ -26,7 +26,7 @@ import { PostImagesOption } from '../types/types.js';
|
|
|
26
26
|
import { escapeRegExp } from '../utils.js';
|
|
27
27
|
import { MongoManager } from './MongoManager.js';
|
|
28
28
|
import { createCheckpoint, getCacheIdentifier, getMongoRelation, STANDALONE_CHECKPOINT_ID } from './MongoRelation.js';
|
|
29
|
-
import {
|
|
29
|
+
import { MongoSnapshotter, MongoSnapshotterHooks } from './MongoSnapshotter.js';
|
|
30
30
|
import {
|
|
31
31
|
ChangeStreamBatch,
|
|
32
32
|
parseChangeDocument,
|
|
@@ -53,12 +53,10 @@ export interface ChangeStreamOptions {
|
|
|
53
53
|
*/
|
|
54
54
|
snapshotChunkLength?: number;
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
storageHooks?: storage.StorageHooks;
|
|
57
|
+
snapshotHooks?: MongoSnapshotterHooks;
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
needsInitialSync: boolean;
|
|
61
|
-
snapshotLsn: string | null;
|
|
59
|
+
logger?: Logger;
|
|
62
60
|
}
|
|
63
61
|
|
|
64
62
|
/**
|
|
@@ -76,7 +74,7 @@ export class ChangeStreamInvalidatedError extends DatabaseConnectionError {
|
|
|
76
74
|
}
|
|
77
75
|
|
|
78
76
|
export class ChangeStream {
|
|
79
|
-
sync_rules:
|
|
77
|
+
sync_rules: HydratedSyncConfig;
|
|
80
78
|
group_id: number;
|
|
81
79
|
|
|
82
80
|
connection_id = 1;
|
|
@@ -90,8 +88,15 @@ export class ChangeStream {
|
|
|
90
88
|
|
|
91
89
|
private readonly maxAwaitTimeMS: number;
|
|
92
90
|
|
|
93
|
-
private
|
|
91
|
+
private abortController = new AbortController();
|
|
92
|
+
private abortSignal: AbortSignal = this.abortController.signal;
|
|
94
93
|
|
|
94
|
+
private initPromise: Promise<void> | null = null;
|
|
95
|
+
private snapshotter: MongoSnapshotter;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* We use the relationCache _only_ for caching static SourceTable info, not for snapshot status.
|
|
99
|
+
*/
|
|
95
100
|
private relationCache = new RelationCache(getCacheIdentifier);
|
|
96
101
|
|
|
97
102
|
private replicationLag = new ReplicationLagTracker();
|
|
@@ -104,15 +109,18 @@ export class ChangeStream {
|
|
|
104
109
|
|
|
105
110
|
private changeStreamTimeout: number;
|
|
106
111
|
|
|
112
|
+
private storageHooks: storage.StorageHooks | undefined;
|
|
113
|
+
|
|
107
114
|
private readonly sourceRowConverter: SourceRowConverter;
|
|
108
115
|
|
|
109
116
|
constructor(options: ChangeStreamOptions) {
|
|
110
117
|
this.storage = options.storage;
|
|
111
118
|
this.metrics = options.metrics;
|
|
112
|
-
this.group_id = options.storage.
|
|
119
|
+
this.group_id = options.storage.replicationStreamId;
|
|
113
120
|
this.connections = options.connections;
|
|
114
121
|
this.maxAwaitTimeMS = options.maxAwaitTimeMS ?? 10_000;
|
|
115
122
|
this.snapshotChunkLength = options.snapshotChunkLength ?? 6_000;
|
|
123
|
+
this.storageHooks = options.storageHooks;
|
|
116
124
|
this.client = this.connections.client;
|
|
117
125
|
this.defaultDb = this.connections.db;
|
|
118
126
|
this.sync_rules = options.storage.getParsedSyncRules({
|
|
@@ -124,20 +132,33 @@ export class ChangeStream {
|
|
|
124
132
|
// so we use 90% of the socket timeout value.
|
|
125
133
|
this.changeStreamTimeout = Math.ceil(this.client.options.socketTimeoutMS * 0.9);
|
|
126
134
|
|
|
127
|
-
|
|
128
|
-
|
|
135
|
+
const baseLogger = options.logger ?? this.storage.logger;
|
|
136
|
+
// Unfortunately the Winston APIs don't have a nice way to append to the prefix,
|
|
137
|
+
// so we replace it here.
|
|
138
|
+
this.logger = baseLogger.child({ prefix: `[${this.storage.replicationStreamName}] [stream] ` });
|
|
139
|
+
const snapshotLogger = baseLogger.child({ prefix: `[${this.storage.replicationStreamName}] [snapshot] ` });
|
|
140
|
+
|
|
141
|
+
this.snapshotter = new MongoSnapshotter({
|
|
142
|
+
...options,
|
|
143
|
+
abortSignal: this.abortSignal,
|
|
144
|
+
logger: snapshotLogger,
|
|
145
|
+
checkpointStreamId: this.checkpointStreamId
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
options.abort_signal.addEventListener(
|
|
129
149
|
'abort',
|
|
130
150
|
() => {
|
|
131
|
-
|
|
151
|
+
this.abortController.abort(options.abort_signal.reason);
|
|
132
152
|
},
|
|
133
153
|
{ once: true }
|
|
134
154
|
);
|
|
135
|
-
|
|
136
|
-
|
|
155
|
+
if (options.abort_signal.aborted) {
|
|
156
|
+
this.abortController.abort(options.abort_signal.reason);
|
|
157
|
+
}
|
|
137
158
|
}
|
|
138
159
|
|
|
139
160
|
get stopped() {
|
|
140
|
-
return this.
|
|
161
|
+
return this.abortSignal.aborted;
|
|
141
162
|
}
|
|
142
163
|
|
|
143
164
|
private get usePostImages() {
|
|
@@ -148,279 +169,6 @@ export class ChangeStream {
|
|
|
148
169
|
return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
|
|
149
170
|
}
|
|
150
171
|
|
|
151
|
-
/**
|
|
152
|
-
* This resolves a pattern, persists the related metadata, and returns
|
|
153
|
-
* the resulting SourceTables.
|
|
154
|
-
*
|
|
155
|
-
* This implicitly checks the collection postImage configuration.
|
|
156
|
-
*/
|
|
157
|
-
async resolveQualifiedTableNames(
|
|
158
|
-
batch: storage.BucketStorageBatch,
|
|
159
|
-
tablePattern: TablePattern
|
|
160
|
-
): Promise<storage.SourceTable[]> {
|
|
161
|
-
const schema = tablePattern.schema;
|
|
162
|
-
if (tablePattern.connectionTag != this.connections.connectionTag) {
|
|
163
|
-
return [];
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
let nameFilter: RegExp | string;
|
|
167
|
-
if (tablePattern.isWildcard) {
|
|
168
|
-
nameFilter = new RegExp('^' + escapeRegExp(tablePattern.tablePrefix));
|
|
169
|
-
} else {
|
|
170
|
-
nameFilter = tablePattern.name;
|
|
171
|
-
}
|
|
172
|
-
let result: storage.SourceTable[] = [];
|
|
173
|
-
|
|
174
|
-
// Check if the collection exists
|
|
175
|
-
const collections = await this.client
|
|
176
|
-
.db(schema)
|
|
177
|
-
.listCollections(
|
|
178
|
-
{
|
|
179
|
-
name: nameFilter
|
|
180
|
-
},
|
|
181
|
-
{ nameOnly: false }
|
|
182
|
-
)
|
|
183
|
-
.toArray();
|
|
184
|
-
|
|
185
|
-
if (!tablePattern.isWildcard && collections.length == 0) {
|
|
186
|
-
this.logger.warn(`Collection ${schema}.${tablePattern.name} not found`);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
for (let collection of collections) {
|
|
190
|
-
const table = await this.handleRelation(
|
|
191
|
-
batch,
|
|
192
|
-
getMongoRelation({ db: schema, coll: collection.name }),
|
|
193
|
-
// This is done as part of the initial setup - snapshot is handled elsewhere
|
|
194
|
-
{ snapshot: false, collectionInfo: collection }
|
|
195
|
-
);
|
|
196
|
-
|
|
197
|
-
result.push(table);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
return result;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
async initSlot(): Promise<InitResult> {
|
|
204
|
-
const status = await this.storage.getStatus();
|
|
205
|
-
if (status.snapshot_done && status.checkpoint_lsn) {
|
|
206
|
-
this.logger.info(`Initial replication already done`);
|
|
207
|
-
return { needsInitialSync: false, snapshotLsn: null };
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
return { needsInitialSync: true, snapshotLsn: status.snapshot_lsn };
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
async estimatedCount(table: storage.SourceTable): Promise<string> {
|
|
214
|
-
const count = await this.estimatedCountNumber(table);
|
|
215
|
-
return `~${count}`;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
async estimatedCountNumber(table: storage.SourceTable): Promise<number> {
|
|
219
|
-
const db = this.client.db(table.schema);
|
|
220
|
-
return await db.collection(table.name).estimatedDocumentCount();
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* This gets a LSN before starting a snapshot, which we can resume streaming from after the snapshot.
|
|
225
|
-
*
|
|
226
|
-
* This LSN can survive initial replication restarts.
|
|
227
|
-
*/
|
|
228
|
-
private async getSnapshotLsn(): Promise<string> {
|
|
229
|
-
const hello = await this.defaultDb.command({ hello: 1 });
|
|
230
|
-
// Basic sanity check
|
|
231
|
-
if (hello.msg == 'isdbgrid') {
|
|
232
|
-
throw new ServiceError(
|
|
233
|
-
ErrorCode.PSYNC_S1341,
|
|
234
|
-
'Sharded MongoDB Clusters are not supported yet (including MongoDB Serverless instances).'
|
|
235
|
-
);
|
|
236
|
-
} else if (hello.setName == null) {
|
|
237
|
-
throw new ServiceError(
|
|
238
|
-
ErrorCode.PSYNC_S1342,
|
|
239
|
-
'Standalone MongoDB instances are not supported - use a replicaset.'
|
|
240
|
-
);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// Open a change stream just to get a resume token for later use.
|
|
244
|
-
// We could use clusterTime from the hello command, but that won't tell us if the
|
|
245
|
-
// snapshot isn't valid anymore.
|
|
246
|
-
// If we just use the first resumeToken from the stream, we get two potential issues:
|
|
247
|
-
// 1. The resumeToken may just be a wrapped clusterTime, which does not detect changes
|
|
248
|
-
// in source db or other stream issues.
|
|
249
|
-
// 2. The first actual change we get may have the same clusterTime, causing us to incorrect
|
|
250
|
-
// skip that event.
|
|
251
|
-
// Instead, we create a new checkpoint document, and wait until we get that document back in the stream.
|
|
252
|
-
// To avoid potential race conditions with the checkpoint creation, we create a new checkpoint document
|
|
253
|
-
// periodically until the timeout is reached.
|
|
254
|
-
|
|
255
|
-
const LSN_TIMEOUT_SECONDS = 60;
|
|
256
|
-
const LSN_CREATE_INTERVAL_SECONDS = 1;
|
|
257
|
-
|
|
258
|
-
// Create a checkpoint, and open a change stream using startAtOperationTime with the checkpoint's operationTime.
|
|
259
|
-
const firstCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
260
|
-
|
|
261
|
-
const startTime = performance.now();
|
|
262
|
-
let lastCheckpointCreated = performance.now();
|
|
263
|
-
let eventsSeen = 0;
|
|
264
|
-
let batchesSeen = 0;
|
|
265
|
-
|
|
266
|
-
const filters = this.getSourceNamespaceFilters();
|
|
267
|
-
const iter = this.rawChangeStreamBatches({
|
|
268
|
-
lsn: firstCheckpointLsn,
|
|
269
|
-
maxAwaitTimeMS: 0,
|
|
270
|
-
signal: this.abort_signal,
|
|
271
|
-
filters
|
|
272
|
-
});
|
|
273
|
-
for await (let { events } of iter) {
|
|
274
|
-
if (performance.now() - startTime >= LSN_TIMEOUT_SECONDS * 1000) {
|
|
275
|
-
break;
|
|
276
|
-
}
|
|
277
|
-
if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
|
|
278
|
-
await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
279
|
-
lastCheckpointCreated = performance.now();
|
|
280
|
-
}
|
|
281
|
-
batchesSeen += 1;
|
|
282
|
-
|
|
283
|
-
for (let rawChangeDocument of events) {
|
|
284
|
-
const changeDocument = parseChangeDocument(rawChangeDocument);
|
|
285
|
-
const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
|
|
286
|
-
|
|
287
|
-
if (ns?.coll == CHECKPOINTS_COLLECTION && 'documentKey' in changeDocument) {
|
|
288
|
-
const checkpointId = changeDocument.documentKey._id as string | mongo.ObjectId;
|
|
289
|
-
if (!this.checkpointStreamId.equals(checkpointId)) {
|
|
290
|
-
continue;
|
|
291
|
-
}
|
|
292
|
-
const { comparable: lsn } = new MongoLSN({
|
|
293
|
-
timestamp: changeDocument.clusterTime!,
|
|
294
|
-
resume_token: changeDocument._id
|
|
295
|
-
});
|
|
296
|
-
return lsn;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
eventsSeen += 1;
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// Could happen if there is a very large replication lag?
|
|
304
|
-
throw new ServiceError(
|
|
305
|
-
ErrorCode.PSYNC_S1301,
|
|
306
|
-
`Timeout after while waiting for checkpoint document for ${LSN_TIMEOUT_SECONDS}s. Streamed events = ${eventsSeen}, batches = ${batchesSeen}`
|
|
307
|
-
);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Given a snapshot LSN, validate that we can read from it, by opening a change stream.
|
|
312
|
-
*/
|
|
313
|
-
private async validateSnapshotLsn(lsn: string) {
|
|
314
|
-
const filters = this.getSourceNamespaceFilters();
|
|
315
|
-
const stream = this.rawChangeStreamBatches({
|
|
316
|
-
lsn: lsn,
|
|
317
|
-
// maxAwaitTimeMS should never actually be used here
|
|
318
|
-
maxAwaitTimeMS: 0,
|
|
319
|
-
filters
|
|
320
|
-
});
|
|
321
|
-
for await (let _batch of stream) {
|
|
322
|
-
// We got a response from the aggregate command, so consider the LSN valid.
|
|
323
|
-
// Close the stream immediately.
|
|
324
|
-
break;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
async initialReplication(snapshotLsn: string | null) {
|
|
329
|
-
const sourceTables = this.sync_rules.getSourceTables();
|
|
330
|
-
await this.client.connect();
|
|
331
|
-
const tracer = new PerformanceTracer('MongoDB initial replication');
|
|
332
|
-
|
|
333
|
-
const flushResult = await this.storage.startBatch(
|
|
334
|
-
{
|
|
335
|
-
logger: this.logger,
|
|
336
|
-
zeroLSN: MongoLSN.ZERO.comparable,
|
|
337
|
-
defaultSchema: this.defaultDb.databaseName,
|
|
338
|
-
storeCurrentData: false,
|
|
339
|
-
skipExistingRows: true,
|
|
340
|
-
tracer
|
|
341
|
-
},
|
|
342
|
-
async (batch) => {
|
|
343
|
-
if (snapshotLsn == null) {
|
|
344
|
-
// First replication attempt - get a snapshot and store the timestamp
|
|
345
|
-
snapshotLsn = await this.getSnapshotLsn();
|
|
346
|
-
await batch.setResumeLsn(snapshotLsn);
|
|
347
|
-
this.logger.info(`Marking snapshot at ${snapshotLsn}`);
|
|
348
|
-
} else {
|
|
349
|
-
this.logger.info(`Resuming snapshot at ${snapshotLsn}`);
|
|
350
|
-
// Check that the snapshot is still valid.
|
|
351
|
-
await this.validateSnapshotLsn(snapshotLsn);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// Start by resolving all tables.
|
|
355
|
-
// This checks postImage configuration, and that should fail as
|
|
356
|
-
// early as possible.
|
|
357
|
-
let allSourceTables: SourceTable[] = [];
|
|
358
|
-
for (let tablePattern of sourceTables) {
|
|
359
|
-
const tables = await this.resolveQualifiedTableNames(batch, tablePattern);
|
|
360
|
-
allSourceTables.push(...tables);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
let tablesWithStatus: SourceTable[] = [];
|
|
364
|
-
for (let table of allSourceTables) {
|
|
365
|
-
if (table.snapshotComplete) {
|
|
366
|
-
this.logger.info(`Skipping ${table.qualifiedName} - snapshot already done`);
|
|
367
|
-
continue;
|
|
368
|
-
}
|
|
369
|
-
let count = await this.estimatedCountNumber(table);
|
|
370
|
-
const updated = await batch.updateTableProgress(table, {
|
|
371
|
-
totalEstimatedCount: count
|
|
372
|
-
});
|
|
373
|
-
tablesWithStatus.push(updated);
|
|
374
|
-
this.relationCache.update(updated);
|
|
375
|
-
this.logger.info(
|
|
376
|
-
`To replicate: ${table.qualifiedName}: ${updated.snapshotStatus?.replicatedCount}/~${updated.snapshotStatus?.totalEstimatedCount}`
|
|
377
|
-
);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
for (let table of tablesWithStatus) {
|
|
381
|
-
await this.snapshotTable(batch, table);
|
|
382
|
-
await batch.markTableSnapshotDone([table]);
|
|
383
|
-
|
|
384
|
-
this.touch();
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// The checkpoint here is a marker - we need to replicate up to at least this
|
|
388
|
-
// point before the data can be considered consistent.
|
|
389
|
-
// We could do this for each individual table, but may as well just do it once for the entire snapshot.
|
|
390
|
-
const checkpoint = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
|
|
391
|
-
await batch.markAllSnapshotDone(checkpoint);
|
|
392
|
-
|
|
393
|
-
// This will not create a consistent checkpoint yet, but will persist the op.
|
|
394
|
-
// Actual checkpoint will be created when streaming replication caught up.
|
|
395
|
-
await batch.commit(snapshotLsn);
|
|
396
|
-
|
|
397
|
-
this.logger.info(`Snapshot done. Need to replicate from ${snapshotLsn} to ${checkpoint} to be consistent`);
|
|
398
|
-
}
|
|
399
|
-
);
|
|
400
|
-
return { lastOpId: flushResult?.flushed_op };
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
private async setupCheckpointsCollection() {
|
|
404
|
-
const collection = await this.getCollectionInfo(this.defaultDb.databaseName, CHECKPOINTS_COLLECTION);
|
|
405
|
-
if (collection == null) {
|
|
406
|
-
await this.defaultDb.createCollection(CHECKPOINTS_COLLECTION, {
|
|
407
|
-
changeStreamPreAndPostImages: { enabled: true }
|
|
408
|
-
});
|
|
409
|
-
} else if (this.usePostImages && collection.options?.changeStreamPreAndPostImages?.enabled != true) {
|
|
410
|
-
// Drop + create requires less permissions than collMod,
|
|
411
|
-
// and we don't care about the data in this collection.
|
|
412
|
-
await this.defaultDb.dropCollection(CHECKPOINTS_COLLECTION);
|
|
413
|
-
await this.defaultDb.createCollection(CHECKPOINTS_COLLECTION, {
|
|
414
|
-
changeStreamPreAndPostImages: { enabled: true }
|
|
415
|
-
});
|
|
416
|
-
} else {
|
|
417
|
-
// Clear the collection on startup, to keep it clean
|
|
418
|
-
// We never query this collection directly, and don't want to keep the data around.
|
|
419
|
-
// We only use this to get data into the oplog/changestream.
|
|
420
|
-
await this.defaultDb.collection(CHECKPOINTS_COLLECTION).deleteMany({});
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
|
|
424
172
|
private getSourceNamespaceFilters(): { $match: any; multipleDatabases: boolean } {
|
|
425
173
|
const sourceTables = this.sync_rules.getSourceTables();
|
|
426
174
|
|
|
@@ -472,89 +220,14 @@ export class ChangeStream {
|
|
|
472
220
|
return { $match: nsFilter, multipleDatabases };
|
|
473
221
|
}
|
|
474
222
|
|
|
475
|
-
private async
|
|
476
|
-
const rowsReplicatedMetric = this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED);
|
|
477
|
-
const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
|
|
478
|
-
const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
|
|
479
|
-
|
|
480
|
-
const totalEstimatedCount = await this.estimatedCountNumber(table);
|
|
481
|
-
let at = table.snapshotStatus?.replicatedCount ?? 0;
|
|
482
|
-
const db = this.client.db(table.schema);
|
|
483
|
-
const collection = db.collection(table.name);
|
|
484
|
-
await using query = new ChunkedSnapshotQuery({
|
|
485
|
-
collection,
|
|
486
|
-
key: table.snapshotStatus?.lastKey,
|
|
487
|
-
batchSize: this.snapshotChunkLength
|
|
488
|
-
});
|
|
489
|
-
if (query.lastKey != null) {
|
|
490
|
-
this.logger.info(
|
|
491
|
-
`Replicating ${table.qualifiedName} ${table.formatSnapshotProgress()} - resuming at _id > ${query.lastKey}`
|
|
492
|
-
);
|
|
493
|
-
} else {
|
|
494
|
-
this.logger.info(`Replicating ${table.qualifiedName} ${table.formatSnapshotProgress()}`);
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
let lastBatch = performance.now();
|
|
498
|
-
let nextChunkPromise = query.nextChunk();
|
|
499
|
-
while (true) {
|
|
500
|
-
const { docs: docBatch, lastKey, bytes: chunkBytes } = await nextChunkPromise;
|
|
501
|
-
if (docBatch.length == 0) {
|
|
502
|
-
// No more data - stop iterating
|
|
503
|
-
break;
|
|
504
|
-
}
|
|
505
|
-
bytesReplicatedMetric.add(chunkBytes);
|
|
506
|
-
chunksReplicatedMetric.add(1);
|
|
507
|
-
|
|
508
|
-
if (this.abort_signal.aborted) {
|
|
509
|
-
throw new ReplicationAbortedError(`Aborted initial replication`, this.abort_signal.reason);
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// Pre-fetch next batch, so that we can read and write concurrently
|
|
513
|
-
nextChunkPromise = query.nextChunk();
|
|
514
|
-
for (let buffer of docBatch) {
|
|
515
|
-
const { row: record, replicaId: replicaId } = this.rawToSqliteRow(buffer);
|
|
516
|
-
|
|
517
|
-
// This auto-flushes when the batch reaches its size limit
|
|
518
|
-
await batch.save({
|
|
519
|
-
tag: SaveOperationTag.INSERT,
|
|
520
|
-
sourceTable: table,
|
|
521
|
-
before: undefined,
|
|
522
|
-
beforeReplicaId: undefined,
|
|
523
|
-
after: record,
|
|
524
|
-
afterReplicaId: replicaId
|
|
525
|
-
});
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
// Important: flush before marking progress
|
|
529
|
-
await batch.flush();
|
|
530
|
-
at += docBatch.length;
|
|
531
|
-
rowsReplicatedMetric.add(docBatch.length);
|
|
532
|
-
|
|
533
|
-
table = await batch.updateTableProgress(table, {
|
|
534
|
-
lastKey,
|
|
535
|
-
replicatedCount: at,
|
|
536
|
-
totalEstimatedCount: totalEstimatedCount
|
|
537
|
-
});
|
|
538
|
-
this.relationCache.update(table);
|
|
539
|
-
|
|
540
|
-
const duration = performance.now() - lastBatch;
|
|
541
|
-
lastBatch = performance.now();
|
|
542
|
-
this.logger.info(
|
|
543
|
-
`Replicating ${table.qualifiedName} ${table.formatSnapshotProgress()} in ${duration.toFixed(0)}ms`
|
|
544
|
-
);
|
|
545
|
-
this.touch();
|
|
546
|
-
}
|
|
547
|
-
// In case the loop was interrupted, make sure we await the last promise.
|
|
548
|
-
await nextChunkPromise;
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
private async getRelation(
|
|
223
|
+
private async getRelations(
|
|
552
224
|
batch: storage.BucketStorageBatch,
|
|
553
225
|
descriptor: SourceEntityDescriptor,
|
|
554
226
|
options: { snapshot: boolean }
|
|
555
|
-
): Promise<SourceTable> {
|
|
556
|
-
const existing = this.relationCache.
|
|
227
|
+
): Promise<SourceTable[]> {
|
|
228
|
+
const existing = this.relationCache.getAll(descriptor);
|
|
557
229
|
if (existing != null) {
|
|
230
|
+
// We do this even when it's an empty result: Empty means nothing to sync, and we don't need to re-resolve.
|
|
558
231
|
return existing;
|
|
559
232
|
}
|
|
560
233
|
|
|
@@ -612,14 +285,11 @@ export class ChangeStream {
|
|
|
612
285
|
}
|
|
613
286
|
|
|
614
287
|
const snapshot = options.snapshot;
|
|
615
|
-
const result = await
|
|
616
|
-
group_id: this.group_id,
|
|
288
|
+
const result = await batch.resolveTables({
|
|
617
289
|
connection_id: this.connection_id,
|
|
618
|
-
|
|
619
|
-
entity_descriptor: descriptor,
|
|
620
|
-
sync_rules: this.sync_rules
|
|
290
|
+
source: descriptor
|
|
621
291
|
});
|
|
622
|
-
this.relationCache.
|
|
292
|
+
this.relationCache.updateAll(descriptor, result.tables);
|
|
623
293
|
|
|
624
294
|
// Drop conflicting collections.
|
|
625
295
|
// This is generally not expected for MongoDB source dbs, so we log an error.
|
|
@@ -634,20 +304,13 @@ export class ChangeStream {
|
|
|
634
304
|
// 1. Snapshot is requested (false for initial snapshot, since that process handles it elsewhere)
|
|
635
305
|
// 2. Snapshot is not already done, AND:
|
|
636
306
|
// 3. The table is used in sync config.
|
|
637
|
-
const
|
|
638
|
-
if (
|
|
307
|
+
const snapshotCandidates = result.tables.filter((table) => snapshot && !table.snapshotComplete && table.syncAny);
|
|
308
|
+
if (snapshotCandidates.length > 0) {
|
|
639
309
|
this.logger.info(`New collection: ${descriptor.schema}.${descriptor.name}`);
|
|
640
|
-
|
|
641
|
-
await batch.truncate([result.table]);
|
|
642
|
-
|
|
643
|
-
await this.snapshotTable(batch, result.table);
|
|
644
|
-
const no_checkpoint_before_lsn = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
|
|
645
|
-
|
|
646
|
-
const [table] = await batch.markTableSnapshotDone([result.table], no_checkpoint_before_lsn);
|
|
647
|
-
return table;
|
|
310
|
+
await this.snapshotter.snapshotTables(batch, snapshotCandidates);
|
|
648
311
|
}
|
|
649
312
|
|
|
650
|
-
return result.
|
|
313
|
+
return result.tables;
|
|
651
314
|
}
|
|
652
315
|
|
|
653
316
|
async writeChange(
|
|
@@ -706,38 +369,80 @@ export class ChangeStream {
|
|
|
706
369
|
}
|
|
707
370
|
|
|
708
371
|
async replicate() {
|
|
372
|
+
let streamPromise: Promise<void> | null = null;
|
|
373
|
+
let loopPromise: Promise<void> | null = null;
|
|
374
|
+
let cleanupPromise: Promise<void> | null = null;
|
|
709
375
|
try {
|
|
710
376
|
// If anything errors here, the entire replication process is halted, and
|
|
711
377
|
// all connections automatically closed, including this one.
|
|
712
|
-
|
|
713
|
-
await this.
|
|
378
|
+
this.initPromise = this.initReplication();
|
|
379
|
+
await this.initPromise;
|
|
380
|
+
loopPromise = this.snapshotter
|
|
381
|
+
.replicationLoop()
|
|
382
|
+
.then(() => {
|
|
383
|
+
throw new ReplicationAssertionError(`Replication snapshotter exited unexpectedly`);
|
|
384
|
+
})
|
|
385
|
+
.catch((e) => {
|
|
386
|
+
this.abortController.abort(e);
|
|
387
|
+
throw e;
|
|
388
|
+
});
|
|
389
|
+
if (!this.snapshotter.supportsConcurrentSnapshots) {
|
|
390
|
+
await Promise.race([this.snapshotter.waitForInitialSnapshot(), loopPromise]);
|
|
391
|
+
}
|
|
392
|
+
// Unlike the other two, this resolves on completion, not an indefinite loop.
|
|
393
|
+
cleanupPromise = this.cleanupStoppedSyncConfigs().catch((e) => {
|
|
394
|
+
this.abortController.abort(e);
|
|
395
|
+
throw e;
|
|
396
|
+
});
|
|
397
|
+
streamPromise = this.streamChanges()
|
|
398
|
+
.then(() => {
|
|
399
|
+
throw new ReplicationAssertionError(`Replication stream exited unexpectedly`);
|
|
400
|
+
})
|
|
401
|
+
.catch((e) => {
|
|
402
|
+
this.abortController.abort(e);
|
|
403
|
+
throw e;
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const results = await Promise.allSettled([loopPromise, streamPromise, cleanupPromise]);
|
|
407
|
+
throw replicationLoopError(results);
|
|
714
408
|
} catch (e) {
|
|
715
409
|
await this.storage.reportError(e);
|
|
716
410
|
throw e;
|
|
411
|
+
} finally {
|
|
412
|
+
this.abortController.abort();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
public async waitForInitialSnapshot() {
|
|
417
|
+
if (this.initPromise == null) {
|
|
418
|
+
throw new ReplicationAssertionError('replicate() must be called before waitForInitialSnapshot()');
|
|
717
419
|
}
|
|
420
|
+
await this.initPromise;
|
|
421
|
+
await this.snapshotter.waitForInitialSnapshot();
|
|
718
422
|
}
|
|
719
423
|
|
|
720
|
-
async initReplication() {
|
|
721
|
-
const result = await this.
|
|
722
|
-
await this.setupCheckpointsCollection();
|
|
424
|
+
private async initReplication() {
|
|
425
|
+
const result = await this.snapshotter.checkSlot();
|
|
426
|
+
await this.snapshotter.setupCheckpointsCollection();
|
|
723
427
|
if (result.needsInitialSync) {
|
|
724
428
|
if (result.snapshotLsn == null) {
|
|
725
429
|
// Snapshot LSN is not present, so we need to start replication from scratch.
|
|
726
|
-
await this.storage.clear({ signal: this.
|
|
727
|
-
}
|
|
728
|
-
const { lastOpId } = await this.initialReplication(result.snapshotLsn);
|
|
729
|
-
if (lastOpId != null) {
|
|
730
|
-
// Populate the cache _after_ initial replication, but _before_ we switch to this replication stream.
|
|
731
|
-
await this.storage.populatePersistentChecksumCache({
|
|
732
|
-
signal: this.abort_signal,
|
|
733
|
-
// No checkpoint yet, but we do have the opId.
|
|
734
|
-
maxOpId: lastOpId
|
|
735
|
-
});
|
|
430
|
+
await this.storage.clear({ signal: this.abortSignal });
|
|
736
431
|
}
|
|
432
|
+
await this.snapshotter.queueSnapshotTables(result.snapshotLsn);
|
|
737
433
|
}
|
|
738
434
|
}
|
|
739
435
|
|
|
740
|
-
async
|
|
436
|
+
private async cleanupStoppedSyncConfigs() {
|
|
437
|
+
await this.storage.cleanupStoppedSyncConfigs?.({
|
|
438
|
+
signal: this.abortSignal,
|
|
439
|
+
logger: this.logger,
|
|
440
|
+
defaultSchema: this.defaultDb.databaseName,
|
|
441
|
+
sourceConnectionTag: this.connections.connectionTag
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private async streamChanges() {
|
|
741
446
|
try {
|
|
742
447
|
await this.streamChangesInternal();
|
|
743
448
|
} catch (e) {
|
|
@@ -830,7 +535,9 @@ export class ChangeStream {
|
|
|
830
535
|
const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
|
|
831
536
|
const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
|
|
832
537
|
|
|
833
|
-
const tracer = new PerformanceTracer
|
|
538
|
+
const tracer = new PerformanceTracer<
|
|
539
|
+
'storage' | 'evaluate' | 'batch' | 'source_checkpoint' | 'changestream' | 'processing'
|
|
540
|
+
>('MongoDB streaming replication');
|
|
834
541
|
await this.storage.startBatch(
|
|
835
542
|
{
|
|
836
543
|
logger: this.logger,
|
|
@@ -838,6 +545,7 @@ export class ChangeStream {
|
|
|
838
545
|
defaultSchema: this.defaultDb.databaseName,
|
|
839
546
|
// We get a complete postimage for every change, so we don't need to store the current data.
|
|
840
547
|
storeCurrentData: false,
|
|
548
|
+
hooks: this.storageHooks,
|
|
841
549
|
tracer
|
|
842
550
|
},
|
|
843
551
|
async (batch) => {
|
|
@@ -860,7 +568,7 @@ export class ChangeStream {
|
|
|
860
568
|
const batchStream = this.rawChangeStreamBatches({
|
|
861
569
|
lsn: resumeFromLsn,
|
|
862
570
|
filters,
|
|
863
|
-
signal: this.
|
|
571
|
+
signal: this.abortSignal,
|
|
864
572
|
tracer
|
|
865
573
|
});
|
|
866
574
|
|
|
@@ -886,7 +594,7 @@ export class ChangeStream {
|
|
|
886
594
|
|
|
887
595
|
bytesReplicatedMetric.add(eventBatch.byteSize);
|
|
888
596
|
chunksReplicatedMetric.add(1);
|
|
889
|
-
if (this.
|
|
597
|
+
if (this.abortSignal.aborted) {
|
|
890
598
|
break;
|
|
891
599
|
}
|
|
892
600
|
this.touch();
|
|
@@ -920,7 +628,7 @@ export class ChangeStream {
|
|
|
920
628
|
for (let eventIndex = 0; eventIndex < events.length; eventIndex++) {
|
|
921
629
|
const rawChangeDocument = events[eventIndex];
|
|
922
630
|
const originalChangeDocument = parseChangeDocument(rawChangeDocument);
|
|
923
|
-
if (this.
|
|
631
|
+
if (this.abortSignal.aborted) {
|
|
924
632
|
break;
|
|
925
633
|
}
|
|
926
634
|
|
|
@@ -1027,11 +735,16 @@ export class ChangeStream {
|
|
|
1027
735
|
// change stream events, collapse standalone checkpoints into the normal batch
|
|
1028
736
|
// checkpoint flow to avoid commit churn under sustained load.
|
|
1029
737
|
const hasBufferedChanges = eventIndex < events.length - 1;
|
|
1030
|
-
if (waitForCheckpointLsn
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
738
|
+
if (hasBufferedChanges && waitForCheckpointLsn == null) {
|
|
739
|
+
// Buffered changes - create a new batch checkpoint to rate limit commits
|
|
740
|
+
using _ = tracer.span('source_checkpoint');
|
|
741
|
+
waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
742
|
+
continue;
|
|
743
|
+
} else if (waitForCheckpointLsn != null) {
|
|
744
|
+
// Skip this checkpoint - wait for the batch checkpoint.
|
|
1034
745
|
continue;
|
|
746
|
+
} else {
|
|
747
|
+
// No buffered changes, and no batch checkpoint pending - commit immediately.
|
|
1035
748
|
}
|
|
1036
749
|
} else if (!this.checkpointStreamId.equals(checkpointId)) {
|
|
1037
750
|
continue;
|
|
@@ -1040,25 +753,15 @@ export class ChangeStream {
|
|
|
1040
753
|
timestamp: changeDocument.clusterTime!,
|
|
1041
754
|
resume_token: changeDocument._id
|
|
1042
755
|
});
|
|
1043
|
-
if (batch.lastCheckpointLsn != null && lsn < batch.lastCheckpointLsn) {
|
|
1044
|
-
// Checkpoint out of order - should never happen with MongoDB.
|
|
1045
|
-
// If it does happen, we throw an error to stop the replication - restarting should recover.
|
|
1046
|
-
// Since we use batch.lastCheckpointLsn for the next resumeAfter, this should not result in an infinite loop.
|
|
1047
|
-
// Originally a workaround for https://jira.mongodb.org/browse/NODE-7042.
|
|
1048
|
-
// This has been fixed in the driver in the meantime, but we still keep this as a safety-check.
|
|
1049
|
-
throw new ReplicationAssertionError(
|
|
1050
|
-
`Change resumeToken ${(changeDocument._id as any)._data} (${timestampToDate(changeDocument.clusterTime!).toISOString()}) is less than last checkpoint LSN ${batch.lastCheckpointLsn}. Restarting replication.`
|
|
1051
|
-
);
|
|
1052
|
-
}
|
|
1053
756
|
|
|
1054
757
|
if (waitForCheckpointLsn != null && lsn >= waitForCheckpointLsn) {
|
|
1055
758
|
waitForCheckpointLsn = null;
|
|
1056
759
|
}
|
|
1057
|
-
const { checkpointBlocked } = await batch.commit(lsn, {
|
|
760
|
+
const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
|
|
1058
761
|
oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
|
|
1059
762
|
});
|
|
1060
763
|
|
|
1061
|
-
if (!checkpointBlocked) {
|
|
764
|
+
if (!checkpointBlocked || checkpointCreated) {
|
|
1062
765
|
this.replicationLag.markCommitted();
|
|
1063
766
|
}
|
|
1064
767
|
} else if (
|
|
@@ -1068,18 +771,20 @@ export class ChangeStream {
|
|
|
1068
771
|
changeDocument.operationType == 'delete'
|
|
1069
772
|
) {
|
|
1070
773
|
if (waitForCheckpointLsn == null) {
|
|
774
|
+
using _ = tracer.span('source_checkpoint');
|
|
1071
775
|
waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
1072
776
|
}
|
|
1073
777
|
|
|
1074
|
-
const rel = getMongoRelation(changeDocument.ns);
|
|
1075
|
-
const
|
|
778
|
+
const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
779
|
+
const tables = await this.getRelations(batch, rel, {
|
|
1076
780
|
// In most cases, we should not need to snapshot this. But if this is the first time we see the collection
|
|
1077
781
|
// for whatever reason, then we do need to snapshot it.
|
|
1078
782
|
// This may result in some duplicate operations when a collection is created for the first time after
|
|
1079
783
|
// sync config was deployed.
|
|
1080
784
|
snapshot: true
|
|
1081
785
|
});
|
|
1082
|
-
|
|
786
|
+
const tablesToReplicate = tables.filter((table) => table.syncAny);
|
|
787
|
+
if (tablesToReplicate.length > 0) {
|
|
1083
788
|
this.replicationLag.trackUncommittedChange(
|
|
1084
789
|
changeDocument.clusterTime == null ? null : timestampToDate(changeDocument.clusterTime)
|
|
1085
790
|
);
|
|
@@ -1094,29 +799,33 @@ export class ChangeStream {
|
|
|
1094
799
|
transactionsReplicatedMetric.add(1);
|
|
1095
800
|
}
|
|
1096
801
|
|
|
1097
|
-
|
|
802
|
+
for (const table of tablesToReplicate) {
|
|
803
|
+
await this.writeChange(batch, table, changeDocument);
|
|
804
|
+
}
|
|
1098
805
|
}
|
|
1099
806
|
} else if (changeDocument.operationType == 'drop') {
|
|
1100
|
-
const rel = getMongoRelation(changeDocument.ns);
|
|
1101
|
-
const
|
|
807
|
+
const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
808
|
+
const tables = await this.getRelations(batch, rel, {
|
|
1102
809
|
// We're "dropping" this collection, so never snapshot it.
|
|
1103
810
|
snapshot: false
|
|
1104
811
|
});
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
812
|
+
const tablesToDrop = tables.filter((table) => table.syncAny);
|
|
813
|
+
if (tablesToDrop.length > 0) {
|
|
814
|
+
await batch.drop(tablesToDrop);
|
|
1108
815
|
}
|
|
816
|
+
this.relationCache.delete(rel);
|
|
1109
817
|
} else if (changeDocument.operationType == 'rename') {
|
|
1110
|
-
const relFrom = getMongoRelation(changeDocument.ns);
|
|
1111
|
-
const relTo = getMongoRelation(changeDocument.to);
|
|
1112
|
-
const
|
|
818
|
+
const relFrom = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
819
|
+
const relTo = getMongoRelation(changeDocument.to, this.connections.connectionTag);
|
|
820
|
+
const tablesFrom = await this.getRelations(batch, relFrom, {
|
|
1113
821
|
// We're "dropping" this collection, so never snapshot it.
|
|
1114
822
|
snapshot: false
|
|
1115
823
|
});
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
824
|
+
const tablesToDrop = tablesFrom.filter((table) => table.syncAny);
|
|
825
|
+
if (tablesToDrop.length > 0) {
|
|
826
|
+
await batch.drop(tablesToDrop);
|
|
1119
827
|
}
|
|
828
|
+
this.relationCache.delete(relFrom);
|
|
1120
829
|
// Here we do need to snapshot the new table
|
|
1121
830
|
const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
|
|
1122
831
|
await this.handleRelation(batch, relTo, {
|
|
@@ -1139,8 +848,8 @@ export class ChangeStream {
|
|
|
1139
848
|
}
|
|
1140
849
|
|
|
1141
850
|
batchSpan.end();
|
|
1142
|
-
const
|
|
1143
|
-
const duration = batchSpan.
|
|
851
|
+
const durationsMicroseconds = outerSpan.end();
|
|
852
|
+
const duration = batchSpan.durationMillis;
|
|
1144
853
|
|
|
1145
854
|
this.logger.info(
|
|
1146
855
|
`Processed batch of ${events.length} changes / ${eventBatch.byteSize} bytes in ${duration}ms`,
|
|
@@ -1148,13 +857,15 @@ export class ChangeStream {
|
|
|
1148
857
|
count: events.length,
|
|
1149
858
|
bytes: eventBatch.byteSize,
|
|
1150
859
|
duration,
|
|
1151
|
-
t:
|
|
860
|
+
t: durationsMicroseconds
|
|
1152
861
|
}
|
|
1153
862
|
);
|
|
1154
863
|
outerSpan = tracer.span('batch');
|
|
1155
864
|
}
|
|
1156
865
|
}
|
|
1157
866
|
);
|
|
867
|
+
|
|
868
|
+
throw new ReplicationAbortedError(`Replication stream aborted`, this.abortSignal.reason);
|
|
1158
869
|
}
|
|
1159
870
|
|
|
1160
871
|
getReplicationLagMillis(): number | undefined {
|
|
@@ -1183,3 +894,26 @@ function transactionKey(doc: Pick<mongo.ChangeStreamDocument, 'lsid' | 'txnNumbe
|
|
|
1183
894
|
}
|
|
1184
895
|
return `${doc.lsid.id.toString('hex')}:${doc.txnNumber}`;
|
|
1185
896
|
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Prioritize errors that are _not_ ReplicationAbortedError. Any error on either loopPromise or
|
|
900
|
+
* streamPromise aborts the other one, which then results in a ReplicationAbortedError, hiding the
|
|
901
|
+
* original cause.
|
|
902
|
+
*/
|
|
903
|
+
function replicationLoopError(results: PromiseSettledResult<any>[]): unknown {
|
|
904
|
+
// 1. Prioritize not ReplicationAbortedError.
|
|
905
|
+
for (const result of results) {
|
|
906
|
+
if (result.status == 'rejected' && !(result.reason instanceof ReplicationAbortedError)) {
|
|
907
|
+
return result.reason;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
// 2. Fallback to ReplicationAbortedError.
|
|
911
|
+
for (const result of results) {
|
|
912
|
+
if (result.status == 'rejected') {
|
|
913
|
+
// At this point only ReplicationAbortedError remains
|
|
914
|
+
return result.reason;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
// 3. Should never happen, but we cover this case.
|
|
918
|
+
return new ReplicationAssertionError(`Replication loop exited unexpectedly`);
|
|
919
|
+
}
|