@powersync/service-module-mongodb 0.0.0-dev-20260225160713 → 0.0.0-dev-20260511080634

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 (70) hide show
  1. package/CHANGELOG.md +119 -6
  2. package/dist/api/MongoRouteAPIAdapter.js +4 -4
  3. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  4. package/dist/replication/ChangeStream.d.ts +8 -16
  5. package/dist/replication/ChangeStream.js +291 -373
  6. package/dist/replication/ChangeStream.js.map +1 -1
  7. package/dist/replication/ChangeStreamReplicationJob.d.ts +1 -1
  8. package/dist/replication/ChangeStreamReplicationJob.js +3 -3
  9. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  10. package/dist/replication/ChangeStreamReplicator.d.ts +1 -2
  11. package/dist/replication/ChangeStreamReplicator.js +1 -22
  12. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  13. package/dist/replication/JsonBufferWriter.d.ts +80 -0
  14. package/dist/replication/JsonBufferWriter.js +342 -0
  15. package/dist/replication/JsonBufferWriter.js.map +1 -0
  16. package/dist/replication/MongoManager.d.ts +1 -1
  17. package/dist/replication/MongoManager.js +1 -1
  18. package/dist/replication/MongoManager.js.map +1 -1
  19. package/dist/replication/MongoRelation.js +4 -0
  20. package/dist/replication/MongoRelation.js.map +1 -1
  21. package/dist/replication/MongoSnapshotQuery.d.ts +3 -1
  22. package/dist/replication/MongoSnapshotQuery.js +9 -4
  23. package/dist/replication/MongoSnapshotQuery.js.map +1 -1
  24. package/dist/replication/RawChangeStream.d.ts +55 -0
  25. package/dist/replication/RawChangeStream.js +322 -0
  26. package/dist/replication/RawChangeStream.js.map +1 -0
  27. package/dist/replication/SourceRowConverter.d.ts +46 -0
  28. package/dist/replication/SourceRowConverter.js +42 -0
  29. package/dist/replication/SourceRowConverter.js.map +1 -0
  30. package/dist/replication/bufferToSqlite.d.ts +43 -0
  31. package/dist/replication/bufferToSqlite.js +740 -0
  32. package/dist/replication/bufferToSqlite.js.map +1 -0
  33. package/dist/replication/internal-mongodb-utils.d.ts +9 -0
  34. package/dist/replication/internal-mongodb-utils.js +16 -0
  35. package/dist/replication/internal-mongodb-utils.js.map +1 -0
  36. package/dist/replication/replication-index.d.ts +5 -2
  37. package/dist/replication/replication-index.js +5 -2
  38. package/dist/replication/replication-index.js.map +1 -1
  39. package/dist/replication/replication-utils.d.ts +1 -1
  40. package/dist/types/types.js.map +1 -1
  41. package/package.json +11 -11
  42. package/scripts/benchmark-change-document-json.mts +358 -0
  43. package/scripts/benchmark-change-document.mts +370 -0
  44. package/src/api/MongoRouteAPIAdapter.ts +4 -4
  45. package/src/replication/ChangeStream.ts +388 -352
  46. package/src/replication/ChangeStreamReplicationJob.ts +3 -3
  47. package/src/replication/ChangeStreamReplicator.ts +2 -26
  48. package/src/replication/JsonBufferWriter.ts +390 -0
  49. package/src/replication/MongoManager.ts +2 -2
  50. package/src/replication/MongoRelation.ts +5 -2
  51. package/src/replication/MongoSnapshotQuery.ts +13 -6
  52. package/src/replication/RawChangeStream.ts +460 -0
  53. package/src/replication/SourceRowConverter.ts +65 -0
  54. package/src/replication/bufferToSqlite.ts +944 -0
  55. package/src/replication/internal-mongodb-utils.ts +25 -0
  56. package/src/replication/replication-index.ts +5 -2
  57. package/src/replication/replication-utils.ts +2 -2
  58. package/src/types/types.ts +1 -1
  59. package/test/src/buffer_to_sqlite.test.ts +1146 -0
  60. package/test/src/change_stream.test.ts +105 -3
  61. package/test/src/change_stream_utils.ts +39 -36
  62. package/test/src/env.ts +1 -1
  63. package/test/src/mongo_test.test.ts +66 -64
  64. package/test/src/parse_document_id.test.ts +54 -0
  65. package/test/src/raw_change_stream.test.ts +547 -0
  66. package/test/src/resume.test.ts +15 -4
  67. package/test/src/util.ts +62 -9
  68. package/test/tsconfig.json +0 -1
  69. package/tsconfig.scripts.json +13 -0
  70. package/tsconfig.tsbuildinfo +1 -1
