@powersync/service-module-mongodb 0.15.4 → 0.16.0

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