@powersync/service-module-mongodb 0.16.0 → 0.18.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 (34) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/api/MongoRouteAPIAdapter.d.ts +2 -2
  3. package/dist/api/MongoRouteAPIAdapter.js +12 -21
  4. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  5. package/dist/replication/ChangeStream.d.ts +19 -37
  6. package/dist/replication/ChangeStream.js +157 -362
  7. package/dist/replication/ChangeStream.js.map +1 -1
  8. package/dist/replication/ChangeStreamReplicationJob.js +1 -1
  9. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  10. package/dist/replication/ChangeStreamReplicator.js +1 -1
  11. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  12. package/dist/replication/MongoRelation.d.ts +1 -1
  13. package/dist/replication/MongoRelation.js +41 -21
  14. package/dist/replication/MongoRelation.js.map +1 -1
  15. package/dist/replication/MongoSnapshotter.d.ts +80 -0
  16. package/dist/replication/MongoSnapshotter.js +585 -0
  17. package/dist/replication/MongoSnapshotter.js.map +1 -0
  18. package/dist/types/types.d.ts +2 -2
  19. package/package.json +11 -21
  20. package/src/api/MongoRouteAPIAdapter.ts +14 -22
  21. package/src/replication/ChangeStream.ts +173 -439
  22. package/src/replication/ChangeStreamReplicationJob.ts +1 -1
  23. package/src/replication/ChangeStreamReplicator.ts +1 -1
  24. package/src/replication/MongoRelation.ts +51 -25
  25. package/src/replication/MongoSnapshotter.ts +719 -0
  26. package/test/src/change_stream.test.ts +214 -20
  27. package/test/src/change_stream_utils.ts +41 -34
  28. package/test/src/checkpoint_retry.test.ts +131 -0
  29. package/test/src/resume.test.ts +2 -2
  30. package/test/src/resuming_snapshots.test.ts +10 -6
  31. package/test/src/util.ts +2 -1
  32. package/tsconfig.json +1 -4
  33. package/tsconfig.scripts.json +1 -2
  34. package/tsconfig.tsbuildinfo +1 -1
@@ -3,7 +3,7 @@ import { setTimeout } from 'node:timers/promises';
3
3
  import { describe, expect, test, vi } from 'vitest';
4
4
 
5
5
  import { mongo } from '@powersync/lib-service-mongodb';
6
- import { createWriteCheckpoint } from '@powersync/service-core';
6
+ import { createWriteCheckpoint, storage } from '@powersync/service-core';
7
7
  import { test_utils } from '@powersync/service-core-tests';
8
8
 
9
9
  import { MongoRouteAPIAdapter } from '@module/api/MongoRouteAPIAdapter.js';
@@ -23,6 +23,8 @@ describe('change stream', () => {
23
23
  });
24
24
 
25
25
  function defineChangeStreamTests({ factory, storageVersion }: StorageVersionTestContext) {
26
+ const supportsConcurrentSnapshots = storageVersion >= 3;
27
+
26
28
  const openContext = (options?: Parameters<typeof ChangeStreamTestContext.open>[1]) => {
27
29
  return ChangeStreamTestContext.open(factory, { ...options, storageVersion });
28
30
  };
@@ -94,6 +96,77 @@ bucket_definitions:
94
96
  ]);
95
97
  });
96
98
 
