@powersync/service-module-mongodb 0.17.0 → 0.18.1
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 +25 -0
- package/dist/api/MongoRouteAPIAdapter.d.ts +2 -2
- package/dist/replication/ChangeStream.d.ts +1 -0
- package/dist/replication/ChangeStream.js +24 -14
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.js +1 -1
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/ChangeStreamReplicator.js +1 -1
- package/dist/replication/ChangeStreamReplicator.js.map +1 -1
- package/dist/replication/MongoSnapshotter.d.ts +0 -1
- package/dist/replication/MongoSnapshotter.js +15 -24
- package/dist/replication/MongoSnapshotter.js.map +1 -1
- package/dist/types/types.d.ts +2 -2
- package/package.json +11 -21
- package/src/api/MongoRouteAPIAdapter.ts +2 -2
- package/src/replication/ChangeStream.ts +26 -16
- package/src/replication/ChangeStreamReplicationJob.ts +1 -1
- package/src/replication/ChangeStreamReplicator.ts +1 -1
- package/src/replication/MongoSnapshotter.ts +15 -25
- package/test/src/change_stream.test.ts +4 -3
- package/test/src/change_stream_utils.ts +17 -17
- package/test/src/checkpoint_retry.test.ts +1 -1
- package/test/src/resume.test.ts +2 -2
- package/test/src/util.ts +2 -1
- package/tsconfig.json +1 -4
- package/tsconfig.scripts.json +1 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -116,7 +116,7 @@ export class ChangeStream {
|
|
|
116
116
|
constructor(options: ChangeStreamOptions) {
|
|
117
117
|
this.storage = options.storage;
|
|
118
118
|
this.metrics = options.metrics;
|
|
119
|
-
this.group_id = options.storage.
|
|
119
|
+
this.group_id = options.storage.replicationStreamId;
|
|
120
120
|
this.connections = options.connections;
|
|
121
121
|
this.maxAwaitTimeMS = options.maxAwaitTimeMS ?? 10_000;
|
|
122
122
|
this.snapshotChunkLength = options.snapshotChunkLength ?? 6_000;
|
|
@@ -132,11 +132,16 @@ export class ChangeStream {
|
|
|
132
132
|
// so we use 90% of the socket timeout value.
|
|
133
133
|
this.changeStreamTimeout = Math.ceil(this.client.options.socketTimeoutMS * 0.9);
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
const baseLogger = options.logger ?? this.storage.logger;
|
|
136
|
+
// Unfortunately the Winston APIs don't have a nice way to append to the prefix,
|
|
137
|
+
// so we replace it here.
|
|
138
|
+
this.logger = baseLogger.child({ prefix: `[${this.storage.replicationStreamName}] [stream] ` });
|
|
139
|
+
const snapshotLogger = baseLogger.child({ prefix: `[${this.storage.replicationStreamName}] [snapshot] ` });
|
|
140
|
+
|
|
136
141
|
this.snapshotter = new MongoSnapshotter({
|
|
137
142
|
...options,
|
|
138
143
|
abortSignal: this.abortSignal,
|
|
139
|
-
logger:
|
|
144
|
+
logger: snapshotLogger,
|
|
140
145
|
checkpointStreamId: this.checkpointStreamId
|
|
141
146
|
});
|
|
142
147
|
|
|
@@ -366,6 +371,7 @@ export class ChangeStream {
|
|
|
366
371
|
async replicate() {
|
|
367
372
|
let streamPromise: Promise<void> | null = null;
|
|
368
373
|
let loopPromise: Promise<void> | null = null;
|
|
374
|
+
let cleanupPromise: Promise<void> | null = null;
|
|
369
375
|
try {
|
|
370
376
|
// If anything errors here, the entire replication process is halted, and
|
|
371
377
|
// all connections automatically closed, including this one.
|
|
@@ -383,6 +389,11 @@ export class ChangeStream {
|
|
|
383
389
|
if (!this.snapshotter.supportsConcurrentSnapshots) {
|
|
384
390
|
await Promise.race([this.snapshotter.waitForInitialSnapshot(), loopPromise]);
|
|
385
391
|
}
|
|
392
|
+
// Unlike the other two, this resolves on completion, not an indefinite loop.
|
|
393
|
+
cleanupPromise = this.cleanupStoppedSyncConfigs().catch((e) => {
|
|
394
|
+
this.abortController.abort(e);
|
|
395
|
+
throw e;
|
|
396
|
+
});
|
|
386
397
|
streamPromise = this.streamChanges()
|
|
387
398
|
.then(() => {
|
|
388
399
|
throw new ReplicationAssertionError(`Replication stream exited unexpectedly`);
|
|
@@ -392,7 +403,7 @@ export class ChangeStream {
|
|
|
392
403
|
throw e;
|
|
393
404
|
});
|
|
394
405
|
|
|
395
|
-
const results = await Promise.allSettled([loopPromise, streamPromise]);
|
|
406
|
+
const results = await Promise.allSettled([loopPromise, streamPromise, cleanupPromise]);
|
|
396
407
|
throw replicationLoopError(results);
|
|
397
408
|
} catch (e) {
|
|
398
409
|
await this.storage.reportError(e);
|
|
@@ -422,6 +433,15 @@ export class ChangeStream {
|
|
|
422
433
|
}
|
|
423
434
|
}
|
|
424
435
|
|
|
436
|
+
private async cleanupStoppedSyncConfigs() {
|
|
437
|
+
await this.storage.cleanupStoppedSyncConfigs?.({
|
|
438
|
+
signal: this.abortSignal,
|
|
439
|
+
logger: this.logger,
|
|
440
|
+
defaultSchema: this.defaultDb.databaseName,
|
|
441
|
+
sourceConnectionTag: this.connections.connectionTag
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
425
445
|
private async streamChanges() {
|
|
426
446
|
try {
|
|
427
447
|
await this.streamChangesInternal();
|
|
@@ -733,25 +753,15 @@ export class ChangeStream {
|
|
|
733
753
|
timestamp: changeDocument.clusterTime!,
|
|
734
754
|
resume_token: changeDocument._id
|
|
735
755
|
});
|
|
736
|
-
if (batch.lastCheckpointLsn != null && lsn < batch.lastCheckpointLsn) {
|
|
737
|
-
// Checkpoint out of order - should never happen with MongoDB.
|
|
738
|
-
// If it does happen, we throw an error to stop the replication - restarting should recover.
|
|
739
|
-
// Since we use batch.lastCheckpointLsn for the next resumeAfter, this should not result in an infinite loop.
|
|
740
|
-
// Originally a workaround for https://jira.mongodb.org/browse/NODE-7042.
|
|
741
|
-
// This has been fixed in the driver in the meantime, but we still keep this as a safety-check.
|
|
742
|
-
throw new ReplicationAssertionError(
|
|
743
|
-
`Change resumeToken ${(changeDocument._id as any)._data} (${timestampToDate(changeDocument.clusterTime!).toISOString()}) is less than last checkpoint LSN ${batch.lastCheckpointLsn}. Restarting replication.`
|
|
744
|
-
);
|
|
745
|
-
}
|
|
746
756
|
|
|
747
757
|
if (waitForCheckpointLsn != null && lsn >= waitForCheckpointLsn) {
|
|
748
758
|
waitForCheckpointLsn = null;
|
|
749
759
|
}
|
|
750
|
-
const { checkpointBlocked } = await batch.commit(lsn, {
|
|
760
|
+
const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
|
|
751
761
|
oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
|
|
752
762
|
});
|
|
753
763
|
|
|
754
|
-
if (!checkpointBlocked) {
|
|
764
|
+
if (!checkpointBlocked || checkpointCreated) {
|
|
755
765
|
this.replicationLag.markCommitted();
|
|
756
766
|
}
|
|
757
767
|
} else if (
|
|
@@ -47,7 +47,7 @@ export class ChangeStreamReplicationJob extends replication.AbstractReplicationJ
|
|
|
47
47
|
|
|
48
48
|
if (e instanceof ChangeStreamInvalidatedError) {
|
|
49
49
|
// This stops replication and restarts with a new instance
|
|
50
|
-
await this.options.storage.factory.restartReplication(this.storage.
|
|
50
|
+
await this.options.storage.factory.restartReplication(this.storage.replicationStreamId);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
// No need to rethrow - the error is already logged, and retry behavior is the same on error
|
|
@@ -17,7 +17,7 @@ export class ChangeStreamReplicator extends replication.AbstractReplicator<Chang
|
|
|
17
17
|
|
|
18
18
|
createJob(options: replication.CreateJobOptions): ChangeStreamReplicationJob {
|
|
19
19
|
return new ChangeStreamReplicationJob({
|
|
20
|
-
id: this.createJobId(options.storage.
|
|
20
|
+
id: this.createJobId(options.storage.replicationStreamId),
|
|
21
21
|
storage: options.storage,
|
|
22
22
|
metrics: this.metrics,
|
|
23
23
|
connectionFactory: this.connectionFactory,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { mongo } from '@powersync/lib-service-mongodb';
|
|
2
2
|
import { container, ErrorCode, Logger, ReplicationAbortedError, ServiceError } from '@powersync/lib-services-framework';
|
|
3
3
|
import {
|
|
4
|
-
InternalOpId,
|
|
5
4
|
MetricsEngine,
|
|
6
5
|
PerformanceTracer,
|
|
7
6
|
SaveOperationTag,
|
|
@@ -71,7 +70,6 @@ export class MongoSnapshotter {
|
|
|
71
70
|
private readonly queue = new Set<SnapshotQueueItem>();
|
|
72
71
|
private initialSnapshotDone = Promise.withResolvers<void>();
|
|
73
72
|
private nextItemQueued: PromiseWithResolvers<void> | null = null;
|
|
74
|
-
private lastSnapshotOpId: InternalOpId | null = null;
|
|
75
73
|
private lastTouchedAt = performance.now();
|
|
76
74
|
|
|
77
75
|
constructor(options: MongoSnapshotterOptions) {
|
|
@@ -112,12 +110,12 @@ export class MongoSnapshotter {
|
|
|
112
110
|
|
|
113
111
|
async checkSlot(): Promise<InitResult> {
|
|
114
112
|
const status = await this.storage.getStatus();
|
|
115
|
-
if (status.
|
|
113
|
+
if (status.snapshotDone) {
|
|
116
114
|
this.logger.info(`Initial replication already done`);
|
|
117
115
|
return { needsInitialSync: false, snapshotLsn: null };
|
|
118
116
|
}
|
|
119
117
|
|
|
120
|
-
return { needsInitialSync: true, snapshotLsn: status.
|
|
118
|
+
return { needsInitialSync: true, snapshotLsn: status.resumeLsn };
|
|
121
119
|
}
|
|
122
120
|
|
|
123
121
|
async setupCheckpointsCollection() {
|
|
@@ -277,20 +275,16 @@ export class MongoSnapshotter {
|
|
|
277
275
|
}
|
|
278
276
|
|
|
279
277
|
const status = await this.storage.getStatus();
|
|
280
|
-
if (status.
|
|
278
|
+
if (status.snapshotDone) {
|
|
281
279
|
return;
|
|
282
280
|
}
|
|
283
281
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
maxOpId: lastOp,
|
|
291
|
-
signal: this.abortSignal
|
|
292
|
-
});
|
|
293
|
-
}
|
|
282
|
+
// Populate the cache _after_ initial replication, but _before_ we switch to this replication stream.
|
|
283
|
+
// Keeping snapshot_done false until this completes makes this resumable after interruption.
|
|
284
|
+
// No checkpoint exists yet - storage defaults to its highest persisted op id.
|
|
285
|
+
await this.storage.populatePersistentChecksumCache({
|
|
286
|
+
signal: this.abortSignal
|
|
287
|
+
});
|
|
294
288
|
|
|
295
289
|
if (this.queue.size != 0) {
|
|
296
290
|
return;
|
|
@@ -345,13 +339,10 @@ export class MongoSnapshotter {
|
|
|
345
339
|
const noCheckpointBefore = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
|
|
346
340
|
await writer.markTableSnapshotDone([table], noCheckpointBefore);
|
|
347
341
|
|
|
348
|
-
// This commit
|
|
342
|
+
// This commit durably records the persisted ops, so a later checkpoint covers them.
|
|
349
343
|
const resumeLsn = writer.resumeFromLsn ?? MongoLSN.ZERO.comparable;
|
|
350
344
|
await writer.commit(resumeLsn);
|
|
351
345
|
|
|
352
|
-
if (writer.last_flushed_op != null) {
|
|
353
|
-
this.lastSnapshotOpId = writer.last_flushed_op;
|
|
354
|
-
}
|
|
355
346
|
this.logger.info(`Flushed snapshot at ${writer.last_flushed_op}`);
|
|
356
347
|
}
|
|
357
348
|
|
|
@@ -445,10 +436,7 @@ export class MongoSnapshotter {
|
|
|
445
436
|
}
|
|
446
437
|
|
|
447
438
|
// Important: flush before marking progress
|
|
448
|
-
|
|
449
|
-
if (result?.flushed_op != null) {
|
|
450
|
-
this.lastSnapshotOpId = result.flushed_op;
|
|
451
|
-
}
|
|
439
|
+
await batch.flush();
|
|
452
440
|
at += docBatch.length;
|
|
453
441
|
rowsReplicatedMetric.add(docBatch.length);
|
|
454
442
|
|
|
@@ -481,10 +469,12 @@ export class MongoSnapshotter {
|
|
|
481
469
|
// Ignore the postImages check in this case.
|
|
482
470
|
}
|
|
483
471
|
|
|
472
|
+
// Note: resolveTables uses the batch's own parsed sync config set. Passing
|
|
473
|
+
// this.syncRules here would pair sources from one parse with the batch's mapping
|
|
474
|
+
// from another parse.
|
|
484
475
|
const result = await batch.resolveTables({
|
|
485
476
|
connection_id: this.connectionId,
|
|
486
|
-
source: descriptor
|
|
487
|
-
syncRules: this.syncRules
|
|
477
|
+
source: descriptor
|
|
488
478
|
});
|
|
489
479
|
|
|
490
480
|
// Drop conflicting collections.
|
|
@@ -820,9 +820,9 @@ bucket_definitions:
|
|
|
820
820
|
|
|
821
821
|
// Simulate an error
|
|
822
822
|
await context.storage!.reportError(new Error('simulated error'));
|
|
823
|
-
const syncRules = await context.factory.
|
|
823
|
+
const syncRules = (await context.factory.getActiveSyncConfig())?.content;
|
|
824
824
|
expect(syncRules).toBeTruthy();
|
|
825
|
-
expect(syncRules?.last_fatal_error).toEqual('simulated error');
|
|
825
|
+
expect((await syncRules?.getSyncConfigStatus())?.last_fatal_error).toEqual('simulated error');
|
|
826
826
|
|
|
827
827
|
// The next checkpoint should clear the error.
|
|
828
828
|
await context.getCheckpoint();
|
|
@@ -830,7 +830,8 @@ bucket_definitions:
|
|
|
830
830
|
// Just wait, and check that the error is cleared automatically.
|
|
831
831
|
await vi.waitUntil(
|
|
832
832
|
async () => {
|
|
833
|
-
const
|
|
833
|
+
const syncRules = (await context.factory.getActiveSyncConfig())?.content;
|
|
834
|
+
const error = (await syncRules?.getSyncConfigStatus())?.last_fatal_error;
|
|
834
835
|
return error == null;
|
|
835
836
|
},
|
|
836
837
|
{ timeout: 2_000 }
|
|
@@ -30,7 +30,7 @@ export class ChangeStreamTestContext {
|
|
|
30
30
|
private _walStream?: ChangeStream;
|
|
31
31
|
private abortController = new AbortController();
|
|
32
32
|
private settledReplicationPromise?: Promise<PromiseSettledResult<void>>;
|
|
33
|
-
private syncRulesContent?: storage.
|
|
33
|
+
private syncRulesContent?: storage.PersistedSyncConfigContent;
|
|
34
34
|
public storage?: SyncRulesBucketStorage;
|
|
35
35
|
|
|
36
36
|
/**
|
|
@@ -101,26 +101,26 @@ export class ChangeStreamTestContext {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
async updateSyncRules(content: string) {
|
|
104
|
-
const
|
|
104
|
+
const replicationStream = await this.factory.updateSyncRules(
|
|
105
105
|
updateSyncRulesFromYaml(content, { validate: true, storageVersion: this.storageVersion })
|
|
106
106
|
);
|
|
107
|
-
this.syncRulesContent =
|
|
108
|
-
this.storage = this.factory.getInstance(
|
|
107
|
+
this.syncRulesContent = replicationStream.syncConfigContent[0];
|
|
108
|
+
this.storage = this.factory.getInstance(replicationStream);
|
|
109
109
|
return this.storage!;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
async loadNextSyncRules() {
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
113
|
+
const syncConfig = await this.factory.getDeployingSyncConfig();
|
|
114
|
+
if (syncConfig == null) {
|
|
115
115
|
throw new Error(`Next sync config not available`);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
this.syncRulesContent =
|
|
119
|
-
this.storage =
|
|
118
|
+
this.syncRulesContent = syncConfig.content;
|
|
119
|
+
this.storage = syncConfig.storage;
|
|
120
120
|
return this.storage!;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
private
|
|
123
|
+
private getSyncConfigContent(): storage.PersistedSyncConfigContent {
|
|
124
124
|
if (this.syncRulesContent == null) {
|
|
125
125
|
throw new Error('Sync config not configured - call updateSyncRules() first');
|
|
126
126
|
}
|
|
@@ -197,8 +197,8 @@ export class ChangeStreamTestContext {
|
|
|
197
197
|
|
|
198
198
|
async getBucketsDataBatch(buckets: Record<string, InternalOpId>, options?: { timeout?: number }) {
|
|
199
199
|
let checkpoint = await this.getCheckpoint(options);
|
|
200
|
-
const
|
|
201
|
-
const map = Object.entries(buckets).map(([bucket, start]) => bucketRequest(
|
|
200
|
+
const syncConfigContent = this.getSyncConfigContent();
|
|
201
|
+
const map = Object.entries(buckets).map(([bucket, start]) => bucketRequest(syncConfigContent, bucket, start));
|
|
202
202
|
return test_utils.fromAsync(this.storage!.getBucketDataBatch(checkpoint, map));
|
|
203
203
|
}
|
|
204
204
|
|
|
@@ -207,9 +207,9 @@ export class ChangeStreamTestContext {
|
|
|
207
207
|
if (typeof start == 'string') {
|
|
208
208
|
start = BigInt(start);
|
|
209
209
|
}
|
|
210
|
-
const
|
|
210
|
+
const syncConfigContent = this.getSyncConfigContent();
|
|
211
211
|
const checkpoint = await this.getCheckpoint(options);
|
|
212
|
-
let map = [bucketRequest(
|
|
212
|
+
let map = [bucketRequest(syncConfigContent, bucket, start)];
|
|
213
213
|
let data: OplogEntry[] = [];
|
|
214
214
|
while (true) {
|
|
215
215
|
const batch = this.storage!.getBucketDataBatch(checkpoint, map);
|
|
@@ -219,15 +219,15 @@ export class ChangeStreamTestContext {
|
|
|
219
219
|
if (batches.length == 0 || !batches[0]!.chunkData.has_more) {
|
|
220
220
|
break;
|
|
221
221
|
}
|
|
222
|
-
map = [bucketRequest(
|
|
222
|
+
map = [bucketRequest(syncConfigContent, bucket, BigInt(batches[0]!.chunkData.next_after))];
|
|
223
223
|
}
|
|
224
224
|
return data;
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
async getChecksums(buckets: string[], options?: { timeout?: number }): Promise<utils.ChecksumMap> {
|
|
228
228
|
let checkpoint = await this.getCheckpoint(options);
|
|
229
|
-
const
|
|
230
|
-
const versionedBuckets = buckets.map((bucket) => bucketRequest(
|
|
229
|
+
const syncConfigContent = this.getSyncConfigContent();
|
|
230
|
+
const versionedBuckets = buckets.map((bucket) => bucketRequest(syncConfigContent, bucket, 0n));
|
|
231
231
|
const checksums = await this.storage!.getChecksums(checkpoint, versionedBuckets);
|
|
232
232
|
|
|
233
233
|
const unversioned: utils.ChecksumMap = new Map();
|
|
@@ -258,7 +258,7 @@ export async function getClientCheckpoint(
|
|
|
258
258
|
let lastCp: ReplicationCheckpoint | null = null;
|
|
259
259
|
|
|
260
260
|
while (Date.now() - start < timeout) {
|
|
261
|
-
const storage = await storageFactory.
|
|
261
|
+
const storage = (await storageFactory.getActiveSyncConfig())?.storage;
|
|
262
262
|
const cp = await storage?.getCheckpoint();
|
|
263
263
|
if (cp != null) {
|
|
264
264
|
lastCp = cp;
|
|
@@ -20,7 +20,7 @@ describe('checkpoint retryable writes', () => {
|
|
|
20
20
|
// It is quite difficult to simulate this, even with failCommand. We currently rely on triggering a socket timeout for the first command,
|
|
21
21
|
// with another write happening right after that.
|
|
22
22
|
|
|
23
|
-
const TIMEOUT =
|
|
23
|
+
const TIMEOUT = 200;
|
|
24
24
|
const INSERT_COUNT = 5;
|
|
25
25
|
|
|
26
26
|
const { db, client } = await connectMongoData({
|
package/test/src/resume.test.ts
CHANGED
|
@@ -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
|
|
71
|
-
context2.storage =
|
|
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();
|
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({
|
package/tsconfig.json
CHANGED