@powersync/service-module-mongodb 0.0.0-dev-20260803144812 → 0.0.0-dev-20260929095716

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 (105) hide show
  1. package/CHANGELOG.md +95 -7
  2. package/ci/test-connection.yaml +12 -0
  3. package/dist/api/MongoRouteAPIAdapter.d.ts +2 -2
  4. package/dist/api/MongoRouteAPIAdapter.js +73 -143
  5. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  6. package/dist/api/infer-collection-schema.d.ts +12 -0
  7. package/dist/api/infer-collection-schema.js +142 -0
  8. package/dist/api/infer-collection-schema.js.map +1 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.js +1 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/module/MongoModule.d.ts +1 -2
  13. package/dist/module/MongoModule.js +2 -2
  14. package/dist/module/MongoModule.js.map +1 -1
  15. package/dist/replication/ChangeStream.d.ts +18 -2
  16. package/dist/replication/ChangeStream.js +275 -307
  17. package/dist/replication/ChangeStream.js.map +1 -1
  18. package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
  19. package/dist/replication/ChangeStreamReplicationJob.js +6 -2
  20. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  21. package/dist/replication/ChangeStreamReplicator.d.ts +3 -0
  22. package/dist/replication/ChangeStreamReplicator.js +3 -0
  23. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  24. package/dist/replication/MongoRelation.js +5 -2
  25. package/dist/replication/MongoRelation.js.map +1 -1
  26. package/dist/replication/MongoReplicationQueryProvider.d.ts +87 -0
  27. package/dist/replication/MongoReplicationQueryProvider.js +23 -0
  28. package/dist/replication/MongoReplicationQueryProvider.js.map +1 -0
  29. package/dist/replication/MongoReplicationStream.d.ts +85 -0
  30. package/dist/replication/MongoReplicationStream.js +101 -0
  31. package/dist/replication/MongoReplicationStream.js.map +1 -0
  32. package/dist/replication/MongoSnapshotQuery.d.ts +2 -0
  33. package/dist/replication/MongoSnapshotQuery.js +12 -2
  34. package/dist/replication/MongoSnapshotQuery.js.map +1 -1
  35. package/dist/replication/MongoSnapshotter.d.ts +7 -2
  36. package/dist/replication/MongoSnapshotter.js +64 -91
  37. package/dist/replication/MongoSnapshotter.js.map +1 -1
  38. package/dist/replication/RawChangeStream.d.ts +4 -12
  39. package/dist/replication/RawChangeStream.js +1 -18
  40. package/dist/replication/RawChangeStream.js.map +1 -1
  41. package/dist/replication/SourceRowConverter.d.ts +1 -0
  42. package/dist/replication/SourceRowConverter.js +4 -2
  43. package/dist/replication/SourceRowConverter.js.map +1 -1
  44. package/dist/replication/bufferToSqlite.d.ts +3 -1
  45. package/dist/replication/bufferToSqlite.js +35 -23
  46. package/dist/replication/bufferToSqlite.js.map +1 -1
  47. package/dist/replication/checkpoints/CheckpointImplementation.d.ts +4 -1
  48. package/dist/replication/checkpoints/SentinelCheckpointImplementation.d.ts +4 -1
  49. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js +1 -1
  50. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js.map +1 -1
  51. package/dist/replication/checkpoints/TimestampCheckpointImplementation.d.ts +4 -1
  52. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js +2 -1
  53. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js.map +1 -1
  54. package/dist/replication/replication-index.d.ts +6 -0
  55. package/dist/replication/replication-index.js +6 -0
  56. package/dist/replication/replication-index.js.map +1 -1
  57. package/dist/test-utils/ChangeStreamTestContext.d.ts +131 -0
  58. package/dist/test-utils/ChangeStreamTestContext.js +391 -0
  59. package/dist/test-utils/ChangeStreamTestContext.js.map +1 -0
  60. package/dist/test-utils/test-utils-index.d.ts +1 -0
  61. package/dist/test-utils/test-utils-index.js +2 -0
  62. package/dist/test-utils/test-utils-index.js.map +1 -0
  63. package/dist/types/types.d.ts +5 -0
  64. package/dist/types/types.js +20 -2
  65. package/dist/types/types.js.map +1 -1
  66. package/package.json +9 -9
  67. package/src/api/MongoRouteAPIAdapter.ts +79 -127
  68. package/src/api/infer-collection-schema.ts +153 -0
  69. package/src/index.ts +1 -0
  70. package/src/module/MongoModule.ts +3 -4
  71. package/src/replication/ChangeStream.ts +303 -350
  72. package/src/replication/ChangeStreamReplicationJob.ts +9 -3
  73. package/src/replication/ChangeStreamReplicator.ts +5 -0
  74. package/src/replication/MongoRelation.ts +8 -1
  75. package/src/replication/MongoReplicationQueryProvider.ts +108 -0
  76. package/src/replication/MongoReplicationStream.ts +201 -0
  77. package/src/replication/MongoSnapshotQuery.ts +19 -3
  78. package/src/replication/MongoSnapshotter.ts +76 -98
  79. package/src/replication/RawChangeStream.ts +5 -32
  80. package/src/replication/SourceRowConverter.ts +4 -2
  81. package/src/replication/bufferToSqlite.ts +43 -23
  82. package/src/replication/checkpoints/CheckpointImplementation.ts +1 -1
  83. package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +2 -2
  84. package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +3 -2
  85. package/src/replication/replication-index.ts +6 -0
  86. package/src/test-utils/ChangeStreamTestContext.ts +520 -0
  87. package/src/test-utils/test-utils-index.ts +1 -0
  88. package/src/types/types.ts +27 -2
  89. package/test/src/buffer_to_sqlite.test.ts +9 -0
  90. package/test/src/change_stream.test.ts +76 -3
  91. package/test/src/change_stream_test_setup.ts +38 -0
  92. package/test/src/chunked_snapshot.test.ts +3 -3
  93. package/test/src/config.test.ts +34 -0
  94. package/test/src/documentdb_mode.test.ts +4 -5
  95. package/test/src/raw_change_stream.test.ts +0 -63
  96. package/test/src/replication_stream.test.ts +311 -0
  97. package/test/src/resume.test.ts +10 -4
  98. package/test/src/resuming_snapshots.test.ts +3 -3
  99. package/test/src/schema.test.ts +223 -0
  100. package/test/src/slow_tests.test.ts +3 -3
  101. package/test/src/snapshot_query_provider.test.ts +95 -0
  102. package/test/src/stream_progress.test.ts +312 -0
  103. package/test/src/util.ts +11 -29
  104. package/tsconfig.tsbuildinfo +1 -1
  105. package/test/src/change_stream_utils.ts +0 -375
