@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/api/MongoRouteAPIAdapter.d.ts +2 -2
  3. package/dist/api/MongoRouteAPIAdapter.js +12 -21
  4. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  5. package/dist/replication/ChangeStream.d.ts +19 -37
  6. package/dist/replication/ChangeStream.js +157 -362
  7. package/dist/replication/ChangeStream.js.map +1 -1
  8. package/dist/replication/ChangeStreamReplicationJob.js +1 -1
  9. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  10. package/dist/replication/ChangeStreamReplicator.js +1 -1
  11. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  12. package/dist/replication/MongoRelation.d.ts +1 -1
  13. package/dist/replication/MongoRelation.js +41 -21
  14. package/dist/replication/MongoRelation.js.map +1 -1
  15. package/dist/replication/MongoSnapshotter.d.ts +80 -0
  16. package/dist/replication/MongoSnapshotter.js +585 -0
  17. package/dist/replication/MongoSnapshotter.js.map +1 -0
  18. package/dist/types/types.d.ts +2 -2
  19. package/package.json +11 -21
  20. package/src/api/MongoRouteAPIAdapter.ts +14 -22
  21. package/src/replication/ChangeStream.ts +173 -439
  22. package/src/replication/ChangeStreamReplicationJob.ts +1 -1
  23. package/src/replication/ChangeStreamReplicator.ts +1 -1
  24. package/src/replication/MongoRelation.ts +51 -25
  25. package/src/replication/MongoSnapshotter.ts +719 -0
  26. package/test/src/change_stream.test.ts +214 -20
  27. package/test/src/change_stream_utils.ts +41 -34
  28. package/test/src/checkpoint_retry.test.ts +131 -0
  29. package/test/src/resume.test.ts +2 -2
  30. package/test/src/resuming_snapshots.test.ts +10 -6
  31. package/test/src/util.ts +2 -1
  32. package/tsconfig.json +1 -4
  33. package/tsconfig.scripts.json +1 -2
  34. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,719 @@
