@powersync/service-module-mongodb 0.0.0-dev-20260313100403 → 0.0.0-dev-20260515144844

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 (69) hide show
  1. package/CHANGELOG.md +95 -7
  2. package/dist/api/MongoRouteAPIAdapter.js +2 -2
  3. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  4. package/dist/replication/ChangeStream.d.ts +8 -16
  5. package/dist/replication/ChangeStream.js +284 -371
  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 +2 -2
  45. package/src/replication/ChangeStream.ts +380 -350
  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 +4 -10
  62. package/test/src/mongo_test.test.ts +66 -64
  63. package/test/src/parse_document_id.test.ts +54 -0
  64. package/test/src/raw_change_stream.test.ts +547 -0
  65. package/test/src/resume.test.ts +12 -2
  66. package/test/src/util.ts +56 -3
  67. package/test/tsconfig.json +0 -1
  68. package/tsconfig.scripts.json +13 -0
  69. 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() {
@@ -266,48 +257,53 @@ export class ChangeStream {
266
257
 
267
258
  // Create a checkpoint, and open a change stream using startAtOperationTime with the checkpoint's operationTime.
268
259
  const firstCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
269
- await using streamManager = this.openChangeStream({ lsn: firstCheckpointLsn, maxAwaitTimeMs: 0 });
270
260
 
271
- const { stream } = streamManager;
272
261
  const startTime = performance.now();
273
262
  let lastCheckpointCreated = performance.now();
274
263
  let eventsSeen = 0;
264
+ let batchesSeen = 0;
275
265
 
276
- 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
+ }
277
277
  if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
278
278
  await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
279
279
  lastCheckpointCreated = performance.now();
280
280
  }
281
+ batchesSeen += 1;
281
282
 
282
- // tryNext() doesn't block, while next() / hasNext() does block until there is data on the stream
283
- const changeDocument = await stream.tryNext().catch((e) => {
284
- throw mapChangeStreamError(e);
285
- });
286
- if (changeDocument == null) {
287
- continue;
288
- }
289
-
290
- const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
283
+ for (let rawChangeDocument of events) {
284
+ const changeDocument = parseChangeDocument(rawChangeDocument);
285
+ const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
291
286
 
292
- if (ns?.coll == CHECKPOINTS_COLLECTION && 'documentKey' in changeDocument) {
293
- const checkpointId = changeDocument.documentKey._id as string | mongo.ObjectId;
294
- if (!this.checkpointStreamId.equals(checkpointId)) {
295
- 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;
296
297
  }
297
- const { comparable: lsn } = new MongoLSN({
298
- timestamp: changeDocument.clusterTime!,
299
- resume_token: changeDocument._id
300
- });
301
- return lsn;
302
- }
303
298
 
304
- eventsSeen += 1;
299
+ eventsSeen += 1;
300
+ }
305
301
  }
306
302
 
307
303
  // Could happen if there is a very large replication lag?
308
304
  throw new ServiceError(
309
305
  ErrorCode.PSYNC_S1301,
310
- `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}`
311
307
  );
312
308
  }
313
309
 
