@powersync/service-module-mongodb 0.18.2 → 0.20.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 (71) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/api/MongoRouteAPIAdapter.d.ts +2 -0
  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 +23 -0
  11. package/dist/replication/ChangeStream.js +167 -47
  12. package/dist/replication/ChangeStream.js.map +1 -1
  13. package/dist/replication/ChangeStreamReplicationJob.js +2 -1
  14. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  15. package/dist/replication/MongoRelation.d.ts +36 -1
  16. package/dist/replication/MongoRelation.js +102 -6
  17. package/dist/replication/MongoRelation.js.map +1 -1
  18. package/dist/replication/MongoSnapshotter.d.ts +9 -0
  19. package/dist/replication/MongoSnapshotter.js +113 -42
  20. package/dist/replication/MongoSnapshotter.js.map +1 -1
  21. package/dist/replication/RawChangeStream.d.ts +12 -1
  22. package/dist/replication/RawChangeStream.js +23 -10
  23. package/dist/replication/RawChangeStream.js.map +1 -1
  24. package/dist/replication/checkpoints/CheckpointImplementation.d.ts +135 -0
  25. package/dist/replication/checkpoints/CheckpointImplementation.js +22 -0
  26. package/dist/replication/checkpoints/CheckpointImplementation.js.map +1 -0
  27. package/dist/replication/checkpoints/SentinelCheckpointImplementation.d.ts +58 -0
  28. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js +224 -0
  29. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js.map +1 -0
  30. package/dist/replication/checkpoints/TimestampCheckpointImplementation.d.ts +27 -0
  31. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js +116 -0
  32. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js.map +1 -0
  33. package/dist/replication/checkpoints/create-checkpoint-implementation.d.ts +6 -0
  34. package/dist/replication/checkpoints/create-checkpoint-implementation.js +10 -0
  35. package/dist/replication/checkpoints/create-checkpoint-implementation.js.map +1 -0
  36. package/dist/replication/replication-utils.d.ts +6 -0
  37. package/dist/replication/replication-utils.js +19 -2
  38. package/dist/replication/replication-utils.js.map +1 -1
  39. package/dist/types/types.d.ts +5 -0
  40. package/dist/types/types.js +20 -2
  41. package/dist/types/types.js.map +1 -1
  42. package/package.json +9 -9
  43. package/src/api/MongoRouteAPIAdapter.ts +26 -37
  44. package/src/common/SentinelLSN.ts +78 -0
  45. package/src/module/MongoModule.ts +2 -1
  46. package/src/replication/ChangeStream.ts +184 -55
  47. package/src/replication/ChangeStreamReplicationJob.ts +2 -1
  48. package/src/replication/MongoRelation.ts +124 -14
  49. package/src/replication/MongoSnapshotter.ts +132 -47
  50. package/src/replication/RawChangeStream.ts +54 -32
  51. package/src/replication/checkpoints/CheckpointImplementation.ts +167 -0
  52. package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +269 -0
  53. package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +145 -0
  54. package/src/replication/checkpoints/create-checkpoint-implementation.ts +14 -0
  55. package/src/replication/replication-utils.ts +23 -2
  56. package/src/types/types.ts +27 -2
  57. package/test/DOCUMENTDB_TESTING.md +115 -0
  58. package/test/src/DatabaseType.ts +25 -0
  59. package/test/src/change_stream.test.ts +97 -65
  60. package/test/src/change_stream_utils.ts +99 -11
  61. package/test/src/checkpoint_retry.test.ts +5 -2
  62. package/test/src/config.test.ts +34 -0
  63. package/test/src/documentdb_helpers.test.ts +124 -0
  64. package/test/src/documentdb_mode.test.ts +1040 -0
  65. package/test/src/mongo_test.test.ts +15 -5
  66. package/test/src/raw_change_stream.test.ts +209 -125
  67. package/test/src/resume_token.test.ts +30 -0
  68. package/test/src/slow_tests.test.ts +4 -1
  69. package/test/src/test-timeouts.ts +23 -0
  70. package/test/src/util.ts +1 -2
  71. 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
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;
@@ -169,6 +193,37 @@ export class ChangeStream {
169
193
  return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
170
194
  }