99
+ test('does not resurrect rows deleted while snapshotting', async () => {
100
+ // Special case for testing:
101
+ // 1. Row A exists.
102
+ // 2. Start a snapshot and streaming.
103
+ // 3. Row A is deleted.
104
+ // 4. Streaming sees the delete, writes the delete.
105
+ // 5. Snapshot still sees row A and writes it.
106
+ // The streaming delete should win, using skipExistingRows. For that to work, we rely on soft deletes.
107
+ // To trigger this case, we need the delete to trigger and process the delete in between the snapshot read and the snapshot write, which we can do using beforeBatchFlush.
108
+
109
+ let collection!: mongo.Collection;
110
+ let testId!: mongo.ObjectId;
111
+ let interceptedSnapshotFlush = false;
112
+ let sourceDeleteWritten = false;
113
+ let sourceDeleteFlushBatch: storage.BucketStorageBatch | undefined;
114
+ const streamDeleteFlushed = Promise.withResolvers<void>();
115
+
116
+ await using context = await openContext({
117
+ streamOptions: {
118
+ snapshotChunkLength: 1,
119
+ storageHooks: {
120
+ beforeBatchFlush: async (batch) => {
121
+ if (!batch.skipExistingRows && sourceDeleteWritten && sourceDeleteFlushBatch == null) {
122
+ sourceDeleteFlushBatch = batch;
123
+ }
124
+
125
+ // skipExistingRows means we're busy with snapshotting.
126
+ if (!batch.skipExistingRows || interceptedSnapshotFlush) {
127
+ return;
128
+ }
129
+
130
+ interceptedSnapshotFlush = true;
131
+ await collection.deleteOne({ _id: testId });
132
+ sourceDeleteWritten = true;
133
+ if (!supportsConcurrentSnapshots) {
134
+ return;
135
+ }
136
+ await Promise.race([
137
+ streamDeleteFlushed.promise,
138
+ setTimeout(10_000).then(() => {
139
+ throw new Error('Timed out waiting for streamed delete to flush');
140
+ })
141
+ ]);
142
+ },
143
+ afterBatchFlush: async (batch) => {
144
+ if (batch == sourceDeleteFlushBatch) {
145
+ streamDeleteFlushed.resolve();
146
+ }
147
+ }
148
+ }
149
+ }
150
+ });
151
+ const { db } = context;
152
+ await context.updateSyncRules(BASIC_SYNC_RULES);
153
+
154
+ await db.createCollection('test_data', {
155
+ changeStreamPreAndPostImages: { enabled: false }
156
+ });
157
+ testId = new mongo.ObjectId();
158
+ collection = db.collection('test_data');
159
+ await collection.insertOne({ _id: testId, description: 'stale snapshot row' });
160
+
161
+ await context.replicateSnapshot();
162
+
163
+ expect(interceptedSnapshotFlush).toBe(true);
164
+ await context.getCheckpoint();
165
+
166
+ const data = await context.getBucketData('global[]');
167
+ expect(test_utils.reduceBucket(data).slice(1)).toEqual([]);
168
+ });
169
+
97
170
  test('updateLookup - no fullDocument available', async () => {
98
171
  await using context = await openContext({
99
172
  mongoOptions: { postImages: PostImagesOption.OFF }
@@ -398,6 +471,119 @@ bucket_definitions:
398
471
  ]);
399
472
  });
400
473
 
