@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
|
@@ -7,7 +7,7 @@ import { MongoLSN } from '../common/MongoLSN.js';
|
|
|
7
7
|
import { PostImagesOption } from '../types/types.js';
|
|
8
8
|
import { escapeRegExp } from '../utils.js';
|
|
9
9
|
import { createCheckpoint, getCacheIdentifier, getMongoRelation, STANDALONE_CHECKPOINT_ID } from './MongoRelation.js';
|
|
10
|
-
import {
|
|
10
|
+
import { MongoSnapshotter } from './MongoSnapshotter.js';
|
|
11
11
|
import { parseChangeDocument, rawChangeStream } from './RawChangeStream.js';
|
|
12
12
|
import { CHECKPOINTS_COLLECTION, timestampToDate } from './replication-utils.js';
|
|
13
13
|
import { DirectSourceRowConverter } from './SourceRowConverter.js';
|
|
@@ -34,21 +34,29 @@ export class ChangeStream {
|
|
|
34
34
|
defaultDb;
|
|
35
35
|
metrics;
|
|
36
36
|
maxAwaitTimeMS;
|
|
37
|
-
|
|
37
|
+
abortController = new AbortController();
|
|
38
|
+
abortSignal = this.abortController.signal;
|
|
39
|
+
initPromise = null;
|
|
40
|
+
snapshotter;
|
|
41
|
+
/**
|
|
42
|
+
* We use the relationCache _only_ for caching static SourceTable info, not for snapshot status.
|
|
43
|
+
*/
|
|
38
44
|
relationCache = new RelationCache(getCacheIdentifier);
|
|
39
45
|
replicationLag = new ReplicationLagTracker();
|
|
40
46
|
checkpointStreamId = new mongo.ObjectId();
|
|
41
47
|
logger;
|
|
42
48
|
snapshotChunkLength;
|
|
43
49
|
changeStreamTimeout;
|
|
50
|
+
storageHooks;
|
|
44
51
|
sourceRowConverter;
|
|
45
52
|
constructor(options) {
|
|
46
53
|
this.storage = options.storage;
|
|
47
54
|
this.metrics = options.metrics;
|
|
48
|
-
this.group_id = options.storage.
|
|
55
|
+
this.group_id = options.storage.replicationStreamId;
|
|
49
56
|
this.connections = options.connections;
|
|
50
57
|
this.maxAwaitTimeMS = options.maxAwaitTimeMS ?? 10_000;
|
|
51
58
|
this.snapshotChunkLength = options.snapshotChunkLength ?? 6_000;
|
|
59
|
+
this.storageHooks = options.storageHooks;
|
|
52
60
|
this.client = this.connections.client;
|
|
53
61
|
this.defaultDb = this.connections.db;
|
|
54
62
|
this.sync_rules = options.storage.getParsedSyncRules({
|
|
@@ -58,14 +66,26 @@ export class ChangeStream {
|
|
|
58
66
|
// The change stream aggregation command should timeout before the socket times out,
|
|
59
67
|
// so we use 90% of the socket timeout value.
|
|
60
68
|
this.changeStreamTimeout = Math.ceil(this.client.options.socketTimeoutMS * 0.9);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
69
|
+
const baseLogger = options.logger ?? this.storage.logger;
|
|
70
|
+
// Unfortunately the Winston APIs don't have a nice way to append to the prefix,
|
|
71
|
+
// so we replace it here.
|
|
72
|
+
this.logger = baseLogger.child({ prefix: `[${this.storage.replicationStreamName}] [stream] ` });
|
|
73
|
+
const snapshotLogger = baseLogger.child({ prefix: `[${this.storage.replicationStreamName}] [snapshot] ` });
|
|
74
|
+
this.snapshotter = new MongoSnapshotter({
|
|
75
|
+
...options,
|
|
76
|
+
abortSignal: this.abortSignal,
|
|
77
|
+
logger: snapshotLogger,
|
|
78
|
+
checkpointStreamId: this.checkpointStreamId
|
|
79
|
+
});
|
|
80
|
+
options.abort_signal.addEventListener('abort', () => {
|
|
81
|
+
this.abortController.abort(options.abort_signal.reason);
|
|
64
82
|
}, { once: true });
|
|
65
|
-
|
|
83
|
+
if (options.abort_signal.aborted) {
|
|
84
|
+
this.abortController.abort(options.abort_signal.reason);
|
|
85
|
+
}
|
|
66
86
|
}
|
|
67
87
|
get stopped() {
|
|
68
|
-
return this.
|
|
88
|
+
return this.abortSignal.aborted;
|
|
69
89
|
}
|
|
70
90
|
get usePostImages() {
|
|
71
91
|
return this.connections.options.postImages != PostImagesOption.OFF;
|
|
@@ -73,229 +93,6 @@ export class ChangeStream {
|
|
|
73
93
|
get configurePostImages() {
|
|
74
94
|
return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
|
|
75
95
|
}
|
|
76
|
-
/**
|
|
77
|
-
* This resolves a pattern, persists the related metadata, and returns
|
|
78
|
-
* the resulting SourceTables.
|
|
79
|
-
*
|
|
80
|
-
* This implicitly checks the collection postImage configuration.
|
|
81
|
-
*/
|
|
82
|
-
async resolveQualifiedTableNames(batch, tablePattern) {
|
|
83
|
-
const schema = tablePattern.schema;
|
|
84
|
-
if (tablePattern.connectionTag != this.connections.connectionTag) {
|
|
85
|
-
return [];
|
|
86
|
-
}
|
|
87
|
-
let nameFilter;
|
|
88
|
-
if (tablePattern.isWildcard) {
|
|
89
|
-
nameFilter = new RegExp('^' + escapeRegExp(tablePattern.tablePrefix));
|
|
90
|
-
}
|
|
91
|
-
else {
|
|
92
|
-
nameFilter = tablePattern.name;
|
|
93
|
-
}
|
|
94
|
-
let result = [];
|
|
95
|
-
// Check if the collection exists
|
|
96
|
-
const collections = await this.client
|
|
97
|
-
.db(schema)
|
|
98
|
-
.listCollections({
|
|
99
|
-
name: nameFilter
|
|
100
|
-
}, { nameOnly: false })
|
|
101
|
-
.toArray();
|
|
102
|
-
if (!tablePattern.isWildcard && collections.length == 0) {
|
|
103
|
-
this.logger.warn(`Collection ${schema}.${tablePattern.name} not found`);
|
|
104
|
-
}
|
|
105
|
-
for (let collection of collections) {
|
|
106
|
-
const table = await this.handleRelation(batch, getMongoRelation({ db: schema, coll: collection.name }),
|
|
107
|
-
// This is done as part of the initial setup - snapshot is handled elsewhere
|
|
108
|
-
{ snapshot: false, collectionInfo: collection });
|
|
109
|
-
result.push(table);
|
|
110
|
-
}
|
|
111
|
-
return result;
|
|
112
|
-
}
|
|
113
|
-
async initSlot() {
|
|
114
|
-
const status = await this.storage.getStatus();
|
|
115
|
-
if (status.snapshot_done && status.checkpoint_lsn) {
|
|
116
|
-
this.logger.info(`Initial replication already done`);
|
|
117
|
-
return { needsInitialSync: false, snapshotLsn: null };
|
|
118
|
-
}
|
|
119
|
-
return { needsInitialSync: true, snapshotLsn: status.snapshot_lsn };
|
|
120
|
-
}
|
|
121
|
-
async estimatedCount(table) {
|
|
122
|
-
const count = await this.estimatedCountNumber(table);
|
|
123
|
-
return `~${count}`;
|
|
124
|
-
}
|
|
125
|
-
async estimatedCountNumber(table) {
|
|
126
|
-
const db = this.client.db(table.schema);
|
|
127
|
-
return await db.collection(table.name).estimatedDocumentCount();
|
|
128
|
-
}
|
|
129
|
-
/**
|
|
130
|
-
* This gets a LSN before starting a snapshot, which we can resume streaming from after the snapshot.
|
|
131
|
-
*
|
|
132
|
-
* This LSN can survive initial replication restarts.
|
|
133
|
-
*/
|
|
134
|
-
async getSnapshotLsn() {
|
|
135
|
-
const hello = await this.defaultDb.command({ hello: 1 });
|
|
136
|
-
// Basic sanity check
|
|
137
|
-
if (hello.msg == 'isdbgrid') {
|
|
138
|
-
throw new ServiceError(ErrorCode.PSYNC_S1341, 'Sharded MongoDB Clusters are not supported yet (including MongoDB Serverless instances).');
|
|
139
|
-
}
|
|
140
|
-
else if (hello.setName == null) {
|
|
141
|
-
throw new ServiceError(ErrorCode.PSYNC_S1342, 'Standalone MongoDB instances are not supported - use a replicaset.');
|
|
142
|
-
}
|
|
143
|
-
// Open a change stream just to get a resume token for later use.
|
|
144
|
-
// We could use clusterTime from the hello command, but that won't tell us if the
|
|
145
|
-
// snapshot isn't valid anymore.
|
|
146
|
-
// If we just use the first resumeToken from the stream, we get two potential issues:
|
|
147
|
-
// 1. The resumeToken may just be a wrapped clusterTime, which does not detect changes
|
|
148
|
-
// in source db or other stream issues.
|
|
149
|
-
// 2. The first actual change we get may have the same clusterTime, causing us to incorrect
|
|
150
|
-
// skip that event.
|
|
151
|
-
// Instead, we create a new checkpoint document, and wait until we get that document back in the stream.
|
|
152
|
-
// To avoid potential race conditions with the checkpoint creation, we create a new checkpoint document
|
|
153
|
-
// periodically until the timeout is reached.
|
|
154
|
-
const LSN_TIMEOUT_SECONDS = 60;
|
|
155
|
-
const LSN_CREATE_INTERVAL_SECONDS = 1;
|
|
156
|
-
// Create a checkpoint, and open a change stream using startAtOperationTime with the checkpoint's operationTime.
|
|
157
|
-
const firstCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
158
|
-
const startTime = performance.now();
|
|
159
|
-
let lastCheckpointCreated = performance.now();
|
|
160
|
-
let eventsSeen = 0;
|
|
161
|
-
let batchesSeen = 0;
|
|
162
|
-
const filters = this.getSourceNamespaceFilters();
|
|
163
|
-
const iter = this.rawChangeStreamBatches({
|
|
164
|
-
lsn: firstCheckpointLsn,
|
|
165
|
-
maxAwaitTimeMS: 0,
|
|
166
|
-
signal: this.abort_signal,
|
|
167
|
-
filters
|
|
168
|
-
});
|
|
169
|
-
for await (let { events } of iter) {
|
|
170
|
-
if (performance.now() - startTime >= LSN_TIMEOUT_SECONDS * 1000) {
|
|
171
|
-
break;
|
|
172
|
-
}
|
|
173
|
-
if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
|
|
174
|
-
await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
175
|
-
lastCheckpointCreated = performance.now();
|
|
176
|
-
}
|
|
177
|
-
batchesSeen += 1;
|
|
178
|
-
for (let rawChangeDocument of events) {
|
|
179
|
-
const changeDocument = parseChangeDocument(rawChangeDocument);
|
|
180
|
-
const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
|
|
181
|
-
if (ns?.coll == CHECKPOINTS_COLLECTION && 'documentKey' in changeDocument) {
|
|
182
|
-
const checkpointId = changeDocument.documentKey._id;
|
|
183
|
-
if (!this.checkpointStreamId.equals(checkpointId)) {
|
|
184
|
-
continue;
|
|
185
|
-
}
|
|
186
|
-
const { comparable: lsn } = new MongoLSN({
|
|
187
|
-
timestamp: changeDocument.clusterTime,
|
|
188
|
-
resume_token: changeDocument._id
|
|
189
|
-
});
|
|
190
|
-
return lsn;
|
|
191
|
-
}
|
|
192
|
-
eventsSeen += 1;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
// Could happen if there is a very large replication lag?
|
|
196
|
-
throw new ServiceError(ErrorCode.PSYNC_S1301, `Timeout after while waiting for checkpoint document for ${LSN_TIMEOUT_SECONDS}s. Streamed events = ${eventsSeen}, batches = ${batchesSeen}`);
|
|
197
|
-
}
|
|
198
|
-
/**
|
|
199
|
-
* Given a snapshot LSN, validate that we can read from it, by opening a change stream.
|
|
200
|
-
*/
|
|
201
|
-
async validateSnapshotLsn(lsn) {
|
|
202
|
-
const filters = this.getSourceNamespaceFilters();
|
|
203
|
-
const stream = this.rawChangeStreamBatches({
|
|
204
|
-
lsn: lsn,
|
|
205
|
-
// maxAwaitTimeMS should never actually be used here
|
|
206
|
-
maxAwaitTimeMS: 0,
|
|
207
|
-
filters
|
|
208
|
-
});
|
|
209
|
-
for await (let _batch of stream) {
|
|
210
|
-
// We got a response from the aggregate command, so consider the LSN valid.
|
|
211
|
-
// Close the stream immediately.
|
|
212
|
-
break;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
async initialReplication(snapshotLsn) {
|
|
216
|
-
const sourceTables = this.sync_rules.getSourceTables();
|
|
217
|
-
await this.client.connect();
|
|
218
|
-
const tracer = new PerformanceTracer('MongoDB initial replication');
|
|
219
|
-
const flushResult = await this.storage.startBatch({
|
|
220
|
-
logger: this.logger,
|
|
221
|
-
zeroLSN: MongoLSN.ZERO.comparable,
|
|
222
|
-
defaultSchema: this.defaultDb.databaseName,
|
|
223
|
-
storeCurrentData: false,
|
|
224
|
-
skipExistingRows: true,
|
|
225
|
-
tracer
|
|
226
|
-
}, async (batch) => {
|
|
227
|
-
if (snapshotLsn == null) {
|
|
228
|
-
// First replication attempt - get a snapshot and store the timestamp
|
|
229
|
-
snapshotLsn = await this.getSnapshotLsn();
|
|
230
|
-
await batch.setResumeLsn(snapshotLsn);
|
|
231
|
-
this.logger.info(`Marking snapshot at ${snapshotLsn}`);
|
|
232
|
-
}
|
|
233
|
-
else {
|
|
234
|
-
this.logger.info(`Resuming snapshot at ${snapshotLsn}`);
|
|
235
|
-
// Check that the snapshot is still valid.
|
|
236
|
-
await this.validateSnapshotLsn(snapshotLsn);
|
|
237
|
-
}
|
|
238
|
-
// Start by resolving all tables.
|
|
239
|
-
// This checks postImage configuration, and that should fail as
|
|
240
|
-
// early as possible.
|
|
241
|
-
let allSourceTables = [];
|
|
242
|
-
for (let tablePattern of sourceTables) {
|
|
243
|
-
const tables = await this.resolveQualifiedTableNames(batch, tablePattern);
|
|
244
|
-
allSourceTables.push(...tables);
|
|
245
|
-
}
|
|
246
|
-
let tablesWithStatus = [];
|
|
247
|
-
for (let table of allSourceTables) {
|
|
248
|
-
if (table.snapshotComplete) {
|
|
249
|
-
this.logger.info(`Skipping ${table.qualifiedName} - snapshot already done`);
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
|
-
let count = await this.estimatedCountNumber(table);
|
|
253
|
-
const updated = await batch.updateTableProgress(table, {
|
|
254
|
-
totalEstimatedCount: count
|
|
255
|
-
});
|
|
256
|
-
tablesWithStatus.push(updated);
|
|
257
|
-
this.relationCache.update(updated);
|
|
258
|
-
this.logger.info(`To replicate: ${table.qualifiedName}: ${updated.snapshotStatus?.replicatedCount}/~${updated.snapshotStatus?.totalEstimatedCount}`);
|
|
259
|
-
}
|
|
260
|
-
for (let table of tablesWithStatus) {
|
|
261
|
-
await this.snapshotTable(batch, table);
|
|
262
|
-
await batch.markTableSnapshotDone([table]);
|
|
263
|
-
this.touch();
|
|
264
|
-
}
|
|
265
|
-
// The checkpoint here is a marker - we need to replicate up to at least this
|
|
266
|
-
// point before the data can be considered consistent.
|
|
267
|
-
// We could do this for each individual table, but may as well just do it once for the entire snapshot.
|
|
268
|
-
const checkpoint = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
|
|
269
|
-
await batch.markAllSnapshotDone(checkpoint);
|
|
270
|
-
// This will not create a consistent checkpoint yet, but will persist the op.
|
|
271
|
-
// Actual checkpoint will be created when streaming replication caught up.
|
|
272
|
-
await batch.commit(snapshotLsn);
|
|
273
|
-
this.logger.info(`Snapshot done. Need to replicate from ${snapshotLsn} to ${checkpoint} to be consistent`);
|
|
274
|
-
});
|
|
275
|
-
return { lastOpId: flushResult?.flushed_op };
|
|
276
|
-
}
|
|
277
|
-
async setupCheckpointsCollection() {
|
|
278
|
-
const collection = await this.getCollectionInfo(this.defaultDb.databaseName, CHECKPOINTS_COLLECTION);
|
|
279
|
-
if (collection == null) {
|
|
280
|
-
await this.defaultDb.createCollection(CHECKPOINTS_COLLECTION, {
|
|
281
|
-
changeStreamPreAndPostImages: { enabled: true }
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
else if (this.usePostImages && collection.options?.changeStreamPreAndPostImages?.enabled != true) {
|
|
285
|
-
// Drop + create requires less permissions than collMod,
|
|
286
|
-
// and we don't care about the data in this collection.
|
|
287
|
-
await this.defaultDb.dropCollection(CHECKPOINTS_COLLECTION);
|
|
288
|
-
await this.defaultDb.createCollection(CHECKPOINTS_COLLECTION, {
|
|
289
|
-
changeStreamPreAndPostImages: { enabled: true }
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
else {
|
|
293
|
-
// Clear the collection on startup, to keep it clean
|
|
294
|
-
// We never query this collection directly, and don't want to keep the data around.
|
|
295
|
-
// We only use this to get data into the oplog/changestream.
|
|
296
|
-
await this.defaultDb.collection(CHECKPOINTS_COLLECTION).deleteMany({});
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
96
|
getSourceNamespaceFilters() {
|
|
300
97
|
const sourceTables = this.sync_rules.getSourceTables();
|
|
301
98
|
let $inFilters = [
|
|
@@ -343,73 +140,10 @@ export class ChangeStream {
|
|
|
343
140
|
}
|
|
344
141
|
return { $match: nsFilter, multipleDatabases };
|
|
345
142
|
}
|
|
346
|
-
async
|
|
347
|
-
const
|
|
348
|
-
const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
|
|
349
|
-
const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
|
|
350
|
-
const totalEstimatedCount = await this.estimatedCountNumber(table);
|
|
351
|
-
let at = table.snapshotStatus?.replicatedCount ?? 0;
|
|
352
|
-
const db = this.client.db(table.schema);
|
|
353
|
-
const collection = db.collection(table.name);
|
|
354
|
-
await using query = new ChunkedSnapshotQuery({
|
|
355
|
-
collection,
|
|
356
|
-
key: table.snapshotStatus?.lastKey,
|
|
357
|
-
batchSize: this.snapshotChunkLength
|
|
358
|
-
});
|
|
359
|
-
if (query.lastKey != null) {
|
|
360
|
-
this.logger.info(`Replicating ${table.qualifiedName} ${table.formatSnapshotProgress()} - resuming at _id > ${query.lastKey}`);
|
|
361
|
-
}
|
|
362
|
-
else {
|
|
363
|
-
this.logger.info(`Replicating ${table.qualifiedName} ${table.formatSnapshotProgress()}`);
|
|
364
|
-
}
|
|
365
|
-
let lastBatch = performance.now();
|
|
366
|
-
let nextChunkPromise = query.nextChunk();
|
|
367
|
-
while (true) {
|
|
368
|
-
const { docs: docBatch, lastKey, bytes: chunkBytes } = await nextChunkPromise;
|
|
369
|
-
if (docBatch.length == 0) {
|
|
370
|
-
// No more data - stop iterating
|
|
371
|
-
break;
|
|
372
|
-
}
|
|
373
|
-
bytesReplicatedMetric.add(chunkBytes);
|
|
374
|
-
chunksReplicatedMetric.add(1);
|
|
375
|
-
if (this.abort_signal.aborted) {
|
|
376
|
-
throw new ReplicationAbortedError(`Aborted initial replication`, this.abort_signal.reason);
|
|
377
|
-
}
|
|
378
|
-
// Pre-fetch next batch, so that we can read and write concurrently
|
|
379
|
-
nextChunkPromise = query.nextChunk();
|
|
380
|
-
for (let buffer of docBatch) {
|
|
381
|
-
const { row: record, replicaId: replicaId } = this.rawToSqliteRow(buffer);
|
|
382
|
-
// This auto-flushes when the batch reaches its size limit
|
|
383
|
-
await batch.save({
|
|
384
|
-
tag: SaveOperationTag.INSERT,
|
|
385
|
-
sourceTable: table,
|
|
386
|
-
before: undefined,
|
|
387
|
-
beforeReplicaId: undefined,
|
|
388
|
-
after: record,
|
|
389
|
-
afterReplicaId: replicaId
|
|
390
|
-
});
|
|
391
|
-
}
|
|
392
|
-
// Important: flush before marking progress
|
|
393
|
-
await batch.flush();
|
|
394
|
-
at += docBatch.length;
|
|
395
|
-
rowsReplicatedMetric.add(docBatch.length);
|
|
396
|
-
table = await batch.updateTableProgress(table, {
|
|
397
|
-
lastKey,
|
|
398
|
-
replicatedCount: at,
|
|
399
|
-
totalEstimatedCount: totalEstimatedCount
|
|
400
|
-
});
|
|
401
|
-
this.relationCache.update(table);
|
|
402
|
-
const duration = performance.now() - lastBatch;
|
|
403
|
-
lastBatch = performance.now();
|
|
404
|
-
this.logger.info(`Replicating ${table.qualifiedName} ${table.formatSnapshotProgress()} in ${duration.toFixed(0)}ms`);
|
|
405
|
-
this.touch();
|
|
406
|
-
}
|
|
407
|
-
// In case the loop was interrupted, make sure we await the last promise.
|
|
408
|
-
await nextChunkPromise;
|
|
409
|
-
}
|
|
410
|
-
async getRelation(batch, descriptor, options) {
|
|
411
|
-
const existing = this.relationCache.get(descriptor);
|
|
143
|
+
async getRelations(batch, descriptor, options) {
|
|
144
|
+
const existing = this.relationCache.getAll(descriptor);
|
|
412
145
|
if (existing != null) {
|
|
146
|
+
// We do this even when it's an empty result: Empty means nothing to sync, and we don't need to re-resolve.
|
|
413
147
|
return existing;
|
|
414
148
|
}
|
|
415
149
|
// Note: collection may have been dropped at this point, so we handle
|
|
@@ -452,14 +186,11 @@ export class ChangeStream {
|
|
|
452
186
|
// Ignore the postImages check in this case.
|
|
453
187
|
}
|
|
454
188
|
const snapshot = options.snapshot;
|
|
455
|
-
const result = await
|
|
456
|
-
group_id: this.group_id,
|
|
189
|
+
const result = await batch.resolveTables({
|
|
457
190
|
connection_id: this.connection_id,
|
|
458
|
-
|
|
459
|
-
entity_descriptor: descriptor,
|
|
460
|
-
sync_rules: this.sync_rules
|
|
191
|
+
source: descriptor
|
|
461
192
|
});
|
|
462
|
-
this.relationCache.
|
|
193
|
+
this.relationCache.updateAll(descriptor, result.tables);
|
|
463
194
|
// Drop conflicting collections.
|
|
464
195
|
// This is generally not expected for MongoDB source dbs, so we log an error.
|
|
465
196
|
if (result.dropTables.length > 0) {
|
|
@@ -470,17 +201,12 @@ export class ChangeStream {
|
|
|
470
201
|
// 1. Snapshot is requested (false for initial snapshot, since that process handles it elsewhere)
|
|
471
202
|
// 2. Snapshot is not already done, AND:
|
|
472
203
|
// 3. The table is used in sync config.
|
|
473
|
-
const
|
|
474
|
-
if (
|
|
204
|
+
const snapshotCandidates = result.tables.filter((table) => snapshot && !table.snapshotComplete && table.syncAny);
|
|
205
|
+
if (snapshotCandidates.length > 0) {
|
|
475
206
|
this.logger.info(`New collection: ${descriptor.schema}.${descriptor.name}`);
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const no_checkpoint_before_lsn = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
|
|
480
|
-
const [table] = await batch.markTableSnapshotDone([result.table], no_checkpoint_before_lsn);
|
|
481
|
-
return table;
|
|
482
|
-
}
|
|
483
|
-
return result.table;
|
|
207
|
+
await this.snapshotter.snapshotTables(batch, snapshotCandidates);
|
|
208
|
+
}
|
|
209
|
+
return result.tables;
|
|
484
210
|
}
|
|
485
211
|
async writeChange(batch, table, change) {
|
|
486
212
|
if (!table.syncAny) {
|
|
@@ -535,36 +261,76 @@ export class ChangeStream {
|
|
|
535
261
|
}
|
|
536
262
|
}
|
|
537
263
|
async replicate() {
|
|
264
|
+
let streamPromise = null;
|
|
265
|
+
let loopPromise = null;
|
|
266
|
+
let cleanupPromise = null;
|
|
538
267
|
try {
|
|
539
268
|
// If anything errors here, the entire replication process is halted, and
|
|
540
269
|
// all connections automatically closed, including this one.
|
|
541
|
-
|
|
542
|
-
await this.
|
|
270
|
+
this.initPromise = this.initReplication();
|
|
271
|
+
await this.initPromise;
|
|
272
|
+
loopPromise = this.snapshotter
|
|
273
|
+
.replicationLoop()
|
|
274
|
+
.then(() => {
|
|
275
|
+
throw new ReplicationAssertionError(`Replication snapshotter exited unexpectedly`);
|
|
276
|
+
})
|
|
277
|
+
.catch((e) => {
|
|
278
|
+
this.abortController.abort(e);
|
|
279
|
+
throw e;
|
|
280
|
+
});
|
|
281
|
+
if (!this.snapshotter.supportsConcurrentSnapshots) {
|
|
282
|
+
await Promise.race([this.snapshotter.waitForInitialSnapshot(), loopPromise]);
|
|
283
|
+
}
|
|
284
|
+
// Unlike the other two, this resolves on completion, not an indefinite loop.
|
|
285
|
+
cleanupPromise = this.cleanupStoppedSyncConfigs().catch((e) => {
|
|
286
|
+
this.abortController.abort(e);
|
|
287
|
+
throw e;
|
|
288
|
+
});
|
|
289
|
+
streamPromise = this.streamChanges()
|
|
290
|
+
.then(() => {
|
|
291
|
+
throw new ReplicationAssertionError(`Replication stream exited unexpectedly`);
|
|
292
|
+
})
|
|
293
|
+
.catch((e) => {
|
|
294
|
+
this.abortController.abort(e);
|
|
295
|
+
throw e;
|
|
296
|
+
});
|
|
297
|
+
const results = await Promise.allSettled([loopPromise, streamPromise, cleanupPromise]);
|
|
298
|
+
throw replicationLoopError(results);
|
|
543
299
|
}
|
|
544
300
|
catch (e) {
|
|
545
301
|
await this.storage.reportError(e);
|
|
546
302
|
throw e;
|
|
547
303
|
}
|
|
304
|
+
finally {
|
|
305
|
+
this.abortController.abort();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async waitForInitialSnapshot() {
|
|
309
|
+
if (this.initPromise == null) {
|
|
310
|
+
throw new ReplicationAssertionError('replicate() must be called before waitForInitialSnapshot()');
|
|
311
|
+
}
|
|
312
|
+
await this.initPromise;
|
|
313
|
+
await this.snapshotter.waitForInitialSnapshot();
|
|
548
314
|
}
|
|
549
315
|
async initReplication() {
|
|
550
|
-
const result = await this.
|
|
551
|
-
await this.setupCheckpointsCollection();
|
|
316
|
+
const result = await this.snapshotter.checkSlot();
|
|
317
|
+
await this.snapshotter.setupCheckpointsCollection();
|
|
552
318
|
if (result.needsInitialSync) {
|
|
553
319
|
if (result.snapshotLsn == null) {
|
|
554
320
|
// Snapshot LSN is not present, so we need to start replication from scratch.
|
|
555
|
-
await this.storage.clear({ signal: this.
|
|
556
|
-
}
|
|
557
|
-
const { lastOpId } = await this.initialReplication(result.snapshotLsn);
|
|
558
|
-
if (lastOpId != null) {
|
|
559
|
-
// Populate the cache _after_ initial replication, but _before_ we switch to this replication stream.
|
|
560
|
-
await this.storage.populatePersistentChecksumCache({
|
|
561
|
-
signal: this.abort_signal,
|
|
562
|
-
// No checkpoint yet, but we do have the opId.
|
|
563
|
-
maxOpId: lastOpId
|
|
564
|
-
});
|
|
321
|
+
await this.storage.clear({ signal: this.abortSignal });
|
|
565
322
|
}
|
|
323
|
+
await this.snapshotter.queueSnapshotTables(result.snapshotLsn);
|
|
566
324
|
}
|
|
567
325
|
}
|
|
326
|
+
async cleanupStoppedSyncConfigs() {
|
|
327
|
+
await this.storage.cleanupStoppedSyncConfigs?.({
|
|
328
|
+
signal: this.abortSignal,
|
|
329
|
+
logger: this.logger,
|
|
330
|
+
defaultSchema: this.defaultDb.databaseName,
|
|
331
|
+
sourceConnectionTag: this.connections.connectionTag
|
|
332
|
+
});
|
|
333
|
+
}
|
|
568
334
|
async streamChanges() {
|
|
569
335
|
try {
|
|
570
336
|
await this.streamChangesInternal();
|
|
@@ -649,6 +415,7 @@ export class ChangeStream {
|
|
|
649
415
|
defaultSchema: this.defaultDb.databaseName,
|
|
650
416
|
// We get a complete postimage for every change, so we don't need to store the current data.
|
|
651
417
|
storeCurrentData: false,
|
|
418
|
+
hooks: this.storageHooks,
|
|
652
419
|
tracer
|
|
653
420
|
}, async (batch) => {
|
|
654
421
|
const { resumeFromLsn } = batch;
|
|
@@ -667,7 +434,7 @@ export class ChangeStream {
|
|
|
667
434
|
const batchStream = this.rawChangeStreamBatches({
|
|
668
435
|
lsn: resumeFromLsn,
|
|
669
436
|
filters,
|
|
670
|
-
signal: this.
|
|
437
|
+
signal: this.abortSignal,
|
|
671
438
|
tracer
|
|
672
439
|
});
|
|
673
440
|
// Always start with a checkpoint.
|
|
@@ -683,7 +450,7 @@ export class ChangeStream {
|
|
|
683
450
|
using batchSpan = tracer.span('processing');
|
|
684
451
|
bytesReplicatedMetric.add(eventBatch.byteSize);
|
|
685
452
|
chunksReplicatedMetric.add(1);
|
|
686
|
-
if (this.
|
|
453
|
+
if (this.abortSignal.aborted) {
|
|
687
454
|
break;
|
|
688
455
|
}
|
|
689
456
|
this.touch();
|
|
@@ -712,7 +479,7 @@ export class ChangeStream {
|
|
|
712
479
|
for (let eventIndex = 0; eventIndex < events.length; eventIndex++) {
|
|
713
480
|
const rawChangeDocument = events[eventIndex];
|
|
714
481
|
const originalChangeDocument = parseChangeDocument(rawChangeDocument);
|
|
715
|
-
if (this.
|
|
482
|
+
if (this.abortSignal.aborted) {
|
|
716
483
|
break;
|
|
717
484
|
}
|
|
718
485
|
if (startAfter != null && originalChangeDocument.clusterTime?.lte(startAfter)) {
|
|
@@ -799,12 +566,19 @@ export class ChangeStream {
|
|
|
799
566
|
// change stream events, collapse standalone checkpoints into the normal batch
|
|
800
567
|
// checkpoint flow to avoid commit churn under sustained load.
|
|
801
568
|
const hasBufferedChanges = eventIndex < events.length - 1;
|
|
802
|
-
if (waitForCheckpointLsn
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
569
|
+
if (hasBufferedChanges && waitForCheckpointLsn == null) {
|
|
570
|
+
// Buffered changes - create a new batch checkpoint to rate limit commits
|
|
571
|
+
using _ = tracer.span('source_checkpoint');
|
|
572
|
+
waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
806
573
|
continue;
|
|
807
574
|
}
|
|
575
|
+
else if (waitForCheckpointLsn != null) {
|
|
576
|
+
// Skip this checkpoint - wait for the batch checkpoint.
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
// No buffered changes, and no batch checkpoint pending - commit immediately.
|
|
581
|
+
}
|
|
808
582
|
}
|
|
809
583
|
else if (!this.checkpointStreamId.equals(checkpointId)) {
|
|
810
584
|
continue;
|
|
@@ -813,21 +587,13 @@ export class ChangeStream {
|
|
|
813
587
|
timestamp: changeDocument.clusterTime,
|
|
814
588
|
resume_token: changeDocument._id
|
|
815
589
|
});
|
|
816
|
-
if (batch.lastCheckpointLsn != null && lsn < batch.lastCheckpointLsn) {
|
|
817
|
-
// Checkpoint out of order - should never happen with MongoDB.
|
|
818
|
-
// If it does happen, we throw an error to stop the replication - restarting should recover.
|
|
819
|
-
// Since we use batch.lastCheckpointLsn for the next resumeAfter, this should not result in an infinite loop.
|
|
820
|
-
// Originally a workaround for https://jira.mongodb.org/browse/NODE-7042.
|
|
821
|
-
// This has been fixed in the driver in the meantime, but we still keep this as a safety-check.
|
|
822
|
-
throw new ReplicationAssertionError(`Change resumeToken ${changeDocument._id._data} (${timestampToDate(changeDocument.clusterTime).toISOString()}) is less than last checkpoint LSN ${batch.lastCheckpointLsn}. Restarting replication.`);
|
|
823
|
-
}
|
|
824
590
|
if (waitForCheckpointLsn != null && lsn >= waitForCheckpointLsn) {
|
|
825
591
|
waitForCheckpointLsn = null;
|
|
826
592
|
}
|
|
827
|
-
const { checkpointBlocked } = await batch.commit(lsn, {
|
|
593
|
+
const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
|
|
828
594
|
oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
|
|
829
595
|
});
|
|
830
|
-
if (!checkpointBlocked) {
|
|
596
|
+
if (!checkpointBlocked || checkpointCreated) {
|
|
831
597
|
this.replicationLag.markCommitted();
|
|
832
598
|
}
|
|
833
599
|
}
|
|
@@ -836,17 +602,19 @@ export class ChangeStream {
|
|
|
836
602
|
changeDocument.operationType == 'replace' ||
|
|
837
603
|
changeDocument.operationType == 'delete') {
|
|
838
604
|
if (waitForCheckpointLsn == null) {
|
|
605
|
+
using _ = tracer.span('source_checkpoint');
|
|
839
606
|
waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
840
607
|
}
|
|
841
|
-
const rel = getMongoRelation(changeDocument.ns);
|
|
842
|
-
const
|
|
608
|
+
const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
609
|
+
const tables = await this.getRelations(batch, rel, {
|
|
843
610
|
// In most cases, we should not need to snapshot this. But if this is the first time we see the collection
|
|
844
611
|
// for whatever reason, then we do need to snapshot it.
|
|
845
612
|
// This may result in some duplicate operations when a collection is created for the first time after
|
|
846
613
|
// sync config was deployed.
|
|
847
614
|
snapshot: true
|
|
848
615
|
});
|
|
849
|
-
|
|
616
|
+
const tablesToReplicate = tables.filter((table) => table.syncAny);
|
|
617
|
+
if (tablesToReplicate.length > 0) {
|
|
850
618
|
this.replicationLag.trackUncommittedChange(changeDocument.clusterTime == null ? null : timestampToDate(changeDocument.clusterTime));
|
|
851
619
|
const transactionKeyValue = transactionKey(changeDocument);
|
|
852
620
|
if (transactionKeyValue == null || lastTxnKey != transactionKeyValue) {
|
|
@@ -856,31 +624,35 @@ export class ChangeStream {
|
|
|
856
624
|
lastTxnKey = transactionKeyValue;
|
|
857
625
|
transactionsReplicatedMetric.add(1);
|
|
858
626
|
}
|
|
859
|
-
|
|
627
|
+
for (const table of tablesToReplicate) {
|
|
628
|
+
await this.writeChange(batch, table, changeDocument);
|
|
629
|
+
}
|
|
860
630
|
}
|
|
861
631
|
}
|
|
862
632
|
else if (changeDocument.operationType == 'drop') {
|
|
863
|
-
const rel = getMongoRelation(changeDocument.ns);
|
|
864
|
-
const
|
|
633
|
+
const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
634
|
+
const tables = await this.getRelations(batch, rel, {
|
|
865
635
|
// We're "dropping" this collection, so never snapshot it.
|
|
866
636
|
snapshot: false
|
|
867
637
|
});
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
638
|
+
const tablesToDrop = tables.filter((table) => table.syncAny);
|
|
639
|
+
if (tablesToDrop.length > 0) {
|
|
640
|
+
await batch.drop(tablesToDrop);
|
|
871
641
|
}
|
|
642
|
+
this.relationCache.delete(rel);
|
|
872
643
|
}
|
|
873
644
|
else if (changeDocument.operationType == 'rename') {
|
|
874
|
-
const relFrom = getMongoRelation(changeDocument.ns);
|
|
875
|
-
const relTo = getMongoRelation(changeDocument.to);
|
|
876
|
-
const
|
|
645
|
+
const relFrom = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
646
|
+
const relTo = getMongoRelation(changeDocument.to, this.connections.connectionTag);
|
|
647
|
+
const tablesFrom = await this.getRelations(batch, relFrom, {
|
|
877
648
|
// We're "dropping" this collection, so never snapshot it.
|
|
878
649
|
snapshot: false
|
|
879
650
|
});
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
651
|
+
const tablesToDrop = tablesFrom.filter((table) => table.syncAny);
|
|
652
|
+
if (tablesToDrop.length > 0) {
|
|
653
|
+
await batch.drop(tablesToDrop);
|
|
883
654
|
}
|
|
655
|
+
this.relationCache.delete(relFrom);
|
|
884
656
|
// Here we do need to snapshot the new table
|
|
885
657
|
const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
|
|
886
658
|
await this.handleRelation(batch, relTo, {
|
|
@@ -901,17 +673,18 @@ export class ChangeStream {
|
|
|
901
673
|
await batch.setResumeLsn(lsn);
|
|
902
674
|
}
|
|
903
675
|
batchSpan.end();
|
|
904
|
-
const
|
|
905
|
-
const duration = batchSpan.
|
|
676
|
+
const durationsMicroseconds = outerSpan.end();
|
|
677
|
+
const duration = batchSpan.durationMillis;
|
|
906
678
|
this.logger.info(`Processed batch of ${events.length} changes / ${eventBatch.byteSize} bytes in ${duration}ms`, {
|
|
907
679
|
count: events.length,
|
|
908
680
|
bytes: eventBatch.byteSize,
|
|
909
681
|
duration,
|
|
910
|
-
t:
|
|
682
|
+
t: durationsMicroseconds
|
|
911
683
|
});
|
|
912
684
|
outerSpan = tracer.span('batch');
|
|
913
685
|
}
|
|
914
686
|
});
|
|
687
|
+
throw new ReplicationAbortedError(`Replication stream aborted`, this.abortSignal.reason);
|
|
915
688
|
}
|
|
916
689
|
getReplicationLagMillis() {
|
|
917
690
|
return this.replicationLag.getLagMillis();
|
|
@@ -936,4 +709,26 @@ function transactionKey(doc) {
|
|
|
936
709
|
}
|
|
937
710
|
return `${doc.lsid.id.toString('hex')}:${doc.txnNumber}`;
|
|
938
711
|
}
|
|
712
|
+
/**
|
|
713
|
+
* Prioritize errors that are _not_ ReplicationAbortedError. Any error on either loopPromise or
|
|
714
|
+
* streamPromise aborts the other one, which then results in a ReplicationAbortedError, hiding the
|
|
715
|
+
* original cause.
|
|
716
|
+
*/
|
|
717
|
+
function replicationLoopError(results) {
|
|
718
|
+
// 1. Prioritize not ReplicationAbortedError.
|
|
719
|
+
for (const result of results) {
|
|
720
|
+
if (result.status == 'rejected' && !(result.reason instanceof ReplicationAbortedError)) {
|
|
721
|
+
return result.reason;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
// 2. Fallback to ReplicationAbortedError.
|
|
725
|
+
for (const result of results) {
|
|
726
|
+
if (result.status == 'rejected') {
|
|
727
|
+
// At this point only ReplicationAbortedError remains
|
|
728
|
+
return result.reason;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
// 3. Should never happen, but we cover this case.
|
|
732
|
+
return new ReplicationAssertionError(`Replication loop exited unexpectedly`);
|
|
733
|
+
}
|
|
939
734
|
//# sourceMappingURL=ChangeStream.js.map
|