171
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 experimental. 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
+
172
227
  private getSourceNamespaceFilters(): { $match: any; multipleDatabases: boolean } {
173
228
  const sourceTables = this.sync_rules.getSourceTables();
174
229
 
@@ -209,7 +264,15 @@ export class ChangeStream {
209
264
  // For details, see:
210
265
  // https://github.com/powersync-ja/powersync-service/pull/417
211
266
  // https://jira.mongodb.org/browse/SERVER-114532
212
- 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
213
276
  ? // cluster-level: filter on the entire namespace
214
277
  { ns: { $in: $inFilters } }
215
278
  : // collection-level: filter on coll only
@@ -465,15 +528,18 @@ export class ChangeStream {
465
528
  signal?: AbortSignal;
466
529
  tracer?: PerformanceTracer<'changestream'>;
467
530
  }): AsyncIterableIterator<ChangeStreamBatch> {
468
- const lastLsn = options.lsn ? MongoLSN.fromSerialized(options.lsn) : null;
469
- const startAfter = lastLsn?.timestamp;
470
- 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;
471
534
 
472
535
  const filters = options.filters;
473
536
 
474
537
  let fullDocument: 'required' | 'updateLookup';
475
538
 
476
- 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) {
477
543
  // 'read_only' or 'auto_configure'
478
544
  // Configuration happens during snapshot, or when we see new
479
545
  // collections.
@@ -482,42 +548,56 @@ export class ChangeStream {
482
548
  fullDocument = 'updateLookup';
483
549
  }
484
550
  const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
485
- showExpandedEvents: true,
486
551
  fullDocument: fullDocument
487
552
  };
553
+ if (!this.isDocumentDb) {
554
+ // DocumentDB does not support showExpandedEvents.
555
+ streamOptions.showExpandedEvents = true;
556
+ }
488
557
  const pipeline: mongo.Document[] = [
489
558
  {
490
559
  $changeStream: streamOptions
491
560
  },
492
561
  {
493
562
  $match: filters.$match
494
- },
495
- { $changeStreamSplitLargeEvent: {} }
563
+ }
496
564
  ];
565
+ if (!this.isDocumentDb) {
566
+ // DocumentDB does not support $changeStreamSplitLargeEvent.
567
+ pipeline.push({ $changeStreamSplitLargeEvent: {} });
568
+ }
497
569
 
498
570
  /**
499
571
  * Only one of these options can be supplied at a time.
500
572
  */
501
573
  if (resumeAfter) {
502
574
  streamOptions.resumeAfter = resumeAfter;
503
- } else {
575
+ } else if (startAfter != null) {
504
576
  // Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
505
577
  // case if we have an old one.
506
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.
507
581
  streamOptions.startAtOperationTime = startAfter;
508
582
  }
509
583
 
510
584
  let watchDb: mongo.Db;
