@powersync/service-module-mongodb-storage 0.0.0-dev-20250903124544 → 0.0.0-dev-20251015143910

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.
@@ -247,7 +247,10 @@ export class MongoChecksums {
247
247
  },
248
248
  last_op: { $max: '$_id.o' }
249
249
  }
250
- }
250
+ },
251
+ // Sort the aggregated results (100 max, so should be fast).
252
+ // This is important to identify which buckets we have partial data for.
253
+ { $sort: { _id: 1 } }
251
254
  ],
252
255
  { session: undefined, readConcern: 'snapshot', maxTimeMS: lib_mongo.MONGO_CHECKSUM_TIMEOUT_MS }
253
256
  )
@@ -284,7 +287,6 @@ export class MongoChecksums {
284
287
  // All done for this bucket
285
288
  requests.delete(bucket);
286
289
  }
287
- batchCount++;
288
290
  }
289
291
  if (!limitReached) {
290
292
  break;
@@ -42,6 +42,17 @@ export interface MongoSyncBucketStorageOptions {
42
42
  checksumOptions?: MongoChecksumOptions;
43
43
  }
44
44
 
45
+ /**
46
+ * Only keep checkpoints around for a minute, before fetching a fresh one.
47
+ *
48
+ * The reason is that we keep a MongoDB snapshot reference (clusterTime) with the checkpoint,
49
+ * and they expire after 5 minutes by default. This is an issue if the checkpoint stream is idle,
50
+ * but new clients connect and use an outdated checkpoint snapshot for parameter queries.
51
+ *
52
+ * These will be filtered out for existing clients, so should not create significant overhead.
53
+ */
54
+ const CHECKPOINT_TIMEOUT_MS = 60_000;
55
+
45
56
  export class MongoSyncBucketStorage
46
57
  extends BaseObserver<storage.SyncRulesBucketStorageListener>
47
58
  implements storage.SyncRulesBucketStorage
@@ -681,25 +692,45 @@ export class MongoSyncBucketStorage
681
692
 
682
693
  // We only watch changes to the active sync rules.
683
694
  // If it changes to inactive, we abort and restart with the new sync rules.
684
- let lastOp: storage.ReplicationCheckpoint | null = null;
695
+ try {
696
+ while (true) {
697
+ // If the stream is idle, we wait a max of a minute (CHECKPOINT_TIMEOUT_MS)
698
+ // before we get another checkpoint, to avoid stale checkpoint snapshots.
699
+ const timeout = timers
700
+ .setTimeout(CHECKPOINT_TIMEOUT_MS, { done: false }, { signal })
701
+ .catch(() => ({ done: true }));
702
+ try {
703
+ const result = await Promise.race([stream.next(), timeout]);
704
+ if (result.done) {
705
+ break;
706
+ }
707
+ } catch (e) {
708
+ if (e.name == 'AbortError') {
709
+ break;
710
+ }
711
+ throw e;
712
+ }
685
713
 
686
- for await (const _ of stream) {
687
- if (signal.aborted) {
688
- break;
689
- }
714
+ if (signal.aborted) {
715
+ // Would likely have been caught by the signal on the timeout or the upstream stream, but we check here anyway
716
+ break;
717
+ }
690
718
 
691
- const op = await this.getCheckpointInternal();
692
- if (op == null) {
693
- // Sync rules have changed - abort and restart.
694
- // We do a soft close of the stream here - no error
695
- break;
696
- }
719
+ const op = await this.getCheckpointInternal();
720
+ if (op == null) {
721
+ // Sync rules have changed - abort and restart.
722
+ // We do a soft close of the stream here - no error
723
+ break;
724
+ }
697
725
 
698
- // Check for LSN / checkpoint changes - ignore other metadata changes
699
- if (lastOp == null || op.lsn != lastOp.lsn || op.checkpoint != lastOp.checkpoint) {
700
- lastOp = op;
726
+ // Previously, we only yielded when the checkpoint or lsn changed.
727
+ // However, we always want to use the latest snapshotTime, so we skip that filtering here.
728
+ // That filtering could be added in the per-user streams if needed, but in general the capped collection
729
+ // should already only contain useful changes in most cases.
701
730
  yield op;
702
731
  }
732
+ } finally {
733
+ await stream.return(null);
703
734
  }
704
735
  }
705
736
 
@@ -715,7 +746,10 @@ export class MongoSyncBucketStorage
715
746
  let lastCheckpoint: ReplicationCheckpoint | null = null;
716
747
 
717
748
  const iter = this.sharedIter[Symbol.asyncIterator](options.signal);
749
+
718
750
  let writeCheckpoint: bigint | null = null;
751
+ // true if we queried the initial write checkpoint, even if it doesn't exist
752
+ let queriedInitialWriteCheckpoint = false;
719
753
 
720
754
  for await (const nextCheckpoint of iter) {
721
755
  // lsn changes are not important by itself.
@@ -723,14 +757,17 @@ export class MongoSyncBucketStorage
723
757
  // 1. checkpoint (op_id) changes.
724
758
  // 2. write checkpoint changes for the specific user
725
759
 
726
- if (nextCheckpoint.lsn != null) {
727
- writeCheckpoint ??= await this.writeCheckpointAPI.lastWriteCheckpoint({
760
+ if (nextCheckpoint.lsn != null && !queriedInitialWriteCheckpoint) {
761
+ // Lookup the first write checkpoint for the user when we can.
762
+ // There will not actually be one in all cases.
763
+ writeCheckpoint = await this.writeCheckpointAPI.lastWriteCheckpoint({
728
764
  sync_rules_id: this.group_id,
729
765
  user_id: options.user_id,
730
766
  heads: {
731
767
  '1': nextCheckpoint.lsn
732
768
  }
733
769
  });
770
+ queriedInitialWriteCheckpoint = true;
734
771
  }
735
772
 
736
773
  if (
@@ -740,12 +777,13 @@ export class MongoSyncBucketStorage
740
777
  ) {
741
778
  // No change - wait for next one
742
779
  // In some cases, many LSNs may be produced in a short time.
743
- // Add a delay to throttle the write checkpoint lookup a bit.
780
+ // Add a delay to throttle the loop a bit.
744
781
  await timers.setTimeout(20 + 10 * Math.random());
745
782
  continue;
746
783
  }
747
784
 
748
785
  if (lastCheckpoint == null) {
786
+ // First message for this stream - "INVALIDATE_ALL" means it will lookup all data
749
787
  yield {
750
788
  base: nextCheckpoint,
751
789
  writeCheckpoint,
@@ -759,7 +797,9 @@ export class MongoSyncBucketStorage
759
797
 
760
798
  let updatedWriteCheckpoint = updates.updatedWriteCheckpoints.get(options.user_id) ?? null;
761
799
  if (updates.invalidateWriteCheckpoints) {
762
- updatedWriteCheckpoint ??= await this.writeCheckpointAPI.lastWriteCheckpoint({
800
+ // Invalidated means there were too many updates to track the individual ones,
801
+ // so we switch to "polling" (querying directly in each stream).
802
+ updatedWriteCheckpoint = await this.writeCheckpointAPI.lastWriteCheckpoint({
763
803
  sync_rules_id: this.group_id,
764
804
  user_id: options.user_id,
765
805
  heads: {
@@ -769,6 +809,9 @@ export class MongoSyncBucketStorage
769
809
  }
770
810
  if (updatedWriteCheckpoint != null && (writeCheckpoint == null || updatedWriteCheckpoint > writeCheckpoint)) {
771
811
  writeCheckpoint = updatedWriteCheckpoint;
812
+ // If it happened that we haven't queried a write checkpoint at this point,
813
+ // then we don't need to anymore, since we got an updated one.
814
+ queriedInitialWriteCheckpoint = true;
772
815
  }
773
816
 
774
817
  yield {
@@ -12,12 +12,10 @@ export type MongoCheckpointAPIOptions = {
12
12
  export class MongoWriteCheckpointAPI implements storage.WriteCheckpointAPI {
13
13
  readonly db: PowerSyncMongo;
14
14
  private _mode: storage.WriteCheckpointMode;
15
- private sync_rules_id: number;
16
15
 
17
16
  constructor(options: MongoCheckpointAPIOptions) {
18
17
  this.db = options.db;
19
18
  this._mode = options.mode;
20
- this.sync_rules_id = options.sync_rules_id;
21
19
  }
22
20
 
23
21
  get writeCheckpointMode() {