474
+ test.runIf(supportsConcurrentSnapshots)(
475
+ 'collection recreated while queued snapshot is waiting does not stall checkpoints',
476
+ async () => {
477
+ // This is a regression test for a specific timing issue in concurrent snapshot logic.
478
+ // Regression flow:
479
+ // 1. Create test_b and wait until streaming has queued a concurrent snapshot for its source table.
480
+ // 2. Pause that queued snapshot before it reads storage state.
481
+ // 3. Drop test_b and wait until streaming has deleted the queued source table row from storage.
482
+ // 4. Recreate the test_b collection, but do not insert replacement data yet. This leaves the collection
483
+ // visible to namespace-based resolution without giving normal streaming an insert event that could
484
+ // discover and queue the replacement source table.
485
+ // 5. Release the stale queued snapshot and wait for a checkpoint. The old implementation re-resolved by
486
+ // namespace here, created a new source table row, then skipped it because its id did not match the
487
+ // queued id. That left snapshot_done=false with no queue item to finish it, stalling checkpoints.
488
+ // 6. After the checkpoint proves the stale snapshot did not orphan a source table, insert replacement data
489
+ // and verify normal streaming still replicates it.
490
+ const testBSnapshotStarted = Promise.withResolvers<void>();
491
+ const releaseTestBSnapshot = Promise.withResolvers<void>();
492
+ let pausedTestBSnapshot = false;
493
+ let queuedTestBTable: storage.SourceTable | null = null;
494
+ let waitForStreamingFlush: PromiseWithResolvers<void> | null = null;
495
+ const waitFor = async (promise: Promise<void>, description: string) => {
496
+ await Promise.race([
497
+ promise,
498
+ setTimeout(10_000).then(() => {
499
+ throw new Error(`Timed out waiting for ${description}`);
500
+ })
501
+ ]);
502
+ };
503
+
504
+ await using context = await openContext({
505
+ streamOptions: {
506
+ snapshotChunkLength: 1,
507
+ snapshotHooks: {
508
+ beforeSnapshotStarted: async (table) => {
509
+ if (table.name != 'test_b' || pausedTestBSnapshot) {
510
+ return;
511
+ }
512
+
513
+ queuedTestBTable = table;
514
+ pausedTestBSnapshot = true;
515
+ testBSnapshotStarted.resolve();
516
+ await releaseTestBSnapshot.promise;
517
+ }
518
+ },
519
+ storageHooks: {
520
+ afterBatchFlush: async (batch) => {
521
+ if (batch.skipExistingRows) {
522
+ return;
523
+ }
524
+
525
+ waitForStreamingFlush?.resolve();
526
+ }
527
+ }
528
+ }
529
+ });
530
+ const { db } = context;
531
+ const waitForSourceTableDropped = async (table: storage.SourceTable) => {
532
+ const deadline = Date.now() + 10_000;
533
+ while (Date.now() < deadline) {
534
+ await using writer = await context.storage!.createWriter(test_utils.BATCH_OPTIONS);
535
+ if ((await writer.getSourceTableStatus(table)) == null) {
536
+ return;
537
+ }
538
+ await setTimeout(50);
539
+ }
540
+ throw new Error('Timed out waiting for test_b source table to be dropped');
541
+ };
542
+ const syncRuleContent = `
543
+ bucket_definitions:
544
+ global:
545
+ data:
546
+ - SELECT _id as id, description FROM "test_%"
547
+ `;
548
+ await context.updateSyncRules(syncRuleContent);
549
+ await context.replicateSnapshot();
550
+ context.startStreaming();
551
+
552
+ try {
553
+ await db.createCollection('test_a');
554
+ const testA = await db.collection('test_a').insertOne({ description: 'test a' });
555
+
556
+ await db.createCollection('test_b');
557
+ waitForStreamingFlush = Promise.withResolvers<void>();
558
+ await db.collection('test_b').insertOne({ description: 'old test b' });
559
+ await waitFor(waitForStreamingFlush.promise, 'old test_b streaming flush');
560
+ await waitFor(testBSnapshotStarted.promise, 'test_b snapshot to start');
561
+
562
+ if (queuedTestBTable == null) {
563
+ throw new Error('test_b snapshot started without a queued source table');
564
+ }
565
+
566
+ await db.collection('test_b').drop();
567
+ await waitForSourceTableDropped(queuedTestBTable);
568
+
569
+ await db.createCollection('test_b');
570
+ releaseTestBSnapshot.resolve();
571
+ await context.getCheckpoint({ timeout: 10_000 });
572
+
573
+ const result = await db.collection('test_b').insertOne({ description: 'new test b' });
574
+
575
+ const data = await context.getBucketData('global[]', undefined, { timeout: 10_000 });
576
+ const reduced = test_utils.reduceBucket(data).slice(1);
577
+ expect(reduced).toMatchObject([
578
+ test_utils.putOp('test_a', { id: testA.insertedId.toHexString(), description: 'test a' }),
579
+ test_utils.putOp('test_b', { id: result.insertedId.toHexString(), description: 'new test b' })
580
+ ]);
581
+ } finally {
582
+ releaseTestBSnapshot.resolve();
583
+ }
584
+ }
585
+ );
586
+
401
587
  test('initial sync', async () => {
402
588
  await using context = await openContext();
403
589
  const { db } = context;
@@ -420,12 +606,6 @@ bucket_definitions:
420
606
  test('coalesces standalone checkpoints when backlog is buffered', async () => {
421
607
  await using context = await openContext();
422
608
  await context.updateSyncRules(BASIC_SYNC_RULES);
423
- await context.replicateSnapshot();
424
- await context.markSnapshotConsistent();
425
- await using api = new MongoRouteAPIAdapter({
426
- type: 'mongodb',
427
- ...TEST_CONNECTION_OPTIONS
428
- });
429
609
 
430
610
  let commitCount = 0;
431
611
  // This relies on internals to count how often checkpoints are committed
@@ -439,6 +619,13 @@ bucket_definitions:
439
619
  }
440
620
  });
441
621
 
622
+ await context.replicateSnapshot();
623
+ await context.markSnapshotConsistent();
624
+ await using api = new MongoRouteAPIAdapter({
625
+ type: 'mongodb',
626
+ ...TEST_CONNECTION_OPTIONS
627
+ });
628
+
442
629
  context.startStreaming();
443
630
 
444
631
  // Wait until the stream is active and caught up, then start counting from zero.
@@ -567,12 +754,19 @@ bucket_definitions:
567
754
  context.startStreaming();
568
755
 
569
756
  const data = await context.getBucketData('global[]');
570
- expect(data).toMatchObject([
571
- // An extra op here, since this triggers a snapshot in addition to getting the event.
572
- test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test2' }),
573
- test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test1' }),
574
- test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test2' })
575
- ]);
757
+ if (data.length == 3) {
758
+ expect(data).toMatchObject([
759
+ // An extra op here, since this triggers a snapshot in addition to getting the event.
760
+ test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test2' }),
761
+ test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test1' }),
762
+ test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test2' })
763
+ ]);
764
+ } else {
765
+ expect(data).toMatchObject([
766
+ test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test1' }),
767
+ test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test2' })
768
+ ]);
769
+ }
576
770
  });