511
- if (filters.multipleDatabases) {
585
+ if (this.isDocumentDb || filters.multipleDatabases) {
586
+ // DocumentDB only supports cluster-level change streams.
512
587
  watchDb = this.client.db('admin');
513
588
  streamOptions.allChangesForCluster = true;
514
589
  } else {
515
590
  watchDb = this.defaultDb;
516
591
  }
517
592
 
593
+ const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
594
+
518
595
  return rawChangeStream(watchDb, pipeline, {
519
596
  batchSize: options.batchSize ?? this.snapshotChunkLength,
520
- maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
597
+ maxAwaitTimeMS,
598
+ // maxAwaitTimeMS can be 0 for probe-style streams that do not want an idle wait.
599
+ // In that case there is no client-side wait to emulate for DocumentDB.
600
+ clientSideMaxAwaitTimeMS: this.isDocumentDb && maxAwaitTimeMS > 0,
521
601
  maxTimeMS: this.changeStreamTimeout,
522
602
 
523
603
  signal: options.signal,
@@ -530,7 +610,14 @@ export class ChangeStream {
530
610
  return this.sourceRowConverter.rawToSqliteRow(row);
531
611
  }
532
612
 
613
+ private async createBatchCheckpoint() {
614
+ const checkpoint = await this.checkpointImplementation.createBatchCheckpoint();
615
+ this.lastBatchCheckpoint = Date.now();
616
+ return checkpoint;
617
+ }
618
+
533
619
  async streamChangesInternal() {
620
+ await this.ensureDetected();
534
621
  const transactionsReplicatedMetric = this.metrics.getCounter(ReplicationMetric.TRANSACTIONS_REPLICATED);
535
622
  const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
536
623
  const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
@@ -541,7 +628,7 @@ export class ChangeStream {
541
628
  await this.storage.startBatch(
542
629
  {
543
630
  logger: this.logger,
544
- zeroLSN: MongoLSN.ZERO.comparable,
631
+ zeroLSN: this.checkpointImplementation.zeroLsn,
545
632
  defaultSchema: this.defaultDb.databaseName,
546
633
  // We get a complete postimage for every change, so we don't need to store the current data.
547
634
  storeCurrentData: false,
@@ -553,15 +640,14 @@ export class ChangeStream {
553
640
  if (resumeFromLsn == null) {
554
641
  throw new ReplicationAssertionError(`No LSN found to resume from`);
555
642
  }
556
- const lastLsn = MongoLSN.fromSerialized(resumeFromLsn);
557
- const startAfter = lastLsn?.timestamp;
643
+ // Seed the implementation's coordinate state from the stored LSN, and parse
644
+ // the legacy startAfter timestamp (timestamp implementation only) for the
645
+ // resume-boundary dedupe guard below.
646
+ this.checkpointImplementation.seedPosition(resumeFromLsn);
647
+ const { startAfter } = this.checkpointImplementation.parseResumePosition(resumeFromLsn);
558
648
  let outerSpan = tracer.span('batch');
559
649
 
560
- // It is normal for this to be a minute or two old when there is a low volume
561
- // of ChangeStream events.
562
- const tokenAgeSeconds = Math.round((Date.now() - timestampToDate(startAfter).getTime()) / 1000);
563
-
564
- this.logger.info(`Resume streaming at ${startAfter?.inspect()} / ${lastLsn} | Token age: ${tokenAgeSeconds}s`);
650
+ this.checkpointImplementation.logResume(resumeFromLsn);
565
651
 
566
652
  const filters = this.getSourceNamespaceFilters();
567
653
  // This is closed when the for loop below returns/breaks/throws
@@ -575,11 +661,7 @@ export class ChangeStream {
575
661
  // Always start with a checkpoint.
576
662
  // This helps us to clear errors when restarting, even if there is
577
663
  // no data to replicate.
578
- let waitForCheckpointLsn: string | null = await createCheckpoint(
579
- this.client,
580
- this.defaultDb,
581
- this.checkpointStreamId
582
- );
664
+ let waitForCheckpointLsn: string | null = await this.createBatchCheckpoint();
583
665
 
584
666
  let splitDocument: ProjectedChangeStreamDocument | null = null;
585
667
 
@@ -599,27 +681,31 @@ export class ChangeStream {
599
681
  }
600
682
  this.touch();
601
683
  if (events.length == 0) {
602
- // No changes in this batch, but we still want to keep the connection alive.
684
+ // No changes in this batch, but we still want to persist progress.
603
685
  // We do this by persisting a keepalive checkpoint.
604
686
  // If we don't update it on empty events, we do keep consistency, but resuming the stream
605
687
  // with old tokens may cause connection timeouts.
606
- if (waitForCheckpointLsn == null && performance.now() - lastEmptyResume > 60_000) {
607
- const { comparable: lsn, timestamp } = MongoLSN.fromResumeToken(resumeToken);
608
- await batch.keepalive(lsn);
688
+ const hadRecentKeepalive = performance.now() - lastEmptyResume < this.keepaliveIntervalMs;
689
+ if (waitForCheckpointLsn == null && !hadRecentKeepalive) {
690
+ // Case 1: We have no changes, and we are not waiting for a checkpoint to be created,
691
+ // and we have not recently persisted a keepalive. Persist one now, and call setResumeLsn() below.
692
+ // This is the normal case for an idle stream.
693
+ // The implementation persists a keepalive (timestamp) or bumps the
694
+ // sentinel so a later event commits (sentinel). Logging is handled
695
+ // inside the implementation.
696
+ await this.checkpointImplementation.keepalive(batch, resumeToken);
609
697
  this.touch();
610
698
  lastEmptyResume = performance.now();
611
- // Log the token update. This helps as a general "replication is still active" message in the logs.
612
- // This token would typically be around 10s behind.
613
- this.logger.info(
614
- `Idle change stream. Persisted resumeToken for ${timestampToDate(timestamp).toISOString()}`
615
- );
616
699
  this.replicationLag.markStarted();
617
- }
618
-
619
- // If we have no changes, we can just persist the keepalive.
620
- // This is throttled to once per minute.
621
- if (performance.now() - lastEmptyResume < 60_000) {
700
+ } else if (hadRecentKeepalive) {
701
+ // Case 2: We have no changes, and may or may not be waiting for a checkpoint to be created.
702
+ // We have recently persisted a keepalive.
703
+ // Continue waiting.
622
704
  continue;
705
+ } else {
706
+ // Case 3: Waiting for a checkpoint; have not had a recent keepalive.
707
+ // We cannot call checkpointImplementation.keepalive() here, but we do call
708
+ // setResumeLsn() below.
623
709
  }
624
710
  }
625
711
 
@@ -726,9 +812,12 @@ export class ChangeStream {
726
812
  // It may be useful to also throttle commits due to standalone checkpoints in the future.
727
813
  // However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
728
814
 
729
- const checkpointId = changeDocument.documentKey._id as string | mongo.ObjectId;
815
+ const kind = this.checkpointImplementation.event.observe(changeDocument);
730
816
 
731
- if (checkpointId == STANDALONE_CHECKPOINT_ID) {
817
+ if (kind == 'foreign') {
818
+ // Another stream's barrier - ignore.
819
+ continue;
820
+ } else if (kind == 'standalone') {
732
821
  // Standalone / write checkpoint received.
733
822
  // When we are caught up, commit immediately to keep write checkpoint latency low.
734
823
  // Once there is already a batch checkpoint pending, or the driver has buffered more
@@ -738,7 +827,7 @@ export class ChangeStream {
738
827
  if (hasBufferedChanges && waitForCheckpointLsn == null) {
739
828
  // Buffered changes - create a new batch checkpoint to rate limit commits
740
829
  using _ = tracer.span('source_checkpoint');
741
- waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
830
+ waitForCheckpointLsn = await this.createBatchCheckpoint();
742
831
  continue;
743
832
  } else if (waitForCheckpointLsn != null) {
744
833
  // Skip this checkpoint - wait for the batch checkpoint.
@@ -746,15 +835,15 @@ export class ChangeStream {
746
835
  } else {
747
836
  // No buffered changes, and no batch checkpoint pending - commit immediately.
748
837
  }
749
- } else if (!this.checkpointStreamId.equals(checkpointId)) {
750
- continue;
751
838
  }
752
- const { comparable: lsn } = new MongoLSN({
753
- timestamp: changeDocument.clusterTime!,
754
- resume_token: changeDocument._id
755
- });
839
+ // kind == 'own-barrier' falls through to commit.
756
840
 
757
- if (waitForCheckpointLsn != null && lsn >= waitForCheckpointLsn) {
841
+ const lsn = this.checkpointImplementation.event.lsn(changeDocument);
842
+
843
+ if (
844
+ waitForCheckpointLsn != null &&
845
+ this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)
846
+ ) {
758
847
  waitForCheckpointLsn = null;
759
848
  }
760
849
  const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
@@ -772,7 +861,7 @@ export class ChangeStream {
772
861
  ) {
773
862
  if (waitForCheckpointLsn == null) {
774
863
  using _ = tracer.span('source_checkpoint');
775
- waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
864
+ waitForCheckpointLsn = await this.createBatchCheckpoint();
776
865
  }
777
866
 
778
867
  const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
@@ -786,7 +875,11 @@ export class ChangeStream {
786
875
  const tablesToReplicate = tables.filter((table) => table.syncAny);
787
876
  if (tablesToReplicate.length > 0) {
788
877
  this.replicationLag.trackUncommittedChange(
789
- changeDocument.clusterTime == null ? null : timestampToDate(changeDocument.clusterTime)
878
+ // Standard MongoDB uses clusterTime, unchanged. DocumentDB has no
879
+ // clusterTime, so fall back to wallTime there for the lag metric.
880
+ changeDocument.clusterTime != null
881
+ ? timestampToDate(changeDocument.clusterTime)
882
+ : ((changeDocument as any).wallTime ?? null)
790
883
  );
791
884
 
792
885
  const transactionKeyValue = transactionKey(changeDocument);
@@ -841,10 +934,18 @@ export class ChangeStream {
841
934
  // Batches are generally large (64MB or 6000 events, whichever comes first),
842
935
  // so this is a good natural point to flush and mark progress.
843
936
  // We avoid this when splitDocument is set, since we cannot resume in the middle of a split event.
844
- const { comparable: lsn } = MongoLSN.fromResumeToken(resumeToken);
937
+ const { lsn, timestamp } = this.checkpointImplementation.lsnFromResumeToken(resumeToken);
845
938
  await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
846
939
  // TODO: We should consider making this standard behavior of flush().
847
940
  await batch.setResumeLsn(lsn);
941
+
942
+ if (timestamp != null) {
943
+ // Note that this timestamp provided by MongoDB is not exact - it can be around 10s behind.
944
+ this.lastPersistedResumeTimestamp = timestamp.getTime();
945
+ } else {
946
+ // DocumentDB: No timestamp associated with the resumeToken. Just use the current time.
947
+ this.lastPersistedResumeTimestamp = Date.now();
948
+ }
848
949
  }
849
950
 
850
951
  batchSpan.end();
@@ -872,6 +973,34 @@ export class ChangeStream {
872
973
  return this.replicationLag.getLagMillis();
873
974
  }
874
975
 
976
+ async keepAlive() {
977
+ // This is called on an interval of keepaliveIntervalMs.
978
+
979
+ // This writes to _powersync_checkpoints.
980
+ // Main use case: When there are massive bulk writes to another database or collection, the change stream may
981
+ // start timing out due to reading through too much data in one batch. This breaks up those bulk writes,
982
+ // allowing the change stream to make progress.
983
+
984
+ // When there is low replication traffic, regular calls to setResumeLsn() with recent resume tokens make this unnecessary.
985
+ // We track that using lastPersistedResumeTimestamp.
986
+ // We add a bit of a margin here, since setResumeLsn is not called on an exact interval.
987
+ const staleResumeLsn = Date.now() - this.lastPersistedResumeTimestamp > this.keepaliveIntervalMs * 1.1;
988
+
989
+ // When there is replication lag, that resumeToken can get too outdated. In that case, we periodically create a
990
+ // new back checkpoint. This interval is controlled by the frequency of the keepAlive() call,
991
+ // while also skipping if there was another call to createBatchCheckpoint().
992
+ // We use a factor of 0.9 here, to make sure this is called on every keepAlive() interval, unless there
993
+ // was another call to createBatchCheckpoint().
994
+ const staleBatchCheckpoint = Date.now() - this.lastBatchCheckpoint > this.keepaliveIntervalMs * 0.9;
995
+
996
+ // We don't use oldestUncommittedChange here, since that may be unset in some edge cases where we do need
997
+ // to persist new checkpoints.
998
+ if (staleResumeLsn && staleBatchCheckpoint) {
999
+ await this.ensureDetected();
1000
+ await this.createBatchCheckpoint();
1001
+ }
1002
+ }
1003
+
875
1004
  private lastTouchedAt = performance.now();
876
1005
 
877
1006
  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() {
@@ -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;
@@ -14,7 +14,9 @@ import {
14
14
  } from '@powersync/service-sync-rules';
15
15
 
16
16
  import { ErrorCode, logger, ServiceAssertionError, ServiceError } from '@powersync/lib-services-framework';
17
+ import { ObjectId } from 'bson';
17
18
  import { MongoLSN } from '../common/MongoLSN.js';
19
+ import { SentinelLSN } from '../common/SentinelLSN.js';
18
20
 
19
21
  export function getMongoRelation(
20
22
  source: mongo.ChangeStreamNameSpace,
@@ -173,15 +175,24 @@ function filterJsonData(data: any, context: CompatibilityContext, depth = 0): an
173
175
  */
174
176
  export const STANDALONE_CHECKPOINT_ID = '_standalone_checkpoint';
175
177
 
176
- export async function createCheckpoint(
177
- client: mongo.MongoClient,
178
- db: mongo.Db,
179
- id: mongo.ObjectId | string
180
- ): Promise<string> {
178
+ /**
179
+ * Id for checkpoint records managed by the {@link SentinelCheckpointImplementation} implementation.
180
+ */
181
+ export const SENTINEL_CHECKPOINT_ID = '_sentinel_checkpoint';
182
+
183
+ /**
184
+ * Create a checkpoint by upserting a document in _powersync_checkpoints, and
185
+ * return a comparable LSN string derived from the write's operationTime.
186
+ *
187
+ * Standard MongoDB only: this requires session.operationTime, which DocumentDB
188
+ * does not provide (it throws PSYNC_S1004 when it is missing). The DocumentDB /
189
+ * sentinel path builds LSNs via {@link createSentinelCheckpointLsn} instead.
190
+ */
191
+ export async function createCheckpoint(db: mongo.Db, id: mongo.ObjectId | string): Promise<string> {
181
192
  const TRIES = 2;
182
193
  for (let i = 0; i < TRIES; i++) {
183
194
  try {
184
- return await createCheckpointInner(client, db, id);
195
+ return await createCheckpointInner(db, id);
185
196
  } catch (e) {
186
197
  if (i < TRIES - 1) {
187
198
  logger.warn(`Failed to create checkpoint on attempt ${i + 1}`, e);
@@ -193,11 +204,7 @@ export async function createCheckpoint(
193
204
  throw new ServiceAssertionError(`Unreachable code`);
194
205
  }
195
206
 
196
- async function createCheckpointInner(
197
- client: mongo.MongoClient,
198
- db: mongo.Db,
199
- id: mongo.ObjectId | string
200
- ): Promise<string> {
207
+ async function createCheckpointInner(db: mongo.Db, id: mongo.ObjectId | string): Promise<string> {
201
208
  // We use an unique id per process, and clear documents on startup.
202
209
  // This is so that we can filter events for our own process only, and ignore
203
210
  // events from other processes.
@@ -208,6 +215,8 @@ async function createCheckpointInner(
208
215
  // Instead, we do manual retries, which does not have the same write de-duplication logic.
209
216
  // A sentinal-based approach would be better here, but that is a much bigger change.
210
217
 
218
+ const update: mongo.Document = { $inc: { i: 1 } };
219
+
211
220
  const response = await db.command({
212
221
  findAndModify: '_powersync_checkpoints',
213
222
  query: {
@@ -215,9 +224,7 @@ async function createCheckpointInner(
215
224
  },
216
225
  new: true,
217
226
  upsert: true,
218
- update: {
219
- $inc: { i: 1 }
220
- }
227
+ update
221
228
  });
222
229
 
223
230
  const time = response.operationTime as mongo.Timestamp | undefined;
@@ -227,6 +234,109 @@ async function createCheckpointInner(
227
234
  return new MongoLSN({ timestamp: time }).comparable;
228
235
  }
229
236
 
237
+ /**
238
+ * Create a DocumentDB comparable LSN by advancing the shared sentinel checkpoint
239
+ * document ({@link SENTINEL_CHECKPOINT_ID}). The returned LSN encodes the
240
+ * checkpoint counter in the same 16-hex shape as a MongoDB timestamp LSN (see
241
+ * {@link SentinelLSN}), so the two formats are directly comparable. The counter is
242
+ * seeded in the epoch-seconds range (see below), so a sentinel LSN always sorts
243
+ * above any real-timestamp LSN issued in the past.
244
+ *
245
+ * This counter is intentionally global to the source database. It is used for
246
+ * storage/client checkpoint comparisons and write checkpoint heads, so it must
247
+ * not reset when a new ChangeStream instance or new sync rules start.
248
+ *
249
+ * The document is a single shared record whose `stream_id` field alternates:
250
+ * batch/keepalive bumps stamp it with the calling stream's id (so a stream can
251
+ * recognise its own private barriers), while standalone bumps (write checkpoint
252
+ * heads, snapshot markers) clear it to null. `i` advances globally regardless.
253
+ *
254
+ * @param changeStreamId
255
+ * When provided, the bump is attributed to this stream (a private barrier).
256
+ * When omitted, it is a standalone bump and stream_id is cleared to null.
257
+ */
258
+ export async function createSentinelCheckpointLsn(
259
+ client: mongo.MongoClient,
260
+ db: mongo.Db,
261
+ changeStreamId?: ObjectId
262
+ ): Promise<string> {
263
+ const session = client.startSession();
264
+ try {
265
+ const collection = db.collection('_powersync_checkpoints');
266
+
267
+ for (let attempt = 0; attempt < 3; attempt++) {
268
+ // Common path: increment the existing counter.
269
+ const result = await collection.findOneAndUpdate(
270
+ {
271
+ _id: SENTINEL_CHECKPOINT_ID as any,
272
+ i: { $exists: true }
273
+ },
274
+ {
275
+ $inc: { i: 1 },
276
+ // Standalone bumps (no changeStreamId) must explicitly clear the
277
+ // stream_id left by a previous batch bump — this is a single shared
278
+ // document whose stream_id alternates. Write null rather than relying
279
+ // on `undefined` being serialized (which depends on the driver's
280
+ // ignoreUndefined option, and collapses to an empty $set if enabled).
281
+ $set: {
282
+ stream_id: changeStreamId ?? null
283
+ }
284
+ },
285
+ {
286
+ returnDocument: 'after',
287
+ session
288
+ }
289
+ );
290
+
291
+ if (result != null) {
292
+ // `i` is a bigint: the client is configured with useBigInt64, and the
293
+ // seed exceeds 2^53 so it could not be safely represented as a number.
294
+ return new SentinelLSN({ sentinel: result.i }).comparable;
295
+ }
296
+
297
+ // The counter document does not exist: first run, or a consumer deleted
298
+ // it in their source database. Seed the counter in the epoch-SECONDS range
299
+ // (seconds in the high 32 bits, mirroring a MongoDB timestamp) rather than
300
+ // starting at 1. Two properties follow:
301
+ //
302
+ // - A sentinel LSN sorts above any real-timestamp LSN issued in the past,
303
+ // because its high 32 bits are the current epoch seconds.
304
+ // - A re-created counter jumps forward instead of backward across deletion:
305
+ // the seed advances by 2^32 each wall-clock second, while checkpoints add
306
+ // 1 each, so any later re-seed exceeds the previously issued coordinate
307
+ // (keeping the LSN domain monotonic; otherwise new write checkpoint heads
308
+ // could resolve against old, higher committed LSNs).
309
+ //
310
+ // $setOnInsert cannot be combined with $inc on the same field, so this
311
+ // is a separate upsert; the loop then retries the increment. The
312
+ // $setOnInsert is a no-op if another process created the document
313
+ // concurrently.
314
+ await collection.updateOne(
315
+ {
316
+ _id: SENTINEL_CHECKPOINT_ID as any
317
+ },
318
+ {
319
+ $setOnInsert: {
320
+ i: BigInt(Math.floor(Date.now() / 1000)) << 32n,
321
+ stream_id: changeStreamId ?? null
322
+ }
323
+ },
324
+ {
325
+ upsert: true,
326
+ session
327
+ }
328
+ );
329
+ }
330
+
331
+ throw new ServiceError(
332
+ ErrorCode.PSYNC_S1301,
333
+ `Failed to increment the sentinel checkpoint counter - the checkpoint document may be getting deleted concurrently.`
334
+ );
335
+ } finally {
336
+ await session.endSession();
337
+ }
338
+ }
339
+
230
340
  const mongoTimeOptions: DateTimeSourceOptions = {
231
341
  subSecondPrecision: TimeValuePrecision.milliseconds,
232
342
  defaultSubSecondPrecision: TimeValuePrecision.milliseconds