@@ -27,13 +27,14 @@ import { CheckpointImplementation } from './checkpoints/CheckpointImplementation
27
27
  import { createCheckpointImplementation } from './checkpoints/create-checkpoint-implementation.js';
28
28
  import { MongoManager } from './MongoManager.js';
29
29
  import { getCacheIdentifier, getMongoRelation } from './MongoRelation.js';
30
- import { MongoSnapshotter, MongoSnapshotterHooks } from './MongoSnapshotter.js';
31
30
  import {
32
- ChangeStreamBatch,
33
- parseChangeDocument,
34
- ProjectedChangeStreamDocument,
35
- rawChangeStream
36
- } from './RawChangeStream.js';
31
+ DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER,
32
+ MongoReplicationQueryProvider,
33
+ MongoReplicationQueryProviderFactory
34
+ } from './MongoReplicationQueryProvider.js';
35
+ import { MongoReplicationStreamItem, openMongoReplicationStream } from './MongoReplicationStream.js';
36
+ import { MongoSnapshotter, MongoSnapshotterHooks } from './MongoSnapshotter.js';
37
+ import { ChangeStreamBatch, ProjectedChangeStreamDocument } from './RawChangeStream.js';
37
38
  import { CHECKPOINTS_COLLECTION, detectDocumentDb, timestampToDate } from './replication-utils.js';
38
39
  import { DirectSourceRowConverter, SourceRowConverter } from './SourceRowConverter.js';