577
771
 
578
772
  test('postImages - new collection with postImages disabled', async () => {
@@ -622,22 +816,22 @@ bucket_definitions:
622
816
  await collection.insertOne({ description: 'test1', num: 1152921504606846976n });
623
817
 
624
818
  await context.replicateSnapshot();
625
- await context.markSnapshotConsistent();
819
+ await context.getCheckpoint();
626
820
 
627
821
  // Simulate an error
628
822
  await context.storage!.reportError(new Error('simulated error'));
629
- const syncRules = await context.factory.getActiveSyncRulesContent();
823
+ const syncRules = (await context.factory.getActiveSyncConfig())?.content;
630
824
  expect(syncRules).toBeTruthy();
631
- expect(syncRules?.last_fatal_error).toEqual('simulated error');
825
+ expect((await syncRules?.getSyncConfigStatus())?.last_fatal_error).toEqual('simulated error');
632
826
 
633
- // startStreaming() should automatically clear the error.
634
- context.startStreaming();
827
+ // The next checkpoint should clear the error.
828
+ await context.getCheckpoint();
635
829
 
636
- // getBucketData() creates a checkpoint that clears the error, so we don't do that
637
830
  // Just wait, and check that the error is cleared automatically.
638
831
  await vi.waitUntil(
639
832
  async () => {
640
- const error = (await context.factory.getActiveSyncRulesContent())?.last_fatal_error;
833
+ const syncRules = (await context.factory.getActiveSyncConfig())?.content;
834
+ const error = (await syncRules?.getSyncConfigStatus())?.last_fatal_error;
641
835
  return error == null;
642
836
  },
643
837
  { timeout: 2_000 }
@@ -1,4 +1,5 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
+ import { ReplicationAbortedError } from '@powersync/lib-services-framework';
2
3
  import {
3
4
  BucketStorageFactory,
4
5
  createCoreReplicationMetrics,
@@ -8,9 +9,11 @@ import {
8
9
  OplogEntry,
9
10
  ProtocolOpId,
10
11
  ReplicationCheckpoint,
12
+ settledPromise,
11
13
  storage,
12
14
  SyncRulesBucketStorage,
13
15
  TestStorageOptions,
16
+ unsettledPromise,
14
17
  updateSyncRulesFromYaml,
15
18
  utils
16
19
  } from '@powersync/service-core';
@@ -26,8 +29,8 @@ import { clearTestDb, TEST_CONNECTION_OPTIONS } from './util.js';
26
29
  export class ChangeStreamTestContext {
27
30
  private _walStream?: ChangeStream;
28
31
  private abortController = new AbortController();
29
- private streamPromise?: Promise<PromiseSettledResult<void>>;
30
- private syncRulesContent?: storage.PersistedSyncRulesContent;
32
+ private settledReplicationPromise?: Promise<PromiseSettledResult<void>>;
33
+ private syncRulesContent?: storage.PersistedSyncConfigContent;
31
34
  public storage?: SyncRulesBucketStorage;
32
35
 
33
36
  /**
@@ -70,13 +73,13 @@ export class ChangeStreamTestContext {
70
73
  /**
71
74
  * Abort snapshot and/or replication, without actively closing connections.
72
75
  */
73
- abort() {
74
- this.abortController.abort();
76
+ abort(cause?: Error) {
77
+ this.abortController.abort(cause);
75
78
  }
76
79
 
77
80
  async dispose() {
78
- this.abort();
79
- await this.streamPromise?.catch((e) => e);
81
+ this.abort(new Error('Disposing test context'));
82
+ await this.settledReplicationPromise;
80
83
  await this.factory[Symbol.asyncDispose]();
81
84
  await this.connectionManager.end();
82
85
  }
@@ -98,26 +101,26 @@ export class ChangeStreamTestContext {
98
101
  }
99
102
 
100
103
  async updateSyncRules(content: string) {
101
- const syncRules = await this.factory.updateSyncRules(
104
+ const replicationStream = await this.factory.updateSyncRules(
102
105
  updateSyncRulesFromYaml(content, { validate: true, storageVersion: this.storageVersion })
103
106
  );
104
- this.syncRulesContent = syncRules;
105
- this.storage = this.factory.getInstance(syncRules);
107
+ this.syncRulesContent = replicationStream.syncConfigContent[0];
108
+ this.storage = this.factory.getInstance(replicationStream);
106
109
  return this.storage!;
107
110
  }
108
111
 
109
112
  async loadNextSyncRules() {
110
- const syncRules = await this.factory.getNextSyncRulesContent();
111
- if (syncRules == null) {
113
+ const syncConfig = await this.factory.getDeployingSyncConfig();
114
+ if (syncConfig == null) {
112
115
  throw new Error(`Next sync config not available`);
113
116
  }
114
117
 
115
- this.syncRulesContent = syncRules;
116
- this.storage = this.factory.getInstance(syncRules);
118
+ this.syncRulesContent = syncConfig.content;
119
+ this.storage = syncConfig.storage;
117
120
  return this.storage!;
118
121
  }
119
122
 
120
- private getSyncRulesContent(): storage.PersistedSyncRulesContent {
123
+ private getSyncConfigContent(): storage.PersistedSyncConfigContent {
121
124
  if (this.syncRulesContent == null) {
122
125
  throw new Error('Sync config not configured - call updateSyncRules() first');
123
126
  }
@@ -139,14 +142,25 @@ export class ChangeStreamTestContext {
139
142
  // Specifically reduce this from the default for tests on MongoDB <= 6.0, otherwise it can take
140
143
  // a long time to abort the stream.
141
144
  maxAwaitTimeMS: this.streamOptions?.maxAwaitTimeMS ?? 200,
142
- snapshotChunkLength: this.streamOptions?.snapshotChunkLength
145
+ snapshotChunkLength: this.streamOptions?.snapshotChunkLength,
146
+ storageHooks: this.streamOptions?.storageHooks,
147
+ snapshotHooks: this.streamOptions?.snapshotHooks,
148
+ logger: this.streamOptions?.logger
143
149
  };
144
150
  this._walStream = new ChangeStream(options);
145
151
  return this._walStream!;
146
152
  }
147
153
 
148
154
  async replicateSnapshot() {
149
- await this.streamer.initReplication();
155
+ this.settledReplicationPromise ??= settledPromise(this.streamer.replicate());
156
+ try {
157
+ await Promise.race([unsettledPromise(this.settledReplicationPromise), this.streamer.waitForInitialSnapshot()]);
158
+ } catch (e) {
159
+ if (e instanceof ReplicationAbortedError && e.cause != null) {
160
+ throw e.cause;
161
+ }
162
+ throw e;
163
+ }
150
164
  }
151
165
 
152
166
  /**
@@ -164,21 +178,14 @@ export class ChangeStreamTestContext {
164
178
  }
165
179
 
166
180
  startStreaming() {
167
- this.streamPromise = this.streamer
168
- .streamChanges()
169
- .then(() => ({ status: 'fulfilled', value: undefined }) satisfies PromiseFulfilledResult<void>)
170
- .catch((reason) => ({ status: 'rejected', reason }) satisfies PromiseRejectedResult);
171
- return this.streamPromise;
181
+ this.settledReplicationPromise ??= settledPromise(this.streamer.replicate());
182
+ return this.settledReplicationPromise;
172
183
  }
173
184
 
174
185
  async getCheckpoint(options?: { timeout?: number }) {
175
186
  let checkpoint = await Promise.race([
176
187
  getClientCheckpoint(this.client, this.db, this.factory, { timeout: options?.timeout ?? 15_000 }),
177
- this.streamPromise?.then((e) => {
178
- if (e.status == 'rejected') {
179
- throw e.reason;
180
- }
181
- })
188
+ this.settledReplicationPromise == null ? undefined : unsettledPromise(this.settledReplicationPromise)
182
189
  ]);
183
190
  if (checkpoint == null) {
184
191
  // This indicates an issue with the test setup - streamingPromise completed instead
@@ -190,8 +197,8 @@ export class ChangeStreamTestContext {
190
197
 
191
198
  async getBucketsDataBatch(buckets: Record<string, InternalOpId>, options?: { timeout?: number }) {
192
199
  let checkpoint = await this.getCheckpoint(options);
193
- const syncRules = this.getSyncRulesContent();
194
- const map = Object.entries(buckets).map(([bucket, start]) => bucketRequest(syncRules, bucket, start));
200
+ const syncConfigContent = this.getSyncConfigContent();
201
+ const map = Object.entries(buckets).map(([bucket, start]) => bucketRequest(syncConfigContent, bucket, start));
195
202
  return test_utils.fromAsync(this.storage!.getBucketDataBatch(checkpoint, map));
196
203
  }
197
204
 
@@ -200,9 +207,9 @@ export class ChangeStreamTestContext {
200
207
  if (typeof start == 'string') {
201
208
  start = BigInt(start);
202
209
  }
203
- const syncRules = this.getSyncRulesContent();
210
+ const syncConfigContent = this.getSyncConfigContent();
204
211
  const checkpoint = await this.getCheckpoint(options);
205
- let map = [bucketRequest(syncRules, bucket, start)];
212
+ let map = [bucketRequest(syncConfigContent, bucket, start)];
206
213
  let data: OplogEntry[] = [];
207
214
  while (true) {
208
215
  const batch = this.storage!.getBucketDataBatch(checkpoint, map);
@@ -212,15 +219,15 @@ export class ChangeStreamTestContext {
212
219
  if (batches.length == 0 || !batches[0]!.chunkData.has_more) {
213
220
  break;
214
221
  }
215
- map = [bucketRequest(syncRules, bucket, BigInt(batches[0]!.chunkData.next_after))];
222
+ map = [bucketRequest(syncConfigContent, bucket, BigInt(batches[0]!.chunkData.next_after))];
216
223
  }
217
224
  return data;
218
225
  }
219
226
 
220
227
  async getChecksums(buckets: string[], options?: { timeout?: number }): Promise<utils.ChecksumMap> {
221
228
  let checkpoint = await this.getCheckpoint(options);
222
- const syncRules = this.getSyncRulesContent();
223
- const versionedBuckets = buckets.map((bucket) => bucketRequest(syncRules, bucket, 0n));
229
+ const syncConfigContent = this.getSyncConfigContent();
230
+ const versionedBuckets = buckets.map((bucket) => bucketRequest(syncConfigContent, bucket, 0n));
224
231
  const checksums = await this.storage!.getChecksums(checkpoint, versionedBuckets);
225
232
 
226
233
  const unversioned: utils.ChecksumMap = new Map();
@@ -251,7 +258,7 @@ export async function getClientCheckpoint(
251
258
  let lastCp: ReplicationCheckpoint | null = null;
252
259
 
253
260
  while (Date.now() - start < timeout) {
254
- const storage = await storageFactory.getActiveStorage();
261
+ const storage = (await storageFactory.getActiveSyncConfig())?.storage;
255
262
  const cp = await storage?.getCheckpoint();
256
263
  if (cp != null) {
257
264
  lastCp = cp;
@@ -0,0 +1,131 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ import { mongo } from '@powersync/lib-service-mongodb';
4
+
5
+ import { MongoLSN } from '@module/common/MongoLSN.js';
6
+ import { createCheckpoint } from '@module/replication/MongoRelation.js';
7
+ import { clearTestDb, connectMongoData, requireFailCommand } from './util.js';
8
+
9
+ describe('checkpoint retryable writes', () => {
10
+ test('returns the persisted checkpoint clusterTime after a retryable write', { repeats: 1 }, async (ctx) => {
11
+ // This test relies on very specific timing:
12
+ //
13
+ // 1. _powersync_checkpoints findAndModify command starts and is sent to the server
14
+ // 2. command is written to the server
15
+ // 3. _powersync_checkpoints findAndModify command fails with a retryable error (e.g. connection timeout)
16
+ // 4. command is retried
17
+ // 5. another write lands on the server, increasing the server's operationTime
18
+ // 6. command succeeds with a new operationTime, but without actually writing again (server de-duplicates based on transaction id)
19
+ //
20
+ // It is quite difficult to simulate this, even with failCommand. We currently rely on triggering a socket timeout for the first command,
21
+ // with another write happening right after that.
22
+
23
+ const TIMEOUT = 200;
24
+ const INSERT_COUNT = 5;
25
+
26
+ const { db, client } = await connectMongoData({
27
+ monitorCommands: true,
28
+ socketTimeoutMS: TIMEOUT,
29
+ maxPoolSize: 1
30
+ });
31
+
32
+ await using _client = { [Symbol.asyncDispose]: async () => await client.close() };
33
+ await client.connect();
34
+ await using failCommand = await requireFailCommand(client, ctx);
35
+
36
+ await clearTestDb(db);
37
+
38
+ const checkpointId = new mongo.ObjectId();
39
+ const advanceClusterTime = db.collection('_advance_cluster_time');
40
+
41
+ const changeStream = db.watch([], { maxAwaitTimeMS: TIMEOUT * 0.8 });
42
+ await using _changeStream = { [Symbol.asyncDispose]: async () => await changeStream.close() };
43
+
44
+ const checkpointEventPromise = waitForCheckpointEvent(changeStream, checkpointId, INSERT_COUNT).catch((e) =>
45
+ console.error(e)
46
+ );
47
+
48
+ const commandsStarted: mongo.CommandStartedEvent[] = [];
49
+
50
+ client.on('commandStarted', (event) => {
51
+ commandsStarted.push(event);
52
+ });
53
+
54
+ const DEBUG_COMMANDS = false;
55
+ if (DEBUG_COMMANDS) {
56
+ // For debugging: Log all commands
57
+ client.on('commandStarted', (event) => {
58
+ console.log('commandStarted:', event);
59
+ });
60
+
61
+ client.on('commandSucceeded', (event) => {
62
+ console.log('commandSucceeded:', event);
63
+ });
64
+
65
+ client.on('commandFailed', (event) => {
66
+ console.log('commandFailed:', event);
67
+ });
68
+ }
69
+
70
+ await failCommand.configure({
71
+ mode: { times: 1 },
72
+ data: {
73
+ failCommands: ['commitTransaction', 'findAndModify'],
74
+ blockConnection: true,
75
+ blockTimeMS: TIMEOUT
76
+ }
77
+ });
78
+ const returnedLsnPromise = createCheckpoint(client, db, checkpointId);
79
+
80
+ for (let i = 0; i < INSERT_COUNT; i++) {
81
+ await advanceClusterTime.insertOne({ at: new Date() });
82
+ }
83
+
84
+ const returnedLsn = await returnedLsnPromise;
85
+ const checkpointEvent = await checkpointEventPromise;
86
+ if (checkpointEvent == null) {
87
+ throw new Error('change stream failed');
88
+ }
89
+
90
+ const eventLsn = new MongoLSN({
91
+ timestamp: checkpointEvent.clusterTime!,
92
+ resume_token: checkpointEvent._id
93
+ }).comparable;
94
+
95
+ if (DEBUG_COMMANDS) {
96
+ const commands = commandsStarted
97
+ .filter((c) => ['findAndModify', 'insert'].includes(c.commandName))
98
+ .map((c) => c.commandName);
99
+ // In the failure case, we had findAndModify, insert, findAndModify, potentially with more inserts in between.
100
+ // With updated behavior, we only have a single findAndModify.
101
+ console.log('Command order:', commands);
102
+ }
103
+
104
+ expect(eventLsn >= returnedLsn).toBe(true);
105
+ expect(checkpointEvent.clusterTime).toEqual(MongoLSN.fromSerialized(returnedLsn).timestamp);
106
+ });
107
+ });
108
+
109
+ async function waitForCheckpointEvent(
110
+ changeStream: mongo.ChangeStream,
111
+ checkpointId: mongo.ObjectId,
112
+ insertCount: number
113
+ ): Promise<mongo.ChangeStreamDocument | null> {
114
+ let lastEvent: mongo.ChangeStreamDocument | null = null;
115
+ let seenInserts = 0;
116
+ for await (const event of changeStream) {
117
+ if (
118
+ (event.operationType == 'insert' || event.operationType == 'update' || event.operationType == 'replace') &&
119
+ checkpointId.equals(event.documentKey._id)
120
+ ) {
121
+ lastEvent = event;
122
+ } else if (event.operationType == 'insert' && event.ns.coll === '_advance_cluster_time') {
123
+ seenInserts++;
124
+ if (seenInserts >= insertCount) {
125
+ // We finish when we hit the expected number of inserts, since the number of checkpoint events are unknown.
126
+ break;
127
+ }
128
+ }
129
+ }
130
+ return lastEvent;
131
+ }
@@ -67,8 +67,8 @@ function defineResumeTest({ factory: factoryGenerator, storageVersion }: Storage
67
67
 
68
68
  // Create a new context without updating the sync config
69
69
  await using context2 = new ChangeStreamTestContext(factory, connectionManager, {}, storageVersion);
70
- const activeContent = await factory.getActiveSyncRulesContent();
71
- context2.storage = factory.getInstance(activeContent!);
70
+ const active = await factory.getActiveSyncConfig();
71
+ context2.storage = active!.storage;
72
72
 
73
73
  // If this test times out, it likely didn't throw the expected error here.
74
74
  const result = await context2.startStreaming();
@@ -126,26 +126,30 @@ async function testResumingReplication(factory: TestStorageFactory, storageVersi
126
126
  // We only test the final version.
127
127
  expect(JSON.parse(updatedRowOps[1].data as string).description).toEqual('update1');
128
128
 
129
- expect(insertedRowOps.length).toEqual(2);
130
129
  expect(JSON.parse(insertedRowOps[0].data as string).description).toEqual('insert1');
131
- expect(JSON.parse(insertedRowOps[1].data as string).description).toEqual('insert1');
130
+ if (insertedRowOps.length != 1) {
131
+ // Also valid.
132
+ expect(insertedRowOps.length).toEqual(2);
133
+ expect(JSON.parse(insertedRowOps[1].data as string).description).toEqual('insert1');
134
+ }
132
135
 
133
136
  // 1000 of test_data1 during first replication attempt.
134
137
  // N >= 1000 of test_data2 during first replication attempt.
135
138
  // 10000 - N - 1 + 1 of test_data2 during second replication attempt.
136
139
  // An additional update during streaming replication (2x total for this row).
137
- // An additional insert during streaming replication (2x total for this row).
140
+ // An additional insert during streaming replication (1x or 2x total for this row).
138
141
  // If the deleted row was part of the first replication batch, it's removed by streaming replication.
139
142
  // This adds 2 ops.
140
143
  // We expect this to be 11002 for stopAfter: 2000, and 11004 for stopAfter: 8000.
141
144
  // However, this is not deterministic.
142
- const expectedCount = 11002 + deletedRowOps.length;
145
+ const expectedCount = 11000 + deletedRowOps.length + insertedRowOps.length;
143
146
  expect(data.length).toEqual(expectedCount);
144
147
 
145
148
  const replicatedCount =
146
149
  ((await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0) - startRowCount;
147
150
 
148
- // With resumable replication, there should be no need to re-replicate anything.
149
- expect(replicatedCount).toEqual(expectedCount);
151
+ // With resumable replication, there should be no need to re-replicate anything, apart from the newly-inserted row.
152
+ expect(replicatedCount).toBeGreaterThanOrEqual(expectedCount);
153
+ expect(replicatedCount).toBeLessThanOrEqual(expectedCount + 1);
150
154
  }
151
155
  }
package/test/src/util.ts CHANGED
@@ -21,7 +21,8 @@ export const TEST_CONNECTION_OPTIONS = types.normalizeConnectionConfig({
21
21
 
22
22
  export const INITIALIZED_MONGO_STORAGE_FACTORY = mongo_storage.test_utils.mongoTestStorageFactoryGenerator({
23
23
  url: env.MONGO_TEST_URL,
24
- isCI: env.CI
24
+ isCI: env.CI,
25
+ supportsMultipleSyncConfigs: true
25
26
  });
26
27
 
27
28
  export const INITIALIZED_POSTGRES_STORAGE_FACTORY = postgres_storage.test_utils.postgresTestSetup({