@powersync/service-module-mongodb 0.16.0 → 0.17.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.
@@ -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,7 +816,7 @@ 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'));
@@ -630,10 +824,9 @@ bucket_definitions:
630
824
  expect(syncRules).toBeTruthy();
631
825
  expect(syncRules?.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 () => {
@@ -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,7 +29,7 @@ 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>>;
32
+ private settledReplicationPromise?: Promise<PromiseSettledResult<void>>;
30
33
  private syncRulesContent?: storage.PersistedSyncRulesContent;
31
34
  public storage?: SyncRulesBucketStorage;
32
35
 
@@ -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
  }
@@ -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
@@ -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 = 100;
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
+ }
@@ -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
  }