@@ -1,8 +1,7 @@
1
- import { isMongoNetworkTimeoutError, isMongoServerError, mongo } from '@powersync/lib-service-mongodb';
1
+ import { mongo } from '@powersync/lib-service-mongodb';
2
2
  import {
3
3
  container,
4
4
  DatabaseConnectionError,
5
- logger as defaultLogger,
6
5
  ErrorCode,
7
6
  Logger,
8
7
  ReplicationAbortedError,
@@ -11,34 +10,31 @@ import {
11
10
  } from '@powersync/lib-services-framework';
12
11
  import {
13
12
  MetricsEngine,
13
+ PerformanceTracer,
14
14
  RelationCache,
15
+ ReplicationLagTracker,
15
16
  SaveOperationTag,
16
17
  SourceEntityDescriptor,
17
18
  SourceTable,
18
19
  storage
19
20
  } from '@powersync/service-core';
20
- import {
21
- DatabaseInputRow,
22
- SqliteInputRow,
23
- SqliteRow,
24
- HydratedSyncRules,
25
- TablePattern
26
- } from '@powersync/service-sync-rules';
21
+ import { HydratedSyncRules, TablePattern } from '@powersync/service-sync-rules';
27
22
  import { ReplicationMetric } from '@powersync/service-types';
23
+ import { performance } from 'node:perf_hooks';
28
24
  import { MongoLSN } from '../common/MongoLSN.js';
29
25
  import { PostImagesOption } from '../types/types.js';
30
26
  import { escapeRegExp } from '../utils.js';
31
27
  import { MongoManager } from './MongoManager.js';
32
- import {
33
- constructAfterRecord,
34
- createCheckpoint,
35
- getCacheIdentifier,
36
- getMongoRelation,
37
- STANDALONE_CHECKPOINT_ID
38
- } from './MongoRelation.js';
28
+ import { createCheckpoint, getCacheIdentifier, getMongoRelation, STANDALONE_CHECKPOINT_ID } from './MongoRelation.js';
39
29
  import { ChunkedSnapshotQuery } from './MongoSnapshotQuery.js';
30
+ import {
31
+ ChangeStreamBatch,
32
+ parseChangeDocument,
33
+ ProjectedChangeStreamDocument,
34
+ rawChangeStream
35
+ } from './RawChangeStream.js';
40
36
  import { CHECKPOINTS_COLLECTION, timestampToDate } from './replication-utils.js';
41
-
37
+ import { DirectSourceRowConverter, SourceRowConverter } from './SourceRowConverter.js';
42
38
  export interface ChangeStreamOptions {
43
39
  connections: MongoManager;
44
40
  storage: storage.SyncRulesBucketStorage;
@@ -98,16 +94,7 @@ export class ChangeStream {
98
94
 
99
95
  private relationCache = new RelationCache(getCacheIdentifier);
100
96
 
101
- /**
102
- * Time of the oldest uncommitted change, according to the source db.
103
- * This is used to determine the replication lag.
104
- */
105
- private oldestUncommittedChange: Date | null = null;
106
- /**
107
- * Keep track of whether we have done a commit or keepalive yet.
108
- * We can only compute replication lag if isStartingReplication == false, or oldestUncommittedChange is present.
109
- */
110
- private isStartingReplication = true;
97
+ private replicationLag = new ReplicationLagTracker();
111
98
 
112
99
  private checkpointStreamId = new mongo.ObjectId();
113
100
 
@@ -117,6 +104,8 @@ export class ChangeStream {
117
104
 
118
105
  private changeStreamTimeout: number;
119
106
 
107
+ private readonly sourceRowConverter: SourceRowConverter;
108
+
120
109
  constructor(options: ChangeStreamOptions) {
121
110
  this.storage = options.storage;
122
111
  this.metrics = options.metrics;
@@ -129,6 +118,8 @@ export class ChangeStream {
129
118
  this.sync_rules = options.storage.getParsedSyncRules({
130
119
  defaultSchema: this.defaultDb.databaseName
131
120
  });
121
+ this.sourceRowConverter = new DirectSourceRowConverter(this.sync_rules.compatibility);
122
+
132
123
  // The change stream aggregation command should timeout before the socket times out,
133
124
  // so we use 90% of the socket timeout value.
134
125
  this.changeStreamTimeout = Math.ceil(this.client.options.socketTimeoutMS * 0.9);
@@ -142,7 +133,7 @@ export class ChangeStream {
142
133
  { once: true }
143
134
  );
144
135
 
145
- this.logger = options.logger ?? defaultLogger;
136
+ this.logger = options.logger ?? this.storage.logger;
146
137
  }
147
138
 
148
139
  get stopped() {
@@ -264,47 +255,55 @@ export class ChangeStream {
264
255
  const LSN_TIMEOUT_SECONDS = 60;
265
256
  const LSN_CREATE_INTERVAL_SECONDS = 1;
266
257
 
267
- await using streamManager = this.openChangeStream({ lsn: null, maxAwaitTimeMs: 0 });
268
- const { stream } = streamManager;
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
+
269
261
  const startTime = performance.now();
270
- let lastCheckpointCreated = -10_000;
262
+ let lastCheckpointCreated = performance.now();
271
263
  let eventsSeen = 0;
264
+ let batchesSeen = 0;
272
265
 
273
- while (performance.now() - startTime < LSN_TIMEOUT_SECONDS * 1000) {
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
+ }
274
277
  if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
275
278
  await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
276
279
  lastCheckpointCreated = performance.now();
277
280
  }
281
+ batchesSeen += 1;
278
282
 
279
- // tryNext() doesn't block, while next() / hasNext() does block until there is data on the stream
280
- const changeDocument = await stream.tryNext().catch((e) => {
281
- throw mapChangeStreamError(e);
282
- });
283
- if (changeDocument == null) {
284
- continue;
285
- }
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
286
 
287
- const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
288
-
289
- if (ns?.coll == CHECKPOINTS_COLLECTION && 'documentKey' in changeDocument) {
290
- const checkpointId = changeDocument.documentKey._id as string | mongo.ObjectId;
291
- if (!this.checkpointStreamId.equals(checkpointId)) {
292
- continue;
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;
293
297
  }
294
- const { comparable: lsn } = new MongoLSN({
295
- timestamp: changeDocument.clusterTime!,
296
- resume_token: changeDocument._id
297
- });
298
- return lsn;
299
- }
300
298
 
301
- eventsSeen += 1;
299
+ eventsSeen += 1;
300
+ }
302
301
  }
303
302
 
304
303
  // Could happen if there is a very large replication lag?
305
304
  throw new ServiceError(
306
305
  ErrorCode.PSYNC_S1301,
307
- `Timeout after while waiting for checkpoint document for ${LSN_TIMEOUT_SECONDS}s. Streamed events = ${eventsSeen}`
306
+ `Timeout after while waiting for checkpoint document for ${LSN_TIMEOUT_SECONDS}s. Streamed events = ${eventsSeen}, batches = ${batchesSeen}`
308
307
  );
309
308
  }
310
309
 
@@ -312,21 +311,24 @@ export class ChangeStream {
312
311
  * Given a snapshot LSN, validate that we can read from it, by opening a change stream.
313
312
  */
314
313
  private async validateSnapshotLsn(lsn: string) {
315
- await using streamManager = this.openChangeStream({ lsn: lsn, maxAwaitTimeMs: 0 });
316
- const { stream } = streamManager;
317
- try {
318
- // tryNext() doesn't block, while next() / hasNext() does block until there is data on the stream
319
- await stream.tryNext();
320
- } catch (e) {
321
- // Note: A timeout here is not handled as a ChangeStreamInvalidatedError, even though
322
- // we possibly cannot recover from it.
323
- throw mapChangeStreamError(e);
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;
324
325
  }
325
326
  }
326
327
 
327
328
  async initialReplication(snapshotLsn: string | null) {
328
329
  const sourceTables = this.sync_rules.getSourceTables();
329
330
  await this.client.connect();
331
+ const tracer = new PerformanceTracer('MongoDB initial replication');
330
332
 
331
333
  const flushResult = await this.storage.startBatch(
332
334
  {
@@ -334,7 +336,8 @@ export class ChangeStream {
334
336
  zeroLSN: MongoLSN.ZERO.comparable,
335
337
  defaultSchema: this.defaultDb.databaseName,
336
338
  storeCurrentData: false,
337
- skipExistingRows: true
339
+ skipExistingRows: true,
340
+ tracer
338
341
  },
339
342
  async (batch) => {
340
343
  if (snapshotLsn == null) {
@@ -376,7 +379,7 @@ export class ChangeStream {
376
379
 
377
380
  for (let table of tablesWithStatus) {
378
381
  await this.snapshotTable(batch, table);
379
- await batch.markSnapshotDone([table], MongoLSN.ZERO.comparable);
382
+ await batch.markTableSnapshotDone([table]);
380
383
 
381
384
  this.touch();
382
385
  }
@@ -385,7 +388,7 @@ export class ChangeStream {
385
388
  // point before the data can be considered consistent.
386
389
  // We could do this for each individual table, but may as well just do it once for the entire snapshot.
387
390
  const checkpoint = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
388
- await batch.markSnapshotDone([], checkpoint);
391
+ await batch.markAllSnapshotDone(checkpoint);
389
392
 
390
393
  // This will not create a consistent checkpoint yet, but will persist the op.
391
394
  // Actual checkpoint will be created when streaming replication caught up.
@@ -469,13 +472,11 @@ export class ChangeStream {
469
472
  return { $match: nsFilter, multipleDatabases };
470
473
  }
471
474
 
472
- static *getQueryData(results: Iterable<DatabaseInputRow>): Generator<SqliteInputRow> {
473
- for (let row of results) {
474
- yield constructAfterRecord(row);
475
- }
476
- }
477
-
478
475
  private async snapshotTable(batch: storage.BucketStorageBatch, table: storage.SourceTable) {
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
+
479
480
  const totalEstimatedCount = await this.estimatedCountNumber(table);
480
481
  let at = table.snapshotStatus?.replicatedCount ?? 0;
481
482
  const db = this.client.db(table.schema);
@@ -496,20 +497,22 @@ export class ChangeStream {
496
497
  let lastBatch = performance.now();
497
498
  let nextChunkPromise = query.nextChunk();
498
499
  while (true) {
499
- const { docs: docBatch, lastKey } = await nextChunkPromise;
500
+ const { docs: docBatch, lastKey, bytes: chunkBytes } = await nextChunkPromise;
500
501
  if (docBatch.length == 0) {
501
502
  // No more data - stop iterating
502
503
  break;
503
504
  }
505
+ bytesReplicatedMetric.add(chunkBytes);
506
+ chunksReplicatedMetric.add(1);
504
507
 
505
508
  if (this.abort_signal.aborted) {
506
- throw new ReplicationAbortedError(`Aborted initial replication`);
509
+ throw new ReplicationAbortedError(`Aborted initial replication`, this.abort_signal.reason);
507
510
  }
508
511
 
509
512
  // Pre-fetch next batch, so that we can read and write concurrently
510
513
  nextChunkPromise = query.nextChunk();
511
- for (let document of docBatch) {
512
- const record = this.constructAfterRecord(document);
514
+ for (let buffer of docBatch) {
515
+ const { row: record, replicaId: replicaId } = this.rawToSqliteRow(buffer);
513
516
 
514
517
  // This auto-flushes when the batch reaches its size limit
515
518
  await batch.save({
@@ -518,14 +521,14 @@ export class ChangeStream {
518
521
  before: undefined,
519
522
  beforeReplicaId: undefined,
520
523
  after: record,
521
- afterReplicaId: document._id
524
+ afterReplicaId: replicaId
522
525
  });
523
526
  }
524
527
 
525
528
  // Important: flush before marking progress
526
529
  await batch.flush();
527
530
  at += docBatch.length;
528
- this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(docBatch.length);
531
+ rowsReplicatedMetric.add(docBatch.length);
529
532
 
530
533
  table = await batch.updateTableProgress(table, {
531
534
  lastKey,
@@ -630,7 +633,7 @@ export class ChangeStream {
630
633
  // Snapshot if:
631
634
  // 1. Snapshot is requested (false for initial snapshot, since that process handles it elsewhere)
632
635
  // 2. Snapshot is not already done, AND:
633
- // 3. The table is used in sync rules.
636
+ // 3. The table is used in sync config.
634
637
  const shouldSnapshot = snapshot && !result.table.snapshotComplete && result.table.syncAny;
635
638
  if (shouldSnapshot) {
636
639
  this.logger.info(`New collection: ${descriptor.schema}.${descriptor.name}`);
@@ -640,37 +643,35 @@ export class ChangeStream {
640
643
  await this.snapshotTable(batch, result.table);
641
644
  const no_checkpoint_before_lsn = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
642
645
 
643
- const [table] = await batch.markSnapshotDone([result.table], no_checkpoint_before_lsn);
646
+ const [table] = await batch.markTableSnapshotDone([result.table], no_checkpoint_before_lsn);
644
647
  return table;
645
648
  }
646
649
 
647
650
  return result.table;
648
651
  }
649
652
 
650
- private constructAfterRecord(document: mongo.Document): SqliteRow {
651
- const inputRow = constructAfterRecord(document);
652
- return this.sync_rules.applyRowContext<never>(inputRow);
653
- }
654
-
655
653
  async writeChange(
656
654
  batch: storage.BucketStorageBatch,
657
655
  table: storage.SourceTable,
658
- change: mongo.ChangeStreamDocument
656
+ change: ProjectedChangeStreamDocument
659
657
  ): Promise<storage.FlushedResult | null> {
660
658
  if (!table.syncAny) {
661
- this.logger.debug(`Collection ${table.qualifiedName} not used in sync rules - skipping`);
659
+ this.logger.debug(`Collection ${table.qualifiedName} not used in sync config - skipping`);
662
660
  return null;
663
661
  }
664
662
 
665
663
  this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(1);
666
664
  if (change.operationType == 'insert') {
667
- const baseRecord = this.constructAfterRecord(change.fullDocument);
665
+ const { row: baseRecord, replicaId: _replicaId } = this.rawToSqliteRow(change.fullDocument);
668
666
  return await batch.save({
669
667
  tag: SaveOperationTag.INSERT,
670
668
  sourceTable: table,
671
669
  before: undefined,
672
670
  beforeReplicaId: undefined,
673
671
  after: baseRecord,
672
+ // Same as _replicaId
673
+ // We specifically need to use the source _id, not the converted one in baseRecord,
674
+ // to preserve _id uniqueness properties.
674
675
  afterReplicaId: change.documentKey._id
675
676
  });
676
677
  } else if (change.operationType == 'update' || change.operationType == 'replace') {
@@ -683,14 +684,14 @@ export class ChangeStream {
683
684
  beforeReplicaId: change.documentKey._id
684
685
  });
685
686
  }
686
- const after = this.constructAfterRecord(change.fullDocument!);
687
+ const { row: after, replicaId: _replicaId } = this.rawToSqliteRow(change.fullDocument!);
687
688
  return await batch.save({
688
689
  tag: SaveOperationTag.UPDATE,
689
690
  sourceTable: table,
690
691
  before: undefined,
691
692
  beforeReplicaId: undefined,
692
693
  after: after,
693
- afterReplicaId: change.documentKey._id
694
+ afterReplicaId: change.documentKey._id // Same as _replicaId
694
695
  });
695
696
  } else if (change.operationType == 'delete') {
696
697
  return await batch.save({
@@ -726,7 +727,7 @@ export class ChangeStream {
726
727
  }
727
728
  const { lastOpId } = await this.initialReplication(result.snapshotLsn);
728
729
  if (lastOpId != null) {
729
- // Populate the cache _after_ initial replication, but _before_ we switch to this sync rules.
730
+ // Populate the cache _after_ initial replication, but _before_ we switch to this replication stream.
730
731
  await this.storage.populatePersistentChecksumCache({
731
732
  signal: this.abort_signal,
732
733
  // No checkpoint yet, but we do have the opId.
@@ -751,19 +752,19 @@ export class ChangeStream {
751
752
  }
752
753
  }
753
754
 
754
- private openChangeStream(options: { lsn: string | null; maxAwaitTimeMs?: number }) {
755
+ private rawChangeStreamBatches(options: {
756
+ lsn: string | null;
757
+ maxAwaitTimeMS?: number;
758
+ batchSize?: number;
759
+ filters: { $match: any; multipleDatabases: boolean };
760
+ signal?: AbortSignal;
761
+ tracer?: PerformanceTracer<'changestream'>;
762
+ }): AsyncIterableIterator<ChangeStreamBatch> {
755
763
  const lastLsn = options.lsn ? MongoLSN.fromSerialized(options.lsn) : null;
756
764
  const startAfter = lastLsn?.timestamp;
757
765
  const resumeAfter = lastLsn?.resumeToken;
758
766
 
759
- const filters = this.getSourceNamespaceFilters();
760
-
761
- const pipeline: mongo.Document[] = [
762
- {
763
- $match: filters.$match
764
- },
765
- { $changeStreamSplitLargeEvent: {} }
766
- ];
767
+ const filters = options.filters;
767
768
 
768
769
  let fullDocument: 'required' | 'updateLookup';
769
770
 
@@ -775,12 +776,19 @@ export class ChangeStream {
775
776
  } else {
776
777
  fullDocument = 'updateLookup';
777
778
  }
778
- const streamOptions: mongo.ChangeStreamOptions = {
779
+ const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
779
780
  showExpandedEvents: true,
780
- maxAwaitTimeMS: options.maxAwaitTimeMs ?? this.maxAwaitTimeMS,
781
- fullDocument: fullDocument,
782
- maxTimeMS: this.changeStreamTimeout
781
+ fullDocument: fullDocument
783
782
  };
783
+ const pipeline: mongo.Document[] = [
784
+ {
785
+ $changeStream: streamOptions
786
+ },
787
+ {
788
+ $match: filters.$match
789
+ },
790
+ { $changeStreamSplitLargeEvent: {} }
791
+ ];
784
792
 
785
793
  /**
786
794
  * Only one of these options can be supplied at a time.
@@ -790,39 +798,47 @@ export class ChangeStream {
790
798
  } else {
791
799
  // Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
792
800
  // case if we have an old one.
801
+ // This is also relevant for getSnapshotLSN().
793
802
  streamOptions.startAtOperationTime = startAfter;
794
803
  }
795
804
 
796
- let stream: mongo.ChangeStream<mongo.Document>;
805
+ let watchDb: mongo.Db;
797
806
  if (filters.multipleDatabases) {
798
- // Requires readAnyDatabase@admin on Atlas
799
- stream = this.client.watch(pipeline, streamOptions);
807
+ watchDb = this.client.db('admin');
808
+ streamOptions.allChangesForCluster = true;
800
809
  } else {
801
- // Same general result, but requires less permissions than the above
802
- stream = this.defaultDb.watch(pipeline, streamOptions);
810
+ watchDb = this.defaultDb;
803
811
  }
804
812
 
805
- this.abort_signal.addEventListener('abort', () => {
806
- stream.close();
813
+ return rawChangeStream(watchDb, pipeline, {
814
+ batchSize: options.batchSize ?? this.snapshotChunkLength,
815
+ maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
816
+ maxTimeMS: this.changeStreamTimeout,
817
+
818
+ signal: options.signal,
819
+ logger: this.logger,
820
+ tracer: options.tracer
807
821
  });
822
+ }
808
823
 
809
- return {
810
- stream,
811
- filters,
812
- [Symbol.asyncDispose]: async () => {
813
- return stream.close();
814
- }
815
- };
824
+ private rawToSqliteRow(row: Buffer) {
825
+ return this.sourceRowConverter.rawToSqliteRow(row);
816
826
  }
817
827
 
818
828
  async streamChangesInternal() {
829
+ const transactionsReplicatedMetric = this.metrics.getCounter(ReplicationMetric.TRANSACTIONS_REPLICATED);
830
+ const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
831
+ const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
832
+
833
+ const tracer = new PerformanceTracer('MongoDB streaming replication');
819
834
  await this.storage.startBatch(
820
835
  {
821
836
  logger: this.logger,
822
837
  zeroLSN: MongoLSN.ZERO.comparable,
823
838
  defaultSchema: this.defaultDb.databaseName,
824
839
  // We get a complete postimage for every change, so we don't need to store the current data.
825
- storeCurrentData: false
840
+ storeCurrentData: false,
841
+ tracer
826
842
  },
827
843
  async (batch) => {
828
844
  const { resumeFromLsn } = batch;
@@ -831,6 +847,7 @@ export class ChangeStream {
831
847
  }
832
848
  const lastLsn = MongoLSN.fromSerialized(resumeFromLsn);
833
849
  const startAfter = lastLsn?.timestamp;
850
+ let outerSpan = tracer.span('batch');
834
851
 
835
852
  // It is normal for this to be a minute or two old when there is a low volume
836
853
  // of ChangeStream events.
@@ -838,12 +855,14 @@ export class ChangeStream {
838
855
 
839
856
  this.logger.info(`Resume streaming at ${startAfter?.inspect()} / ${lastLsn} | Token age: ${tokenAgeSeconds}s`);
840
857
 
841
- await using streamManager = this.openChangeStream({ lsn: resumeFromLsn });
842
- const { stream, filters } = streamManager;
843
- if (this.abort_signal.aborted) {
844
- await stream.close();
845
- return;
846
- }
858
+ const filters = this.getSourceNamespaceFilters();
859
+ // This is closed when the for loop below returns/breaks/throws
860
+ const batchStream = this.rawChangeStreamBatches({
861
+ lsn: resumeFromLsn,
862
+ filters,
863
+ signal: this.abort_signal,
864
+ tracer
865
+ });
847
866
 
848
867
  // Always start with a checkpoint.
849
868
  // This helps us to clear errors when restarting, even if there is
@@ -854,43 +873,30 @@ export class ChangeStream {
854
873
  this.checkpointStreamId
855
874
  );
856
875
 
857
- let splitDocument: mongo.ChangeStreamDocument | null = null;
876
+ let splitDocument: ProjectedChangeStreamDocument | null = null;
858
877
 
859
878
  let flexDbNameWorkaroundLogged = false;
860
- let changesSinceLastCheckpoint = 0;
861
879
 
862
880
  let lastEmptyResume = performance.now();
881
+ let lastTxnKey: string | null = null;
863
882
 
864
- while (true) {
865
- if (this.abort_signal.aborted) {
866
- break;
867
- }
868
-
869
- const originalChangeDocument = await stream.tryNext().catch((e) => {
870
- throw mapChangeStreamError(e);
871
- });
872
- // The stream was closed, we will only ever receive `null` from it
873
- if (!originalChangeDocument && stream.closed) {
874
- break;
875
- }
883
+ for await (let eventBatch of batchStream) {
884
+ const { events, resumeToken } = eventBatch;
885
+ using batchSpan = tracer.span('processing');
876
886
 
887
+ bytesReplicatedMetric.add(eventBatch.byteSize);
888
+ chunksReplicatedMetric.add(1);
877
889
  if (this.abort_signal.aborted) {
878
890
  break;
879
891
  }
880
-
881
- if (originalChangeDocument == null) {
882
- // We get a new null document after `maxAwaitTimeMS` if there were no other events.
883
- // In this case, stream.resumeToken is the resume token associated with the last response.
884
- // stream.resumeToken is not updated if stream.tryNext() returns data, while stream.next()
885
- // does update it.
886
- // From observed behavior, the actual resumeToken changes around once every 10 seconds.
892
+ this.touch();
893
+ if (events.length == 0) {
894
+ // No changes in this batch, but we still want to keep the connection alive.
895
+ // We do this by persisting a keepalive checkpoint.
887
896
  // If we don't update it on empty events, we do keep consistency, but resuming the stream
888
897
  // with old tokens may cause connection timeouts.
889
- // We throttle this further by only persisting a keepalive once a minute.
890
- // We add an additional check for waitForCheckpointLsn == null, to make sure we're not
891
- // doing a keepalive in the middle of a transaction.
892
898
  if (waitForCheckpointLsn == null && performance.now() - lastEmptyResume > 60_000) {
893
- const { comparable: lsn, timestamp } = MongoLSN.fromResumeToken(stream.resumeToken);
899
+ const { comparable: lsn, timestamp } = MongoLSN.fromResumeToken(resumeToken);
894
900
  await batch.keepalive(lsn);
895
901
  this.touch();
896
902
  lastEmptyResume = performance.now();
@@ -899,218 +905,260 @@ export class ChangeStream {
899
905
  this.logger.info(
900
906
  `Idle change stream. Persisted resumeToken for ${timestampToDate(timestamp).toISOString()}`
901
907
  );
902
- this.isStartingReplication = false;
908
+ this.replicationLag.markStarted();
903
909
  }
904
- continue;
905
- }
906
-
907
- this.touch();
908
910
 
909
- if (startAfter != null && originalChangeDocument.clusterTime?.lte(startAfter)) {
910
- continue;
911
+ // If we have no changes, we can just persist the keepalive.
912
+ // This is throttled to once per minute.
913
+ if (performance.now() - lastEmptyResume < 60_000) {
914
+ continue;
915
+ }
911
916
  }
912
917
 
913
- let changeDocument = originalChangeDocument;
914
- if (originalChangeDocument?.splitEvent != null) {
915
- // Handle split events from $changeStreamSplitLargeEvent.
916
- // This is only relevant for very large update operations.
917
- const splitEvent = originalChangeDocument?.splitEvent;
918
+ this.touch();
918
919
 
919
- if (splitDocument == null) {
920
- splitDocument = originalChangeDocument;
921
- } else {
922
- splitDocument = Object.assign(splitDocument, originalChangeDocument);
920
+ for (let eventIndex = 0; eventIndex < events.length; eventIndex++) {
921
+ const rawChangeDocument = events[eventIndex];
922
+ const originalChangeDocument = parseChangeDocument(rawChangeDocument);
923
+ if (this.abort_signal.aborted) {
924
+ break;
923
925
  }
924
926
 
925
- if (splitEvent.fragment == splitEvent.of) {
926
- // Got all fragments
927
- changeDocument = splitDocument;
928
- splitDocument = null;
929
- } else {
930
- // Wait for more fragments
927
+ if (startAfter != null && originalChangeDocument.clusterTime?.lte(startAfter)) {
931
928
  continue;
932
929
  }
933
- } else if (splitDocument != null) {
934
- // We were waiting for fragments, but got a different event
935
- throw new ReplicationAssertionError(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
936
- }
937
930
 
938
- if (
939
- !filters.multipleDatabases &&
940
- 'ns' in changeDocument &&
941
- changeDocument.ns.db != this.defaultDb.databaseName &&
942
- changeDocument.ns.db.endsWith(`_${this.defaultDb.databaseName}`)
943
- ) {
944
- // When all of the following conditions are met:
945
- // 1. We're replicating from an Atlas Flex instance.
946
- // 2. There were changestream events recorded while the PowerSync service is paused.
947
- // 3. We're only replicating from a single database.
948
- // Then we've observed an ns with for example {db: '67b83e86cd20730f1e766dde_ps'},
949
- // instead of the expected {db: 'ps'}.
950
- // We correct this.
951
- changeDocument.ns.db = this.defaultDb.databaseName;
952
-
953
- if (!flexDbNameWorkaroundLogged) {
954
- flexDbNameWorkaroundLogged = true;
955
- this.logger.warn(
956
- `Incorrect DB name in change stream: ${changeDocument.ns.db}. Changed to ${this.defaultDb.databaseName}.`
957
- );
958
- }
959
- }
931
+ let changeDocument = originalChangeDocument;
932
+ if (originalChangeDocument?.splitEvent != null) {
933
+ // Handle split events from $changeStreamSplitLargeEvent.
934
+ // This is only relevant for very large update operations.
935
+ const splitEvent = originalChangeDocument?.splitEvent;
960
936
 
961
- const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
962
-
963
- if (ns?.coll == CHECKPOINTS_COLLECTION) {
964
- /**
965
- * Dropping the database does not provide an `invalidate` event.
966
- * We typically would receive `drop` events for the collection which we
967
- * would process below.
968
- *
969
- * However we don't commit the LSN after collections are dropped.
970
- * The prevents the `startAfter` or `resumeToken` from advancing past the drop events.
971
- * The stream also closes after the drop events.
972
- * This causes an infinite loop of processing the collection drop events.
973
- *
974
- * This check here invalidates the change stream if our `_checkpoints` collection
975
- * is dropped. This allows for detecting when the DB is dropped.
976
- */
977
- if (changeDocument.operationType == 'drop') {
978
- throw new ChangeStreamInvalidatedError(
979
- 'Internal collections have been dropped',
980
- new Error('_checkpoints collection was dropped')
981
- );
937
+ if (splitDocument == null) {
938
+ splitDocument = originalChangeDocument;
939
+ } else {
940
+ splitDocument = Object.assign(splitDocument, originalChangeDocument);
941
+ }
942
+
943
+ if (splitEvent.fragment == splitEvent.of) {
944
+ // Got all fragments
945
+ changeDocument = splitDocument;
946
+ splitDocument = null;
947
+ } else {
948
+ // Wait for more fragments
949
+ continue;
950
+ }
951
+ } else if (splitDocument != null) {
952
+ // We were waiting for fragments, but got a different event
953
+ throw new ReplicationAssertionError(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
982
954
  }
983
955
 
984
956
  if (
985
- !(
986
- changeDocument.operationType == 'insert' ||
987
- changeDocument.operationType == 'update' ||
988
- changeDocument.operationType == 'replace'
989
- )
957
+ !filters.multipleDatabases &&
958
+ 'ns' in changeDocument &&
959
+ changeDocument.ns.db != this.defaultDb.databaseName &&
960
+ changeDocument.ns.db.endsWith(`_${this.defaultDb.databaseName}`)
990
961
  ) {
991
- continue;
962
+ // When all of the following conditions are met:
963
+ // 1. We're replicating from an Atlas Flex instance.
964
+ // 2. There were changestream events recorded while the PowerSync service is paused.
965
+ // 3. We're only replicating from a single database.
966
+ // Then we've observed an ns with for example {db: '67b83e86cd20730f1e766dde_ps'},
967
+ // instead of the expected {db: 'ps'}.
968
+ // We correct this.
969
+ changeDocument.ns.db = this.defaultDb.databaseName;
970
+
971
+ if (!flexDbNameWorkaroundLogged) {
972
+ flexDbNameWorkaroundLogged = true;
973
+ this.logger.warn(
974
+ `Incorrect DB name in change stream: ${changeDocument.ns.db}. Changed to ${this.defaultDb.databaseName}.`
975
+ );
976
+ }
992
977
  }
993
978
 
994
- // We handle two types of checkpoint events:
995
- // 1. "Standalone" checkpoints, typically write checkpoints. We want to process these
996
- // immediately, regardless of where they were created.
997
- // 2. "Batch" checkpoints for the current stream. This is used as a form of dynamic rate
998
- // limiting of commits, so we specifically want to exclude checkpoints from other streams.
999
- //
1000
- // It may be useful to also throttle commits due to standalone checkpoints in the future.
1001
- // However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
1002
-
1003
- const checkpointId = changeDocument.documentKey._id as string | mongo.ObjectId;
1004
- if (!(checkpointId == STANDALONE_CHECKPOINT_ID || this.checkpointStreamId.equals(checkpointId))) {
1005
- continue;
1006
- }
1007
- const { comparable: lsn } = new MongoLSN({
1008
- timestamp: changeDocument.clusterTime!,
1009
- resume_token: changeDocument._id
1010
- });
1011
- if (batch.lastCheckpointLsn != null && lsn < batch.lastCheckpointLsn) {
1012
- // Checkpoint out of order - should never happen with MongoDB.
1013
- // If it does happen, we throw an error to stop the replication - restarting should recover.
1014
- // Since we use batch.lastCheckpointLsn for the next resumeAfter, this should not result in an infinite loop.
1015
- // Originally a workaround for https://jira.mongodb.org/browse/NODE-7042.
1016
- // This has been fixed in the driver in the meantime, but we still keep this as a safety-check.
1017
- throw new ReplicationAssertionError(
1018
- `Change resumeToken ${(changeDocument._id as any)._data} (${timestampToDate(changeDocument.clusterTime!).toISOString()}) is less than last checkpoint LSN ${batch.lastCheckpointLsn}. Restarting replication.`
1019
- );
1020
- }
979
+ const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
980
+
981
+ if (ns?.coll == CHECKPOINTS_COLLECTION) {
982
+ /**
983
+ * Dropping the database does not provide an `invalidate` event.
984
+ * We typically would receive `drop` events for the collection which we
985
+ * would process below.
986
+ *
987
+ * However we don't commit the LSN after collections are dropped.
988
+ * This prevents the `startAfter` or `resumeToken` from advancing past the drop events.
989
+ * The stream also closes after the drop events.
990
+ * This causes an infinite loop of processing the collection drop events.
991
+ *
992
+ * This check here invalidates the change stream if our `_powersync_checkpoints` collection
993
+ * is dropped. This allows for detecting when the DB is dropped.
994
+ */
995
+ if (changeDocument.operationType == 'drop') {
996
+ throw new ChangeStreamInvalidatedError(
997
+ 'Internal collections have been dropped',
998
+ new Error('_powersync_checkpoints collection was dropped')
999
+ );
1000
+ }
1021
1001
 
1022
- if (waitForCheckpointLsn != null && lsn >= waitForCheckpointLsn) {
1023
- waitForCheckpointLsn = null;
1024
- }
1025
- const didCommit = await batch.commit(lsn, { oldestUncommittedChange: this.oldestUncommittedChange });
1002
+ if (
1003
+ !(
1004
+ changeDocument.operationType == 'insert' ||
1005
+ changeDocument.operationType == 'update' ||
1006
+ changeDocument.operationType == 'replace'
1007
+ )
1008
+ ) {
1009
+ continue;
1010
+ }
1026
1011
 
1027
- if (didCommit) {
1028
- this.oldestUncommittedChange = null;
1029
- this.isStartingReplication = false;
1030
- changesSinceLastCheckpoint = 0;
1031
- }
1032
- } else if (
1033
- changeDocument.operationType == 'insert' ||
1034
- changeDocument.operationType == 'update' ||
1035
- changeDocument.operationType == 'replace' ||
1036
- changeDocument.operationType == 'delete'
1037
- ) {
1038
- if (waitForCheckpointLsn == null) {
1039
- waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
1040
- }
1041
- const rel = getMongoRelation(changeDocument.ns);
1042
- const table = await this.getRelation(batch, rel, {
1043
- // In most cases, we should not need to snapshot this. But if this is the first time we see the collection
1044
- // for whatever reason, then we do need to snapshot it.
1045
- // This may result in some duplicate operations when a collection is created for the first time after
1046
- // sync rules was deployed.
1047
- snapshot: true
1048
- });
1049
- if (table.syncAny) {
1050
- if (this.oldestUncommittedChange == null && changeDocument.clusterTime != null) {
1051
- this.oldestUncommittedChange = timestampToDate(changeDocument.clusterTime);
1012
+ // We handle two types of checkpoint events:
1013
+ // 1. "Standalone" checkpoints, typically write checkpoints. We want to process these
1014
+ // immediately, regardless of where they were created.
1015
+ // 2. "Batch" checkpoints for the current stream. This is used as a form of dynamic rate
1016
+ // limiting of commits, so we specifically want to exclude checkpoints from other streams.
1017
+ //
1018
+ // It may be useful to also throttle commits due to standalone checkpoints in the future.
1019
+ // However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
1020
+
1021
+ const checkpointId = changeDocument.documentKey._id as string | mongo.ObjectId;
1022
+
1023
+ if (checkpointId == STANDALONE_CHECKPOINT_ID) {
1024
+ // Standalone / write checkpoint received.
1025
+ // When we are caught up, commit immediately to keep write checkpoint latency low.
1026
+ // Once there is already a batch checkpoint pending, or the driver has buffered more
1027
+ // change stream events, collapse standalone checkpoints into the normal batch
1028
+ // checkpoint flow to avoid commit churn under sustained load.
1029
+ const hasBufferedChanges = eventIndex < events.length - 1;
1030
+ if (waitForCheckpointLsn != null || hasBufferedChanges) {
1031
+ if (waitForCheckpointLsn == null) {
1032
+ waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
1033
+ }
1034
+ continue;
1035
+ }
1036
+ } else if (!this.checkpointStreamId.equals(checkpointId)) {
1037
+ continue;
1052
1038
  }
1053
- const flushResult = await this.writeChange(batch, table, changeDocument);
1054
- changesSinceLastCheckpoint += 1;
1055
- if (flushResult != null && changesSinceLastCheckpoint >= 20_000) {
1056
- // When we are catching up replication after an initial snapshot, there may be a very long delay
1057
- // before we do a commit(). In that case, we need to periodically persist the resume LSN, so
1058
- // we don't restart from scratch if we restart replication.
1059
- // The same could apply if we need to catch up on replication after some downtime.
1060
- const { comparable: lsn } = new MongoLSN({
1061
- timestamp: changeDocument.clusterTime!,
1062
- resume_token: changeDocument._id
1063
- });
1064
- this.logger.info(`Updating resume LSN to ${lsn} after ${changesSinceLastCheckpoint} changes`);
1065
- await batch.setResumeLsn(lsn);
1066
- changesSinceLastCheckpoint = 0;
1039
+ const { comparable: lsn } = new MongoLSN({
1040
+ timestamp: changeDocument.clusterTime!,
1041
+ resume_token: changeDocument._id
1042
+ });
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
+ );
1067
1052
  }
1053
+
1054
+ if (waitForCheckpointLsn != null && lsn >= waitForCheckpointLsn) {
1055
+ waitForCheckpointLsn = null;
1056
+ }
1057
+ const { checkpointBlocked } = await batch.commit(lsn, {
1058
+ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
1059
+ });
1060
+
1061
+ if (!checkpointBlocked) {
1062
+ this.replicationLag.markCommitted();
1063
+ }
1064
+ } else if (
1065
+ changeDocument.operationType == 'insert' ||
1066
+ changeDocument.operationType == 'update' ||
1067
+ changeDocument.operationType == 'replace' ||
1068
+ changeDocument.operationType == 'delete'
1069
+ ) {
1070
+ if (waitForCheckpointLsn == null) {
1071
+ waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
1072
+ }
1073
+
1074
+ const rel = getMongoRelation(changeDocument.ns);
1075
+ const table = await this.getRelation(batch, rel, {
1076
+ // In most cases, we should not need to snapshot this. But if this is the first time we see the collection
1077
+ // for whatever reason, then we do need to snapshot it.
1078
+ // This may result in some duplicate operations when a collection is created for the first time after
1079
+ // sync config was deployed.
1080
+ snapshot: true
1081
+ });
1082
+ if (table.syncAny) {
1083
+ this.replicationLag.trackUncommittedChange(
1084
+ changeDocument.clusterTime == null ? null : timestampToDate(changeDocument.clusterTime)
1085
+ );
1086
+
1087
+ const transactionKeyValue = transactionKey(changeDocument);
1088
+
1089
+ if (transactionKeyValue == null || lastTxnKey != transactionKeyValue) {
1090
+ // Very crude metric for counting transactions replicated.
1091
+ // We ignore operations other than basic CRUD, and ignore changes to _powersync_checkpoints.
1092
+ // Individual writes may not have a txnNumber, in which case we count them as separate transactions.
1093
+ lastTxnKey = transactionKeyValue;
1094
+ transactionsReplicatedMetric.add(1);
1095
+ }
1096
+
1097
+ await this.writeChange(batch, table, changeDocument);
1098
+ }
1099
+ } else if (changeDocument.operationType == 'drop') {
1100
+ const rel = getMongoRelation(changeDocument.ns);
1101
+ const table = await this.getRelation(batch, rel, {
1102
+ // We're "dropping" this collection, so never snapshot it.
1103
+ snapshot: false
1104
+ });
1105
+ if (table.syncAny) {
1106
+ await batch.drop([table]);
1107
+ this.relationCache.delete(table);
1108
+ }
1109
+ } else if (changeDocument.operationType == 'rename') {
1110
+ const relFrom = getMongoRelation(changeDocument.ns);
1111
+ const relTo = getMongoRelation(changeDocument.to);
1112
+ const tableFrom = await this.getRelation(batch, relFrom, {
1113
+ // We're "dropping" this collection, so never snapshot it.
1114
+ snapshot: false
1115
+ });
1116
+ if (tableFrom.syncAny) {
1117
+ await batch.drop([tableFrom]);
1118
+ this.relationCache.delete(relFrom);
1119
+ }
1120
+ // Here we do need to snapshot the new table
1121
+ const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
1122
+ await this.handleRelation(batch, relTo, {
1123
+ // This is a new (renamed) collection, so always snapshot it.
1124
+ snapshot: true,
1125
+ collectionInfo: collection
1126
+ });
1068
1127
  }
1069
- } else if (changeDocument.operationType == 'drop') {
1070
- const rel = getMongoRelation(changeDocument.ns);
1071
- const table = await this.getRelation(batch, rel, {
1072
- // We're "dropping" this collection, so never snapshot it.
1073
- snapshot: false
1074
- });
1075
- if (table.syncAny) {
1076
- await batch.drop([table]);
1077
- this.relationCache.delete(table);
1078
- }
1079
- } else if (changeDocument.operationType == 'rename') {
1080
- const relFrom = getMongoRelation(changeDocument.ns);
1081
- const relTo = getMongoRelation(changeDocument.to);
1082
- const tableFrom = await this.getRelation(batch, relFrom, {
1083
- // We're "dropping" this collection, so never snapshot it.
1084
- snapshot: false
1085
- });
1086
- if (tableFrom.syncAny) {
1087
- await batch.drop([tableFrom]);
1088
- this.relationCache.delete(relFrom);
1089
- }
1090
- // Here we do need to snapshot the new table
1091
- const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
1092
- await this.handleRelation(batch, relTo, {
1093
- // This is a new (renamed) collection, so always snapshot it.
1094
- snapshot: true,
1095
- collectionInfo: collection
1096
- });
1097
1128
  }
1129
+
1130
+ if (splitDocument == null) {
1131
+ // We flush and mark progress on every batch of data we receive.
1132
+ // Batches are generally large (64MB or 6000 events, whichever comes first),
1133
+ // so this is a good natural point to flush and mark progress.
1134
+ // We avoid this when splitDocument is set, since we cannot resume in the middle of a split event.
1135
+ const { comparable: lsn } = MongoLSN.fromResumeToken(resumeToken);
1136
+ await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
1137
+ // TODO: We should consider making this standard behavior of flush().
1138
+ await batch.setResumeLsn(lsn);
1139
+ }
1140
+
1141
+ batchSpan.end();
1142
+ const durations = outerSpan.end();
1143
+ const duration = batchSpan.endAt - batchSpan.startAt;
1144
+
1145
+ this.logger.info(
1146
+ `Processed batch of ${events.length} changes / ${eventBatch.byteSize} bytes in ${duration}ms`,
1147
+ {
1148
+ count: events.length,
1149
+ bytes: eventBatch.byteSize,
1150
+ duration,
1151
+ t: durations
1152
+ }
1153
+ );
1154
+ outerSpan = tracer.span('batch');
1098
1155
  }
1099
1156
  }
1100
1157
  );
1101
1158
  }
1102
1159
 
1103
- async getReplicationLagMillis(): Promise<number | undefined> {
1104
- if (this.oldestUncommittedChange == null) {
1105
- if (this.isStartingReplication) {
1106
- // We don't have anything to compute replication lag with yet.
1107
- return undefined;
1108
- } else {
1109
- // We don't have any uncommitted changes, so replication is up-to-date.
1110
- return 0;
1111
- }
1112
- }
1113
- return Date.now() - this.oldestUncommittedChange.getTime();
1160
+ getReplicationLagMillis(): number | undefined {
1161
+ return this.replicationLag.getLagMillis();
1114
1162
  }
1115
1163
 
1116
1164
  private lastTouchedAt = performance.now();
@@ -1126,24 +1174,12 @@ export class ChangeStream {
1126
1174
  }
1127
1175
  }
1128
1176
 
1129
- function mapChangeStreamError(e: any) {
1130
- if (isMongoNetworkTimeoutError(e)) {
1131
- // This typically has an unhelpful message like "connection 2 to 159.41.94.47:27017 timed out".
1132
- // We wrap the error to make it more useful.
1133
- throw new DatabaseConnectionError(ErrorCode.PSYNC_S1345, `Timeout while reading MongoDB ChangeStream`, e);
1134
- } else if (isMongoServerError(e) && e.codeName == 'MaxTimeMSExpired') {
1135
- // maxTimeMS was reached. Example message:
1136
- // MongoServerError: Executor error during aggregate command on namespace: powersync_test_data.$cmd.aggregate :: caused by :: operation exceeded time limit
1137
- throw new DatabaseConnectionError(ErrorCode.PSYNC_S1345, `Timeout while reading MongoDB ChangeStream`, e);
1138
- } else if (
1139
- isMongoServerError(e) &&
1140
- e.codeName == 'NoMatchingDocument' &&
1141
- e.errmsg?.includes('post-image was not found')
1142
- ) {
1143
- throw new ChangeStreamInvalidatedError(e.errmsg, e);
1144
- } else if (isMongoServerError(e) && e.hasErrorLabel('NonResumableChangeStreamError')) {
1145
- throw new ChangeStreamInvalidatedError(e.message, e);
1146
- } else {
1147
- throw new DatabaseConnectionError(ErrorCode.PSYNC_S1346, `Error reading MongoDB ChangeStream`, e);
1177
+ /**
1178
+ * Transaction key for a change stream event, used to detect transaction boundaries. Returns null if the event is not part of a transaction.
1179
+ */
1180
+ function transactionKey(doc: Pick<mongo.ChangeStreamDocument, 'lsid' | 'txnNumber'>): string | null {
1181
+ if (doc.txnNumber == null || doc.lsid == null) {
1182
+ return null;
1148
1183
  }
1184
+ return `${doc.lsid.id.toString('hex')}:${doc.txnNumber}`;
1149
1185
  }