@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.
- package/CHANGELOG.md +33 -0
- package/dist/api/MongoRouteAPIAdapter.js +12 -21
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +18 -37
- package/dist/replication/ChangeStream.js +136 -351
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/MongoRelation.d.ts +1 -1
- package/dist/replication/MongoRelation.js +41 -21
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/MongoSnapshotter.d.ts +81 -0
- package/dist/replication/MongoSnapshotter.js +594 -0
- package/dist/replication/MongoSnapshotter.js.map +1 -0
- package/package.json +8 -8
- package/src/api/MongoRouteAPIAdapter.ts +13 -21
- package/src/replication/ChangeStream.ts +150 -426
- package/src/replication/MongoRelation.ts +51 -25
- package/src/replication/MongoSnapshotter.ts +729 -0
- package/test/src/change_stream.test.ts +210 -17
- package/test/src/change_stream_utils.ts +24 -17
- package/test/src/checkpoint_retry.test.ts +131 -0
- package/test/src/resuming_snapshots.test.ts +10 -6
- 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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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.
|
|
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
|
-
//
|
|
634
|
-
context.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
168
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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).
|
|
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
|
}
|