@powersync/service-module-mongodb 0.15.4 → 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 +67 -0
- package/dist/api/MongoRouteAPIAdapter.js +12 -21
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +23 -42
- package/dist/replication/ChangeStream.js +363 -600
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.js +2 -2
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/JsonBufferWriter.d.ts +80 -0
- package/dist/replication/JsonBufferWriter.js +342 -0
- package/dist/replication/JsonBufferWriter.js.map +1 -0
- package/dist/replication/MongoRelation.d.ts +1 -1
- package/dist/replication/MongoRelation.js +45 -21
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/MongoSnapshotQuery.d.ts +1 -1
- package/dist/replication/MongoSnapshotQuery.js +6 -3
- package/dist/replication/MongoSnapshotQuery.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/dist/replication/RawChangeStream.d.ts +55 -0
- package/dist/replication/RawChangeStream.js +322 -0
- package/dist/replication/RawChangeStream.js.map +1 -0
- package/dist/replication/SourceRowConverter.d.ts +46 -0
- package/dist/replication/SourceRowConverter.js +42 -0
- package/dist/replication/SourceRowConverter.js.map +1 -0
- package/dist/replication/bufferToSqlite.d.ts +43 -0
- package/dist/replication/bufferToSqlite.js +740 -0
- package/dist/replication/bufferToSqlite.js.map +1 -0
- package/dist/replication/internal-mongodb-utils.d.ts +0 -12
- package/dist/replication/internal-mongodb-utils.js +0 -54
- package/dist/replication/internal-mongodb-utils.js.map +1 -1
- package/dist/replication/replication-index.d.ts +2 -0
- package/dist/replication/replication-index.js +2 -0
- package/dist/replication/replication-index.js.map +1 -1
- package/package.json +11 -11
- package/scripts/benchmark-change-document-json.mts +358 -0
- package/scripts/benchmark-change-document.mts +370 -0
- package/src/api/MongoRouteAPIAdapter.ts +13 -21
- package/src/replication/ChangeStream.ts +421 -720
- package/src/replication/ChangeStreamReplicationJob.ts +2 -2
- package/src/replication/JsonBufferWriter.ts +390 -0
- package/src/replication/MongoRelation.ts +54 -25
- package/src/replication/MongoSnapshotQuery.ts +8 -5
- package/src/replication/MongoSnapshotter.ts +729 -0
- package/src/replication/RawChangeStream.ts +460 -0
- package/src/replication/SourceRowConverter.ts +65 -0
- package/src/replication/bufferToSqlite.ts +944 -0
- package/src/replication/internal-mongodb-utils.ts +0 -65
- package/src/replication/replication-index.ts +2 -0
- package/test/src/buffer_to_sqlite.test.ts +1146 -0
- package/test/src/change_stream.test.ts +259 -19
- package/test/src/change_stream_utils.ts +28 -27
- package/test/src/checkpoint_retry.test.ts +131 -0
- package/test/src/mongo_test.test.ts +66 -64
- package/test/src/parse_document_id.test.ts +54 -0
- package/test/src/raw_change_stream.test.ts +547 -0
- package/test/src/resume.test.ts +12 -2
- package/test/src/resuming_snapshots.test.ts +10 -6
- package/test/src/util.ts +56 -3
- package/test/tsconfig.json +0 -1
- package/tsconfig.scripts.json +13 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/test/src/internal_mongodb_utils.test.ts +0 -103
|
@@ -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 }
|
|
@@ -257,6 +330,53 @@ bucket_definitions:
|
|
|
257
330
|
expect(data).toMatchObject([test_utils.putOp('test_DATA', { id: test_id, description: 'test1' })]);
|
|
258
331
|
});
|
|
259
332
|
|
|
333
|
+
test('replicating from multiple databases in the same cluster', async () => {
|
|
334
|
+
await using context = await openContext();
|
|
335
|
+
const { client, db } = context;
|
|
336
|
+
const otherDb = client.db(`${db.databaseName}_other_${storageVersion}`);
|
|
337
|
+
await otherDb.dropDatabase();
|
|
338
|
+
await using _ = {
|
|
339
|
+
[Symbol.asyncDispose]: async () => {
|
|
340
|
+
await otherDb.dropDatabase();
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
await context.updateSyncRules(`
|
|
345
|
+
bucket_definitions:
|
|
346
|
+
global:
|
|
347
|
+
data:
|
|
348
|
+
- SELECT _id as id, description FROM "${db.databaseName}"."test_data_default"
|
|
349
|
+
- SELECT _id as id, description FROM "${otherDb.databaseName}"."test_data_other"
|
|
350
|
+
`);
|
|
351
|
+
|
|
352
|
+
await db.createCollection('test_data_default');
|
|
353
|
+
await otherDb.createCollection('test_data_other');
|
|
354
|
+
await context.replicateSnapshot();
|
|
355
|
+
context.startStreaming();
|
|
356
|
+
|
|
357
|
+
const defaultResult = await db.collection('test_data_default').insertOne({ description: 'default db' });
|
|
358
|
+
const otherResult = await otherDb.collection('test_data_other').insertOne({ description: 'other db' });
|
|
359
|
+
|
|
360
|
+
const data = await context.getBucketData('global[]');
|
|
361
|
+
|
|
362
|
+
expect(data).toEqual(
|
|
363
|
+
expect.arrayContaining([
|
|
364
|
+
expect.objectContaining(
|
|
365
|
+
test_utils.putOp('test_data_default', {
|
|
366
|
+
id: defaultResult.insertedId.toHexString(),
|
|
367
|
+
description: 'default db'
|
|
368
|
+
})
|
|
369
|
+
),
|
|
370
|
+
expect.objectContaining(
|
|
371
|
+
test_utils.putOp('test_data_other', {
|
|
372
|
+
id: otherResult.insertedId.toHexString(),
|
|
373
|
+
description: 'other db'
|
|
374
|
+
})
|
|
375
|
+
)
|
|
376
|
+
])
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
|
|
260
380
|
test('replicating large values', async () => {
|
|
261
381
|
await using context = await openContext();
|
|
262
382
|
const { db } = context;
|
|
@@ -351,6 +471,119 @@ bucket_definitions:
|
|
|
351
471
|
]);
|
|
352
472
|
});
|
|
353
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
|
+
|
|
354
587
|
test('initial sync', async () => {
|
|
355
588
|
await using context = await openContext();
|
|
356
589
|
const { db } = context;
|
|
@@ -373,12 +606,6 @@ bucket_definitions:
|
|
|
373
606
|
test('coalesces standalone checkpoints when backlog is buffered', async () => {
|
|
374
607
|
await using context = await openContext();
|
|
375
608
|
await context.updateSyncRules(BASIC_SYNC_RULES);
|
|
376
|
-
await context.replicateSnapshot();
|
|
377
|
-
await context.markSnapshotConsistent();
|
|
378
|
-
await using api = new MongoRouteAPIAdapter({
|
|
379
|
-
type: 'mongodb',
|
|
380
|
-
...TEST_CONNECTION_OPTIONS
|
|
381
|
-
});
|
|
382
609
|
|
|
383
610
|
let commitCount = 0;
|
|
384
611
|
// This relies on internals to count how often checkpoints are committed
|
|
@@ -392,6 +619,13 @@ bucket_definitions:
|
|
|
392
619
|
}
|
|
393
620
|
});
|
|
394
621
|
|
|
622
|
+
await context.replicateSnapshot();
|
|
623
|
+
await context.markSnapshotConsistent();
|
|
624
|
+
await using api = new MongoRouteAPIAdapter({
|
|
625
|
+
type: 'mongodb',
|
|
626
|
+
...TEST_CONNECTION_OPTIONS
|
|
627
|
+
});
|
|
628
|
+
|
|
395
629
|
context.startStreaming();
|
|
396
630
|
|
|
397
631
|
// Wait until the stream is active and caught up, then start counting from zero.
|
|
@@ -449,7 +683,7 @@ bucket_definitions:
|
|
|
449
683
|
// The field appears twice in the ChangeStream event, so the total size
|
|
450
684
|
// is > 16MB.
|
|
451
685
|
|
|
452
|
-
// We don't actually have this description field in the sync
|
|
686
|
+
// We don't actually have this description field in the sync config,
|
|
453
687
|
// That causes other issues, not relevant for this specific test.
|
|
454
688
|
const largeDescription = crypto.randomBytes(12000000 / 2).toString('hex');
|
|
455
689
|
|
|
@@ -478,7 +712,7 @@ bucket_definitions:
|
|
|
478
712
|
});
|
|
479
713
|
});
|
|
480
714
|
|
|
481
|
-
test('collection not in sync
|
|
715
|
+
test('collection not in sync config', async () => {
|
|
482
716
|
await using context = await openContext();
|
|
483
717
|
const { db } = context;
|
|
484
718
|
await context.updateSyncRules(BASIC_SYNC_RULES);
|
|
@@ -520,12 +754,19 @@ bucket_definitions:
|
|
|
520
754
|
context.startStreaming();
|
|
521
755
|
|
|
522
756
|
const data = await context.getBucketData('global[]');
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
+
}
|
|
529
770
|
});
|
|
530
771
|
|
|
531
772
|
test('postImages - new collection with postImages disabled', async () => {
|
|
@@ -575,7 +816,7 @@ bucket_definitions:
|
|
|
575
816
|
await collection.insertOne({ description: 'test1', num: 1152921504606846976n });
|
|
576
817
|
|
|
577
818
|
await context.replicateSnapshot();
|
|
578
|
-
await context.
|
|
819
|
+
await context.getCheckpoint();
|
|
579
820
|
|
|
580
821
|
// Simulate an error
|
|
581
822
|
await context.storage!.reportError(new Error('simulated error'));
|
|
@@ -583,10 +824,9 @@ bucket_definitions:
|
|
|
583
824
|
expect(syncRules).toBeTruthy();
|
|
584
825
|
expect(syncRules?.last_fatal_error).toEqual('simulated error');
|
|
585
826
|
|
|
586
|
-
//
|
|
587
|
-
context.
|
|
827
|
+
// The next checkpoint should clear the error.
|
|
828
|
+
await context.getCheckpoint();
|
|
588
829
|
|
|
589
|
-
// getBucketData() creates a checkpoint that clears the error, so we don't do that
|
|
590
830
|
// Just wait, and check that the error is cleared automatically.
|
|
591
831
|
await vi.waitUntil(
|
|
592
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,10 +9,11 @@ import {
|
|
|
8
9
|
OplogEntry,
|
|
9
10
|
ProtocolOpId,
|
|
10
11
|
ReplicationCheckpoint,
|
|
12
|
+
settledPromise,
|
|
11
13
|
storage,
|
|
12
|
-
STORAGE_VERSION_CONFIG,
|
|
13
14
|
SyncRulesBucketStorage,
|
|
14
15
|
TestStorageOptions,
|
|
16
|
+
unsettledPromise,
|
|
15
17
|
updateSyncRulesFromYaml,
|
|
16
18
|
utils
|
|
17
19
|
} from '@powersync/service-core';
|
|
@@ -27,8 +29,7 @@ import { clearTestDb, TEST_CONNECTION_OPTIONS } from './util.js';
|
|
|
27
29
|
export class ChangeStreamTestContext {
|
|
28
30
|
private _walStream?: ChangeStream;
|
|
29
31
|
private abortController = new AbortController();
|
|
30
|
-
private
|
|
31
|
-
private syncRulesId?: number;
|
|
32
|
+
private settledReplicationPromise?: Promise<PromiseSettledResult<void>>;
|
|
32
33
|
private syncRulesContent?: storage.PersistedSyncRulesContent;
|
|
33
34
|
public storage?: SyncRulesBucketStorage;
|
|
34
35
|
|
|
@@ -55,17 +56,15 @@ export class ChangeStreamTestContext {
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
const storageVersion = options?.storageVersion ?? LEGACY_STORAGE_VERSION;
|
|
58
|
-
const versionedBuckets = STORAGE_VERSION_CONFIG[storageVersion]?.versionedBuckets ?? false;
|
|
59
59
|
|
|
60
|
-
return new ChangeStreamTestContext(f, connectionManager, options?.streamOptions, storageVersion
|
|
60
|
+
return new ChangeStreamTestContext(f, connectionManager, options?.streamOptions, storageVersion);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
constructor(
|
|
64
64
|
public factory: BucketStorageFactory,
|
|
65
65
|
public connectionManager: MongoManager,
|
|
66
66
|
private streamOptions: Partial<ChangeStreamOptions> = {},
|
|
67
|
-
private storageVersion: number = LEGACY_STORAGE_VERSION
|
|
68
|
-
private versionedBuckets: boolean = STORAGE_VERSION_CONFIG[storageVersion]?.versionedBuckets ?? false
|
|
67
|
+
private storageVersion: number = LEGACY_STORAGE_VERSION
|
|
69
68
|
) {
|
|
70
69
|
createCoreReplicationMetrics(METRICS_HELPER.metricsEngine);
|
|
71
70
|
initializeCoreReplicationMetrics(METRICS_HELPER.metricsEngine);
|
|
@@ -74,13 +73,13 @@ export class ChangeStreamTestContext {
|
|
|
74
73
|
/**
|
|
75
74
|
* Abort snapshot and/or replication, without actively closing connections.
|
|
76
75
|
*/
|
|
77
|
-
abort() {
|
|
78
|
-
this.abortController.abort();
|
|
76
|
+
abort(cause?: Error) {
|
|
77
|
+
this.abortController.abort(cause);
|
|
79
78
|
}
|
|
80
79
|
|
|
81
80
|
async dispose() {
|
|
82
|
-
this.abort();
|
|
83
|
-
await this.
|
|
81
|
+
this.abort(new Error('Disposing test context'));
|
|
82
|
+
await this.settledReplicationPromise;
|
|
84
83
|
await this.factory[Symbol.asyncDispose]();
|
|
85
84
|
await this.connectionManager.end();
|
|
86
85
|
}
|
|
@@ -105,7 +104,6 @@ export class ChangeStreamTestContext {
|
|
|
105
104
|
const syncRules = await this.factory.updateSyncRules(
|
|
106
105
|
updateSyncRulesFromYaml(content, { validate: true, storageVersion: this.storageVersion })
|
|
107
106
|
);
|
|
108
|
-
this.syncRulesId = syncRules.id;
|
|
109
107
|
this.syncRulesContent = syncRules;
|
|
110
108
|
this.storage = this.factory.getInstance(syncRules);
|
|
111
109
|
return this.storage!;
|
|
@@ -114,10 +112,9 @@ export class ChangeStreamTestContext {
|
|
|
114
112
|
async loadNextSyncRules() {
|
|
115
113
|
const syncRules = await this.factory.getNextSyncRulesContent();
|
|
116
114
|
if (syncRules == null) {
|
|
117
|
-
throw new Error(`Next sync
|
|
115
|
+
throw new Error(`Next sync config not available`);
|
|
118
116
|
}
|
|
119
117
|
|
|
120
|
-
this.syncRulesId = syncRules.id;
|
|
121
118
|
this.syncRulesContent = syncRules;
|
|
122
119
|
this.storage = this.factory.getInstance(syncRules);
|
|
123
120
|
return this.storage!;
|
|
@@ -125,7 +122,7 @@ export class ChangeStreamTestContext {
|
|
|
125
122
|
|
|
126
123
|
private getSyncRulesContent(): storage.PersistedSyncRulesContent {
|
|
127
124
|
if (this.syncRulesContent == null) {
|
|
128
|
-
throw new Error('Sync
|
|
125
|
+
throw new Error('Sync config not configured - call updateSyncRules() first');
|
|
129
126
|
}
|
|
130
127
|
return this.syncRulesContent;
|
|
131
128
|
}
|
|
@@ -145,14 +142,25 @@ export class ChangeStreamTestContext {
|
|
|
145
142
|
// Specifically reduce this from the default for tests on MongoDB <= 6.0, otherwise it can take
|
|
146
143
|
// a long time to abort the stream.
|
|
147
144
|
maxAwaitTimeMS: this.streamOptions?.maxAwaitTimeMS ?? 200,
|
|
148
|
-
snapshotChunkLength: this.streamOptions?.snapshotChunkLength
|
|
145
|
+
snapshotChunkLength: this.streamOptions?.snapshotChunkLength,
|
|
146
|
+
storageHooks: this.streamOptions?.storageHooks,
|
|
147
|
+
snapshotHooks: this.streamOptions?.snapshotHooks,
|
|
148
|
+
logger: this.streamOptions?.logger
|
|
149
149
|
};
|
|
150
150
|
this._walStream = new ChangeStream(options);
|
|
151
151
|
return this._walStream!;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
async replicateSnapshot() {
|
|
155
|
-
|
|
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
|
+
}
|
|
156
164
|
}
|
|
157
165
|
|
|
158
166
|
/**
|
|
@@ -170,21 +178,14 @@ export class ChangeStreamTestContext {
|
|
|
170
178
|
}
|
|
171
179
|
|
|
172
180
|
startStreaming() {
|
|
173
|
-
this.
|
|
174
|
-
|
|
175
|
-
.then(() => ({ status: 'fulfilled', value: undefined }) satisfies PromiseFulfilledResult<void>)
|
|
176
|
-
.catch((reason) => ({ status: 'rejected', reason }) satisfies PromiseRejectedResult);
|
|
177
|
-
return this.streamPromise;
|
|
181
|
+
this.settledReplicationPromise ??= settledPromise(this.streamer.replicate());
|
|
182
|
+
return this.settledReplicationPromise;
|
|
178
183
|
}
|
|
179
184
|
|
|
180
185
|
async getCheckpoint(options?: { timeout?: number }) {
|
|
181
186
|
let checkpoint = await Promise.race([
|
|
182
187
|
getClientCheckpoint(this.client, this.db, this.factory, { timeout: options?.timeout ?? 15_000 }),
|
|
183
|
-
this.
|
|
184
|
-
if (e.status == 'rejected') {
|
|
185
|
-
throw e.reason;
|
|
186
|
-
}
|
|
187
|
-
})
|
|
188
|
+
this.settledReplicationPromise == null ? undefined : unsettledPromise(this.settledReplicationPromise)
|
|
188
189
|
]);
|
|
189
190
|
if (checkpoint == null) {
|
|
190
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
|
+
}
|