1
+ import { mongo } from '@powersync/lib-service-mongodb';
2
+ import { container, ErrorCode, Logger, ReplicationAbortedError, ServiceError } from '@powersync/lib-services-framework';
3
+ import {
4
+ MetricsEngine,
5
+ PerformanceTracer,
6
+ SaveOperationTag,
7
+ SourceEntityDescriptor,
8
+ SourceTable,
9
+ storage
10
+ } from '@powersync/service-core';
11
+ import { HydratedSyncConfig, TablePattern } from '@powersync/service-sync-rules';
12
+ import { ReplicationMetric } from '@powersync/service-types';
13
+ import { performance } from 'node:perf_hooks';
14
+ import { MongoLSN } from '../common/MongoLSN.js';
15
+ import { PostImagesOption } from '../types/types.js';
16
+ import { escapeRegExp } from '../utils.js';
17
+ import { MongoManager } from './MongoManager.js';
18
+ import { createCheckpoint, getMongoRelation, STANDALONE_CHECKPOINT_ID } from './MongoRelation.js';
19
+ import { ChunkedSnapshotQuery } from './MongoSnapshotQuery.js';
20
+ import { ChangeStreamBatch, parseChangeDocument, rawChangeStream } from './RawChangeStream.js';
21
+ import { CHECKPOINTS_COLLECTION } from './replication-utils.js';
22
+ import { DirectSourceRowConverter, SourceRowConverter } from './SourceRowConverter.js';
23
+
24
+ export interface MongoSnapshotterOptions {
25
+ connections: MongoManager;
26
+ storage: storage.SyncRulesBucketStorage;
27
+ metrics: MetricsEngine;
28
+ abortSignal: AbortSignal;
29
+ maxAwaitTimeMS?: number;
30
+ snapshotChunkLength?: number;
31
+ logger?: Logger;
32
+ checkpointStreamId: mongo.ObjectId;
33
+ storageHooks?: storage.StorageHooks;
34
+ snapshotHooks?: MongoSnapshotterHooks;
35
+ }
36
+
37
+ export interface MongoSnapshotterHooks {
38
+ beforeSnapshotStarted?: (table: SourceTable) => Promise<void>;
39
+ }
40
+
41
+ interface InitResult {
42
+ needsInitialSync: boolean;
43
+ snapshotLsn: string | null;
44
+ }
45
+
46
+ interface SnapshotQueueItem {
47
+ table: SourceTable;
48
+ ready: Promise<void>;
49
+ cancelled: boolean;
50
+ }
51
+
52
+ export class MongoSnapshotter {
53
+ private readonly storage: storage.SyncRulesBucketStorage;
54
+ private readonly metrics: MetricsEngine;
55
+ private readonly connections: MongoManager;
56
+ private readonly client: mongo.MongoClient;
57
+ private readonly defaultDb: mongo.Db;
58
+ private readonly syncRules: HydratedSyncConfig;
59
+ private readonly sourceRowConverter: SourceRowConverter;
60
+ private readonly maxAwaitTimeMS: number;
61
+ private readonly snapshotChunkLength: number;
62
+ private readonly abortSignal: AbortSignal;
63
+ private readonly logger: Logger;
64
+ private readonly checkpointStreamId: mongo.ObjectId;
65
+ private readonly storageHooks: storage.StorageHooks | undefined;
66
+ private readonly snapshotHooks: MongoSnapshotterHooks | undefined;
67
+ private readonly changeStreamTimeout: number;
68
+
69
+ private readonly connectionId = 1;
70
+ private readonly queue = new Set<SnapshotQueueItem>();
71
+ private initialSnapshotDone = Promise.withResolvers<void>();
72
+ private nextItemQueued: PromiseWithResolvers<void> | null = null;
73
+ private lastTouchedAt = performance.now();
74
+
75
+ constructor(options: MongoSnapshotterOptions) {
76
+ this.storage = options.storage;
77
+ this.metrics = options.metrics;
78
+ this.connections = options.connections;
79
+ this.client = options.connections.client;
80
+ this.defaultDb = options.connections.db;
81
+ this.maxAwaitTimeMS = options.maxAwaitTimeMS ?? 10_000;
82
+ this.snapshotChunkLength = options.snapshotChunkLength ?? 6_000;
83
+ this.abortSignal = options.abortSignal;
84
+ this.logger = options.logger ?? options.storage.logger;
85
+ this.checkpointStreamId = options.checkpointStreamId;
86
+ this.storageHooks = options.storageHooks;
87
+ this.snapshotHooks = options.snapshotHooks;
88
+ this.changeStreamTimeout = Math.ceil(this.client.options.socketTimeoutMS * 0.9);
89
+ this.syncRules = options.storage.getParsedSyncRules({
90
+ defaultSchema: this.defaultDb.databaseName
91
+ });
92
+ this.sourceRowConverter = new DirectSourceRowConverter(this.syncRules.compatibility);
93
+
94
+ this.abortSignal.addEventListener('abort', () => {
95
+ this.nextItemQueued?.resolve();
96
+ });
97
+ }
98
+
99
+ private get usePostImages() {
100
+ return this.connections.options.postImages != PostImagesOption.OFF;
101
+ }
102
+
103
+ private get configurePostImages() {
104
+ return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
105
+ }
106
+
107
+ public get supportsConcurrentSnapshots() {
108
+ return this.storage.storageConfig.softDeleteCurrentData;
109
+ }
110
+
111
+ async checkSlot(): Promise<InitResult> {
112
+ const status = await this.storage.getStatus();
113
+ if (status.snapshotDone) {
114
+ this.logger.info(`Initial replication already done`);
115
+ return { needsInitialSync: false, snapshotLsn: null };
116
+ }
117
+
118
+ return { needsInitialSync: true, snapshotLsn: status.resumeLsn };
119
+ }
120
+
121
+ async setupCheckpointsCollection() {
122
+ const collection = await this.getCollectionInfo(this.defaultDb.databaseName, CHECKPOINTS_COLLECTION);
123
+ if (collection == null) {
124
+ await this.defaultDb.createCollection(CHECKPOINTS_COLLECTION, {
125
+ changeStreamPreAndPostImages: { enabled: true }
126
+ });
127
+ } else if (this.usePostImages && collection.options?.changeStreamPreAndPostImages?.enabled != true) {
128
+ // Drop + create requires less permissions than collMod,
129
+ // and we don't care about the data in this collection.
130
+ await this.defaultDb.dropCollection(CHECKPOINTS_COLLECTION);
131
+ await this.defaultDb.createCollection(CHECKPOINTS_COLLECTION, {
132
+ changeStreamPreAndPostImages: { enabled: true }
133
+ });
134
+ } else {
135
+ // Clear the collection on startup, to keep it clean
136
+ // We never query this collection directly, and don't want to keep the data around.
137
+ // We only use this to get data into the oplog/changestream.
138
+ await this.defaultDb.collection(CHECKPOINTS_COLLECTION).deleteMany({});
139
+ }
140
+ }
141
+
142
+ async queueSnapshotTables(snapshotLsn: string | null) {
143
+ await this.client.connect();
144
+ await using writer = await this.storage.createWriter({
145
+ zeroLSN: MongoLSN.ZERO.comparable,
146
+ defaultSchema: this.defaultDb.databaseName,
147
+ storeCurrentData: false,
148
+ skipExistingRows: true,
149
+ tracer: new PerformanceTracer('MongoDB initial snapshot setup')
150
+ });
151
+ if (snapshotLsn == null) {
152
+ // First replication attempt - get a snapshot and store the timestamp
153
+ snapshotLsn = await this.getSnapshotLsn();
154
+ await writer.setResumeLsn(snapshotLsn);
155
+ this.logger.info(`Marking snapshot at ${snapshotLsn}`);
156
+ } else {
157
+ this.logger.info(`Resuming snapshot at ${snapshotLsn}`);
158
+ // Check that the snapshot is still valid.
159
+ await this.validateSnapshotLsn(snapshotLsn);
160
+ }
161
+
162
+ // Start by resolving all tables.
163
+ // This checks postImage configuration, and that should fail as
164
+ // early as possible.
165
+ const allSourceTables: SourceTable[] = [];
166
+ for (const tablePattern of this.syncRules.getSourceTables()) {
167
+ allSourceTables.push(...(await this.resolveQualifiedTableNames(writer, tablePattern)));
168
+ }
169
+
170
+ for (const table of allSourceTables) {
171
+ if (table.snapshotComplete) {
172
+ this.logger.info(`Skipping ${table.qualifiedName} - snapshot already done`);
173
+ continue;
174
+ }
175
+ const count = await this.estimatedCountNumber(table);
176
+ const updated = await writer.updateTableProgress(table, {
177
+ totalEstimatedCount: count
178
+ });
179
+ this.queueTable(updated);
180
+ this.logger.info(
181
+ `To replicate: ${updated.qualifiedName}: ${updated.snapshotStatus?.replicatedCount}/~${updated.snapshotStatus?.totalEstimatedCount}`
182
+ );
183
+ }
184
+ }
185
+
186
+ async waitForInitialSnapshot() {
187
+ await this.initialSnapshotDone.promise;
188
+ }
189
+
190
+ async replicationLoop() {
191
+ try {
192
+ if (this.queue.size == 0) {
193
+ // Special case where we start with no tables to snapshot
194
+ await this.markSnapshotDone();
195
+ }
196
+ while (!this.abortSignal.aborted) {
197
+ const item = this.queue.values().next().value;
198
+ if (item == null) {
199
+ this.initialSnapshotDone.resolve();
200
+ this.nextItemQueued = Promise.withResolvers<void>();
201
+ await this.nextItemQueued.promise;
202
+ this.nextItemQueued = null;
203
+ continue;
204
+ }
205
+
206
+ await item.ready;
207
+ if (!item.cancelled) {
208
+ await this.replicateTable(item.table);
209
+ }
210
+ this.queue.delete(item);
211
+ if (this.queue.size == 0) {
212
+ await this.markSnapshotDone();
213
+ }
214
+ }
215
+ throw new ReplicationAbortedError(`Replication snapshotter aborted`, this.abortSignal.reason);
216
+ } catch (e) {
217
+ // If initial snapshot already completed, this has no effect
218
+ this.initialSnapshotDone.reject(e);
219
+ throw e;
220
+ }
221
+ }
222
+
223
+ private async queueSnapshot(batch: storage.BucketStorageBatch, table: storage.SourceTable) {
224
+ const ready = Promise.withResolvers<void>();
225
+ const item = this.queueTable(table, ready.promise);
226
+ try {
227
+ await batch.markTableSnapshotRequired(table);
228
+ ready.resolve();
229
+ } catch (e) {
230
+ item.cancelled = true;
231
+ ready.resolve();
232
+ throw e;
233
+ } finally {
234
+ this.nextItemQueued?.resolve();
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Snapshot tables.
240
+ *
241
+ * If concurrency is supported, the snapshots are queued and processed in the background.
242
+ * Otherwise, snapshots are processed inline.
243
+ */
244
+ async snapshotTables(batch: storage.BucketStorageBatch, tables: storage.SourceTable[]): Promise<void> {
245
+ if (this.supportsConcurrentSnapshots) {
246
+ // Queue concurrent snapshots
247
+ for (const tableToSnapshot of tables) {
248
+ await this.queueSnapshot(batch, tableToSnapshot);
249
+ }
250
+ } else {
251
+ // No concurrency supported - snapshot inline
252
+ // Truncate in case a previous inline snapshot was interrupted after flushing rows, but before
253
+ // recording snapshot progress. Without this, resuming can replay already-flushed rows on v1/v2 storage.
254
+ await batch.truncate(tables);
255
+
256
+ for (const table of tables) {
257
+ await this.snapshotTable(batch, table);
258
+ }
259
+ const noCheckpointBefore = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
260
+
261
+ await batch.markTableSnapshotDone(tables, noCheckpointBefore);
262
+ }
263
+ }
264
+
265
+ private queueTable(table: SourceTable, ready = Promise.resolve()) {
266
+ const item: SnapshotQueueItem = { table, ready, cancelled: false };
267
+ this.queue.add(item);
268
+ this.nextItemQueued?.resolve();
269
+ return item;
270
+ }
271
+
272
+ private async markSnapshotDone() {
273
+ if (this.queue.size != 0) {
274
+ return;
275
+ }
276
+
277
+ const status = await this.storage.getStatus();
278
+ if (status.snapshotDone) {
279
+ return;
280
+ }
281
+
282
+ // Populate the cache _after_ initial replication, but _before_ we switch to this replication stream.
283
+ // Keeping snapshot_done false until this completes makes this resumable after interruption.
284
+ // No checkpoint exists yet - storage defaults to its highest persisted op id.
285
+ await this.storage.populatePersistentChecksumCache({
286
+ signal: this.abortSignal
287
+ });
288
+
289
+ if (this.queue.size != 0) {
290
+ return;
291
+ }
292
+
293
+ await using writer = await this.storage.createWriter({
294
+ logger: this.logger,
295
+ zeroLSN: MongoLSN.ZERO.comparable,
296
+ defaultSchema: this.defaultDb.databaseName,
297
+ storeCurrentData: false,
298
+ skipExistingRows: true
299
+ });
300
+
301
+ // The checkpoint here is a marker - we need to replicate up to at least this
302
+ // point before the data can be considered consistent.
303
+ const checkpoint = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
304
+ if (this.queue.size != 0) {
305
+ return;
306
+ }
307
+
308
+ await writer.markSnapshotDone(checkpoint, {
309
+ // If there is a conflict, we'll try again after the next snapshot
310
+ throwOnConflict: false
311
+ });
312
+ // KLUDGE: We need to create an extra checkpoint _after_ marking the snapshot done, to fix
313
+ // issues with order of processing commits(). This is picked up by tests on postgres storage,
314
+ // the issue may be specific to that storage engine.
315
+ await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
316
+ }
317
+
318
+ private async replicateTable(tableRequest: SourceTable) {
319
+ await this.snapshotHooks?.beforeSnapshotStarted?.(tableRequest);
320
+
321
+ await using writer = await this.storage.createWriter({
322
+ logger: this.logger,
323
+ zeroLSN: MongoLSN.ZERO.comparable,
324
+ defaultSchema: this.defaultDb.databaseName,
325
+ storeCurrentData: false,
326
+ skipExistingRows: true,
327
+ hooks: this.storageHooks,
328
+ tracer: new PerformanceTracer('MongoDB snapshot table')
329
+ });
330
+ // Get fresh table info, in case it was updated while queuing.
331
+ // This deliberately does not resolve by namespace, since that could recreate a replacement source table
332
+ // for a dropped/recreated collection and leave the original queued snapshot with no owner.
333
+ const table = await writer.getSourceTableStatus(tableRequest);
334
+ if (table == null || table.snapshotComplete) {
335
+ return;
336
+ }
337
+
338
+ await this.snapshotTable(writer, table);
339
+ const noCheckpointBefore = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
340
+ await writer.markTableSnapshotDone([table], noCheckpointBefore);
341
+
342
+ // This commit durably records the persisted ops, so a later checkpoint covers them.
343
+ const resumeLsn = writer.resumeFromLsn ?? MongoLSN.ZERO.comparable;
344
+ await writer.commit(resumeLsn);
345
+
346
+ this.logger.info(`Flushed snapshot at ${writer.last_flushed_op}`);
347
+ }
348
+
349
+ private async resolveQualifiedTableNames(
350
+ batch: storage.BucketStorageBatch,
351
+ tablePattern: TablePattern
352
+ ): Promise<storage.SourceTable[]> {
353
+ const schema = tablePattern.schema;
354
+ if (tablePattern.connectionTag != this.connections.connectionTag) {
355
+ return [];
356
+ }
357
+
358
+ const nameFilter = tablePattern.isWildcard
359
+ ? new RegExp('^' + escapeRegExp(tablePattern.tablePrefix))
360
+ : tablePattern.name;
361
+ // Check if the collection exists
362
+ const collections = await this.client
363
+ .db(schema)
364
+ .listCollections({ name: nameFilter }, { nameOnly: false })
365
+ .toArray();
366
+
367
+ if (!tablePattern.isWildcard && collections.length == 0) {
368
+ this.logger.warn(`Collection ${schema}.${tablePattern.name} not found`);
369
+ }
370
+
371
+ const result: storage.SourceTable[] = [];
372
+ for (const collection of collections) {
373
+ result.push(
374
+ ...(await this.handleRelation(
375
+ batch,
376
+ getMongoRelation({ db: schema, coll: collection.name }, this.connections.connectionTag),
377
+ {
378
+ collectionInfo: collection
379
+ }
380
+ ))
381
+ );
382
+ }
383
+
384
+ return result;
385
+ }
386
+
387
+ private async snapshotTable(batch: storage.BucketStorageBatch, table: storage.SourceTable) {
388
+ const rowsReplicatedMetric = this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED);
389
+ const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
390
+ const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
391
+
392
+ const totalEstimatedCount = await this.estimatedCountNumber(table);
393
+ let at = table.snapshotStatus?.replicatedCount ?? 0;
394
+ const collection = this.client.db(table.schema).collection(table.name);
395
+ await using query = new ChunkedSnapshotQuery({
396
+ collection,
397
+ key: table.snapshotStatus?.lastKey,
398
+ batchSize: this.snapshotChunkLength
399
+ });
400
+ if (query.lastKey != null) {
401
+ this.logger.info(
402
+ `Replicating ${table.qualifiedName} ${table.formatSnapshotProgress()} - resuming at _id > ${query.lastKey}`
403
+ );
404
+ } else {
405
+ this.logger.info(`Replicating ${table.qualifiedName} ${table.formatSnapshotProgress()}`);
406
+ }
407
+
408
+ let lastBatch = performance.now();
409
+ let nextChunkPromise = query.nextChunk();
410
+ while (true) {
411
+ const { docs: docBatch, lastKey, bytes: chunkBytes } = await nextChunkPromise;
412
+ if (docBatch.length == 0) {
413
+ // No more data - stop iterating
414
+ break;
415
+ }
416
+ bytesReplicatedMetric.add(chunkBytes);
417
+ chunksReplicatedMetric.add(1);
418
+
419
+ if (this.abortSignal.aborted) {
420
+ throw new ReplicationAbortedError(`Aborted initial replication`, this.abortSignal.reason);
421
+ }
422
+
423
+ // Pre-fetch next batch, so that we can read and write concurrently
424
+ nextChunkPromise = query.nextChunk();
425
+ for (const buffer of docBatch) {
426
+ const { row, replicaId } = this.sourceRowConverter.rawToSqliteRow(buffer);
427
+ // This auto-flushes when the batch reaches its size limit
428
+ await batch.save({
429
+ tag: SaveOperationTag.INSERT,
430
+ sourceTable: table,
431
+ before: undefined,
432
+ beforeReplicaId: undefined,
433
+ after: row,
434
+ afterReplicaId: replicaId
435
+ });
436
+ }
437
+
438
+ // Important: flush before marking progress
439
+ await batch.flush();
440
+ at += docBatch.length;
441
+ rowsReplicatedMetric.add(docBatch.length);
442
+
443
+ table = await batch.updateTableProgress(table, {
444
+ lastKey,
445
+ replicatedCount: at,
446
+ totalEstimatedCount
447
+ });
448
+
449
+ const duration = performance.now() - lastBatch;
450
+ lastBatch = performance.now();
451
+ this.logger.info(
452
+ `Replicating ${table.qualifiedName} ${table.formatSnapshotProgress()} in ${duration.toFixed(0)}ms`
453
+ );
454
+ this.touch();
455
+ }
456
+ // In case the loop was interrupted, make sure we await the last promise.
457
+ await nextChunkPromise;
458
+ }
459
+
460
+ private async handleRelation(
461
+ batch: storage.BucketStorageBatch,
462
+ descriptor: SourceEntityDescriptor,
463
+ options: { collectionInfo: mongo.CollectionInfo | undefined }
464
+ ): Promise<SourceTable[]> {
465
+ if (options.collectionInfo != null) {
466
+ await this.checkPostImages(descriptor.schema, options.collectionInfo);
467
+ } else {
468
+ // If collectionInfo is null, the collection may have been dropped.
469
+ // Ignore the postImages check in this case.
470
+ }
471
+
472
+ // Note: resolveTables uses the batch's own parsed sync config set. Passing
473
+ // this.syncRules here would pair sources from one parse with the batch's mapping
474
+ // from another parse.
475
+ const result = await batch.resolveTables({
476
+ connection_id: this.connectionId,
477
+ source: descriptor
478
+ });
479
+
480
+ // Drop conflicting collections.
481
+ // This is generally not expected for MongoDB source dbs, so we log an error.
482
+ if (result.dropTables.length > 0) {
483
+ this.logger.error(
484
+ `Conflicting collections found for ${JSON.stringify(descriptor)}. Dropping: ${result.dropTables.map((t) => t.id).join(', ')}`
485
+ );
486
+ await batch.drop(result.dropTables);
487
+ }
488
+
489
+ return result.tables;
490
+ }
491
+
492
+ private async estimatedCountNumber(table: storage.SourceTable): Promise<number> {
493
+ return await this.client.db(table.schema).collection(table.name).estimatedDocumentCount();
494
+ }
495
+
496
+ private async getCollectionInfo(db: string, name: string): Promise<mongo.CollectionInfo | undefined> {
497
+ return (await this.client.db(db).listCollections({ name }, { nameOnly: false }).toArray())[0];
498
+ }
499
+
500
+ private async checkPostImages(db: string, collectionInfo: mongo.CollectionInfo) {
501
+ if (!this.usePostImages) {
502
+ // Nothing to check
503
+ return;
504
+ }
505
+
506
+ const enabled = collectionInfo.options?.changeStreamPreAndPostImages?.enabled == true;
507
+ if (!enabled && this.configurePostImages) {
508
+ await this.client.db(db).command({
509
+ collMod: collectionInfo.name,
510
+ changeStreamPreAndPostImages: { enabled: true }
511
+ });
512
+ this.logger.info(`Enabled postImages on ${db}.${collectionInfo.name}`);
513
+ } else if (!enabled) {
514
+ throw new ServiceError(ErrorCode.PSYNC_S1343, `postImages not enabled on ${db}.${collectionInfo.name}`);
515
+ }
516
+ }
517
+
518
+ private async getSnapshotLsn(): Promise<string> {
519
+ const hello = await this.defaultDb.command({ hello: 1 });
520
+ // Basic sanity check
521
+ if (hello.msg == 'isdbgrid') {
522
+ throw new ServiceError(
523
+ ErrorCode.PSYNC_S1341,
524
+ 'Sharded MongoDB Clusters are not supported yet (including MongoDB Serverless instances).'
525
+ );
526
+ } else if (hello.setName == null) {
527
+ throw new ServiceError(
528
+ ErrorCode.PSYNC_S1342,
529
+ 'Standalone MongoDB instances are not supported - use a replicaset.'
530
+ );
531
+ }
532
+
533
+ // Open a change stream just to get a resume token for later use.
534
+ // We could use clusterTime from the hello command, but that won't tell us if the
535
+ // snapshot isn't valid anymore.
536
+ // If we just use the first resumeToken from the stream, we get two potential issues:
537
+ // 1. The resumeToken may just be a wrapped clusterTime, which does not detect changes
538
+ // in source db or other stream issues.
539
+ // 2. The first actual change we get may have the same clusterTime, causing us to incorrect
540
+ // skip that event.
541
+ // Instead, we create a new checkpoint document, and wait until we get that document back in the stream.
542
+ // To avoid potential race conditions with the checkpoint creation, we create a new checkpoint document
543
+ // periodically until the timeout is reached.
544
+
545
+ const LSN_TIMEOUT_SECONDS = 60;
546
+ const LSN_CREATE_INTERVAL_SECONDS = 1;
547
+
548
+ const firstCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
549
+ const filters = this.getSourceNamespaceFilters();
550
+ const iter = this.rawChangeStreamBatches({
551
+ lsn: firstCheckpointLsn,
552
+ maxAwaitTimeMS: 0,
553
+ signal: this.abortSignal,
554
+ filters
555
+ });
556
+ const startTime = performance.now();
557
+ let lastCheckpointCreated = performance.now();
558
+ let eventsSeen = 0;
559
+ let batchesSeen = 0;
560
+
561
+ for await (const { events } of iter) {
562
+ if (performance.now() - startTime >= LSN_TIMEOUT_SECONDS * 1000) {
563
+ break;
564
+ }
565
+ if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
566
+ await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
567
+ lastCheckpointCreated = performance.now();
568
+ }
569
+ batchesSeen += 1;
570
+
571
+ for (const rawChangeDocument of events) {
572
+ const changeDocument = parseChangeDocument(rawChangeDocument);
573
+ const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
574
+
575
+ if (ns?.coll == CHECKPOINTS_COLLECTION && 'documentKey' in changeDocument) {
576
+ const checkpointId = changeDocument.documentKey._id as string | mongo.ObjectId;
577
+ if (!this.checkpointStreamId.equals(checkpointId)) {
578
+ continue;
579
+ }
580
+ return new MongoLSN({
581
+ timestamp: changeDocument.clusterTime!,
582
+ resume_token: changeDocument._id
583
+ }).comparable;
584
+ }
585
+
586
+ eventsSeen += 1;
587
+ }
588
+ }
589
+
590
+ // Could happen if there is a very large replication lag?
591
+ throw new ServiceError(
592
+ ErrorCode.PSYNC_S1301,
593
+ `Timeout after while waiting for checkpoint document for ${LSN_TIMEOUT_SECONDS}s. Streamed events = ${eventsSeen}, batches = ${batchesSeen}`
594
+ );
595
+ }
596
+
597
+ /**
598
+ * Given a snapshot LSN, validate that we can read from it, by opening a change stream.
599
+ */
600
+ private async validateSnapshotLsn(lsn: string) {
601
+ const stream = this.rawChangeStreamBatches({
602
+ lsn,
603
+ maxAwaitTimeMS: 0,
604
+ filters: this.getSourceNamespaceFilters()
605
+ });
606
+ for await (const _batch of stream) {
607
+ break;
608
+ }
609
+ }
610
+
611
+ private getSourceNamespaceFilters(): { $match: any; multipleDatabases: boolean } {
612
+ const sourceTables = this.syncRules.getSourceTables();
613
+
614
+ const inFilters: { db: string; coll: string }[] = [
615
+ { db: this.defaultDb.databaseName, coll: CHECKPOINTS_COLLECTION }
616
+ ];
617
+ const regexFilters: { 'ns.db': string; 'ns.coll': RegExp }[] = [];
618
+ let multipleDatabases = false;
619
+ for (const tablePattern of sourceTables) {
620
+ if (tablePattern.connectionTag != this.connections.connectionTag) {
621
+ continue;
622
+ }
623
+
624
+ if (tablePattern.schema != this.defaultDb.databaseName) {
625
+ multipleDatabases = true;
626
+ }
627
+
628
+ if (tablePattern.isWildcard) {
629
+ regexFilters.push({
630
+ 'ns.db': tablePattern.schema,
631
+ 'ns.coll': new RegExp('^' + escapeRegExp(tablePattern.tablePrefix))
632
+ });
633
+ } else {
634
+ inFilters.push({
635
+ db: tablePattern.schema,
636
+ coll: tablePattern.name
637
+ });
638
+ }
639
+ }
640
+
641
+ const nsFilter = multipleDatabases
642
+ ? { ns: { $in: inFilters } }
643
+ : { 'ns.coll': { $in: inFilters.map((ns) => ns.coll) } };
644
+ if (regexFilters.length > 0) {
645
+ return { $match: { $or: [nsFilter, ...regexFilters] }, multipleDatabases };
646
+ }
647
+ return { $match: nsFilter, multipleDatabases };
648
+ }
649
+
650
+ private rawChangeStreamBatches(options: {
651
+ lsn: string | null;
652
+ maxAwaitTimeMS?: number;
653
+ batchSize?: number;
654
+ filters: { $match: any; multipleDatabases: boolean };
655
+ signal?: AbortSignal;
656
+ tracer?: PerformanceTracer<'changestream'>;
657
+ }): AsyncIterableIterator<ChangeStreamBatch> {
658
+ const lastLsn = options.lsn ? MongoLSN.fromSerialized(options.lsn) : null;
659
+ const startAfter = lastLsn?.timestamp;
660
+ const resumeAfter = lastLsn?.resumeToken;
661
+
662
+ let fullDocument: 'required' | 'updateLookup';
663
+ if (this.usePostImages) {
664
+ // 'read_only' or 'auto_configure'
665
+ // Configuration happens during snapshot, or when we see new
666
+ // collections.
667
+ fullDocument = 'required';
668
+ } else {
669
+ fullDocument = 'updateLookup';
670
+ }
671
+ const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
672
+ showExpandedEvents: true,
673
+ fullDocument
674
+ };
675
+ const pipeline: mongo.Document[] = [
676
+ { $changeStream: streamOptions },
677
+ { $match: options.filters.$match },
678
+ { $changeStreamSplitLargeEvent: {} }
679
+ ];
680
+
681
+ // Only one of these options can be supplied at a time.
682
+ if (resumeAfter) {
683
+ streamOptions.resumeAfter = resumeAfter;
684
+ } else {
685
+ // Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
686
+ // case if we have an old one.
687
+ streamOptions.startAtOperationTime = startAfter;
688
+ }
689
+
690
+ let watchDb: mongo.Db;
691
+ if (options.filters.multipleDatabases) {
692
+ // Requires readAnyDatabase@admin on Atlas
693
+ watchDb = this.client.db('admin');
694
+ streamOptions.allChangesForCluster = true;
695
+ } else {
696
+ // Same general result, but requires less permissions than the above
697
+ watchDb = this.defaultDb;
698
+ }
699
+
700
+ return rawChangeStream(watchDb, pipeline, {
701
+ batchSize: options.batchSize ?? this.snapshotChunkLength,
702
+ maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
703
+ maxTimeMS: this.changeStreamTimeout,
704
+ signal: options.signal,
705
+ logger: this.logger,
706
+ tracer: options.tracer
707
+ });
708
+ }
709
+
710
+ private touch() {
711
+ if (performance.now() - this.lastTouchedAt > 1_000) {
712
+ this.lastTouchedAt = performance.now();
713
+ // Update the probes, but don't wait for it
714
+ container.probes.touch().catch((e) => {
715
+ this.logger.error(`Failed to touch the container probe: ${e.message}`, e);
716
+ });
717
+ }
718
+ }
719
+ }