@powersync/service-module-mongodb 0.6.0 → 0.6.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 +16 -0
- package/dist/replication/ChangeStream.d.ts +1 -0
- package/dist/replication/ChangeStream.js +66 -31
- 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/MongoRelation.d.ts +4 -0
- package/dist/replication/MongoRelation.js +8 -1
- package/dist/replication/MongoRelation.js.map +1 -1
- package/package.json +8 -8
- package/src/replication/ChangeStream.ts +77 -32
- package/src/replication/ChangeStreamReplicationJob.ts +2 -2
- package/src/replication/MongoRelation.ts +9 -1
- package/test/src/change_stream.test.ts +6 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -14,7 +14,7 @@ import { MongoLSN } from '../common/MongoLSN.js';
|
|
|
14
14
|
import { PostImagesOption } from '../types/types.js';
|
|
15
15
|
import { escapeRegExp } from '../utils.js';
|
|
16
16
|
import { MongoManager } from './MongoManager.js';
|
|
17
|
-
import { constructAfterRecord, createCheckpoint, getMongoRelation } from './MongoRelation.js';
|
|
17
|
+
import { constructAfterRecord, createCheckpoint, getCacheIdentifier, getMongoRelation } from './MongoRelation.js';
|
|
18
18
|
import { CHECKPOINTS_COLLECTION } from './replication-utils.js';
|
|
19
19
|
|
|
20
20
|
export interface ChangeStreamOptions {
|
|
@@ -89,6 +89,10 @@ export class ChangeStream {
|
|
|
89
89
|
return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
private get logPrefix() {
|
|
93
|
+
return `[powersync_${this.group_id}]`;
|
|
94
|
+
}
|
|
95
|
+
|
|
92
96
|
/**
|
|
93
97
|
* This resolves a pattern, persists the related metadata, and returns
|
|
94
98
|
* the resulting SourceTables.
|
|
@@ -124,18 +128,13 @@ export class ChangeStream {
|
|
|
124
128
|
.toArray();
|
|
125
129
|
|
|
126
130
|
if (!tablePattern.isWildcard && collections.length == 0) {
|
|
127
|
-
logger.warn(
|
|
131
|
+
logger.warn(`${this.logPrefix} Collection ${schema}.${tablePattern.name} not found`);
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
for (let collection of collections) {
|
|
131
135
|
const table = await this.handleRelation(
|
|
132
136
|
batch,
|
|
133
|
-
{
|
|
134
|
-
name: collection.name,
|
|
135
|
-
schema,
|
|
136
|
-
objectId: collection.name,
|
|
137
|
-
replicationColumns: [{ name: '_id' }]
|
|
138
|
-
} as SourceEntityDescriptor,
|
|
137
|
+
getMongoRelation({ db: schema, coll: collection.name }),
|
|
139
138
|
// This is done as part of the initial setup - snapshot is handled elsewhere
|
|
140
139
|
{ snapshot: false, collectionInfo: collection }
|
|
141
140
|
);
|
|
@@ -149,7 +148,7 @@ export class ChangeStream {
|
|
|
149
148
|
async initSlot(): Promise<InitResult> {
|
|
150
149
|
const status = await this.storage.getStatus();
|
|
151
150
|
if (status.snapshot_done && status.checkpoint_lsn) {
|
|
152
|
-
logger.info(
|
|
151
|
+
logger.info(`${this.logPrefix} Initial replication already done`);
|
|
153
152
|
return { needsInitialSync: false };
|
|
154
153
|
}
|
|
155
154
|
|
|
@@ -220,7 +219,7 @@ export class ChangeStream {
|
|
|
220
219
|
}
|
|
221
220
|
|
|
222
221
|
const { comparable: lsn } = new MongoLSN({ timestamp: snapshotTime });
|
|
223
|
-
logger.info(
|
|
222
|
+
logger.info(`${this.logPrefix} Snapshot commit at ${snapshotTime.inspect()} / ${lsn}`);
|
|
224
223
|
await batch.commit(lsn);
|
|
225
224
|
}
|
|
226
225
|
);
|
|
@@ -289,7 +288,7 @@ export class ChangeStream {
|
|
|
289
288
|
table: storage.SourceTable,
|
|
290
289
|
session?: mongo.ClientSession
|
|
291
290
|
) {
|
|
292
|
-
logger.info(
|
|
291
|
+
logger.info(`${this.logPrefix} Replicating ${table.qualifiedName}`);
|
|
293
292
|
const estimatedCount = await this.estimatedCount(table);
|
|
294
293
|
let at = 0;
|
|
295
294
|
let lastLogIndex = 0;
|
|
@@ -319,7 +318,7 @@ export class ChangeStream {
|
|
|
319
318
|
|
|
320
319
|
at += 1;
|
|
321
320
|
if (at - lastLogIndex >= 5000) {
|
|
322
|
-
logger.info(
|
|
321
|
+
logger.info(`${this.logPrefix} Replicating ${table.qualifiedName} ${at}/${estimatedCount}`);
|
|
323
322
|
lastLogIndex = at;
|
|
324
323
|
}
|
|
325
324
|
Metrics.getInstance().rows_replicated_total.add(1);
|
|
@@ -328,14 +327,16 @@ export class ChangeStream {
|
|
|
328
327
|
}
|
|
329
328
|
|
|
330
329
|
await batch.flush();
|
|
331
|
-
logger.info(
|
|
330
|
+
logger.info(`${this.logPrefix} Replicated ${at} documents for ${table.qualifiedName}`);
|
|
332
331
|
}
|
|
333
332
|
|
|
334
333
|
private async getRelation(
|
|
335
334
|
batch: storage.BucketStorageBatch,
|
|
336
|
-
descriptor: SourceEntityDescriptor
|
|
335
|
+
descriptor: SourceEntityDescriptor,
|
|
336
|
+
options: { snapshot: boolean }
|
|
337
337
|
): Promise<SourceTable> {
|
|
338
|
-
const
|
|
338
|
+
const cacheId = getCacheIdentifier(descriptor);
|
|
339
|
+
const existing = this.relation_cache.get(cacheId);
|
|
339
340
|
if (existing != null) {
|
|
340
341
|
return existing;
|
|
341
342
|
}
|
|
@@ -344,7 +345,7 @@ export class ChangeStream {
|
|
|
344
345
|
// missing values.
|
|
345
346
|
const collection = await this.getCollectionInfo(descriptor.schema, descriptor.name);
|
|
346
347
|
|
|
347
|
-
return this.handleRelation(batch, descriptor, { snapshot:
|
|
348
|
+
return this.handleRelation(batch, descriptor, { snapshot: options.snapshot, collectionInfo: collection });
|
|
348
349
|
}
|
|
349
350
|
|
|
350
351
|
private async getCollectionInfo(db: string, name: string): Promise<mongo.CollectionInfo | undefined> {
|
|
@@ -375,7 +376,7 @@ export class ChangeStream {
|
|
|
375
376
|
collMod: collectionInfo.name,
|
|
376
377
|
changeStreamPreAndPostImages: { enabled: true }
|
|
377
378
|
});
|
|
378
|
-
logger.info(
|
|
379
|
+
logger.info(`${this.logPrefix} Enabled postImages on ${db}.${collectionInfo.name}`);
|
|
379
380
|
} else if (!enabled) {
|
|
380
381
|
throw new ServiceError(ErrorCode.PSYNC_S1343, `postImages not enabled on ${db}.${collectionInfo.name}`);
|
|
381
382
|
}
|
|
@@ -394,9 +395,6 @@ export class ChangeStream {
|
|
|
394
395
|
}
|
|
395
396
|
|
|
396
397
|
const snapshot = options.snapshot;
|
|
397
|
-
if (!descriptor.objectId && typeof descriptor.objectId != 'string') {
|
|
398
|
-
throw new ReplicationAssertionError('MongoDB replication - objectId expected');
|
|
399
|
-
}
|
|
400
398
|
const result = await this.storage.resolveTable({
|
|
401
399
|
group_id: this.group_id,
|
|
402
400
|
connection_id: this.connection_id,
|
|
@@ -404,10 +402,16 @@ export class ChangeStream {
|
|
|
404
402
|
entity_descriptor: descriptor,
|
|
405
403
|
sync_rules: this.sync_rules
|
|
406
404
|
});
|
|
407
|
-
this.relation_cache.set(descriptor
|
|
405
|
+
this.relation_cache.set(getCacheIdentifier(descriptor), result.table);
|
|
408
406
|
|
|
409
|
-
// Drop conflicting
|
|
410
|
-
|
|
407
|
+
// Drop conflicting collections.
|
|
408
|
+
// This is generally not expected for MongoDB source dbs, so we log an error.
|
|
409
|
+
if (result.dropTables.length > 0) {
|
|
410
|
+
logger.error(
|
|
411
|
+
`Conflicting collections found for ${JSON.stringify(descriptor)}. Dropping: ${result.dropTables.map((t) => t.id).join(', ')}`
|
|
412
|
+
);
|
|
413
|
+
await batch.drop(result.dropTables);
|
|
414
|
+
}
|
|
411
415
|
|
|
412
416
|
// Snapshot if:
|
|
413
417
|
// 1. Snapshot is requested (false for initial snapshot, since that process handles it elsewhere)
|
|
@@ -415,6 +419,7 @@ export class ChangeStream {
|
|
|
415
419
|
// 3. The table is used in sync rules.
|
|
416
420
|
const shouldSnapshot = snapshot && !result.table.snapshotComplete && result.table.syncAny;
|
|
417
421
|
if (shouldSnapshot) {
|
|
422
|
+
logger.info(`${this.logPrefix} New collection: ${descriptor.schema}.${descriptor.name}`);
|
|
418
423
|
// Truncate this table, in case a previous snapshot was interrupted.
|
|
419
424
|
await batch.truncate([result.table]);
|
|
420
425
|
|
|
@@ -434,7 +439,7 @@ export class ChangeStream {
|
|
|
434
439
|
change: mongo.ChangeStreamDocument
|
|
435
440
|
): Promise<storage.FlushedResult | null> {
|
|
436
441
|
if (!table.syncAny) {
|
|
437
|
-
logger.debug(
|
|
442
|
+
logger.debug(`${this.logPrefix} Collection ${table.qualifiedName} not used in sync rules - skipping`);
|
|
438
443
|
return null;
|
|
439
444
|
}
|
|
440
445
|
|
|
@@ -528,7 +533,7 @@ export class ChangeStream {
|
|
|
528
533
|
const startAfter = lastLsn?.timestamp;
|
|
529
534
|
const resumeAfter = lastLsn?.resumeToken;
|
|
530
535
|
|
|
531
|
-
logger.info(
|
|
536
|
+
logger.info(`${this.logPrefix} Resume streaming at ${startAfter?.inspect()} / ${lastLsn}`);
|
|
532
537
|
|
|
533
538
|
const filters = this.getSourceNamespaceFilters();
|
|
534
539
|
|
|
@@ -590,13 +595,14 @@ export class ChangeStream {
|
|
|
590
595
|
|
|
591
596
|
let splitDocument: mongo.ChangeStreamDocument | null = null;
|
|
592
597
|
|
|
598
|
+
let flexDbNameWorkaroundLogged = false;
|
|
599
|
+
|
|
593
600
|
while (true) {
|
|
594
601
|
if (this.abort_signal.aborted) {
|
|
595
602
|
break;
|
|
596
603
|
}
|
|
597
604
|
|
|
598
605
|
const originalChangeDocument = await stream.tryNext();
|
|
599
|
-
|
|
600
606
|
// The stream was closed, we will only ever receive `null` from it
|
|
601
607
|
if (!originalChangeDocument && stream.closed) {
|
|
602
608
|
break;
|
|
@@ -636,6 +642,29 @@ export class ChangeStream {
|
|
|
636
642
|
throw new ReplicationAssertionError(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
|
|
637
643
|
}
|
|
638
644
|
|
|
645
|
+
if (
|
|
646
|
+
!filters.multipleDatabases &&
|
|
647
|
+
'ns' in changeDocument &&
|
|
648
|
+
changeDocument.ns.db != this.defaultDb.databaseName &&
|
|
649
|
+
changeDocument.ns.db.endsWith(`_${this.defaultDb.databaseName}`)
|
|
650
|
+
) {
|
|
651
|
+
// When all of the following conditions are met:
|
|
652
|
+
// 1. We're replicating from an Atlas Flex instance.
|
|
653
|
+
// 2. There were changestream events recorded while the PowerSync service is paused.
|
|
654
|
+
// 3. We're only replicating from a single database.
|
|
655
|
+
// Then we've observed an ns with for example {db: '67b83e86cd20730f1e766dde_ps'},
|
|
656
|
+
// instead of the expected {db: 'ps'}.
|
|
657
|
+
// We correct this.
|
|
658
|
+
changeDocument.ns.db = this.defaultDb.databaseName;
|
|
659
|
+
|
|
660
|
+
if (!flexDbNameWorkaroundLogged) {
|
|
661
|
+
flexDbNameWorkaroundLogged = true;
|
|
662
|
+
logger.warn(
|
|
663
|
+
`${this.logPrefix} Incorrect DB name in change stream: ${changeDocument.ns.db}. Changed to ${this.defaultDb.databaseName}.`
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
639
668
|
if (
|
|
640
669
|
(changeDocument.operationType == 'insert' ||
|
|
641
670
|
changeDocument.operationType == 'update' ||
|
|
@@ -682,28 +711,44 @@ export class ChangeStream {
|
|
|
682
711
|
waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb);
|
|
683
712
|
}
|
|
684
713
|
const rel = getMongoRelation(changeDocument.ns);
|
|
685
|
-
const table = await this.getRelation(batch, rel
|
|
714
|
+
const table = await this.getRelation(batch, rel, {
|
|
715
|
+
// In most cases, we should not need to snapshot this. But if this is the first time we see the collection
|
|
716
|
+
// for whatever reason, then we do need to snapshot it.
|
|
717
|
+
// This may result in some duplicate operations when a collection is created for the first time after
|
|
718
|
+
// sync rules was deployed.
|
|
719
|
+
snapshot: true
|
|
720
|
+
});
|
|
686
721
|
if (table.syncAny) {
|
|
687
722
|
await this.writeChange(batch, table, changeDocument);
|
|
688
723
|
}
|
|
689
724
|
} else if (changeDocument.operationType == 'drop') {
|
|
690
725
|
const rel = getMongoRelation(changeDocument.ns);
|
|
691
|
-
const table = await this.getRelation(batch, rel
|
|
726
|
+
const table = await this.getRelation(batch, rel, {
|
|
727
|
+
// We're "dropping" this collection, so never snapshot it.
|
|
728
|
+
snapshot: false
|
|
729
|
+
});
|
|
692
730
|
if (table.syncAny) {
|
|
693
731
|
await batch.drop([table]);
|
|
694
|
-
this.relation_cache.delete(
|
|
732
|
+
this.relation_cache.delete(getCacheIdentifier(rel));
|
|
695
733
|
}
|
|
696
734
|
} else if (changeDocument.operationType == 'rename') {
|
|
697
735
|
const relFrom = getMongoRelation(changeDocument.ns);
|
|
698
736
|
const relTo = getMongoRelation(changeDocument.to);
|
|
699
|
-
const tableFrom = await this.getRelation(batch, relFrom
|
|
737
|
+
const tableFrom = await this.getRelation(batch, relFrom, {
|
|
738
|
+
// We're "dropping" this collection, so never snapshot it.
|
|
739
|
+
snapshot: false
|
|
740
|
+
});
|
|
700
741
|
if (tableFrom.syncAny) {
|
|
701
742
|
await batch.drop([tableFrom]);
|
|
702
|
-
this.relation_cache.delete(
|
|
743
|
+
this.relation_cache.delete(getCacheIdentifier(relFrom));
|
|
703
744
|
}
|
|
704
745
|
// Here we do need to snapshot the new table
|
|
705
746
|
const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
|
|
706
|
-
await this.handleRelation(batch, relTo, {
|
|
747
|
+
await this.handleRelation(batch, relTo, {
|
|
748
|
+
// This is a new (renamed) collection, so always snapshot it.
|
|
749
|
+
snapshot: true,
|
|
750
|
+
collectionInfo: collection
|
|
751
|
+
});
|
|
707
752
|
}
|
|
708
753
|
}
|
|
709
754
|
}
|
|
@@ -40,8 +40,8 @@ export class ChangeStreamReplicationJob extends replication.AbstractReplicationJ
|
|
|
40
40
|
this.logger.error(`Replication failed`, e);
|
|
41
41
|
|
|
42
42
|
if (e instanceof ChangeStreamInvalidatedError) {
|
|
43
|
-
// This stops replication
|
|
44
|
-
await this.options.storage.factory.
|
|
43
|
+
// This stops replication and restarts with a new instance
|
|
44
|
+
await this.options.storage.factory.restartReplication(this.storage.group_id);
|
|
45
45
|
}
|
|
46
46
|
} finally {
|
|
47
47
|
this.abortController.abort();
|
|
@@ -11,11 +11,19 @@ export function getMongoRelation(source: mongo.ChangeStreamNameSpace): storage.S
|
|
|
11
11
|
return {
|
|
12
12
|
name: source.coll,
|
|
13
13
|
schema: source.db,
|
|
14
|
-
|
|
14
|
+
// Not relevant for MongoDB - we use db + coll name as the identifier
|
|
15
|
+
objectId: undefined,
|
|
15
16
|
replicationColumns: [{ name: '_id' }]
|
|
16
17
|
} satisfies storage.SourceEntityDescriptor;
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* For in-memory cache only.
|
|
22
|
+
*/
|
|
23
|
+
export function getCacheIdentifier(source: storage.SourceEntityDescriptor): string {
|
|
24
|
+
return `${source.schema}.${source.name}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
export function constructAfterRecord(document: mongo.Document): SqliteRow {
|
|
20
28
|
let record: SqliteRow = {};
|
|
21
29
|
for (let key of Object.keys(document)) {
|
|
@@ -239,6 +239,7 @@ bucket_definitions:
|
|
|
239
239
|
- SELECT _id as id, description FROM "test_DATA"
|
|
240
240
|
`);
|
|
241
241
|
|
|
242
|
+
await db.createCollection('test_DATA');
|
|
242
243
|
await context.replicateSnapshot();
|
|
243
244
|
|
|
244
245
|
context.startStreaming();
|
|
@@ -261,6 +262,7 @@ bucket_definitions:
|
|
|
261
262
|
data:
|
|
262
263
|
- SELECT _id as id, name, description FROM "test_data"
|
|
263
264
|
`);
|
|
265
|
+
await db.createCollection('test_data');
|
|
264
266
|
|
|
265
267
|
await context.replicateSnapshot();
|
|
266
268
|
context.startStreaming();
|
|
@@ -371,6 +373,8 @@ bucket_definitions:
|
|
|
371
373
|
- SELECT _id as id, name, other FROM "test_data"`);
|
|
372
374
|
const { db } = context;
|
|
373
375
|
|
|
376
|
+
await db.createCollection('test_data');
|
|
377
|
+
|
|
374
378
|
await context.replicateSnapshot();
|
|
375
379
|
|
|
376
380
|
const collection = db.collection('test_data');
|
|
@@ -451,6 +455,8 @@ bucket_definitions:
|
|
|
451
455
|
|
|
452
456
|
const data = await context.getBucketData('global[]');
|
|
453
457
|
expect(data).toMatchObject([
|
|
458
|
+
// An extra op here, since this triggers a snapshot in addition to getting the event.
|
|
459
|
+
test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test2' }),
|
|
454
460
|
test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test1' }),
|
|
455
461
|
test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test2' })
|
|
456
462
|
]);
|