@powersync/service-module-mongodb 0.0.0-dev-20260602073133 → 0.0.0-dev-20260909133214

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 (77) hide show
  1. package/CHANGELOG.md +153 -5
  2. package/dist/api/MongoRouteAPIAdapter.d.ts +4 -2
  3. package/dist/api/MongoRouteAPIAdapter.js +23 -32
  4. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  5. package/dist/common/SentinelLSN.d.ts +37 -0
  6. package/dist/common/SentinelLSN.js +59 -0
  7. package/dist/common/SentinelLSN.js.map +1 -0
  8. package/dist/module/MongoModule.js +2 -1
  9. package/dist/module/MongoModule.js.map +1 -1
  10. package/dist/replication/ChangeStream.d.ts +24 -0
  11. package/dist/replication/ChangeStream.js +203 -67
  12. package/dist/replication/ChangeStream.js.map +1 -1
  13. package/dist/replication/ChangeStreamReplicationJob.js +3 -2
  14. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  15. package/dist/replication/ChangeStreamReplicator.js +1 -1
  16. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  17. package/dist/replication/MongoRelation.d.ts +36 -1
  18. package/dist/replication/MongoRelation.js +133 -18
  19. package/dist/replication/MongoRelation.js.map +1 -1
  20. package/dist/replication/MongoSnapshotter.d.ts +9 -1
  21. package/dist/replication/MongoSnapshotter.js +129 -67
  22. package/dist/replication/MongoSnapshotter.js.map +1 -1
  23. package/dist/replication/RawChangeStream.d.ts +4 -1
  24. package/dist/replication/RawChangeStream.js +6 -10
  25. package/dist/replication/RawChangeStream.js.map +1 -1
  26. package/dist/replication/checkpoints/CheckpointImplementation.d.ts +135 -0
  27. package/dist/replication/checkpoints/CheckpointImplementation.js +22 -0
  28. package/dist/replication/checkpoints/CheckpointImplementation.js.map +1 -0
  29. package/dist/replication/checkpoints/SentinelCheckpointImplementation.d.ts +58 -0
  30. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js +224 -0
  31. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js.map +1 -0
  32. package/dist/replication/checkpoints/TimestampCheckpointImplementation.d.ts +27 -0
  33. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js +116 -0
  34. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js.map +1 -0
  35. package/dist/replication/checkpoints/create-checkpoint-implementation.d.ts +6 -0
  36. package/dist/replication/checkpoints/create-checkpoint-implementation.js +10 -0
  37. package/dist/replication/checkpoints/create-checkpoint-implementation.js.map +1 -0
  38. package/dist/replication/replication-utils.d.ts +6 -0
  39. package/dist/replication/replication-utils.js +19 -2
  40. package/dist/replication/replication-utils.js.map +1 -1
  41. package/dist/types/types.d.ts +7 -2
  42. package/dist/types/types.js +20 -2
  43. package/dist/types/types.js.map +1 -1
  44. package/package.json +11 -21
  45. package/src/api/MongoRouteAPIAdapter.ts +28 -39
  46. package/src/common/SentinelLSN.ts +78 -0
  47. package/src/module/MongoModule.ts +2 -1
  48. package/src/replication/ChangeStream.ts +223 -78
  49. package/src/replication/ChangeStreamReplicationJob.ts +3 -2
  50. package/src/replication/ChangeStreamReplicator.ts +1 -1
  51. package/src/replication/MongoRelation.ts +153 -21
  52. package/src/replication/MongoSnapshotter.ts +148 -73
  53. package/src/replication/RawChangeStream.ts +27 -32
  54. package/src/replication/checkpoints/CheckpointImplementation.ts +167 -0
  55. package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +269 -0
  56. package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +145 -0
  57. package/src/replication/checkpoints/create-checkpoint-implementation.ts +14 -0
  58. package/src/replication/replication-utils.ts +23 -2
  59. package/src/types/types.ts +27 -2
  60. package/test/DOCUMENTDB_TESTING.md +115 -0
  61. package/test/src/DatabaseType.ts +25 -0
  62. package/test/src/change_stream.test.ts +180 -71
  63. package/test/src/change_stream_utils.ts +117 -29
  64. package/test/src/checkpoint_retry.test.ts +134 -0
  65. package/test/src/config.test.ts +34 -0
  66. package/test/src/documentdb_helpers.test.ts +124 -0
  67. package/test/src/documentdb_mode.test.ts +1039 -0
  68. package/test/src/mongo_test.test.ts +15 -5
  69. package/test/src/raw_change_stream.test.ts +147 -126
  70. package/test/src/resume.test.ts +2 -2
  71. package/test/src/resume_token.test.ts +30 -0
  72. package/test/src/slow_tests.test.ts +4 -1
  73. package/test/src/test-timeouts.ts +23 -0
  74. package/test/src/util.ts +14 -32
  75. package/tsconfig.json +1 -4
  76. package/tsconfig.scripts.json +1 -2
  77. package/tsconfig.tsbuildinfo +1 -1
