@powersync/service-module-mongodb 0.0.0-dev-20260909133214 → 0.0.0-dev-20260929095716
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 +38 -6
- package/ci/test-connection.yaml +12 -0
- package/dist/api/MongoRouteAPIAdapter.d.ts +2 -2
- package/dist/api/MongoRouteAPIAdapter.js +73 -143
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/api/infer-collection-schema.d.ts +12 -0
- package/dist/api/infer-collection-schema.js +142 -0
- package/dist/api/infer-collection-schema.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/module/MongoModule.d.ts +1 -2
- package/dist/module/MongoModule.js +0 -1
- package/dist/module/MongoModule.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +10 -2
- package/dist/replication/ChangeStream.js +228 -317
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
- package/dist/replication/ChangeStreamReplicationJob.js +4 -1
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/ChangeStreamReplicator.d.ts +3 -0
- package/dist/replication/ChangeStreamReplicator.js +3 -0
- package/dist/replication/ChangeStreamReplicator.js.map +1 -1
- package/dist/replication/MongoRelation.js +5 -2
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/MongoReplicationQueryProvider.d.ts +87 -0
- package/dist/replication/MongoReplicationQueryProvider.js +23 -0
- package/dist/replication/MongoReplicationQueryProvider.js.map +1 -0
- package/dist/replication/MongoReplicationStream.d.ts +85 -0
- package/dist/replication/MongoReplicationStream.js +101 -0
- package/dist/replication/MongoReplicationStream.js.map +1 -0
- package/dist/replication/MongoSnapshotQuery.d.ts +2 -0
- package/dist/replication/MongoSnapshotQuery.js +12 -2
- package/dist/replication/MongoSnapshotQuery.js.map +1 -1
- package/dist/replication/MongoSnapshotter.d.ts +7 -2
- package/dist/replication/MongoSnapshotter.js +58 -85
- package/dist/replication/MongoSnapshotter.js.map +1 -1
- package/dist/replication/RawChangeStream.d.ts +1 -1
- package/dist/replication/RawChangeStream.js +1 -1
- package/dist/replication/RawChangeStream.js.map +1 -1
- package/dist/replication/SourceRowConverter.d.ts +1 -0
- package/dist/replication/SourceRowConverter.js +4 -2
- package/dist/replication/SourceRowConverter.js.map +1 -1
- package/dist/replication/bufferToSqlite.d.ts +3 -1
- package/dist/replication/bufferToSqlite.js +35 -23
- package/dist/replication/bufferToSqlite.js.map +1 -1
- package/dist/replication/replication-index.d.ts +6 -0
- package/dist/replication/replication-index.js +6 -0
- package/dist/replication/replication-index.js.map +1 -1
- package/dist/test-utils/ChangeStreamTestContext.d.ts +131 -0
- package/dist/test-utils/ChangeStreamTestContext.js +391 -0
- package/dist/test-utils/ChangeStreamTestContext.js.map +1 -0
- package/dist/test-utils/test-utils-index.d.ts +1 -0
- package/dist/test-utils/test-utils-index.js +2 -0
- package/dist/test-utils/test-utils-index.js.map +1 -0
- package/package.json +8 -8
- package/src/api/MongoRouteAPIAdapter.ts +79 -127
- package/src/api/infer-collection-schema.ts +153 -0
- package/src/index.ts +1 -0
- package/src/module/MongoModule.ts +1 -3
- package/src/replication/ChangeStream.ts +253 -359
- package/src/replication/ChangeStreamReplicationJob.ts +7 -2
- package/src/replication/ChangeStreamReplicator.ts +5 -0
- package/src/replication/MongoRelation.ts +8 -1
- package/src/replication/MongoReplicationQueryProvider.ts +108 -0
- package/src/replication/MongoReplicationStream.ts +201 -0
- package/src/replication/MongoSnapshotQuery.ts +19 -3
- package/src/replication/MongoSnapshotter.ts +70 -92
- package/src/replication/RawChangeStream.ts +2 -2
- package/src/replication/SourceRowConverter.ts +4 -2
- package/src/replication/bufferToSqlite.ts +43 -23
- package/src/replication/replication-index.ts +6 -0
- package/src/test-utils/ChangeStreamTestContext.ts +520 -0
- package/src/test-utils/test-utils-index.ts +1 -0
- package/test/src/buffer_to_sqlite.test.ts +9 -0
- package/test/src/change_stream.test.ts +3 -3
- package/test/src/change_stream_test_setup.ts +38 -0
- package/test/src/chunked_snapshot.test.ts +3 -3
- package/test/src/documentdb_mode.test.ts +3 -3
- package/test/src/replication_stream.test.ts +311 -0
- package/test/src/resume.test.ts +10 -4
- package/test/src/resuming_snapshots.test.ts +3 -3
- package/test/src/schema.test.ts +223 -0
- package/test/src/slow_tests.test.ts +3 -3
- package/test/src/snapshot_query_provider.test.ts +95 -0
- package/test/src/stream_progress.test.ts +312 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/test/src/change_stream_utils.ts +0 -375
|
@@ -27,13 +27,14 @@ import { CheckpointImplementation } from './checkpoints/CheckpointImplementation
|
|
|
27
27
|
import { createCheckpointImplementation } from './checkpoints/create-checkpoint-implementation.js';
|
|
28
28
|
import { MongoManager } from './MongoManager.js';
|
|
29
29
|
import { getCacheIdentifier, getMongoRelation } from './MongoRelation.js';
|
|
30
|
-
import { MongoSnapshotter, MongoSnapshotterHooks } from './MongoSnapshotter.js';
|
|
31
30
|
import {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
} from './
|
|
31
|
+
DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER,
|
|
32
|
+
MongoReplicationQueryProvider,
|
|
33
|
+
MongoReplicationQueryProviderFactory
|
|
34
|
+
} from './MongoReplicationQueryProvider.js';
|
|
35
|
+
import { MongoReplicationStreamItem, openMongoReplicationStream } from './MongoReplicationStream.js';
|
|
36
|
+
import { MongoSnapshotter, MongoSnapshotterHooks } from './MongoSnapshotter.js';
|
|
37
|
+
import { ChangeStreamBatch, ProjectedChangeStreamDocument } from './RawChangeStream.js';
|
|
37
38
|
import { CHECKPOINTS_COLLECTION, detectDocumentDb, timestampToDate } from './replication-utils.js';
|
|
38
39
|
import { DirectSourceRowConverter, SourceRowConverter } from './SourceRowConverter.js';
|
|
39
40
|
export interface ChangeStreamOptions {
|
|
@@ -61,6 +62,10 @@ export interface ChangeStreamOptions {
|
|
|
61
62
|
|
|
62
63
|
storageHooks?: storage.StorageHooks;
|
|
63
64
|
snapshotHooks?: MongoSnapshotterHooks;
|
|
65
|
+
/**
|
|
66
|
+
* Create one adapter per replication attempt, shared with its snapshotter and source probes.
|
|
67
|
+
*/
|
|
68
|
+
createReplicationQueryProvider?: MongoReplicationQueryProviderFactory;
|
|
64
69
|
|
|
65
70
|
logger?: Logger;
|
|
66
71
|
}
|
|
@@ -118,6 +123,7 @@ export class ChangeStream {
|
|
|
118
123
|
private storageHooks: storage.StorageHooks | undefined;
|
|
119
124
|
|
|
120
125
|
private readonly sourceRowConverter: SourceRowConverter;
|
|
126
|
+
private readonly queryProvider: MongoReplicationQueryProvider;
|
|
121
127
|
|
|
122
128
|
private keepaliveIntervalMs: number;
|
|
123
129
|
|
|
@@ -150,6 +156,12 @@ export class ChangeStream {
|
|
|
150
156
|
this.sync_rules = options.storage.getParsedSyncRules({
|
|
151
157
|
defaultSchema: this.defaultDb.databaseName
|
|
152
158
|
});
|
|
159
|
+
this.queryProvider =
|
|
160
|
+
options.createReplicationQueryProvider?.({
|
|
161
|
+
syncConfig: this.sync_rules,
|
|
162
|
+
connectionTag: this.connections.connectionTag,
|
|
163
|
+
defaultSchema: this.defaultDb.databaseName
|
|
164
|
+
}) ?? DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER;
|
|
153
165
|
this.sourceRowConverter = new DirectSourceRowConverter(this.sync_rules.compatibility);
|
|
154
166
|
|
|
155
167
|
// The change stream aggregation command should timeout before the socket times out,
|
|
@@ -166,7 +178,8 @@ export class ChangeStream {
|
|
|
166
178
|
...options,
|
|
167
179
|
abortSignal: this.abortSignal,
|
|
168
180
|
logger: snapshotLogger,
|
|
169
|
-
checkpointStreamId: this.checkpointStreamId
|
|
181
|
+
checkpointStreamId: this.checkpointStreamId,
|
|
182
|
+
queryProvider: this.queryProvider
|
|
170
183
|
});
|
|
171
184
|
|
|
172
185
|
options.abort_signal.addEventListener(
|
|
@@ -193,7 +206,9 @@ export class ChangeStream {
|
|
|
193
206
|
return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
|
|
194
207
|
}
|
|
195
208
|
|
|
196
|
-
/**
|
|
209
|
+
/**
|
|
210
|
+
* The active checkpoint strategy. Only valid after ensureDetected().
|
|
211
|
+
*/
|
|
197
212
|
private get checkpointImplementation(): CheckpointImplementation {
|
|
198
213
|
if (this._checkpointImplementation == null) {
|
|
199
214
|
throw new ReplicationAssertionError('Checkpoint implementation not initialized - call ensureDetected() first');
|
|
@@ -216,6 +231,7 @@ export class ChangeStream {
|
|
|
216
231
|
'Azure DocumentDB support is in alpha. APIs and behavior may change, and long-term stability is not yet guaranteed.'
|
|
217
232
|
);
|
|
218
233
|
}
|
|
234
|
+
await this.queryProvider.validateSource?.({ connectionManager: this.connections, isDocumentDb: this.isDocumentDb });
|
|
219
235
|
this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
|
|
220
236
|
client: this.client,
|
|
221
237
|
db: this.defaultDb,
|
|
@@ -512,7 +528,7 @@ export class ChangeStream {
|
|
|
512
528
|
if (
|
|
513
529
|
e instanceof mongo.MongoServerError &&
|
|
514
530
|
e.codeName == 'NoMatchingDocument' &&
|
|
515
|
-
e.errmsg?.includes('post-image was not found')
|
|
531
|
+
(e.errmsg?.includes('post-image was not found') || e.errmsg?.includes('pre-image was not found'))
|
|
516
532
|
) {
|
|
517
533
|
throw new ChangeStreamInvalidatedError(e.errmsg, e);
|
|
518
534
|
}
|
|
@@ -520,86 +536,32 @@ export class ChangeStream {
|
|
|
520
536
|
}
|
|
521
537
|
}
|
|
522
538
|
|
|
523
|
-
private
|
|
539
|
+
private openChangeStream(options: {
|
|
524
540
|
lsn: string | null;
|
|
525
541
|
maxAwaitTimeMS?: number;
|
|
526
542
|
batchSize?: number;
|
|
527
543
|
filters: { $match: any; multipleDatabases: boolean };
|
|
528
544
|
signal?: AbortSignal;
|
|
529
545
|
tracer?: PerformanceTracer<'changestream'>;
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
fullDocument = 'updateLookup';
|
|
549
|
-
}
|
|
550
|
-
const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
|
|
551
|
-
fullDocument: fullDocument
|
|
552
|
-
};
|
|
553
|
-
if (!this.isDocumentDb) {
|
|
554
|
-
// DocumentDB does not support showExpandedEvents.
|
|
555
|
-
streamOptions.showExpandedEvents = true;
|
|
556
|
-
}
|
|
557
|
-
const pipeline: mongo.Document[] = [
|
|
558
|
-
{
|
|
559
|
-
$changeStream: streamOptions
|
|
560
|
-
},
|
|
561
|
-
{
|
|
562
|
-
$match: filters.$match
|
|
546
|
+
onBatch?: (batch: ChangeStreamBatch) => Disposable | void;
|
|
547
|
+
}): AsyncIterableIterator<MongoReplicationStreamItem> {
|
|
548
|
+
return openMongoReplicationStream({
|
|
549
|
+
db: this.defaultDb,
|
|
550
|
+
queryProvider: this.queryProvider,
|
|
551
|
+
namespaceFilter: options.filters,
|
|
552
|
+
isDocumentDb: this.isDocumentDb,
|
|
553
|
+
usePostImages: this.usePostImages,
|
|
554
|
+
position: options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null,
|
|
555
|
+
skipInitialTimestamp: true,
|
|
556
|
+
onBatch: options.onBatch,
|
|
557
|
+
options: {
|
|
558
|
+
batchSize: options.batchSize ?? this.snapshotChunkLength,
|
|
559
|
+
maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
|
|
560
|
+
maxTimeMS: this.changeStreamTimeout,
|
|
561
|
+
signal: options.signal,
|
|
562
|
+
logger: this.logger,
|
|
563
|
+
tracer: options.tracer
|
|
563
564
|
}
|
|
564
|
-
];
|
|
565
|
-
if (!this.isDocumentDb) {
|
|
566
|
-
// DocumentDB does not support $changeStreamSplitLargeEvent.
|
|
567
|
-
pipeline.push({ $changeStreamSplitLargeEvent: {} });
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
/**
|
|
571
|
-
* Only one of these options can be supplied at a time.
|
|
572
|
-
*/
|
|
573
|
-
if (resumeAfter) {
|
|
574
|
-
streamOptions.resumeAfter = resumeAfter;
|
|
575
|
-
} else if (startAfter != null) {
|
|
576
|
-
// Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
|
|
577
|
-
// case if we have an old one.
|
|
578
|
-
// This is also relevant for getSnapshotLSN().
|
|
579
|
-
// The sentinel implementation never produces a startAfter, and a fresh DocumentDB stream
|
|
580
|
-
// opens from "now" with neither option set.
|
|
581
|
-
streamOptions.startAtOperationTime = startAfter;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
let watchDb: mongo.Db;
|
|
585
|
-
if (this.isDocumentDb || filters.multipleDatabases) {
|
|
586
|
-
// DocumentDB only supports cluster-level change streams.
|
|
587
|
-
watchDb = this.client.db('admin');
|
|
588
|
-
streamOptions.allChangesForCluster = true;
|
|
589
|
-
} else {
|
|
590
|
-
watchDb = this.defaultDb;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
|
|
594
|
-
|
|
595
|
-
return rawChangeStream(watchDb, pipeline, {
|
|
596
|
-
batchSize: options.batchSize ?? this.snapshotChunkLength,
|
|
597
|
-
maxAwaitTimeMS,
|
|
598
|
-
maxTimeMS: this.changeStreamTimeout,
|
|
599
|
-
|
|
600
|
-
signal: options.signal,
|
|
601
|
-
logger: this.logger,
|
|
602
|
-
tracer: options.tracer
|
|
603
565
|
});
|
|
604
566
|
}
|
|
605
567
|
|
|
@@ -638,22 +600,30 @@ export class ChangeStream {
|
|
|
638
600
|
if (resumeFromLsn == null) {
|
|
639
601
|
throw new ReplicationAssertionError(`No LSN found to resume from`);
|
|
640
602
|
}
|
|
641
|
-
// Seed the
|
|
642
|
-
// the legacy startAfter timestamp (timestamp implementation only) for the
|
|
643
|
-
// resume-boundary dedupe guard below.
|
|
603
|
+
// Seed the checkpoint strategy's coordinate from the durable source position.
|
|
644
604
|
this.checkpointImplementation.seedPosition(resumeFromLsn);
|
|
645
|
-
const { startAfter } = this.checkpointImplementation.parseResumePosition(resumeFromLsn);
|
|
646
605
|
let outerSpan = tracer.span('batch');
|
|
647
606
|
|
|
648
607
|
this.checkpointImplementation.logResume(resumeFromLsn);
|
|
649
608
|
|
|
650
609
|
const filters = this.getSourceNamespaceFilters();
|
|
651
610
|
// This is closed when the for loop below returns/breaks/throws
|
|
652
|
-
|
|
611
|
+
let processingSpan: ReturnType<typeof tracer.span> | undefined;
|
|
612
|
+
let receivedBytes = 0;
|
|
613
|
+
let changesSinceProgress = 0;
|
|
614
|
+
const stream = this.openChangeStream({
|
|
653
615
|
lsn: resumeFromLsn,
|
|
654
616
|
filters,
|
|
655
617
|
signal: this.abortSignal,
|
|
656
|
-
tracer
|
|
618
|
+
tracer,
|
|
619
|
+
onBatch: (batch) => {
|
|
620
|
+
// Count actual transport, even if the adapter drops every envelope or reassembles fragments.
|
|
621
|
+
bytesReplicatedMetric.add(batch.byteSize);
|
|
622
|
+
chunksReplicatedMetric.add(1);
|
|
623
|
+
receivedBytes += batch.byteSize;
|
|
624
|
+
processingSpan = tracer.span('processing');
|
|
625
|
+
return processingSpan;
|
|
626
|
+
}
|
|
657
627
|
});
|
|
658
628
|
|
|
659
629
|
// Always start with a checkpoint.
|
|
@@ -661,305 +631,229 @@ export class ChangeStream {
|
|
|
661
631
|
// no data to replicate.
|
|
662
632
|
let waitForCheckpointLsn: string | null = await this.createBatchCheckpoint();
|
|
663
633
|
|
|
664
|
-
let
|
|
665
|
-
|
|
666
|
-
let flexDbNameWorkaroundLogged = false;
|
|
667
|
-
|
|
668
|
-
let lastEmptyResume = performance.now();
|
|
634
|
+
let lastKeepalive = performance.now();
|
|
669
635
|
let lastTxnKey: string | null = null;
|
|
670
636
|
|
|
671
|
-
for await (
|
|
672
|
-
|
|
673
|
-
using batchSpan = tracer.span('processing');
|
|
674
|
-
|
|
675
|
-
bytesReplicatedMetric.add(eventBatch.byteSize);
|
|
676
|
-
chunksReplicatedMetric.add(1);
|
|
677
|
-
if (this.abortSignal.aborted) {
|
|
678
|
-
break;
|
|
679
|
-
}
|
|
637
|
+
for await (const item of stream) {
|
|
638
|
+
if (this.abortSignal.aborted) break;
|
|
680
639
|
this.touch();
|
|
681
|
-
if (
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
640
|
+
if (item.type == 'progress') {
|
|
641
|
+
const { resumeToken, filteredCount } = item;
|
|
642
|
+
if (changesSinceProgress == 0) {
|
|
643
|
+
// No retained changes since the previous progress item: this is either idle or filtered-only traffic.
|
|
644
|
+
// Case 1: No pending barrier and the keepalive interval has elapsed. Advance checkpoints as below,
|
|
645
|
+
// then flush and save the resume token.
|
|
646
|
+
// Case 2: Idle traffic with a recent keepalive. Skip this resume update, whether or not a barrier
|
|
647
|
+
// is pending, to preserve the idle throttle.
|
|
648
|
+
// Case 3: All other combinations. Flush and save the resume token without requesting a checkpoint.
|
|
649
|
+
// A pending barrier will provide the checkpoint boundary once replication reaches it. Filtered
|
|
650
|
+
// progress still saves its token even during the keepalive interval, so excluded work is resumable.
|
|
651
|
+
const hadRecentKeepalive = performance.now() - lastKeepalive < this.keepaliveIntervalMs;
|
|
652
|
+
if (waitForCheckpointLsn == null && !hadRecentKeepalive) {
|
|
653
|
+
if (filteredCount > 0) {
|
|
654
|
+
// Case 1a: A transaction can span batches: this batch may contain only filtered changes,
|
|
655
|
+
// while retained changes from the same transaction are still unread. They share a timestamp,
|
|
656
|
+
// so publishing a checkpoint here could acknowledge the transaction before its data is saved.
|
|
657
|
+
// Request a source barrier; reaching it ensures those retained changes have been processed.
|
|
658
|
+
// The progress token can still be saved below for recovery without publishing a checkpoint.
|
|
659
|
+
using _ = tracer.span('source_checkpoint');
|
|
660
|
+
waitForCheckpointLsn = await this.createBatchCheckpoint();
|
|
661
|
+
} else {
|
|
662
|
+
// Case 1b: Idle traffic. The timestamp implementation persists a keepalive directly;
|
|
663
|
+
// the sentinel implementation writes a source marker whose event will commit later.
|
|
664
|
+
await this.checkpointImplementation.keepalive(batch, resumeToken);
|
|
665
|
+
this.replicationLag.markStarted();
|
|
666
|
+
}
|
|
667
|
+
this.touch();
|
|
668
|
+
lastKeepalive = performance.now();
|
|
669
|
+
} else if (filteredCount == 0 && hadRecentKeepalive) {
|
|
670
|
+
// Case 2: Only idle progress skips resume persistence. Cases 1 and 3 fall through below.
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
707
673
|
}
|
|
708
|
-
}
|
|
709
674
|
|
|
710
|
-
|
|
675
|
+
const { lsn, timestamp } = this.checkpointImplementation.lsnFromResumeToken(resumeToken);
|
|
676
|
+
// Row writes must be durable before their source token. This advances recovery without
|
|
677
|
+
// publishing a checkpoint ahead of an outstanding barrier, transaction or snapshot.
|
|
678
|
+
await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
|
|
679
|
+
await batch.setResumeLsn(lsn);
|
|
680
|
+
// MongoDB's token timestamp may lag by about 10 seconds. DocumentDB tokens have no timestamp.
|
|
681
|
+
this.lastPersistedResumeTimestamp = timestamp?.getTime() ?? Date.now();
|
|
682
|
+
processingSpan?.end();
|
|
683
|
+
const durationsMicroseconds = outerSpan.end();
|
|
684
|
+
this.logger.info(`Processed ${changesSinceProgress} changes and ${filteredCount} filtered events`, {
|
|
685
|
+
count: changesSinceProgress,
|
|
686
|
+
filteredCount,
|
|
687
|
+
bytes: receivedBytes,
|
|
688
|
+
duration: processingSpan?.durationMillis,
|
|
689
|
+
t: durationsMicroseconds
|
|
690
|
+
});
|
|
691
|
+
changesSinceProgress = 0;
|
|
692
|
+
receivedBytes = 0;
|
|
693
|
+
outerSpan = tracer.span('batch');
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
711
696
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
697
|
+
// The shared reader has already reassembled complete events and normalized their namespaces.
|
|
698
|
+
const changeDocument = item.event;
|
|
699
|
+
changesSinceProgress++;
|
|
700
|
+
const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
|
|
701
|
+
|
|
702
|
+
if (ns?.coll == CHECKPOINTS_COLLECTION) {
|
|
703
|
+
/**
|
|
704
|
+
* Dropping the database does not provide an `invalidate` event.
|
|
705
|
+
* We typically would receive `drop` events for the collection which we
|
|
706
|
+
* would process below.
|
|
707
|
+
*
|
|
708
|
+
* However we don't commit the LSN after collections are dropped.
|
|
709
|
+
* This prevents the `startAfter` or `resumeToken` from advancing past the drop events.
|
|
710
|
+
* The stream also closes after the drop events.
|
|
711
|
+
* This causes an infinite loop of processing the collection drop events.
|
|
712
|
+
*
|
|
713
|
+
* This check here invalidates the change stream if our `_powersync_checkpoints` collection
|
|
714
|
+
* is dropped. This allows for detecting when the DB is dropped.
|
|
715
|
+
*/
|
|
716
|
+
if (changeDocument.operationType == 'drop') {
|
|
717
|
+
throw new ChangeStreamInvalidatedError(
|
|
718
|
+
'Internal collections have been dropped',
|
|
719
|
+
new Error('_powersync_checkpoints collection was dropped')
|
|
720
|
+
);
|
|
717
721
|
}
|
|
718
722
|
|
|
719
|
-
if (
|
|
723
|
+
if (
|
|
724
|
+
!(
|
|
725
|
+
changeDocument.operationType == 'insert' ||
|
|
726
|
+
changeDocument.operationType == 'update' ||
|
|
727
|
+
changeDocument.operationType == 'replace'
|
|
728
|
+
)
|
|
729
|
+
) {
|
|
720
730
|
continue;
|
|
721
731
|
}
|
|
722
732
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
733
|
+
// We handle two types of checkpoint events:
|
|
734
|
+
// 1. "Standalone" checkpoints, typically write checkpoints. We want to process these
|
|
735
|
+
// immediately, regardless of where they were created.
|
|
736
|
+
// 2. "Batch" checkpoints for the current stream. This is used as a form of dynamic rate
|
|
737
|
+
// limiting of commits, so we specifically want to exclude checkpoints from other streams.
|
|
738
|
+
//
|
|
739
|
+
// It may be useful to also throttle commits due to standalone checkpoints in the future.
|
|
740
|
+
// However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
|
|
728
741
|
|
|
729
|
-
|
|
730
|
-
splitDocument = originalChangeDocument;
|
|
731
|
-
} else {
|
|
732
|
-
splitDocument = Object.assign(splitDocument, originalChangeDocument);
|
|
733
|
-
}
|
|
742
|
+
const kind = this.checkpointImplementation.event.observe(changeDocument);
|
|
734
743
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
744
|
+
if (kind == 'foreign') {
|
|
745
|
+
// Another stream's barrier - ignore.
|
|
746
|
+
continue;
|
|
747
|
+
} else if (kind == 'standalone') {
|
|
748
|
+
// Standalone / write checkpoint received.
|
|
749
|
+
// When we are caught up, commit immediately to keep write checkpoint latency low.
|
|
750
|
+
// Once there is already a batch checkpoint pending, or the driver has buffered more
|
|
751
|
+
// change stream events, collapse standalone checkpoints into the normal batch
|
|
752
|
+
// checkpoint flow to avoid commit churn under sustained load.
|
|
753
|
+
const hasBufferedChanges = item.hasBufferedChanges;
|
|
754
|
+
if (hasBufferedChanges && waitForCheckpointLsn == null) {
|
|
755
|
+
// Buffered changes - create a new batch checkpoint to rate limit commits
|
|
756
|
+
using _ = tracer.span('source_checkpoint');
|
|
757
|
+
waitForCheckpointLsn = await this.createBatchCheckpoint();
|
|
741
758
|
continue;
|
|
759
|
+
} else if (waitForCheckpointLsn != null) {
|
|
760
|
+
// Skip this checkpoint - wait for the batch checkpoint.
|
|
761
|
+
continue;
|
|
762
|
+
} else {
|
|
763
|
+
// No buffered changes, and no batch checkpoint pending - commit immediately.
|
|
742
764
|
}
|
|
743
|
-
} else if (splitDocument != null) {
|
|
744
|
-
// We were waiting for fragments, but got a different event
|
|
745
|
-
throw new ReplicationAssertionError(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
|
|
746
765
|
}
|
|
766
|
+
// kind == 'own-barrier' falls through to commit.
|
|
767
|
+
|
|
768
|
+
const lsn = this.checkpointImplementation.event.lsn(changeDocument);
|
|
747
769
|
|
|
748
770
|
if (
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
changeDocument.ns.db != this.defaultDb.databaseName &&
|
|
752
|
-
changeDocument.ns.db.endsWith(`_${this.defaultDb.databaseName}`)
|
|
771
|
+
waitForCheckpointLsn != null &&
|
|
772
|
+
this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)
|
|
753
773
|
) {
|
|
754
|
-
|
|
755
|
-
// 1. We're replicating from an Atlas Flex instance.
|
|
756
|
-
// 2. There were changestream events recorded while the PowerSync service is paused.
|
|
757
|
-
// 3. We're only replicating from a single database.
|
|
758
|
-
// Then we've observed an ns with for example {db: '67b83e86cd20730f1e766dde_ps'},
|
|
759
|
-
// instead of the expected {db: 'ps'}.
|
|
760
|
-
// We correct this.
|
|
761
|
-
changeDocument.ns.db = this.defaultDb.databaseName;
|
|
762
|
-
|
|
763
|
-
if (!flexDbNameWorkaroundLogged) {
|
|
764
|
-
flexDbNameWorkaroundLogged = true;
|
|
765
|
-
this.logger.warn(
|
|
766
|
-
`Incorrect DB name in change stream: ${changeDocument.ns.db}. Changed to ${this.defaultDb.databaseName}.`
|
|
767
|
-
);
|
|
768
|
-
}
|
|
774
|
+
waitForCheckpointLsn = null;
|
|
769
775
|
}
|
|
776
|
+
const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
|
|
777
|
+
oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
|
|
778
|
+
});
|
|
770
779
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
* This check here invalidates the change stream if our `_powersync_checkpoints` collection
|
|
785
|
-
* is dropped. This allows for detecting when the DB is dropped.
|
|
786
|
-
*/
|
|
787
|
-
if (changeDocument.operationType == 'drop') {
|
|
788
|
-
throw new ChangeStreamInvalidatedError(
|
|
789
|
-
'Internal collections have been dropped',
|
|
790
|
-
new Error('_powersync_checkpoints collection was dropped')
|
|
791
|
-
);
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
if (
|
|
795
|
-
!(
|
|
796
|
-
changeDocument.operationType == 'insert' ||
|
|
797
|
-
changeDocument.operationType == 'update' ||
|
|
798
|
-
changeDocument.operationType == 'replace'
|
|
799
|
-
)
|
|
800
|
-
) {
|
|
801
|
-
continue;
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
// We handle two types of checkpoint events:
|
|
805
|
-
// 1. "Standalone" checkpoints, typically write checkpoints. We want to process these
|
|
806
|
-
// immediately, regardless of where they were created.
|
|
807
|
-
// 2. "Batch" checkpoints for the current stream. This is used as a form of dynamic rate
|
|
808
|
-
// limiting of commits, so we specifically want to exclude checkpoints from other streams.
|
|
809
|
-
//
|
|
810
|
-
// It may be useful to also throttle commits due to standalone checkpoints in the future.
|
|
811
|
-
// However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
|
|
812
|
-
|
|
813
|
-
const kind = this.checkpointImplementation.event.observe(changeDocument);
|
|
814
|
-
|
|
815
|
-
if (kind == 'foreign') {
|
|
816
|
-
// Another stream's barrier - ignore.
|
|
817
|
-
continue;
|
|
818
|
-
} else if (kind == 'standalone') {
|
|
819
|
-
// Standalone / write checkpoint received.
|
|
820
|
-
// When we are caught up, commit immediately to keep write checkpoint latency low.
|
|
821
|
-
// Once there is already a batch checkpoint pending, or the driver has buffered more
|
|
822
|
-
// change stream events, collapse standalone checkpoints into the normal batch
|
|
823
|
-
// checkpoint flow to avoid commit churn under sustained load.
|
|
824
|
-
const hasBufferedChanges = eventIndex < events.length - 1;
|
|
825
|
-
if (hasBufferedChanges && waitForCheckpointLsn == null) {
|
|
826
|
-
// Buffered changes - create a new batch checkpoint to rate limit commits
|
|
827
|
-
using _ = tracer.span('source_checkpoint');
|
|
828
|
-
waitForCheckpointLsn = await this.createBatchCheckpoint();
|
|
829
|
-
continue;
|
|
830
|
-
} else if (waitForCheckpointLsn != null) {
|
|
831
|
-
// Skip this checkpoint - wait for the batch checkpoint.
|
|
832
|
-
continue;
|
|
833
|
-
} else {
|
|
834
|
-
// No buffered changes, and no batch checkpoint pending - commit immediately.
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
// kind == 'own-barrier' falls through to commit.
|
|
838
|
-
|
|
839
|
-
const lsn = this.checkpointImplementation.event.lsn(changeDocument);
|
|
840
|
-
|
|
841
|
-
if (
|
|
842
|
-
waitForCheckpointLsn != null &&
|
|
843
|
-
this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)
|
|
844
|
-
) {
|
|
845
|
-
waitForCheckpointLsn = null;
|
|
846
|
-
}
|
|
847
|
-
const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
|
|
848
|
-
oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
|
|
849
|
-
});
|
|
780
|
+
if (!checkpointBlocked || checkpointCreated) {
|
|
781
|
+
this.replicationLag.markCommitted();
|
|
782
|
+
}
|
|
783
|
+
} else if (
|
|
784
|
+
changeDocument.operationType == 'insert' ||
|
|
785
|
+
changeDocument.operationType == 'update' ||
|
|
786
|
+
changeDocument.operationType == 'replace' ||
|
|
787
|
+
changeDocument.operationType == 'delete'
|
|
788
|
+
) {
|
|
789
|
+
if (waitForCheckpointLsn == null) {
|
|
790
|
+
using _ = tracer.span('source_checkpoint');
|
|
791
|
+
waitForCheckpointLsn = await this.createBatchCheckpoint();
|
|
792
|
+
}
|
|
850
793
|
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
)
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
794
|
+
const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
795
|
+
const tables = await this.getRelations(batch, rel, {
|
|
796
|
+
// In most cases, we should not need to snapshot this. But if this is the first time we see the collection
|
|
797
|
+
// for whatever reason, then we do need to snapshot it.
|
|
798
|
+
// This may result in some duplicate operations when a collection is created for the first time after
|
|
799
|
+
// sync config was deployed.
|
|
800
|
+
snapshot: true
|
|
801
|
+
});
|
|
802
|
+
const tablesToReplicate = tables.filter((table) => table.syncAny);
|
|
803
|
+
if (tablesToReplicate.length > 0) {
|
|
804
|
+
this.replicationLag.trackUncommittedChange(
|
|
805
|
+
// Standard MongoDB uses clusterTime, unchanged. DocumentDB has no
|
|
806
|
+
// clusterTime, so fall back to wallTime there for the lag metric.
|
|
807
|
+
changeDocument.clusterTime != null
|
|
808
|
+
? timestampToDate(changeDocument.clusterTime)
|
|
809
|
+
: ((changeDocument as any).wallTime ?? null)
|
|
810
|
+
);
|
|
811
|
+
|
|
812
|
+
const transactionKeyValue = transactionKey(changeDocument);
|
|
813
|
+
|
|
814
|
+
if (transactionKeyValue == null || lastTxnKey != transactionKeyValue) {
|
|
815
|
+
// Very crude metric for counting transactions replicated.
|
|
816
|
+
// We ignore operations other than basic CRUD, and ignore changes to _powersync_checkpoints.
|
|
817
|
+
// Individual writes may not have a txnNumber, in which case we count them as separate transactions.
|
|
818
|
+
lastTxnKey = transactionKeyValue;
|
|
819
|
+
transactionsReplicatedMetric.add(1);
|
|
863
820
|
}
|
|
864
821
|
|
|
865
|
-
const
|
|
866
|
-
|
|
867
|
-
// In most cases, we should not need to snapshot this. But if this is the first time we see the collection
|
|
868
|
-
// for whatever reason, then we do need to snapshot it.
|
|
869
|
-
// This may result in some duplicate operations when a collection is created for the first time after
|
|
870
|
-
// sync config was deployed.
|
|
871
|
-
snapshot: true
|
|
872
|
-
});
|
|
873
|
-
const tablesToReplicate = tables.filter((table) => table.syncAny);
|
|
874
|
-
if (tablesToReplicate.length > 0) {
|
|
875
|
-
this.replicationLag.trackUncommittedChange(
|
|
876
|
-
// Standard MongoDB uses clusterTime, unchanged. DocumentDB has no
|
|
877
|
-
// clusterTime, so fall back to wallTime there for the lag metric.
|
|
878
|
-
changeDocument.clusterTime != null
|
|
879
|
-
? timestampToDate(changeDocument.clusterTime)
|
|
880
|
-
: ((changeDocument as any).wallTime ?? null)
|
|
881
|
-
);
|
|
882
|
-
|
|
883
|
-
const transactionKeyValue = transactionKey(changeDocument);
|
|
884
|
-
|
|
885
|
-
if (transactionKeyValue == null || lastTxnKey != transactionKeyValue) {
|
|
886
|
-
// Very crude metric for counting transactions replicated.
|
|
887
|
-
// We ignore operations other than basic CRUD, and ignore changes to _powersync_checkpoints.
|
|
888
|
-
// Individual writes may not have a txnNumber, in which case we count them as separate transactions.
|
|
889
|
-
lastTxnKey = transactionKeyValue;
|
|
890
|
-
transactionsReplicatedMetric.add(1);
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
for (const table of tablesToReplicate) {
|
|
894
|
-
await this.writeChange(batch, table, changeDocument);
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
} else if (changeDocument.operationType == 'drop') {
|
|
898
|
-
const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
899
|
-
const tables = await this.getRelations(batch, rel, {
|
|
900
|
-
// We're "dropping" this collection, so never snapshot it.
|
|
901
|
-
snapshot: false
|
|
902
|
-
});
|
|
903
|
-
const tablesToDrop = tables.filter((table) => table.syncAny);
|
|
904
|
-
if (tablesToDrop.length > 0) {
|
|
905
|
-
await batch.drop(tablesToDrop);
|
|
822
|
+
for (const table of tablesToReplicate) {
|
|
823
|
+
await this.writeChange(batch, table, changeDocument);
|
|
906
824
|
}
|
|
907
|
-
this.relationCache.delete(rel);
|
|
908
|
-
} else if (changeDocument.operationType == 'rename') {
|
|
909
|
-
const relFrom = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
910
|
-
const relTo = getMongoRelation(changeDocument.to, this.connections.connectionTag);
|
|
911
|
-
const tablesFrom = await this.getRelations(batch, relFrom, {
|
|
912
|
-
// We're "dropping" this collection, so never snapshot it.
|
|
913
|
-
snapshot: false
|
|
914
|
-
});
|
|
915
|
-
const tablesToDrop = tablesFrom.filter((table) => table.syncAny);
|
|
916
|
-
if (tablesToDrop.length > 0) {
|
|
917
|
-
await batch.drop(tablesToDrop);
|
|
918
|
-
}
|
|
919
|
-
this.relationCache.delete(relFrom);
|
|
920
|
-
// Here we do need to snapshot the new table
|
|
921
|
-
const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
|
|
922
|
-
await this.handleRelation(batch, relTo, {
|
|
923
|
-
// This is a new (renamed) collection, so always snapshot it.
|
|
924
|
-
snapshot: true,
|
|
925
|
-
collectionInfo: collection
|
|
926
|
-
});
|
|
927
825
|
}
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
// TODO: We should consider making this standard behavior of flush().
|
|
938
|
-
await batch.setResumeLsn(lsn);
|
|
939
|
-
|
|
940
|
-
if (timestamp != null) {
|
|
941
|
-
// Note that this timestamp provided by MongoDB is not exact - it can be around 10s behind.
|
|
942
|
-
this.lastPersistedResumeTimestamp = timestamp.getTime();
|
|
943
|
-
} else {
|
|
944
|
-
// DocumentDB: No timestamp associated with the resumeToken. Just use the current time.
|
|
945
|
-
this.lastPersistedResumeTimestamp = Date.now();
|
|
826
|
+
} else if (changeDocument.operationType == 'drop') {
|
|
827
|
+
const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
828
|
+
const tables = await this.getRelations(batch, rel, {
|
|
829
|
+
// We're "dropping" this collection, so never snapshot it.
|
|
830
|
+
snapshot: false
|
|
831
|
+
});
|
|
832
|
+
const tablesToDrop = tables.filter((table) => table.syncAny);
|
|
833
|
+
if (tablesToDrop.length > 0) {
|
|
834
|
+
await batch.drop(tablesToDrop);
|
|
946
835
|
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
duration,
|
|
959
|
-
t: durationsMicroseconds
|
|
836
|
+
this.relationCache.delete(rel);
|
|
837
|
+
} else if (changeDocument.operationType == 'rename') {
|
|
838
|
+
const relFrom = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
839
|
+
const relTo = getMongoRelation(changeDocument.to, this.connections.connectionTag);
|
|
840
|
+
const tablesFrom = await this.getRelations(batch, relFrom, {
|
|
841
|
+
// We're "dropping" this collection, so never snapshot it.
|
|
842
|
+
snapshot: false
|
|
843
|
+
});
|
|
844
|
+
const tablesToDrop = tablesFrom.filter((table) => table.syncAny);
|
|
845
|
+
if (tablesToDrop.length > 0) {
|
|
846
|
+
await batch.drop(tablesToDrop);
|
|
960
847
|
}
|
|
961
|
-
|
|
962
|
-
|
|
848
|
+
this.relationCache.delete(relFrom);
|
|
849
|
+
// Here we do need to snapshot the new table
|
|
850
|
+
const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
|
|
851
|
+
await this.handleRelation(batch, relTo, {
|
|
852
|
+
// This is a new (renamed) collection, so always snapshot it.
|
|
853
|
+
snapshot: true,
|
|
854
|
+
collectionInfo: collection
|
|
855
|
+
});
|
|
856
|
+
}
|
|
963
857
|
}
|
|
964
858
|
}
|
|
965
859
|
);
|