39
40
  export interface ChangeStreamOptions {
@@ -61,6 +62,10 @@ export interface ChangeStreamOptions {
61
62
 
62
63
  storageHooks?: storage.StorageHooks;
63
64
  snapshotHooks?: MongoSnapshotterHooks;
65
+ /**
66
+ * Create one adapter per replication attempt, shared with its snapshotter and source probes.
67
+ */
68
+ createReplicationQueryProvider?: MongoReplicationQueryProviderFactory;
64
69
 
65
70
  logger?: Logger;
66
71
  }
@@ -118,26 +123,45 @@ export class ChangeStream {
118
123
  private storageHooks: storage.StorageHooks | undefined;
119
124
 
120
125
  private readonly sourceRowConverter: SourceRowConverter;
126
+ private readonly queryProvider: MongoReplicationQueryProvider;
121
127
 
122
128
  private keepaliveIntervalMs: number;
123
129
 
124
130
  private isDocumentDb = false;
125
131
  private _checkpointImplementation: CheckpointImplementation | null = null;
132
+ /**
133
+ * Last persisted resume timestamp as Date.now(). This represents the equivalent timestamp from the
134
+ * source database, rather than the time when we persisted it.
135
+ */
136
+ private lastPersistedResumeTimestamp = 0;
137
+ private lastBatchCheckpoint = 0;
126
138
 
127
139
  constructor(options: ChangeStreamOptions) {
128
140
  this.storage = options.storage;
129
141
  this.metrics = options.metrics;
130
142
  this.group_id = options.storage.replicationStreamId;
131
143
  this.connections = options.connections;
132
- this.maxAwaitTimeMS = options.maxAwaitTimeMS ?? 10_000;
133
144
  this.snapshotChunkLength = options.snapshotChunkLength ?? 6_000;
134
145
  this.keepaliveIntervalMs = options.keepaliveIntervalMs ?? 60_000;
146
+ if (options.maxAwaitTimeMS) {
147
+ this.maxAwaitTimeMS = options.maxAwaitTimeMS;
148
+ } else {
149
+ // Default to 10s, unless keepaliveIntervalMs is shorter. In that case, use keepaliveIntervalMs,
150
+ // but add a bit of a margin, so that our keepalive logic is triggered on each empty change stream batch.
151
+ this.maxAwaitTimeMS = Math.min(10_000, this.keepaliveIntervalMs + 100);
152
+ }
135
153
  this.storageHooks = options.storageHooks;
136
154
  this.client = this.connections.client;
137
155
  this.defaultDb = this.connections.db;
138
156
  this.sync_rules = options.storage.getParsedSyncRules({
139
157
  defaultSchema: this.defaultDb.databaseName
140
158
  });
159
+ this.queryProvider =
160
+ options.createReplicationQueryProvider?.({
161
+ syncConfig: this.sync_rules,
162
+ connectionTag: this.connections.connectionTag,
163
+ defaultSchema: this.defaultDb.databaseName
164
+ }) ?? DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER;
141
165
  this.sourceRowConverter = new DirectSourceRowConverter(this.sync_rules.compatibility);
142
166
 
143
167
  // The change stream aggregation command should timeout before the socket times out,
@@ -154,7 +178,8 @@ export class ChangeStream {
154
178
  ...options,
155
179
  abortSignal: this.abortSignal,
156
180
  logger: snapshotLogger,
157
- checkpointStreamId: this.checkpointStreamId
181
+ checkpointStreamId: this.checkpointStreamId,
182
+ queryProvider: this.queryProvider
158
183
  });
159
184
 
160
185
  options.abort_signal.addEventListener(
@@ -181,7 +206,9 @@ export class ChangeStream {
181
206
  return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
182
207
  }
183
208
 
184
- /** The active checkpoint strategy. Only valid after ensureDetected(). */
209
+ /**
210
+ * The active checkpoint strategy. Only valid after ensureDetected().
211
+ */
185
212
  private get checkpointImplementation(): CheckpointImplementation {
186
213
  if (this._checkpointImplementation == null) {
187
214
  throw new ReplicationAssertionError('Checkpoint implementation not initialized - call ensureDetected() first');
@@ -201,9 +228,10 @@ export class ChangeStream {
201
228
  this.isDocumentDb = await detectDocumentDb(this.defaultDb);
202
229
  if (this.isDocumentDb) {
203
230
  this.logger.warn(
204
- 'Azure DocumentDB support is experimental. APIs and behavior may change, and long-term stability is not yet guaranteed.'
231
+ 'Azure DocumentDB support is in alpha. APIs and behavior may change, and long-term stability is not yet guaranteed.'
205
232
  );
206
233
  }
234
+ await this.queryProvider.validateSource?.({ connectionManager: this.connections, isDocumentDb: this.isDocumentDb });
207
235
  this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
208
236
  client: this.client,
209
237
  db: this.defaultDb,
@@ -500,7 +528,7 @@ export class ChangeStream {
500
528
  if (
501
529
  e instanceof mongo.MongoServerError &&
502
530
  e.codeName == 'NoMatchingDocument' &&
503
- e.errmsg?.includes('post-image was not found')
531
+ (e.errmsg?.includes('post-image was not found') || e.errmsg?.includes('pre-image was not found'))
504
532
  ) {
505
533
  throw new ChangeStreamInvalidatedError(e.errmsg, e);
506
534
  }
@@ -508,89 +536,32 @@ export class ChangeStream {
508
536
  }
509
537
  }
510
538
 
511
- private rawChangeStreamBatches(options: {
539
+ private openChangeStream(options: {
512
540
  lsn: string | null;
513
541
  maxAwaitTimeMS?: number;
514
542
  batchSize?: number;
515
543
  filters: { $match: any; multipleDatabases: boolean };
516
544
  signal?: AbortSignal;
517
545
  tracer?: PerformanceTracer<'changestream'>;
518
- }): AsyncIterableIterator<ChangeStreamBatch> {
519
- const position = options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null;
520
- const startAfter = position?.startAfter ?? undefined;
521
- const resumeAfter = position?.resumeAfter ?? undefined;
522
-
523
- const filters = options.filters;
524
-
525
- let fullDocument: 'required' | 'updateLookup';
526
-
527
- if (this.isDocumentDb) {
528
- // DocumentDB does not support changeStreamPreAndPostImages, so 'required' won't work.
529
- fullDocument = 'updateLookup';
530
- } else if (this.usePostImages) {
531
- // 'read_only' or 'auto_configure'
532
- // Configuration happens during snapshot, or when we see new
533
- // collections.
534
- fullDocument = 'required';
535
- } else {
536
- fullDocument = 'updateLookup';
537
- }
538
- const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
539
- fullDocument: fullDocument
540
- };
541
- if (!this.isDocumentDb) {
542
- // DocumentDB does not support showExpandedEvents.
543
- streamOptions.showExpandedEvents = true;
544
- }
545
- const pipeline: mongo.Document[] = [
546
- {
547
- $changeStream: streamOptions
548
- },
549
- {
550
- $match: filters.$match
546
+ onBatch?: (batch: ChangeStreamBatch) => Disposable | void;
547
+ }): AsyncIterableIterator<MongoReplicationStreamItem> {
548
+ return openMongoReplicationStream({
549
+ db: this.defaultDb,
550
+ queryProvider: this.queryProvider,
551
+ namespaceFilter: options.filters,
552
+ isDocumentDb: this.isDocumentDb,
553
+ usePostImages: this.usePostImages,
554
+ position: options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null,
555
+ skipInitialTimestamp: true,
556
+ onBatch: options.onBatch,
557
+ options: {
558
+ batchSize: options.batchSize ?? this.snapshotChunkLength,
559
+ maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
560
+ maxTimeMS: this.changeStreamTimeout,
561
+ signal: options.signal,
562
+ logger: this.logger,
563
+ tracer: options.tracer
551
564
  }
552
- ];
553
- if (!this.isDocumentDb) {
554
- // DocumentDB does not support $changeStreamSplitLargeEvent.
555
- pipeline.push({ $changeStreamSplitLargeEvent: {} });
556
- }
557
-
558
- /**
559
- * Only one of these options can be supplied at a time.
560
- */
561
- if (resumeAfter) {
562
- streamOptions.resumeAfter = resumeAfter;
563
- } else if (startAfter != null) {
564
- // Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
565
- // case if we have an old one.
566
- // This is also relevant for getSnapshotLSN().
567
- // The sentinel implementation never produces a startAfter, and a fresh DocumentDB stream
568
- // opens from "now" with neither option set.
569
- streamOptions.startAtOperationTime = startAfter;
570
- }
571
-
572
- let watchDb: mongo.Db;
573
- if (this.isDocumentDb || filters.multipleDatabases) {
574
- // DocumentDB only supports cluster-level change streams.
575
- watchDb = this.client.db('admin');
576
- streamOptions.allChangesForCluster = true;
577
- } else {
578
- watchDb = this.defaultDb;
579
- }
580
-
581
- const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
582
-
583
- return rawChangeStream(watchDb, pipeline, {
584
- batchSize: options.batchSize ?? this.snapshotChunkLength,
585
- maxAwaitTimeMS,
586
- // maxAwaitTimeMS can be 0 for probe-style streams that do not want an idle wait.
587
- // In that case there is no client-side wait to emulate for DocumentDB.
588
- clientSideMaxAwaitTimeMS: this.isDocumentDb && maxAwaitTimeMS > 0,
589
- maxTimeMS: this.changeStreamTimeout,
590
-
591
- signal: options.signal,
592
- logger: this.logger,
593
- tracer: options.tracer
594
565
  });
595
566
  }
596
567
 
@@ -598,6 +569,12 @@ export class ChangeStream {
598
569
  return this.sourceRowConverter.rawToSqliteRow(row);
599
570
  }
600
571
 
572
+ private async createBatchCheckpoint() {
573
+ const checkpoint = await this.checkpointImplementation.createBatchCheckpoint();
574
+ this.lastBatchCheckpoint = Date.now();
575
+ return checkpoint;
576
+ }
577
+
601
578
  async streamChangesInternal() {
602
579
  await this.ensureDetected();
603
580
  const transactionsReplicatedMetric = this.metrics.getCounter(ReplicationMetric.TRANSACTIONS_REPLICATED);
@@ -615,6 +592,7 @@ export class ChangeStream {
615
592
  // We get a complete postimage for every change, so we don't need to store the current data.
616
593
  storeCurrentData: false,
617
594
  hooks: this.storageHooks,
595
+ signal: this.abortSignal,
618
596
  tracer
619
597
  },
620
598
  async (batch) => {
@@ -622,313 +600,260 @@ export class ChangeStream {
622
600
  if (resumeFromLsn == null) {
623
601
  throw new ReplicationAssertionError(`No LSN found to resume from`);
624
602
  }
625
- // Seed the implementation's coordinate state from the stored LSN, and parse
626
- // the legacy startAfter timestamp (timestamp implementation only) for the
627
- // resume-boundary dedupe guard below.
603
+ // Seed the checkpoint strategy's coordinate from the durable source position.
628
604
  this.checkpointImplementation.seedPosition(resumeFromLsn);
629
- const { startAfter } = this.checkpointImplementation.parseResumePosition(resumeFromLsn);
630
605
  let outerSpan = tracer.span('batch');
631
606
 
632
607
  this.checkpointImplementation.logResume(resumeFromLsn);
633
608
 
634
609
  const filters = this.getSourceNamespaceFilters();
635
610
  // This is closed when the for loop below returns/breaks/throws
636
- const batchStream = this.rawChangeStreamBatches({
611
+ let processingSpan: ReturnType<typeof tracer.span> | undefined;
612
+ let receivedBytes = 0;
613
+ let changesSinceProgress = 0;
614
+ const stream = this.openChangeStream({
637
615
  lsn: resumeFromLsn,
638
616
  filters,
639
617
  signal: this.abortSignal,
640
- tracer
618
+ tracer,
619
+ onBatch: (batch) => {
620
+ // Count actual transport, even if the adapter drops every envelope or reassembles fragments.
621
+ bytesReplicatedMetric.add(batch.byteSize);
622
+ chunksReplicatedMetric.add(1);
623
+ receivedBytes += batch.byteSize;
624
+ processingSpan = tracer.span('processing');
625
+ return processingSpan;
626
+ }
641
627
  });
642
628
 
643
629
  // Always start with a checkpoint.
644
630
  // This helps us to clear errors when restarting, even if there is
645
631
  // no data to replicate.
646
- let waitForCheckpointLsn: string | null = await this.checkpointImplementation.createBatchCheckpoint();
647
-
648
- let splitDocument: ProjectedChangeStreamDocument | null = null;
649
-
650
- let flexDbNameWorkaroundLogged = false;
632
+ let waitForCheckpointLsn: string | null = await this.createBatchCheckpoint();
651
633
 
652
- let lastEmptyResume = performance.now();
634
+ let lastKeepalive = performance.now();
653
635
  let lastTxnKey: string | null = null;
654
636
 
655
- for await (let eventBatch of batchStream) {
656
- const { events, resumeToken } = eventBatch;
657
- using batchSpan = tracer.span('processing');
658
-
659
- bytesReplicatedMetric.add(eventBatch.byteSize);
660
- chunksReplicatedMetric.add(1);
661
- if (this.abortSignal.aborted) {
662
- break;
663
- }
637
+ for await (const item of stream) {
638
+ if (this.abortSignal.aborted) break;
664
639
  this.touch();
665
- if (events.length == 0) {
666
- // No changes in this batch, but we still want to keep the connection alive.
667
- // We do this by persisting a keepalive checkpoint.
668
- // If we don't update it on empty events, we do keep consistency, but resuming the stream
669
- // with old tokens may cause connection timeouts.
670
- if (waitForCheckpointLsn == null && performance.now() - lastEmptyResume > this.keepaliveIntervalMs) {
671
- // The implementation persists a keepalive (timestamp) or bumps the
672
- // sentinel so a later event commits (sentinel). Logging is handled
673
- // inside the implementation.
674
- await this.checkpointImplementation.keepalive(batch, resumeToken);
675
- this.touch();
676
- lastEmptyResume = performance.now();
677
- this.replicationLag.markStarted();
640
+ if (item.type == 'progress') {
641
+ const { resumeToken, filteredCount } = item;
642
+ if (changesSinceProgress == 0) {
643
+ // No retained changes since the previous progress item: this is either idle or filtered-only traffic.
644
+ // Case 1: No pending barrier and the keepalive interval has elapsed. Advance checkpoints as below,
645
+ // then flush and save the resume token.
646
+ // Case 2: Idle traffic with a recent keepalive. Skip this resume update, whether or not a barrier
647
+ // is pending, to preserve the idle throttle.
648
+ // Case 3: All other combinations. Flush and save the resume token without requesting a checkpoint.
649
+ // A pending barrier will provide the checkpoint boundary once replication reaches it. Filtered
650
+ // progress still saves its token even during the keepalive interval, so excluded work is resumable.
651
+ const hadRecentKeepalive = performance.now() - lastKeepalive < this.keepaliveIntervalMs;
652
+ if (waitForCheckpointLsn == null && !hadRecentKeepalive) {
653
+ if (filteredCount > 0) {
654
+ // Case 1a: A transaction can span batches: this batch may contain only filtered changes,
655
+ // while retained changes from the same transaction are still unread. They share a timestamp,
656
+ // so publishing a checkpoint here could acknowledge the transaction before its data is saved.
657
+ // Request a source barrier; reaching it ensures those retained changes have been processed.
658
+ // The progress token can still be saved below for recovery without publishing a checkpoint.
659
+ using _ = tracer.span('source_checkpoint');
660
+ waitForCheckpointLsn = await this.createBatchCheckpoint();
661
+ } else {
662
+ // Case 1b: Idle traffic. The timestamp implementation persists a keepalive directly;
663
+ // the sentinel implementation writes a source marker whose event will commit later.
664
+ await this.checkpointImplementation.keepalive(batch, resumeToken);
665
+ this.replicationLag.markStarted();
666
+ }
667
+ this.touch();
668
+ lastKeepalive = performance.now();
669
+ } else if (filteredCount == 0 && hadRecentKeepalive) {
670
+ // Case 2: Only idle progress skips resume persistence. Cases 1 and 3 fall through below.
671
+ continue;
672
+ }
678
673
  }
679
674
 
680
- // If we have no changes, we can just persist the keepalive.
681
- // This is throttled to once per interval.
682
- if (performance.now() - lastEmptyResume < this.keepaliveIntervalMs) {
683
- continue;
684
- }
675
+ const { lsn, timestamp } = this.checkpointImplementation.lsnFromResumeToken(resumeToken);
676
+ // Row writes must be durable before their source token. This advances recovery without
677
+ // publishing a checkpoint ahead of an outstanding barrier, transaction or snapshot.
678
+ await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
679
+ await batch.setResumeLsn(lsn);
680
+ // MongoDB's token timestamp may lag by about 10 seconds. DocumentDB tokens have no timestamp.
681
+ this.lastPersistedResumeTimestamp = timestamp?.getTime() ?? Date.now();
682
+ processingSpan?.end();
683
+ const durationsMicroseconds = outerSpan.end();
684
+ this.logger.info(`Processed ${changesSinceProgress} changes and ${filteredCount} filtered events`, {
685
+ count: changesSinceProgress,
686
+ filteredCount,
687
+ bytes: receivedBytes,
688
+ duration: processingSpan?.durationMillis,
689
+ t: durationsMicroseconds
690
+ });
691
+ changesSinceProgress = 0;
692
+ receivedBytes = 0;
693
+ outerSpan = tracer.span('batch');
694
+ continue;
685
695
  }
686
696
 
687
- this.touch();
688
-
689
- for (let eventIndex = 0; eventIndex < events.length; eventIndex++) {
690
- const rawChangeDocument = events[eventIndex];
691
- const originalChangeDocument = parseChangeDocument(rawChangeDocument);
692
- if (this.abortSignal.aborted) {
693
- break;
697
+ // The shared reader has already reassembled complete events and normalized their namespaces.
698
+ const changeDocument = item.event;
699
+ changesSinceProgress++;
700
+ const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
701
+
702
+ if (ns?.coll == CHECKPOINTS_COLLECTION) {
703
+ /**
704
+ * Dropping the database does not provide an `invalidate` event.
705
+ * We typically would receive `drop` events for the collection which we
706
+ * would process below.
707
+ *
708
+ * However we don't commit the LSN after collections are dropped.
709
+ * This prevents the `startAfter` or `resumeToken` from advancing past the drop events.
710
+ * The stream also closes after the drop events.
711
+ * This causes an infinite loop of processing the collection drop events.
712
+ *
713
+ * This check here invalidates the change stream if our `_powersync_checkpoints` collection
714
+ * is dropped. This allows for detecting when the DB is dropped.
715
+ */
716
+ if (changeDocument.operationType == 'drop') {
717
+ throw new ChangeStreamInvalidatedError(
718
+ 'Internal collections have been dropped',
719
+ new Error('_powersync_checkpoints collection was dropped')
720
+ );
694
721
  }
695
722
 
696
- if (startAfter != null && originalChangeDocument.clusterTime?.lte(startAfter)) {
723
+ if (
724
+ !(
725
+ changeDocument.operationType == 'insert' ||
726
+ changeDocument.operationType == 'update' ||
727
+ changeDocument.operationType == 'replace'
728
+ )
729
+ ) {
697
730
  continue;
698
731
  }
699
732
 
700
- let changeDocument = originalChangeDocument;
701
- if (originalChangeDocument?.splitEvent != null) {
702
- // Handle split events from $changeStreamSplitLargeEvent.
703
- // This is only relevant for very large update operations.
704
- const splitEvent = originalChangeDocument?.splitEvent;
733
+ // We handle two types of checkpoint events:
734
+ // 1. "Standalone" checkpoints, typically write checkpoints. We want to process these
735
+ // immediately, regardless of where they were created.
736
+ // 2. "Batch" checkpoints for the current stream. This is used as a form of dynamic rate
737
+ // limiting of commits, so we specifically want to exclude checkpoints from other streams.
738
+ //
739
+ // It may be useful to also throttle commits due to standalone checkpoints in the future.
740
+ // However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
705
741
 
706
- if (splitDocument == null) {
707
- splitDocument = originalChangeDocument;
708
- } else {
709
- splitDocument = Object.assign(splitDocument, originalChangeDocument);
710
- }
742
+ const kind = this.checkpointImplementation.event.observe(changeDocument);
711
743
 
712
- if (splitEvent.fragment == splitEvent.of) {
713
- // Got all fragments
714
- changeDocument = splitDocument;
715
- splitDocument = null;
716
- } else {
717
- // Wait for more fragments
744
+ if (kind == 'foreign') {
745
+ // Another stream's barrier - ignore.
746
+ continue;
747
+ } else if (kind == 'standalone') {
748
+ // Standalone / write checkpoint received.
749
+ // When we are caught up, commit immediately to keep write checkpoint latency low.
750
+ // Once there is already a batch checkpoint pending, or the driver has buffered more
751
+ // change stream events, collapse standalone checkpoints into the normal batch
752
+ // checkpoint flow to avoid commit churn under sustained load.
753
+ const hasBufferedChanges = item.hasBufferedChanges;
754
+ if (hasBufferedChanges && waitForCheckpointLsn == null) {
755
+ // Buffered changes - create a new batch checkpoint to rate limit commits
756
+ using _ = tracer.span('source_checkpoint');
757
+ waitForCheckpointLsn = await this.createBatchCheckpoint();
758
+ continue;
759
+ } else if (waitForCheckpointLsn != null) {
760
+ // Skip this checkpoint - wait for the batch checkpoint.
718
761
  continue;
762
+ } else {
763
+ // No buffered changes, and no batch checkpoint pending - commit immediately.
719
764
  }
720
- } else if (splitDocument != null) {
721
- // We were waiting for fragments, but got a different event
722
- throw new ReplicationAssertionError(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
723
765
  }
766
+ // kind == 'own-barrier' falls through to commit.
767
+
768
+ const lsn = this.checkpointImplementation.event.lsn(changeDocument);
724
769
 
725
770
  if (
726
- !filters.multipleDatabases &&
727
- 'ns' in changeDocument &&
728
- changeDocument.ns.db != this.defaultDb.databaseName &&
729
- changeDocument.ns.db.endsWith(`_${this.defaultDb.databaseName}`)
771
+ waitForCheckpointLsn != null &&
772
+ this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)
730
773
  ) {
731
- // When all of the following conditions are met:
732
- // 1. We're replicating from an Atlas Flex instance.
733
- // 2. There were changestream events recorded while the PowerSync service is paused.
734
- // 3. We're only replicating from a single database.
735
- // Then we've observed an ns with for example {db: '67b83e86cd20730f1e766dde_ps'},
736
- // instead of the expected {db: 'ps'}.
737
- // We correct this.
738
- changeDocument.ns.db = this.defaultDb.databaseName;
739
-
740
- if (!flexDbNameWorkaroundLogged) {
741
- flexDbNameWorkaroundLogged = true;
742
- this.logger.warn(
743
- `Incorrect DB name in change stream: ${changeDocument.ns.db}. Changed to ${this.defaultDb.databaseName}.`
744
- );
745
- }
774
+ waitForCheckpointLsn = null;
746
775
  }
776
+ const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
777
+ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
778
+ });
747
779
 
748
- const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
749
-
750
- if (ns?.coll == CHECKPOINTS_COLLECTION) {
751
- /**
752
- * Dropping the database does not provide an `invalidate` event.
753
- * We typically would receive `drop` events for the collection which we
754
- * would process below.
755
- *
756
- * However we don't commit the LSN after collections are dropped.
757
- * This prevents the `startAfter` or `resumeToken` from advancing past the drop events.
758
- * The stream also closes after the drop events.
759
- * This causes an infinite loop of processing the collection drop events.
760
- *
761
- * This check here invalidates the change stream if our `_powersync_checkpoints` collection
762
- * is dropped. This allows for detecting when the DB is dropped.
763
- */
764
- if (changeDocument.operationType == 'drop') {
765
- throw new ChangeStreamInvalidatedError(
766
- 'Internal collections have been dropped',
767
- new Error('_powersync_checkpoints collection was dropped')
768
- );
769
- }
770
-
771
- if (
772
- !(
773
- changeDocument.operationType == 'insert' ||
774
- changeDocument.operationType == 'update' ||
775
- changeDocument.operationType == 'replace'
776
- )
777
- ) {
778
- continue;
779
- }
780
-
781
- // We handle two types of checkpoint events:
782
- // 1. "Standalone" checkpoints, typically write checkpoints. We want to process these
783
- // immediately, regardless of where they were created.
784
- // 2. "Batch" checkpoints for the current stream. This is used as a form of dynamic rate
785
- // limiting of commits, so we specifically want to exclude checkpoints from other streams.
786
- //
787
- // It may be useful to also throttle commits due to standalone checkpoints in the future.
788
- // However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
789
-
790
- const kind = this.checkpointImplementation.event.observe(changeDocument);
791
-
792
- if (kind == 'foreign') {
793
- // Another stream's barrier - ignore.
794
- continue;
795
- } else if (kind == 'standalone') {
796
- // Standalone / write checkpoint received.
797
- // When we are caught up, commit immediately to keep write checkpoint latency low.
798
- // Once there is already a batch checkpoint pending, or the driver has buffered more
799
- // change stream events, collapse standalone checkpoints into the normal batch
800
- // checkpoint flow to avoid commit churn under sustained load.
801
- const hasBufferedChanges = eventIndex < events.length - 1;
802
- if (hasBufferedChanges && waitForCheckpointLsn == null) {
803
- // Buffered changes - create a new batch checkpoint to rate limit commits
804
- using _ = tracer.span('source_checkpoint');
805
- waitForCheckpointLsn = await this.checkpointImplementation.createBatchCheckpoint();
806
- continue;
807
- } else if (waitForCheckpointLsn != null) {
808
- // Skip this checkpoint - wait for the batch checkpoint.
809
- continue;
810
- } else {
811
- // No buffered changes, and no batch checkpoint pending - commit immediately.
812
- }
813
- }
814
- // kind == 'own-barrier' falls through to commit.
815
-
816
- const lsn = this.checkpointImplementation.event.lsn(changeDocument);
780
+ if (!checkpointBlocked || checkpointCreated) {
781
+ this.replicationLag.markCommitted();
782
+ }
783
+ } else if (
784
+ changeDocument.operationType == 'insert' ||
785
+ changeDocument.operationType == 'update' ||
786
+ changeDocument.operationType == 'replace' ||
787
+ changeDocument.operationType == 'delete'
788
+ ) {
789
+ if (waitForCheckpointLsn == null) {
790
+ using _ = tracer.span('source_checkpoint');
791
+ waitForCheckpointLsn = await this.createBatchCheckpoint();
792
+ }
817
793
 
818
- if (
819
- waitForCheckpointLsn != null &&
820
- this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)
821
- ) {
822
- waitForCheckpointLsn = null;
794
+ const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
795
+ const tables = await this.getRelations(batch, rel, {
796
+ // In most cases, we should not need to snapshot this. But if this is the first time we see the collection
797
+ // for whatever reason, then we do need to snapshot it.
798
+ // This may result in some duplicate operations when a collection is created for the first time after
799
+ // sync config was deployed.
800
+ snapshot: true
801
+ });
802
+ const tablesToReplicate = tables.filter((table) => table.syncAny);
803
+ if (tablesToReplicate.length > 0) {
804
+ this.replicationLag.trackUncommittedChange(
805
+ // Standard MongoDB uses clusterTime, unchanged. DocumentDB has no
806
+ // clusterTime, so fall back to wallTime there for the lag metric.
807
+ changeDocument.clusterTime != null
808
+ ? timestampToDate(changeDocument.clusterTime)
809
+ : ((changeDocument as any).wallTime ?? null)
810
+ );
811
+
812
+ const transactionKeyValue = transactionKey(changeDocument);
813
+
814
+ if (transactionKeyValue == null || lastTxnKey != transactionKeyValue) {
815
+ // Very crude metric for counting transactions replicated.
816
+ // We ignore operations other than basic CRUD, and ignore changes to _powersync_checkpoints.
817
+ // Individual writes may not have a txnNumber, in which case we count them as separate transactions.
818
+ lastTxnKey = transactionKeyValue;
819
+ transactionsReplicatedMetric.add(1);
823
820
  }
824
- const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
825
- oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
826
- });
827
821
 
828
- if (!checkpointBlocked || checkpointCreated) {
829
- this.replicationLag.markCommitted();
822
+ for (const table of tablesToReplicate) {
823
+ await this.writeChange(batch, table, changeDocument);
830
824
  }
831
- } else if (
832
- changeDocument.operationType == 'insert' ||
833
- changeDocument.operationType == 'update' ||
834
- changeDocument.operationType == 'replace' ||
835
- changeDocument.operationType == 'delete'
836
- ) {
837
- if (waitForCheckpointLsn == null) {
838
- using _ = tracer.span('source_checkpoint');
839
- waitForCheckpointLsn = await this.checkpointImplementation.createBatchCheckpoint();
840
- }
841
-
842
- const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
843
- const tables = await this.getRelations(batch, rel, {
844
- // In most cases, we should not need to snapshot this. But if this is the first time we see the collection
845
- // for whatever reason, then we do need to snapshot it.
846
- // This may result in some duplicate operations when a collection is created for the first time after
847
- // sync config was deployed.
848
- snapshot: true
849
- });
850
- const tablesToReplicate = tables.filter((table) => table.syncAny);
851
- if (tablesToReplicate.length > 0) {
852
- this.replicationLag.trackUncommittedChange(
853
- // Standard MongoDB uses clusterTime, unchanged. DocumentDB has no
854
- // clusterTime, so fall back to wallTime there for the lag metric.
855
- changeDocument.clusterTime != null
856
- ? timestampToDate(changeDocument.clusterTime)
857
- : ((changeDocument as any).wallTime ?? null)
858
- );
859
-
860
- const transactionKeyValue = transactionKey(changeDocument);
861
-
862
- if (transactionKeyValue == null || lastTxnKey != transactionKeyValue) {
863
- // Very crude metric for counting transactions replicated.
864
- // We ignore operations other than basic CRUD, and ignore changes to _powersync_checkpoints.
865
- // Individual writes may not have a txnNumber, in which case we count them as separate transactions.
866
- lastTxnKey = transactionKeyValue;
867
- transactionsReplicatedMetric.add(1);
868
- }
869
-
870
- for (const table of tablesToReplicate) {
871
- await this.writeChange(batch, table, changeDocument);
872
- }
873
- }
874
- } else if (changeDocument.operationType == 'drop') {
875
- const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
876
- const tables = await this.getRelations(batch, rel, {
877
- // We're "dropping" this collection, so never snapshot it.
878
- snapshot: false
879
- });
880
- const tablesToDrop = tables.filter((table) => table.syncAny);
881
- if (tablesToDrop.length > 0) {
882
- await batch.drop(tablesToDrop);
883
- }
884
- this.relationCache.delete(rel);
885
- } else if (changeDocument.operationType == 'rename') {
886
- const relFrom = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
887
- const relTo = getMongoRelation(changeDocument.to, this.connections.connectionTag);
888
- const tablesFrom = await this.getRelations(batch, relFrom, {
889
- // We're "dropping" this collection, so never snapshot it.
890
- snapshot: false
891
- });
892
- const tablesToDrop = tablesFrom.filter((table) => table.syncAny);
893
- if (tablesToDrop.length > 0) {
894
- await batch.drop(tablesToDrop);
895
- }
896
- this.relationCache.delete(relFrom);
897
- // Here we do need to snapshot the new table
898
- const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
899
- await this.handleRelation(batch, relTo, {
900
- // This is a new (renamed) collection, so always snapshot it.
901
- snapshot: true,
902
- collectionInfo: collection
903
- });
904
825
  }
905
- }
906
-
907
- if (splitDocument == null) {
908
- // We flush and mark progress on every batch of data we receive.
909
- // Batches are generally large (64MB or 6000 events, whichever comes first),
910
- // so this is a good natural point to flush and mark progress.
911
- // We avoid this when splitDocument is set, since we cannot resume in the middle of a split event.
912
- const lsn = this.checkpointImplementation.lsnFromResumeToken(resumeToken);
913
- await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
914
- // TODO: We should consider making this standard behavior of flush().
915
- await batch.setResumeLsn(lsn);
916
- }
917
-
918
- batchSpan.end();
919
- const durationsMicroseconds = outerSpan.end();
920
- const duration = batchSpan.durationMillis;
921
-
922
- this.logger.info(
923
- `Processed batch of ${events.length} changes / ${eventBatch.byteSize} bytes in ${duration}ms`,
924
- {
925
- count: events.length,
926
- bytes: eventBatch.byteSize,
927
- duration,
928
- t: durationsMicroseconds
826
+ } else if (changeDocument.operationType == 'drop') {
827
+ const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
828
+ const tables = await this.getRelations(batch, rel, {
829
+ // We're "dropping" this collection, so never snapshot it.
830
+ snapshot: false
831
+ });
832
+ const tablesToDrop = tables.filter((table) => table.syncAny);
833
+ if (tablesToDrop.length > 0) {
834
+ await batch.drop(tablesToDrop);
835
+ }
836
+ this.relationCache.delete(rel);
837
+ } else if (changeDocument.operationType == 'rename') {
838
+ const relFrom = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
839
+ const relTo = getMongoRelation(changeDocument.to, this.connections.connectionTag);
840
+ const tablesFrom = await this.getRelations(batch, relFrom, {
841
+ // We're "dropping" this collection, so never snapshot it.
842
+ snapshot: false
843
+ });
844
+ const tablesToDrop = tablesFrom.filter((table) => table.syncAny);
845
+ if (tablesToDrop.length > 0) {
846
+ await batch.drop(tablesToDrop);
929
847
  }
930
- );
931
- outerSpan = tracer.span('batch');
848
+ this.relationCache.delete(relFrom);
849
+ // Here we do need to snapshot the new table
850
+ const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
851
+ await this.handleRelation(batch, relTo, {
852
+ // This is a new (renamed) collection, so always snapshot it.
853
+ snapshot: true,
854
+ collectionInfo: collection
855
+ });
856
+ }
932
857
  }
933
858
  }
934
859
  );
@@ -940,6 +865,34 @@ export class ChangeStream {
940
865
  return this.replicationLag.getLagMillis();
941
866
  }
942
867
 
868
+ async keepAlive() {
869
+ // This is called on an interval of keepaliveIntervalMs.
870
+
871
+ // This writes to _powersync_checkpoints.
872
+ // Main use case: When there are massive bulk writes to another database or collection, the change stream may
873
+ // start timing out due to reading through too much data in one batch. This breaks up those bulk writes,
874
+ // allowing the change stream to make progress.
875
+
876
+ // When there is low replication traffic, regular calls to setResumeLsn() with recent resume tokens make this unnecessary.
877
+ // We track that using lastPersistedResumeTimestamp.
878
+ // We add a bit of a margin here, since setResumeLsn is not called on an exact interval.
879
+ const staleResumeLsn = Date.now() - this.lastPersistedResumeTimestamp > this.keepaliveIntervalMs * 1.1;
880
+
881
+ // When there is replication lag, that resumeToken can get too outdated. In that case, we periodically create a
882
+ // new back checkpoint. This interval is controlled by the frequency of the keepAlive() call,
883
+ // while also skipping if there was another call to createBatchCheckpoint().
884
+ // We use a factor of 0.9 here, to make sure this is called on every keepAlive() interval, unless there
885
+ // was another call to createBatchCheckpoint().
886
+ const staleBatchCheckpoint = Date.now() - this.lastBatchCheckpoint > this.keepaliveIntervalMs * 0.9;
887
+
888
+ // We don't use oldestUncommittedChange here, since that may be unset in some edge cases where we do need
889
+ // to persist new checkpoints.
890
+ if (staleResumeLsn && staleBatchCheckpoint) {
891
+ await this.ensureDetected();
892
+ await this.createBatchCheckpoint();
893
+ }
894
+ }
895
+
943
896
  private lastTouchedAt = performance.now();
944
897
 
945
898
  private touch() {