@@ -315,21 +311,24 @@ export class ChangeStream {
315
311
  * Given a snapshot LSN, validate that we can read from it, by opening a change stream.
316
312
  */
317
313
  private async validateSnapshotLsn(lsn: string) {
318
- await using streamManager = this.openChangeStream({ lsn: lsn, maxAwaitTimeMs: 0 });
319
- const { stream } = streamManager;
320
- try {
321
- // tryNext() doesn't block, while next() / hasNext() does block until there is data on the stream
322
- await stream.tryNext();
323
- } catch (e) {
324
- // Note: A timeout here is not handled as a ChangeStreamInvalidatedError, even though
325
- // we possibly cannot recover from it.
326
- 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;
327
325
  }
328
326
  }
329
327
 
330
328
  async initialReplication(snapshotLsn: string | null) {
331
329
  const sourceTables = this.sync_rules.getSourceTables();
332
330
  await this.client.connect();
331
+ const tracer = new PerformanceTracer('MongoDB initial replication');
333
332
 
334
333
  const flushResult = await this.storage.startBatch(
335
334
  {
@@ -337,7 +336,8 @@ export class ChangeStream {
337
336
  zeroLSN: MongoLSN.ZERO.comparable,
338
337
  defaultSchema: this.defaultDb.databaseName,
339
338
  storeCurrentData: false,
340
- skipExistingRows: true
339
+ skipExistingRows: true,
340
+ tracer
341
341
  },
342
342
  async (batch) => {
343
343
  if (snapshotLsn == null) {
@@ -472,13 +472,11 @@ export class ChangeStream {
472
472
  return { $match: nsFilter, multipleDatabases };
473
473
  }
474
474
 
475
- static *getQueryData(results: Iterable<DatabaseInputRow>): Generator<SqliteInputRow> {
476
- for (let row of results) {
477
- yield constructAfterRecord(row);
478
- }
479
- }
480
-
481
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
+
482
480
  const totalEstimatedCount = await this.estimatedCountNumber(table);
483
481
  let at = table.snapshotStatus?.replicatedCount ?? 0;
484
482
  const db = this.client.db(table.schema);
@@ -499,11 +497,13 @@ export class ChangeStream {
499
497
  let lastBatch = performance.now();
500
498
  let nextChunkPromise = query.nextChunk();
501
499
  while (true) {
502
- const { docs: docBatch, lastKey } = await nextChunkPromise;
500
+ const { docs: docBatch, lastKey, bytes: chunkBytes } = await nextChunkPromise;
503
501
  if (docBatch.length == 0) {
504
502
  // No more data - stop iterating
505
503
  break;
506
504
  }
505
+ bytesReplicatedMetric.add(chunkBytes);
506
+ chunksReplicatedMetric.add(1);
507
507
 
508
508
  if (this.abort_signal.aborted) {
509
509
  throw new ReplicationAbortedError(`Aborted initial replication`, this.abort_signal.reason);
@@ -511,8 +511,8 @@ export class ChangeStream {
511
511
 
512
512
  // Pre-fetch next batch, so that we can read and write concurrently
513
513
  nextChunkPromise = query.nextChunk();
514
- for (let document of docBatch) {
515
- const record = this.constructAfterRecord(document);
514
+ for (let buffer of docBatch) {
515
+ const { row: record, replicaId: replicaId } = this.rawToSqliteRow(buffer);
516
516
 
517
517
  // This auto-flushes when the batch reaches its size limit
518
518
  await batch.save({
@@ -521,14 +521,14 @@ export class ChangeStream {
521
521
  before: undefined,
522
522
  beforeReplicaId: undefined,
523
523
  after: record,
524
- afterReplicaId: document._id
524
+ afterReplicaId: replicaId
525
525
  });
526
526
  }
527
527
 
528
528
  // Important: flush before marking progress
529
529
  await batch.flush();
530
530
  at += docBatch.length;
531
- this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(docBatch.length);
531
+ rowsReplicatedMetric.add(docBatch.length);
532
532
 
533
533
  table = await batch.updateTableProgress(table, {
534
534
  lastKey,
@@ -633,7 +633,7 @@ export class ChangeStream {
633
633
  // Snapshot if:
634
634
  // 1. Snapshot is requested (false for initial snapshot, since that process handles it elsewhere)
635
635
  // 2. Snapshot is not already done, AND:
636
- // 3. The table is used in sync rules.
636
+ // 3. The table is used in sync config.
637
637
  const shouldSnapshot = snapshot && !result.table.snapshotComplete && result.table.syncAny;
638
638
  if (shouldSnapshot) {
639
639
  this.logger.info(`New collection: ${descriptor.schema}.${descriptor.name}`);
@@ -650,30 +650,28 @@ export class ChangeStream {
650
650
  return result.table;
651
651
  }
652
652
 
653
- private constructAfterRecord(document: mongo.Document): SqliteRow {
654
- const inputRow = constructAfterRecord(document);
655
- return this.sync_rules.applyRowContext<never>(inputRow);
656
- }
657
-
658
653
  async writeChange(
659
654
  batch: storage.BucketStorageBatch,
660
655
  table: storage.SourceTable,
661
- change: mongo.ChangeStreamDocument
656
+ change: ProjectedChangeStreamDocument
662
657
  ): Promise<storage.FlushedResult | null> {
663
658
  if (!table.syncAny) {
664
- 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`);
665
660
  return null;
666
661
  }
667
662
 
668
663
  this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(1);
669
664
  if (change.operationType == 'insert') {
670
- const baseRecord = this.constructAfterRecord(change.fullDocument);
665
+ const { row: baseRecord, replicaId: _replicaId } = this.rawToSqliteRow(change.fullDocument);
671
666
  return await batch.save({
672
667
  tag: SaveOperationTag.INSERT,
673
668
  sourceTable: table,
674
669
  before: undefined,
675
670
  beforeReplicaId: undefined,
676
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.
677
675
  afterReplicaId: change.documentKey._id
678
676
  });
679
677
  } else if (change.operationType == 'update' || change.operationType == 'replace') {
@@ -686,14 +684,14 @@ export class ChangeStream {
686
684
  beforeReplicaId: change.documentKey._id
687
685
  });
688
686
  }
689
- const after = this.constructAfterRecord(change.fullDocument!);
687
+ const { row: after, replicaId: _replicaId } = this.rawToSqliteRow(change.fullDocument!);
690
688
  return await batch.save({
691
689
  tag: SaveOperationTag.UPDATE,
692
690
  sourceTable: table,
693
691
  before: undefined,
694
692
  beforeReplicaId: undefined,
695
693
  after: after,
696
- afterReplicaId: change.documentKey._id
694
+ afterReplicaId: change.documentKey._id // Same as _replicaId
697
695
  });
698
696
  } else if (change.operationType == 'delete') {
699
697
  return await batch.save({
@@ -729,7 +727,7 @@ export class ChangeStream {
729
727
  }
730
728
  const { lastOpId } = await this.initialReplication(result.snapshotLsn);
731
729
  if (lastOpId != null) {
732
- // 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.
733
731
  await this.storage.populatePersistentChecksumCache({
734
732
  signal: this.abort_signal,
735
733
  // No checkpoint yet, but we do have the opId.
@@ -754,19 +752,19 @@ export class ChangeStream {
754
752
  }
755
753
  }
756
754
 
757
- 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> {
758
763
  const lastLsn = options.lsn ? MongoLSN.fromSerialized(options.lsn) : null;
759
764
  const startAfter = lastLsn?.timestamp;
760
765
  const resumeAfter = lastLsn?.resumeToken;
761
766
 
762
- const filters = this.getSourceNamespaceFilters();
763
-
764
- const pipeline: mongo.Document[] = [
765
- {
766
- $match: filters.$match
767
- },
768
- { $changeStreamSplitLargeEvent: {} }
769
- ];
767
+ const filters = options.filters;
770
768
 
771
769
  let fullDocument: 'required' | 'updateLookup';
772
770
 
@@ -778,12 +776,19 @@ export class ChangeStream {
778
776
  } else {
779
777
  fullDocument = 'updateLookup';
780
778
  }
781
- const streamOptions: mongo.ChangeStreamOptions = {
779
+ const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
782
780
  showExpandedEvents: true,
783
- maxAwaitTimeMS: options.maxAwaitTimeMs ?? this.maxAwaitTimeMS,
784
- fullDocument: fullDocument,
785
- maxTimeMS: this.changeStreamTimeout
781
+ fullDocument: fullDocument
786
782
  };
783
+ const pipeline: mongo.Document[] = [
784
+ {
785
+ $changeStream: streamOptions
786
+ },
787
+ {
788
+ $match: filters.$match
789
+ },
790
+ { $changeStreamSplitLargeEvent: {} }
791
+ ];
787
792
 
788
793
  /**
789
794
  * Only one of these options can be supplied at a time.
@@ -797,36 +802,43 @@ export class ChangeStream {
797
802
  streamOptions.startAtOperationTime = startAfter;
798
803
  }
799
804
 
800
- let stream: mongo.ChangeStream<mongo.Document>;
805
+ let watchDb: mongo.Db;
801
806
  if (filters.multipleDatabases) {
802
- // Requires readAnyDatabase@admin on Atlas
803
- stream = this.client.watch(pipeline, streamOptions);
807
+ watchDb = this.client.db('admin');
808
+ streamOptions.allChangesForCluster = true;
804
809
  } else {
805
- // Same general result, but requires less permissions than the above
806
- stream = this.defaultDb.watch(pipeline, streamOptions);
810
+ watchDb = this.defaultDb;
807
811
  }
808
812
 
809
- this.abort_signal.addEventListener('abort', () => {
810
- 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
811
821
  });
822
+ }
812
823
 
813
- return {
814
- stream,
815
- filters,
816
- [Symbol.asyncDispose]: async () => {
817
- return stream.close();
818
- }
819
- };
824
+ private rawToSqliteRow(row: Buffer) {
825
+ return this.sourceRowConverter.rawToSqliteRow(row);
820
826
  }
821
827
 
822
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');
823
834
  await this.storage.startBatch(
824
835
  {
825
836
  logger: this.logger,
826
837
  zeroLSN: MongoLSN.ZERO.comparable,
827
838
  defaultSchema: this.defaultDb.databaseName,
828
839
  // We get a complete postimage for every change, so we don't need to store the current data.
829
- storeCurrentData: false
840
+ storeCurrentData: false,
841
+ tracer
830
842
  },
831
843
  async (batch) => {
832
844
  const { resumeFromLsn } = batch;
@@ -835,6 +847,7 @@ export class ChangeStream {
835
847
  }
836
848
  const lastLsn = MongoLSN.fromSerialized(resumeFromLsn);
837
849
  const startAfter = lastLsn?.timestamp;
850
+ let outerSpan = tracer.span('batch');
838
851
 
839
852
  // It is normal for this to be a minute or two old when there is a low volume
840
853
  // of ChangeStream events.
@@ -842,12 +855,14 @@ export class ChangeStream {
842
855
 
843
856
  this.logger.info(`Resume streaming at ${startAfter?.inspect()} / ${lastLsn} | Token age: ${tokenAgeSeconds}s`);
844
857
 
845
- await using streamManager = this.openChangeStream({ lsn: resumeFromLsn });
846
- const { stream, filters } = streamManager;
847
- if (this.abort_signal.aborted) {
848
- await stream.close();
849
- return;
850
- }
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
+ });
851
866
 
852
867
  // Always start with a checkpoint.
853
868
  // This helps us to clear errors when restarting, even if there is
@@ -858,43 +873,30 @@ export class ChangeStream {
858
873
  this.checkpointStreamId
859
874
  );
860
875
 
861
- let splitDocument: mongo.ChangeStreamDocument | null = null;
876
+ let splitDocument: ProjectedChangeStreamDocument | null = null;
862
877
 
863
878
  let flexDbNameWorkaroundLogged = false;
864
- let changesSinceLastCheckpoint = 0;
865
879
 
866
880
  let lastEmptyResume = performance.now();
881
+ let lastTxnKey: string | null = null;
867
882
 
868
- while (true) {
869
- if (this.abort_signal.aborted) {
870
- break;
871
- }
872
-
873
- const originalChangeDocument = await stream.tryNext().catch((e) => {
874
- throw mapChangeStreamError(e);
875
- });
876
- // The stream was closed, we will only ever receive `null` from it
877
- if (!originalChangeDocument && stream.closed) {
878
- break;
879
- }
883
+ for await (let eventBatch of batchStream) {
884
+ const { events, resumeToken } = eventBatch;
885
+ using batchSpan = tracer.span('processing');
880
886
 
887
+ bytesReplicatedMetric.add(eventBatch.byteSize);
888
+ chunksReplicatedMetric.add(1);
881
889
  if (this.abort_signal.aborted) {
882
890
  break;
883
891
  }
884
-
885
- if (originalChangeDocument == null) {
886
- // We get a new null document after `maxAwaitTimeMS` if there were no other events.
887
- // In this case, stream.resumeToken is the resume token associated with the last response.
888
- // stream.resumeToken is not updated if stream.tryNext() returns data, while stream.next()
889
- // does update it.
890
- // 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.
891
896
  // If we don't update it on empty events, we do keep consistency, but resuming the stream
892
897
  // with old tokens may cause connection timeouts.
893
- // We throttle this further by only persisting a keepalive once a minute.
894
- // We add an additional check for waitForCheckpointLsn == null, to make sure we're not
895
- // doing a keepalive in the middle of a transaction.
896
898
  if (waitForCheckpointLsn == null && performance.now() - lastEmptyResume > 60_000) {
897
- const { comparable: lsn, timestamp } = MongoLSN.fromResumeToken(stream.resumeToken);
899
+ const { comparable: lsn, timestamp } = MongoLSN.fromResumeToken(resumeToken);
898
900
  await batch.keepalive(lsn);
899
901
  this.touch();
900
902
  lastEmptyResume = performance.now();
@@ -903,220 +905,260 @@ export class ChangeStream {
903
905
  this.logger.info(
904
906
  `Idle change stream. Persisted resumeToken for ${timestampToDate(timestamp).toISOString()}`
905
907
  );
906
- this.isStartingReplication = false;
908
+ this.replicationLag.markStarted();
907
909
  }
908
- continue;
909
- }
910
-
911
- this.touch();
912
910
 
913
- if (startAfter != null && originalChangeDocument.clusterTime?.lte(startAfter)) {
914
- 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
+ }
915
916
  }
916
917
 
917
- let changeDocument = originalChangeDocument;
918
- if (originalChangeDocument?.splitEvent != null) {
919
- // Handle split events from $changeStreamSplitLargeEvent.
920
- // This is only relevant for very large update operations.
921
- const splitEvent = originalChangeDocument?.splitEvent;
918
+ this.touch();
922
919
 
923
- if (splitDocument == null) {
924
- splitDocument = originalChangeDocument;
925
- } else {
926
- 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;
927
925
  }
928
926
 
929
- if (splitEvent.fragment == splitEvent.of) {
930
- // Got all fragments
931
- changeDocument = splitDocument;
932
- splitDocument = null;
933
- } else {
934
- // Wait for more fragments
927
+ if (startAfter != null && originalChangeDocument.clusterTime?.lte(startAfter)) {
935
928
  continue;
936
929
  }
937
- } else if (splitDocument != null) {
938
- // We were waiting for fragments, but got a different event
939
- throw new ReplicationAssertionError(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
940
- }
941
930
 
942
- if (
943
- !filters.multipleDatabases &&
944
- 'ns' in changeDocument &&
945
- changeDocument.ns.db != this.defaultDb.databaseName &&
946
- changeDocument.ns.db.endsWith(`_${this.defaultDb.databaseName}`)
947
- ) {
948
- // When all of the following conditions are met:
949
- // 1. We're replicating from an Atlas Flex instance.
950
- // 2. There were changestream events recorded while the PowerSync service is paused.
951
- // 3. We're only replicating from a single database.
952
- // Then we've observed an ns with for example {db: '67b83e86cd20730f1e766dde_ps'},
953
- // instead of the expected {db: 'ps'}.
954
- // We correct this.
955
- changeDocument.ns.db = this.defaultDb.databaseName;
956
-
957
- if (!flexDbNameWorkaroundLogged) {
958
- flexDbNameWorkaroundLogged = true;
959
- this.logger.warn(
960
- `Incorrect DB name in change stream: ${changeDocument.ns.db}. Changed to ${this.defaultDb.databaseName}.`
961
- );
962
- }
963
- }
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;
964
936
 
965
- const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
966
-
967
- if (ns?.coll == CHECKPOINTS_COLLECTION) {
968
- /**
969
- * Dropping the database does not provide an `invalidate` event.
970
- * We typically would receive `drop` events for the collection which we
971
- * would process below.
972
- *
973
- * However we don't commit the LSN after collections are dropped.
974
- * The prevents the `startAfter` or `resumeToken` from advancing past the drop events.
975
- * The stream also closes after the drop events.
976
- * This causes an infinite loop of processing the collection drop events.
977
- *
978
- * This check here invalidates the change stream if our `_checkpoints` collection
979
- * is dropped. This allows for detecting when the DB is dropped.
980
- */
981
- if (changeDocument.operationType == 'drop') {
982
- throw new ChangeStreamInvalidatedError(
983
- 'Internal collections have been dropped',
984
- new Error('_checkpoints collection was dropped')
985
- );
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)}`);
986
954
  }
987
955
 
988
956
  if (
989
- !(
990
- changeDocument.operationType == 'insert' ||
991
- changeDocument.operationType == 'update' ||
992
- changeDocument.operationType == 'replace'
993
- )
957
+ !filters.multipleDatabases &&
958
+ 'ns' in changeDocument &&
959
+ changeDocument.ns.db != this.defaultDb.databaseName &&
960
+ changeDocument.ns.db.endsWith(`_${this.defaultDb.databaseName}`)
994
961
  ) {
995
- 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
+ }
996
977
  }
997
978
 
998
- // We handle two types of checkpoint events:
999
- // 1. "Standalone" checkpoints, typically write checkpoints. We want to process these
1000
- // immediately, regardless of where they were created.
1001
- // 2. "Batch" checkpoints for the current stream. This is used as a form of dynamic rate
1002
- // limiting of commits, so we specifically want to exclude checkpoints from other streams.
1003
- //
1004
- // It may be useful to also throttle commits due to standalone checkpoints in the future.
1005
- // However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
1006
-
1007
- const checkpointId = changeDocument.documentKey._id as string | mongo.ObjectId;
1008
- if (!(checkpointId == STANDALONE_CHECKPOINT_ID || this.checkpointStreamId.equals(checkpointId))) {
1009
- continue;
1010
- }
1011
- const { comparable: lsn } = new MongoLSN({
1012
- timestamp: changeDocument.clusterTime!,
1013
- resume_token: changeDocument._id
1014
- });
1015
- if (batch.lastCheckpointLsn != null && lsn < batch.lastCheckpointLsn) {
1016
- // Checkpoint out of order - should never happen with MongoDB.
1017
- // If it does happen, we throw an error to stop the replication - restarting should recover.
1018
- // Since we use batch.lastCheckpointLsn for the next resumeAfter, this should not result in an infinite loop.
1019
- // Originally a workaround for https://jira.mongodb.org/browse/NODE-7042.
1020
- // This has been fixed in the driver in the meantime, but we still keep this as a safety-check.
1021
- throw new ReplicationAssertionError(
1022
- `Change resumeToken ${(changeDocument._id as any)._data} (${timestampToDate(changeDocument.clusterTime!).toISOString()}) is less than last checkpoint LSN ${batch.lastCheckpointLsn}. Restarting replication.`
1023
- );
1024
- }
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
+ }
1025
1001
 
1026
- if (waitForCheckpointLsn != null && lsn >= waitForCheckpointLsn) {
1027
- waitForCheckpointLsn = null;
1028
- }
1029
- const { checkpointBlocked } = await batch.commit(lsn, {
1030
- oldestUncommittedChange: this.oldestUncommittedChange
1031
- });
1032
-
1033
- if (!checkpointBlocked) {
1034
- this.oldestUncommittedChange = null;
1035
- this.isStartingReplication = false;
1036
- changesSinceLastCheckpoint = 0;
1037
- }
1038
- } else if (
1039
- changeDocument.operationType == 'insert' ||
1040
- changeDocument.operationType == 'update' ||
1041
- changeDocument.operationType == 'replace' ||
1042
- changeDocument.operationType == 'delete'
1043
- ) {
1044
- if (waitForCheckpointLsn == null) {
1045
- waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
1046
- }
1047
- const rel = getMongoRelation(changeDocument.ns);
1048
- const table = await this.getRelation(batch, rel, {
1049
- // In most cases, we should not need to snapshot this. But if this is the first time we see the collection
1050
- // for whatever reason, then we do need to snapshot it.
1051
- // This may result in some duplicate operations when a collection is created for the first time after
1052
- // sync rules was deployed.
1053
- snapshot: true
1054
- });
1055
- if (table.syncAny) {
1056
- if (this.oldestUncommittedChange == null && changeDocument.clusterTime != null) {
1057
- this.oldestUncommittedChange = timestampToDate(changeDocument.clusterTime);
1002
+ if (
1003
+ !(
1004
+ changeDocument.operationType == 'insert' ||
1005
+ changeDocument.operationType == 'update' ||
1006
+ changeDocument.operationType == 'replace'
1007
+ )
1008
+ ) {
1009
+ continue;
1010
+ }
1011
+
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;
1058
1038
  }
1059
- const flushResult = await this.writeChange(batch, table, changeDocument);
1060
- changesSinceLastCheckpoint += 1;
1061
- if (flushResult != null && changesSinceLastCheckpoint >= 20_000) {
1062
- // When we are catching up replication after an initial snapshot, there may be a very long delay
1063
- // before we do a commit(). In that case, we need to periodically persist the resume LSN, so
1064
- // we don't restart from scratch if we restart replication.
1065
- // The same could apply if we need to catch up on replication after some downtime.
1066
- const { comparable: lsn } = new MongoLSN({
1067
- timestamp: changeDocument.clusterTime!,
1068
- resume_token: changeDocument._id
1069
- });
1070
- this.logger.info(`Updating resume LSN to ${lsn} after ${changesSinceLastCheckpoint} changes`);
1071
- await batch.setResumeLsn(lsn);
1072
- 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
+ );
1073
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
+ });
1074
1127
  }
1075
- } else if (changeDocument.operationType == 'drop') {
1076
- const rel = getMongoRelation(changeDocument.ns);
1077
- const table = await this.getRelation(batch, rel, {
1078
- // We're "dropping" this collection, so never snapshot it.
1079
- snapshot: false
1080
- });
1081
- if (table.syncAny) {
1082
- await batch.drop([table]);
1083
- this.relationCache.delete(table);
1084
- }
1085
- } else if (changeDocument.operationType == 'rename') {
1086
- const relFrom = getMongoRelation(changeDocument.ns);
1087
- const relTo = getMongoRelation(changeDocument.to);
1088
- const tableFrom = await this.getRelation(batch, relFrom, {
1089
- // We're "dropping" this collection, so never snapshot it.
1090
- snapshot: false
1091
- });
1092
- if (tableFrom.syncAny) {
1093
- await batch.drop([tableFrom]);
1094
- this.relationCache.delete(relFrom);
1095
- }
1096
- // Here we do need to snapshot the new table
1097
- const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
1098
- await this.handleRelation(batch, relTo, {
1099
- // This is a new (renamed) collection, so always snapshot it.
1100
- snapshot: true,
1101
- collectionInfo: collection
1102
- });
1103
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');
1104
1155
  }
1105
1156
  }
1106
1157
  );
1107
1158
  }
1108
1159
 
1109
- async getReplicationLagMillis(): Promise<number | undefined> {
1110
- if (this.oldestUncommittedChange == null) {
1111
- if (this.isStartingReplication) {
1112
- // We don't have anything to compute replication lag with yet.
1113
- return undefined;
1114
- } else {
1115
- // We don't have any uncommitted changes, so replication is up-to-date.
1116
- return 0;
1117
- }
1118
- }
1119
- return Date.now() - this.oldestUncommittedChange.getTime();
1160
+ getReplicationLagMillis(): number | undefined {
1161
+ return this.replicationLag.getLagMillis();
1120
1162
  }
1121
1163
 
1122
1164
  private lastTouchedAt = performance.now();
@@ -1132,24 +1174,12 @@ export class ChangeStream {
1132
1174
  }
1133
1175
  }
1134
1176
 
1135
- function mapChangeStreamError(e: any) {
1136
- if (isMongoNetworkTimeoutError(e)) {
1137
- // This typically has an unhelpful message like "connection 2 to 159.41.94.47:27017 timed out".
1138
- // We wrap the error to make it more useful.
1139
- throw new DatabaseConnectionError(ErrorCode.PSYNC_S1345, `Timeout while reading MongoDB ChangeStream`, e);
1140
- } else if (isMongoServerError(e) && e.codeName == 'MaxTimeMSExpired') {
1141
- // maxTimeMS was reached. Example message:
1142
- // MongoServerError: Executor error during aggregate command on namespace: powersync_test_data.$cmd.aggregate :: caused by :: operation exceeded time limit
1143
- throw new DatabaseConnectionError(ErrorCode.PSYNC_S1345, `Timeout while reading MongoDB ChangeStream`, e);
1144
- } else if (
1145
- isMongoServerError(e) &&
1146
- e.codeName == 'NoMatchingDocument' &&
1147
- e.errmsg?.includes('post-image was not found')
1148
- ) {
1149
- throw new ChangeStreamInvalidatedError(e.errmsg, e);
1150
- } else if (isMongoServerError(e) && e.hasErrorLabel('NonResumableChangeStreamError')) {
1151
- throw new ChangeStreamInvalidatedError(e.message, e);
1152
- } else {
1153
- 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;
1154
1183
  }
1184
+ return `${doc.lsid.id.toString('hex')}:${doc.txnNumber}`;
1155
1185
  }