@@ -21,11 +21,12 @@ import {
21
21
  import { HydratedSyncConfig } from '@powersync/service-sync-rules';
22
22
  import { ReplicationMetric } from '@powersync/service-types';
23
23
  import { performance } from 'node:perf_hooks';
24
- import { MongoLSN } from '../common/MongoLSN.js';
25
24
  import { PostImagesOption } from '../types/types.js';
26
25
  import { escapeRegExp } from '../utils.js';
26
+ import { CheckpointImplementation } from './checkpoints/CheckpointImplementation.js';
27
+ import { createCheckpointImplementation } from './checkpoints/create-checkpoint-implementation.js';
27
28
  import { MongoManager } from './MongoManager.js';
28
- import { createCheckpoint, getCacheIdentifier, getMongoRelation, STANDALONE_CHECKPOINT_ID } from './MongoRelation.js';
29
+ import { getCacheIdentifier, getMongoRelation } from './MongoRelation.js';
29
30
  import { MongoSnapshotter, MongoSnapshotterHooks } from './MongoSnapshotter.js';
30
31
  import {
31
32
  ChangeStreamBatch,
@@ -33,7 +34,7 @@ import {
33
34
  ProjectedChangeStreamDocument,
34
35
  rawChangeStream
35
36
  } from './RawChangeStream.js';
36
- import { CHECKPOINTS_COLLECTION, timestampToDate } from './replication-utils.js';
37
+ import { CHECKPOINTS_COLLECTION, detectDocumentDb, timestampToDate } from './replication-utils.js';
37
38
  import { DirectSourceRowConverter, SourceRowConverter } from './SourceRowConverter.js';
38
39
  export interface ChangeStreamOptions {
39
40
  connections: MongoManager;
@@ -53,6 +54,11 @@ export interface ChangeStreamOptions {
53
54
  */
54
55
  snapshotChunkLength?: number;
55
56
 
57
+ /**
58
+ * Override keepalive interval for testing (defaults to 60_000ms).
59
+ */
60
+ keepaliveIntervalMs?: number;
61
+
56
62
  storageHooks?: storage.StorageHooks;
57
63
  snapshotHooks?: MongoSnapshotterHooks;
58
64
 
@@ -113,13 +119,31 @@ export class ChangeStream {
113
119
 
114
120
  private readonly sourceRowConverter: SourceRowConverter;
115
121
 
122
+ private keepaliveIntervalMs: number;
123
+
124
+ private isDocumentDb = false;
125
+ private _checkpointImplementation: CheckpointImplementation | null = null;
126
+ /**
127
+ * Last persisted resume timestamp as Date.now(). This represents the equivalent timestamp from the
128
+ * source database, rather than the time when we persisted it.
129
+ */
130
+ private lastPersistedResumeTimestamp = 0;
131
+ private lastBatchCheckpoint = 0;
132
+
116
133
  constructor(options: ChangeStreamOptions) {
117
134
  this.storage = options.storage;
118
135
  this.metrics = options.metrics;
119
- this.group_id = options.storage.group_id;
136
+ this.group_id = options.storage.replicationStreamId;
120
137
  this.connections = options.connections;
121
- this.maxAwaitTimeMS = options.maxAwaitTimeMS ?? 10_000;
122
138
  this.snapshotChunkLength = options.snapshotChunkLength ?? 6_000;
139
+ this.keepaliveIntervalMs = options.keepaliveIntervalMs ?? 60_000;
140
+ if (options.maxAwaitTimeMS) {
141
+ this.maxAwaitTimeMS = options.maxAwaitTimeMS;
142
+ } else {
143
+ // Default to 10s, unless keepaliveIntervalMs is shorter. In that case, use keepaliveIntervalMs,
144
+ // but add a bit of a margin, so that our keepalive logic is triggered on each empty change stream batch.
145
+ this.maxAwaitTimeMS = Math.min(10_000, this.keepaliveIntervalMs + 100);
146
+ }
123
147
  this.storageHooks = options.storageHooks;
124
148
  this.client = this.connections.client;
125
149
  this.defaultDb = this.connections.db;
@@ -132,11 +156,16 @@ export class ChangeStream {
132
156
  // so we use 90% of the socket timeout value.
133
157
  this.changeStreamTimeout = Math.ceil(this.client.options.socketTimeoutMS * 0.9);
134
158
 
135
- this.logger = options.logger ?? this.storage.logger;
159
+ const baseLogger = options.logger ?? this.storage.logger;
160
+ // Unfortunately the Winston APIs don't have a nice way to append to the prefix,
161
+ // so we replace it here.
162
+ this.logger = baseLogger.child({ prefix: `[${this.storage.replicationStreamName}] [stream] ` });
163
+ const snapshotLogger = baseLogger.child({ prefix: `[${this.storage.replicationStreamName}] [snapshot] ` });
164
+
136
165
  this.snapshotter = new MongoSnapshotter({
137
166
  ...options,
138
167
  abortSignal: this.abortSignal,
139
- logger: this.logger,
168
+ logger: snapshotLogger,
140
169
  checkpointStreamId: this.checkpointStreamId
141
170
  });
142
171
 
@@ -164,6 +193,37 @@ export class ChangeStream {
164
193
  return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
165
194
  }
166
195
 
196
+ /** The active checkpoint strategy. Only valid after ensureDetected(). */
197
+ private get checkpointImplementation(): CheckpointImplementation {
198
+ if (this._checkpointImplementation == null) {
199
+ throw new ReplicationAssertionError('Checkpoint implementation not initialized - call ensureDetected() first');
200
+ }
201
+ return this._checkpointImplementation;
202
+ }
203
+
204
+ /**
205
+ * Detect DocumentDB and select the checkpoint implementation for the streaming
206
+ * loop. Idempotent. The snapshotter detects independently; the two coordinate
207
+ * through stored LSNs, not shared in-memory state.
208
+ */
209
+ private async ensureDetected(): Promise<void> {
210
+ if (this._checkpointImplementation != null) {
211
+ return;
212
+ }
213
+ this.isDocumentDb = await detectDocumentDb(this.defaultDb);
214
+ if (this.isDocumentDb) {
215
+ this.logger.warn(
216
+ 'Azure DocumentDB support is in alpha. APIs and behavior may change, and long-term stability is not yet guaranteed.'
217
+ );
218
+ }
219
+ this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
220
+ client: this.client,
221
+ db: this.defaultDb,
222
+ checkpointStreamId: this.checkpointStreamId,
223
+ logger: this.logger
224
+ });
225
+ }
226
+
167
227
  private getSourceNamespaceFilters(): { $match: any; multipleDatabases: boolean } {
168
228
  const sourceTables = this.sync_rules.getSourceTables();
169
229
 
@@ -204,7 +264,15 @@ export class ChangeStream {
204
264
  // For details, see:
205
265
  // https://github.com/powersync-ja/powersync-service/pull/417
206
266
  // https://jira.mongodb.org/browse/SERVER-114532
207
- const nsFilter = multipleDatabases
267
+ //
268
+ // DocumentDB always opens a cluster-level change stream (admin +
269
+ // allChangesForCluster), even in single-database mode. A coll-only filter
270
+ // would then match same-named collections (including _powersync_checkpoints)
271
+ // in other databases of the cluster, letting a foreign standalone-checkpoint
272
+ // event advance/resolve checkpoints against the wrong source database. So we
273
+ // must filter on the full namespace whenever the stream is cluster-scoped.
274
+ const useFullNamespaceFilter = this.isDocumentDb || multipleDatabases;
275
+ const nsFilter = useFullNamespaceFilter
208
276
  ? // cluster-level: filter on the entire namespace
209
277
  { ns: { $in: $inFilters } }
210
278
  : // collection-level: filter on coll only
@@ -366,6 +434,7 @@ export class ChangeStream {
366
434
  async replicate() {
367
435
  let streamPromise: Promise<void> | null = null;
368
436
  let loopPromise: Promise<void> | null = null;
437
+ let cleanupPromise: Promise<void> | null = null;
369
438
  try {
370
439
  // If anything errors here, the entire replication process is halted, and
371
440
  // all connections automatically closed, including this one.
@@ -383,6 +452,11 @@ export class ChangeStream {
383
452
  if (!this.snapshotter.supportsConcurrentSnapshots) {
384
453
  await Promise.race([this.snapshotter.waitForInitialSnapshot(), loopPromise]);
385
454
  }
455
+ // Unlike the other two, this resolves on completion, not an indefinite loop.
456
+ cleanupPromise = this.cleanupStoppedSyncConfigs().catch((e) => {
457
+ this.abortController.abort(e);
458
+ throw e;
459
+ });
386
460
  streamPromise = this.streamChanges()
387
461
  .then(() => {
388
462
  throw new ReplicationAssertionError(`Replication stream exited unexpectedly`);
@@ -392,7 +466,7 @@ export class ChangeStream {
392
466
  throw e;
393
467
  });
394
468
 
395
- const results = await Promise.allSettled([loopPromise, streamPromise]);
469
+ const results = await Promise.allSettled([loopPromise, streamPromise, cleanupPromise]);
396
470
  throw replicationLoopError(results);
397
471
  } catch (e) {
398
472
  await this.storage.reportError(e);
@@ -422,6 +496,15 @@ export class ChangeStream {
422
496
  }
423
497
  }
424
498
 
499
+ private async cleanupStoppedSyncConfigs() {
500
+ await this.storage.cleanupStoppedSyncConfigs?.({
501
+ signal: this.abortSignal,
502
+ logger: this.logger,
503
+ defaultSchema: this.defaultDb.databaseName,
504
+ sourceConnectionTag: this.connections.connectionTag
505
+ });
506
+ }
507
+
425
508
  private async streamChanges() {
426
509
  try {
427
510
  await this.streamChangesInternal();
@@ -445,15 +528,18 @@ export class ChangeStream {
445
528
  signal?: AbortSignal;
446
529
  tracer?: PerformanceTracer<'changestream'>;
447
530
  }): AsyncIterableIterator<ChangeStreamBatch> {
448
- const lastLsn = options.lsn ? MongoLSN.fromSerialized(options.lsn) : null;
449
- const startAfter = lastLsn?.timestamp;
450
- const resumeAfter = lastLsn?.resumeToken;
531
+ const position = options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null;
532
+ const startAfter = position?.startAfter ?? undefined;
533
+ const resumeAfter = position?.resumeAfter ?? undefined;
451
534
 
452
535
  const filters = options.filters;
453
536
 
454
537
  let fullDocument: 'required' | 'updateLookup';
455
538
 
456
- if (this.usePostImages) {
539
+ if (this.isDocumentDb) {
540
+ // DocumentDB does not support changeStreamPreAndPostImages, so 'required' won't work.
541
+ fullDocument = 'updateLookup';
542
+ } else if (this.usePostImages) {
457
543
  // 'read_only' or 'auto_configure'
458
544
  // Configuration happens during snapshot, or when we see new
459
545
  // collections.
@@ -462,42 +548,53 @@ export class ChangeStream {
462
548
  fullDocument = 'updateLookup';
463
549
  }
464
550
  const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
465
- showExpandedEvents: true,
466
551
  fullDocument: fullDocument
467
552
  };
553
+ if (!this.isDocumentDb) {
554
+ // DocumentDB does not support showExpandedEvents.
555
+ streamOptions.showExpandedEvents = true;
556
+ }
468
557
  const pipeline: mongo.Document[] = [
469
558
  {
470
559
  $changeStream: streamOptions
471
560
  },
472
561
  {
473
562
  $match: filters.$match
474
- },
475
- { $changeStreamSplitLargeEvent: {} }
563
+ }
476
564
  ];
565
+ if (!this.isDocumentDb) {
566
+ // DocumentDB does not support $changeStreamSplitLargeEvent.
567
+ pipeline.push({ $changeStreamSplitLargeEvent: {} });
568
+ }
477
569
 
478
570
  /**
479
571
  * Only one of these options can be supplied at a time.
480
572
  */
481
573
  if (resumeAfter) {
482
574
  streamOptions.resumeAfter = resumeAfter;
483
- } else {
575
+ } else if (startAfter != null) {
484
576
  // Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
485
577
  // case if we have an old one.
486
578
  // This is also relevant for getSnapshotLSN().
579
+ // The sentinel implementation never produces a startAfter, and a fresh DocumentDB stream
580
+ // opens from "now" with neither option set.
487
581
  streamOptions.startAtOperationTime = startAfter;
488
582
  }
489
583
 
490
584
  let watchDb: mongo.Db;
491
- if (filters.multipleDatabases) {
585
+ if (this.isDocumentDb || filters.multipleDatabases) {
586
+ // DocumentDB only supports cluster-level change streams.
492
587
  watchDb = this.client.db('admin');
493
588
  streamOptions.allChangesForCluster = true;
494
589
  } else {
495
590
  watchDb = this.defaultDb;
496
591
  }
497
592
 
593
+ const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
594
+
498
595
  return rawChangeStream(watchDb, pipeline, {
499
596
  batchSize: options.batchSize ?? this.snapshotChunkLength,
500
- maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
597
+ maxAwaitTimeMS,
501
598
  maxTimeMS: this.changeStreamTimeout,
502
599
 
503
600
  signal: options.signal,
@@ -510,20 +607,30 @@ export class ChangeStream {
510
607
  return this.sourceRowConverter.rawToSqliteRow(row);
511
608
  }
512
609
 
610
+ private async createBatchCheckpoint() {
611
+ const checkpoint = await this.checkpointImplementation.createBatchCheckpoint();
612
+ this.lastBatchCheckpoint = Date.now();
613
+ return checkpoint;
614
+ }
615
+
513
616
  async streamChangesInternal() {
617
+ await this.ensureDetected();
514
618
  const transactionsReplicatedMetric = this.metrics.getCounter(ReplicationMetric.TRANSACTIONS_REPLICATED);
515
619
  const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
516
620
  const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
517
621
 
518
- const tracer = new PerformanceTracer('MongoDB streaming replication');
622
+ const tracer = new PerformanceTracer<
623
+ 'storage' | 'evaluate' | 'batch' | 'source_checkpoint' | 'changestream' | 'processing'
624
+ >('MongoDB streaming replication');
519
625
  await this.storage.startBatch(
520
626
  {
521
627
  logger: this.logger,
522
- zeroLSN: MongoLSN.ZERO.comparable,
628
+ zeroLSN: this.checkpointImplementation.zeroLsn,
523
629
  defaultSchema: this.defaultDb.databaseName,
524
630
  // We get a complete postimage for every change, so we don't need to store the current data.
525
631
  storeCurrentData: false,
526
632
  hooks: this.storageHooks,
633
+ signal: this.abortSignal,
527
634
  tracer
528
635
  },
529
636
  async (batch) => {
@@ -531,15 +638,14 @@ export class ChangeStream {
531
638
  if (resumeFromLsn == null) {
532
639
  throw new ReplicationAssertionError(`No LSN found to resume from`);
533
640
  }
534
- const lastLsn = MongoLSN.fromSerialized(resumeFromLsn);
535
- const startAfter = lastLsn?.timestamp;
641
+ // Seed the implementation's coordinate state from the stored LSN, and parse
642
+ // the legacy startAfter timestamp (timestamp implementation only) for the
643
+ // resume-boundary dedupe guard below.
644
+ this.checkpointImplementation.seedPosition(resumeFromLsn);
645
+ const { startAfter } = this.checkpointImplementation.parseResumePosition(resumeFromLsn);
536
646
  let outerSpan = tracer.span('batch');
537
647
 
538
- // It is normal for this to be a minute or two old when there is a low volume
539
- // of ChangeStream events.
540
- const tokenAgeSeconds = Math.round((Date.now() - timestampToDate(startAfter).getTime()) / 1000);
541
-
542
- this.logger.info(`Resume streaming at ${startAfter?.inspect()} / ${lastLsn} | Token age: ${tokenAgeSeconds}s`);
648
+ this.checkpointImplementation.logResume(resumeFromLsn);
543
649
 
544
650
  const filters = this.getSourceNamespaceFilters();
545
651
  // This is closed when the for loop below returns/breaks/throws
@@ -553,11 +659,7 @@ export class ChangeStream {
553
659
  // Always start with a checkpoint.
554
660
  // This helps us to clear errors when restarting, even if there is
555
661
  // no data to replicate.
556
- let waitForCheckpointLsn: string | null = await createCheckpoint(
557
- this.client,
558
- this.defaultDb,
559
- this.checkpointStreamId
560
- );
662
+ let waitForCheckpointLsn: string | null = await this.createBatchCheckpoint();
561
663
 
562
664
  let splitDocument: ProjectedChangeStreamDocument | null = null;
563
665
 
@@ -577,27 +679,31 @@ export class ChangeStream {
577
679
  }
578
680
  this.touch();
579
681
  if (events.length == 0) {
580
- // No changes in this batch, but we still want to keep the connection alive.
682
+ // No changes in this batch, but we still want to persist progress.
581
683
  // We do this by persisting a keepalive checkpoint.
582
684
  // If we don't update it on empty events, we do keep consistency, but resuming the stream
583
685
  // with old tokens may cause connection timeouts.
584
- if (waitForCheckpointLsn == null && performance.now() - lastEmptyResume > 60_000) {
585
- const { comparable: lsn, timestamp } = MongoLSN.fromResumeToken(resumeToken);
586
- await batch.keepalive(lsn);
686
+ const hadRecentKeepalive = performance.now() - lastEmptyResume < this.keepaliveIntervalMs;
687
+ if (waitForCheckpointLsn == null && !hadRecentKeepalive) {
688
+ // Case 1: We have no changes, and we are not waiting for a checkpoint to be created,
689
+ // and we have not recently persisted a keepalive. Persist one now, and call setResumeLsn() below.
690
+ // This is the normal case for an idle stream.
691
+ // The implementation persists a keepalive (timestamp) or bumps the
692
+ // sentinel so a later event commits (sentinel). Logging is handled
693
+ // inside the implementation.
694
+ await this.checkpointImplementation.keepalive(batch, resumeToken);
587
695
  this.touch();
588
696
  lastEmptyResume = performance.now();
589
- // Log the token update. This helps as a general "replication is still active" message in the logs.
590
- // This token would typically be around 10s behind.
591
- this.logger.info(
592
- `Idle change stream. Persisted resumeToken for ${timestampToDate(timestamp).toISOString()}`
593
- );
594
697
  this.replicationLag.markStarted();
595
- }
596
-
597
- // If we have no changes, we can just persist the keepalive.
598
- // This is throttled to once per minute.
599
- if (performance.now() - lastEmptyResume < 60_000) {
698
+ } else if (hadRecentKeepalive) {
699
+ // Case 2: We have no changes, and may or may not be waiting for a checkpoint to be created.
700
+ // We have recently persisted a keepalive.
701
+ // Continue waiting.
600
702
  continue;
703
+ } else {
704
+ // Case 3: Waiting for a checkpoint; have not had a recent keepalive.
705
+ // We cannot call checkpointImplementation.keepalive() here, but we do call
706
+ // setResumeLsn() below.
601
707
  }
602
708
  }
603
709
 
@@ -704,47 +810,45 @@ export class ChangeStream {
704
810
  // It may be useful to also throttle commits due to standalone checkpoints in the future.
705
811
  // However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
706
812
 
707
- const checkpointId = changeDocument.documentKey._id as string | mongo.ObjectId;
813
+ const kind = this.checkpointImplementation.event.observe(changeDocument);
708
814
 
709
- if (checkpointId == STANDALONE_CHECKPOINT_ID) {
815
+ if (kind == 'foreign') {
816
+ // Another stream's barrier - ignore.
817
+ continue;
818
+ } else if (kind == 'standalone') {
710
819
  // Standalone / write checkpoint received.
711
820
  // When we are caught up, commit immediately to keep write checkpoint latency low.
712
821
  // Once there is already a batch checkpoint pending, or the driver has buffered more
713
822
  // change stream events, collapse standalone checkpoints into the normal batch
714
823
  // checkpoint flow to avoid commit churn under sustained load.
715
824
  const hasBufferedChanges = eventIndex < events.length - 1;
716
- if (waitForCheckpointLsn != null || hasBufferedChanges) {
717
- if (waitForCheckpointLsn == null) {
718
- waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
719
- }
825
+ if (hasBufferedChanges && waitForCheckpointLsn == null) {
826
+ // Buffered changes - create a new batch checkpoint to rate limit commits
827
+ using _ = tracer.span('source_checkpoint');
828
+ waitForCheckpointLsn = await this.createBatchCheckpoint();
720
829
  continue;
830
+ } else if (waitForCheckpointLsn != null) {
831
+ // Skip this checkpoint - wait for the batch checkpoint.
832
+ continue;
833
+ } else {
834
+ // No buffered changes, and no batch checkpoint pending - commit immediately.
721
835
  }
722
- } else if (!this.checkpointStreamId.equals(checkpointId)) {
723
- continue;
724
- }
725
- const { comparable: lsn } = new MongoLSN({
726
- timestamp: changeDocument.clusterTime!,
727
- resume_token: changeDocument._id
728
- });
729
- if (batch.lastCheckpointLsn != null && lsn < batch.lastCheckpointLsn) {
730
- // Checkpoint out of order - should never happen with MongoDB.
731
- // If it does happen, we throw an error to stop the replication - restarting should recover.
732
- // Since we use batch.lastCheckpointLsn for the next resumeAfter, this should not result in an infinite loop.
733
- // Originally a workaround for https://jira.mongodb.org/browse/NODE-7042.
734
- // This has been fixed in the driver in the meantime, but we still keep this as a safety-check.
735
- throw new ReplicationAssertionError(
736
- `Change resumeToken ${(changeDocument._id as any)._data} (${timestampToDate(changeDocument.clusterTime!).toISOString()}) is less than last checkpoint LSN ${batch.lastCheckpointLsn}. Restarting replication.`
737
- );
738
836
  }
837
+ // kind == 'own-barrier' falls through to commit.
838
+
839
+ const lsn = this.checkpointImplementation.event.lsn(changeDocument);
739
840
 
740
- if (waitForCheckpointLsn != null && lsn >= waitForCheckpointLsn) {
841
+ if (
842
+ waitForCheckpointLsn != null &&
843
+ this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)
844
+ ) {
741
845
  waitForCheckpointLsn = null;
742
846
  }
743
- const { checkpointBlocked } = await batch.commit(lsn, {
847
+ const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
744
848
  oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
745
849
  });
746
850
 
747
- if (!checkpointBlocked) {
851
+ if (!checkpointBlocked || checkpointCreated) {
748
852
  this.replicationLag.markCommitted();
749
853
  }
750
854
  } else if (
@@ -754,7 +858,8 @@ export class ChangeStream {
754
858
  changeDocument.operationType == 'delete'
755
859
  ) {
756
860
  if (waitForCheckpointLsn == null) {
757
- waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
861
+ using _ = tracer.span('source_checkpoint');
862
+ waitForCheckpointLsn = await this.createBatchCheckpoint();
758
863
  }
759
864
 
760
865
  const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
@@ -768,7 +873,11 @@ export class ChangeStream {
768
873
  const tablesToReplicate = tables.filter((table) => table.syncAny);
769
874
  if (tablesToReplicate.length > 0) {
770
875
  this.replicationLag.trackUncommittedChange(
771
- changeDocument.clusterTime == null ? null : timestampToDate(changeDocument.clusterTime)
876
+ // Standard MongoDB uses clusterTime, unchanged. DocumentDB has no
877
+ // clusterTime, so fall back to wallTime there for the lag metric.
878
+ changeDocument.clusterTime != null
879
+ ? timestampToDate(changeDocument.clusterTime)
880
+ : ((changeDocument as any).wallTime ?? null)
772
881
  );
773
882
 
774
883
  const transactionKeyValue = transactionKey(changeDocument);
@@ -823,15 +932,23 @@ export class ChangeStream {
823
932
  // Batches are generally large (64MB or 6000 events, whichever comes first),
824
933
  // so this is a good natural point to flush and mark progress.
825
934
  // We avoid this when splitDocument is set, since we cannot resume in the middle of a split event.
826
- const { comparable: lsn } = MongoLSN.fromResumeToken(resumeToken);
935
+ const { lsn, timestamp } = this.checkpointImplementation.lsnFromResumeToken(resumeToken);
827
936
  await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
828
937
  // TODO: We should consider making this standard behavior of flush().
829
938
  await batch.setResumeLsn(lsn);
939
+
940
+ if (timestamp != null) {
941
+ // Note that this timestamp provided by MongoDB is not exact - it can be around 10s behind.
942
+ this.lastPersistedResumeTimestamp = timestamp.getTime();
943
+ } else {
944
+ // DocumentDB: No timestamp associated with the resumeToken. Just use the current time.
945
+ this.lastPersistedResumeTimestamp = Date.now();
946
+ }
830
947
  }
831
948
 
832
949
  batchSpan.end();
833
- const durations = outerSpan.end();
834
- const duration = batchSpan.endAt - batchSpan.startAt;
950
+ const durationsMicroseconds = outerSpan.end();
951
+ const duration = batchSpan.durationMillis;
835
952
 
836
953
  this.logger.info(
837
954
  `Processed batch of ${events.length} changes / ${eventBatch.byteSize} bytes in ${duration}ms`,
@@ -839,7 +956,7 @@ export class ChangeStream {
839
956
  count: events.length,
840
957
  bytes: eventBatch.byteSize,
841
958
  duration,
842
- t: durations
959
+ t: durationsMicroseconds
843
960
  }
844
961
  );
845
962
  outerSpan = tracer.span('batch');
@@ -854,6 +971,34 @@ export class ChangeStream {
854
971
  return this.replicationLag.getLagMillis();
855
972
  }
856
973
 
974
+ async keepAlive() {
975
+ // This is called on an interval of keepaliveIntervalMs.
976
+
977
+ // This writes to _powersync_checkpoints.
978
+ // Main use case: When there are massive bulk writes to another database or collection, the change stream may
979
+ // start timing out due to reading through too much data in one batch. This breaks up those bulk writes,
980
+ // allowing the change stream to make progress.
981
+
982
+ // When there is low replication traffic, regular calls to setResumeLsn() with recent resume tokens make this unnecessary.
983
+ // We track that using lastPersistedResumeTimestamp.
984
+ // We add a bit of a margin here, since setResumeLsn is not called on an exact interval.
985
+ const staleResumeLsn = Date.now() - this.lastPersistedResumeTimestamp > this.keepaliveIntervalMs * 1.1;
986
+
987
+ // When there is replication lag, that resumeToken can get too outdated. In that case, we periodically create a
988
+ // new back checkpoint. This interval is controlled by the frequency of the keepAlive() call,
989
+ // while also skipping if there was another call to createBatchCheckpoint().
990
+ // We use a factor of 0.9 here, to make sure this is called on every keepAlive() interval, unless there
991
+ // was another call to createBatchCheckpoint().
992
+ const staleBatchCheckpoint = Date.now() - this.lastBatchCheckpoint > this.keepaliveIntervalMs * 0.9;
993
+
994
+ // We don't use oldestUncommittedChange here, since that may be unset in some edge cases where we do need
995
+ // to persist new checkpoints.
996
+ if (staleResumeLsn && staleBatchCheckpoint) {
997
+ await this.ensureDetected();
998
+ await this.createBatchCheckpoint();
999
+ }
1000
+ }
1001
+
857
1002
  private lastTouchedAt = performance.now();
858
1003
 
859
1004
  private touch() {
@@ -24,7 +24,7 @@ export class ChangeStreamReplicationJob extends replication.AbstractReplicationJ
24
24
  }
25
25
 
26
26
  async keepAlive() {
27
- // Nothing needed here
27
+ await this.lastStream?.keepAlive();
28
28
  }
29
29
 
30
30
  async replicate() {
@@ -47,7 +47,7 @@ export class ChangeStreamReplicationJob extends replication.AbstractReplicationJ
47
47
 
48
48
  if (e instanceof ChangeStreamInvalidatedError) {
49
49
  // This stops replication and restarts with a new instance
50
- await this.options.storage.factory.restartReplication(this.storage.group_id);
50
+ await this.options.storage.factory.restartReplication(this.storage.replicationStreamId);
51
51
  }
52
52
 
53
53
  // No need to rethrow - the error is already logged, and retry behavior is the same on error
@@ -71,6 +71,7 @@ export class ChangeStreamReplicationJob extends replication.AbstractReplicationJ
71
71
  storage: this.options.storage,
72
72
  metrics: this.options.metrics,
73
73
  connections: connectionManager,
74
+ keepaliveIntervalMs: connectionManager.options.heartbeat_interval_seconds * 1_000,
74
75
  logger: this.logger
75
76
  });
76
77
  this.lastStream = stream;
@@ -17,7 +17,7 @@ export class ChangeStreamReplicator extends replication.AbstractReplicator<Chang
17
17
 
18
18
  createJob(options: replication.CreateJobOptions): ChangeStreamReplicationJob {
19
19
  return new ChangeStreamReplicationJob({
20
- id: this.createJobId(options.storage.group_id),
20
+ id: this.createJobId(options.storage.replicationStreamId),
21
21
  storage: options.storage,
22
22
  metrics: this.metrics,
23
23
  connectionFactory: this.connectionFactory,