@powersync/service-module-mongodb 0.21.2 → 0.22.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 +51 -0
- package/benchmarks/env.ts +11 -0
- package/benchmarks/replication_branch_throughput.benchmark.ts +240 -0
- package/dist/api/MongoRouteAPIAdapter.d.ts +6 -0
- package/dist/api/MongoRouteAPIAdapter.js +21 -2
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- 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 -1
- package/dist/module/MongoModule.js +1 -0
- package/dist/module/MongoModule.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +13 -3
- package/dist/replication/ChangeStream.js +130 -196
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
- package/dist/replication/ChangeStreamReplicationJob.js +19 -8
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/ChangeStreamReplicator.d.ts +4 -0
- package/dist/replication/ChangeStreamReplicator.js +6 -0
- package/dist/replication/ChangeStreamReplicator.js.map +1 -1
- package/dist/replication/MongoReplicationQueryProvider.d.ts +85 -0
- package/dist/replication/MongoReplicationQueryProvider.js +23 -0
- package/dist/replication/MongoReplicationQueryProvider.js.map +1 -0
- package/dist/replication/MongoReplicationStream.d.ts +74 -0
- package/dist/replication/MongoReplicationStream.js +228 -0
- package/dist/replication/MongoReplicationStream.js.map +1 -0
- package/dist/replication/MongoReplicationStreamTypes.d.ts +157 -0
- package/dist/replication/MongoReplicationStreamTypes.js +19 -0
- package/dist/replication/MongoReplicationStreamTypes.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 +90 -88
- 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/estimateFilteredSnapshotCount.d.ts +12 -0
- package/dist/replication/estimateFilteredSnapshotCount.js +79 -0
- package/dist/replication/estimateFilteredSnapshotCount.js.map +1 -0
- package/dist/replication/replication-index.d.ts +7 -0
- package/dist/replication/replication-index.js +7 -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 +390 -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 +11 -10
- package/src/api/MongoRouteAPIAdapter.ts +26 -2
- package/src/index.ts +1 -0
- package/src/module/MongoModule.ts +2 -1
- package/src/replication/ChangeStream.ts +143 -226
- package/src/replication/ChangeStreamReplicationJob.ts +21 -9
- package/src/replication/ChangeStreamReplicator.ts +9 -0
- package/src/replication/MongoReplicationQueryProvider.ts +106 -0
- package/src/replication/MongoReplicationStream.ts +314 -0
- package/src/replication/MongoReplicationStreamTypes.ts +169 -0
- package/src/replication/MongoSnapshotQuery.ts +19 -3
- package/src/replication/MongoSnapshotter.ts +101 -92
- package/src/replication/RawChangeStream.ts +2 -2
- package/src/replication/estimateFilteredSnapshotCount.ts +93 -0
- package/src/replication/replication-index.ts +7 -0
- package/src/test-utils/ChangeStreamTestContext.ts +520 -0
- package/src/test-utils/test-utils-index.ts +1 -0
- package/test/src/change_event_memory.test.ts +33 -0
- package/test/src/change_stream.test.ts +3 -3
- package/test/src/change_stream_abort.test.ts +110 -0
- 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/filtering_example.test.ts +65 -0
- package/test/src/filtering_lifecycle.test.ts +101 -0
- package/test/src/query_provider_availability.test.ts +77 -0
- package/test/src/query_provider_diagnostics.test.ts +55 -0
- package/test/src/replication_stream.test.ts +688 -0
- package/test/src/resume.test.ts +10 -4
- package/test/src/resuming_snapshots.test.ts +3 -3
- package/test/src/slow_tests.test.ts +3 -3
- package/test/src/snapshot_count.test.ts +146 -0
- package/test/src/snapshot_count_integration.test.ts +70 -0
- package/test/src/snapshot_query_provider.test.ts +122 -0
- package/test/src/stream_progress.test.ts +334 -0
- package/test/tsconfig.json +2 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.benchmark.config.ts +15 -0
- package/test/src/change_stream_utils.ts +0 -375
|
@@ -15,12 +15,19 @@ export class ChunkedSnapshotQuery implements AsyncDisposable {
|
|
|
15
15
|
private lastCursor: mongo.FindCursor | null = null;
|
|
16
16
|
private collection: mongo.Collection;
|
|
17
17
|
private batchSize: number;
|
|
18
|
+
private readonly filter: mongo.Filter<mongo.Document> | null;
|
|
18
19
|
|
|
19
|
-
public constructor(options: {
|
|
20
|
+
public constructor(options: {
|
|
21
|
+
collection: mongo.Collection;
|
|
22
|
+
batchSize: number;
|
|
23
|
+
key?: Uint8Array | null;
|
|
24
|
+
filter?: mongo.Filter<mongo.Document> | null;
|
|
25
|
+
}) {
|
|
20
26
|
this.lastKey = options.key ? bson.deserialize(options.key, { useBigInt64: true })._id : null;
|
|
21
27
|
this.lastCursor = null;
|
|
22
28
|
this.collection = options.collection;
|
|
23
29
|
this.batchSize = options.batchSize;
|
|
30
|
+
this.filter = options.filter ?? null;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
async nextChunk(): Promise<
|
|
@@ -39,8 +46,14 @@ export class ChunkedSnapshotQuery implements AsyncDisposable {
|
|
|
39
46
|
// any parsing as an operator.
|
|
40
47
|
// Starting in MongoDB 5.0, this filter can use the _id index. Source:
|
|
41
48
|
// https://www.mongodb.com/docs/manual/release-notes/5.0/#general-aggregation-improvements
|
|
42
|
-
const
|
|
49
|
+
const continuationFilter: mongo.Filter<mongo.Document> =
|
|
43
50
|
this.lastKey == null ? {} : { $expr: { $gt: ['$_id', { $literal: this.lastKey }] } };
|
|
51
|
+
const filter: mongo.Filter<mongo.Document> =
|
|
52
|
+
this.filter == null
|
|
53
|
+
? continuationFilter
|
|
54
|
+
: this.lastKey == null
|
|
55
|
+
? this.filter
|
|
56
|
+
: { $and: [this.filter, continuationFilter] };
|
|
44
57
|
cursor = this.collection.find(filter, {
|
|
45
58
|
readConcern: 'majority',
|
|
46
59
|
limit: this.batchSize,
|
|
@@ -48,7 +61,10 @@ export class ChunkedSnapshotQuery implements AsyncDisposable {
|
|
|
48
61
|
// See https://github.com/mongodb/node-mongodb-native/pull/4580
|
|
49
62
|
batchSize: this.batchSize + 1,
|
|
50
63
|
sort: { _id: 1 },
|
|
51
|
-
raw: true
|
|
64
|
+
raw: true,
|
|
65
|
+
// Replication predicates have stable binary semantics independent of a
|
|
66
|
+
// collection's default collation.
|
|
67
|
+
...(this.filter == null ? {} : { collation: { locale: 'simple' } })
|
|
52
68
|
});
|
|
53
69
|
newCursor = true;
|
|
54
70
|
}
|
|
@@ -16,10 +16,17 @@ import { PostImagesOption } from '../types/types.js';
|
|
|
16
16
|
import { escapeRegExp } from '../utils.js';
|
|
17
17
|
import { CheckpointImplementation } from './checkpoints/CheckpointImplementation.js';
|
|
18
18
|
import { createCheckpointImplementation } from './checkpoints/create-checkpoint-implementation.js';
|
|
19
|
+
import { estimateFilteredSnapshotCount } from './estimateFilteredSnapshotCount.js';
|
|
19
20
|
import { MongoManager } from './MongoManager.js';
|
|
20
21
|
import { getMongoRelation } from './MongoRelation.js';
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER,
|
|
24
|
+
MongoReplicationQueryProvider
|
|
25
|
+
} from './MongoReplicationQueryProvider.js';
|
|
26
|
+
import { openMongoReplicationStream } from './MongoReplicationStream.js';
|
|
27
|
+
import type { MongoReplicationStreamBatch } from './MongoReplicationStreamTypes.js';
|
|
21
28
|
import { ChunkedSnapshotQuery } from './MongoSnapshotQuery.js';
|
|
22
|
-
import { ChangeStreamBatch
|
|
29
|
+
import { ChangeStreamBatch } from './RawChangeStream.js';
|
|
23
30
|
import { CHECKPOINTS_COLLECTION, detectDocumentDb } from './replication-utils.js';
|
|
24
31
|
import { DirectSourceRowConverter, SourceRowConverter } from './SourceRowConverter.js';
|
|
25
32
|
|
|
@@ -34,6 +41,7 @@ export interface MongoSnapshotterOptions {
|
|
|
34
41
|
checkpointStreamId: mongo.ObjectId;
|
|
35
42
|
storageHooks?: storage.StorageHooks;
|
|
36
43
|
snapshotHooks?: MongoSnapshotterHooks;
|
|
44
|
+
queryProvider?: MongoReplicationQueryProvider;
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
export interface MongoSnapshotterHooks {
|
|
@@ -59,6 +67,7 @@ export class MongoSnapshotter {
|
|
|
59
67
|
private readonly defaultDb: mongo.Db;
|
|
60
68
|
private readonly syncRules: HydratedSyncConfig;
|
|
61
69
|
private readonly sourceRowConverter: SourceRowConverter;
|
|
70
|
+
private readonly queryProvider: MongoReplicationQueryProvider;
|
|
62
71
|
private readonly maxAwaitTimeMS: number;
|
|
63
72
|
private readonly snapshotChunkLength: number;
|
|
64
73
|
private readonly abortSignal: AbortSignal;
|
|
@@ -90,6 +99,7 @@ export class MongoSnapshotter {
|
|
|
90
99
|
this.checkpointStreamId = options.checkpointStreamId;
|
|
91
100
|
this.storageHooks = options.storageHooks;
|
|
92
101
|
this.snapshotHooks = options.snapshotHooks;
|
|
102
|
+
this.queryProvider = options.queryProvider ?? DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER;
|
|
93
103
|
this.changeStreamTimeout = Math.ceil(this.client.options.socketTimeoutMS * 0.9);
|
|
94
104
|
this.syncRules = options.storage.getParsedSyncRules({
|
|
95
105
|
defaultSchema: this.defaultDb.databaseName
|
|
@@ -116,7 +126,9 @@ export class MongoSnapshotter {
|
|
|
116
126
|
return this.storage.storageConfig.softDeleteCurrentData;
|
|
117
127
|
}
|
|
118
128
|
|
|
119
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* The active checkpoint strategy. Only valid after ensureDetected().
|
|
131
|
+
*/
|
|
120
132
|
private get checkpointImplementation(): CheckpointImplementation {
|
|
121
133
|
if (this._checkpointImplementation == null) {
|
|
122
134
|
throw new ServiceError(
|
|
@@ -150,6 +162,7 @@ export class MongoSnapshotter {
|
|
|
150
162
|
);
|
|
151
163
|
}
|
|
152
164
|
}
|
|
165
|
+
await this.queryProvider.validateSource?.({ connectionManager: this.connections, isDocumentDb: this.isDocumentDb });
|
|
153
166
|
this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
|
|
154
167
|
client: this.client,
|
|
155
168
|
db: this.defaultDb,
|
|
@@ -244,9 +257,7 @@ export class MongoSnapshotter {
|
|
|
244
257
|
totalEstimatedCount: count
|
|
245
258
|
});
|
|
246
259
|
this.queueTable(updated);
|
|
247
|
-
this.logger.info(
|
|
248
|
-
`To replicate: ${updated.qualifiedName}: ${updated.snapshotStatus?.replicatedCount}/~${updated.snapshotStatus?.totalEstimatedCount}`
|
|
249
|
-
);
|
|
260
|
+
this.logger.info(`To replicate: ${updated.qualifiedName} ${updated.formatSnapshotProgress()}`);
|
|
250
261
|
}
|
|
251
262
|
}
|
|
252
263
|
|
|
@@ -465,13 +476,20 @@ export class MongoSnapshotter {
|
|
|
465
476
|
const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
|
|
466
477
|
const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
|
|
467
478
|
|
|
468
|
-
const
|
|
479
|
+
const filter = this.queryProvider.getSnapshotFilter(table);
|
|
480
|
+
// Reuse known filtered estimates to avoid repeating an expensive count and sample. Unknown totals
|
|
481
|
+
// (-1) can also represent uninitialized progress, so retry them. Unfiltered metadata counts are cheap
|
|
482
|
+
// and refreshed before copying rows.
|
|
483
|
+
const previousEstimate = table.snapshotStatus?.totalEstimatedCount ?? -1;
|
|
484
|
+
const totalEstimatedCount =
|
|
485
|
+
filter != null && previousEstimate >= 0 ? previousEstimate : await this.estimatedCountNumber(table, filter);
|
|
469
486
|
let at = table.snapshotStatus?.replicatedCount ?? 0;
|
|
470
487
|
const collection = this.client.db(table.schema).collection(table.name);
|
|
471
488
|
await using query = new ChunkedSnapshotQuery({
|
|
472
489
|
collection,
|
|
473
490
|
key: table.snapshotStatus?.lastKey,
|
|
474
|
-
batchSize: this.snapshotChunkLength
|
|
491
|
+
batchSize: this.snapshotChunkLength,
|
|
492
|
+
filter
|
|
475
493
|
});
|
|
476
494
|
if (query.lastKey != null) {
|
|
477
495
|
this.logger.info(
|
|
@@ -565,8 +583,20 @@ export class MongoSnapshotter {
|
|
|
565
583
|
return result.tables;
|
|
566
584
|
}
|
|
567
585
|
|
|
568
|
-
private async estimatedCountNumber(
|
|
569
|
-
|
|
586
|
+
private async estimatedCountNumber(
|
|
587
|
+
table: storage.SourceTable,
|
|
588
|
+
filter = this.queryProvider.getSnapshotFilter(table)
|
|
589
|
+
): Promise<number> {
|
|
590
|
+
const collection = this.client.db(table.schema).collection(table.name);
|
|
591
|
+
if (filter != null) {
|
|
592
|
+
return await estimateFilteredSnapshotCount({
|
|
593
|
+
collection,
|
|
594
|
+
filter,
|
|
595
|
+
signal: this.abortSignal,
|
|
596
|
+
logger: this.logger
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
return await collection.estimatedDocumentCount();
|
|
570
600
|
}
|
|
571
601
|
|
|
572
602
|
private async getCollectionInfo(db: string, name: string): Promise<mongo.CollectionInfo | undefined> {
|
|
@@ -616,7 +646,7 @@ export class MongoSnapshotter {
|
|
|
616
646
|
this.checkpointImplementation.seedPosition(null);
|
|
617
647
|
const startStreamFromLsn = await this.checkpointImplementation.createFirstBarrier();
|
|
618
648
|
const filters = this.getSourceNamespaceFilters();
|
|
619
|
-
const iter = this.
|
|
649
|
+
const iter = this.openChangeStream({
|
|
620
650
|
lsn: startStreamFromLsn,
|
|
621
651
|
maxAwaitTimeMS: 0,
|
|
622
652
|
signal: this.abortSignal,
|
|
@@ -627,33 +657,48 @@ export class MongoSnapshotter {
|
|
|
627
657
|
let eventsSeen = 0;
|
|
628
658
|
let batchesSeen = 0;
|
|
629
659
|
|
|
630
|
-
|
|
660
|
+
// Returns false once the timeout is reached. Otherwise creates another checkpoint when one is due.
|
|
661
|
+
const shouldKeepWaiting = async () => {
|
|
631
662
|
if (performance.now() - startTime >= LSN_TIMEOUT_SECONDS * 1000) {
|
|
632
|
-
|
|
663
|
+
return false;
|
|
633
664
|
}
|
|
634
665
|
if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
|
|
635
666
|
await this.checkpointImplementation.createBatchCheckpoint();
|
|
636
667
|
lastCheckpointCreated = performance.now();
|
|
637
668
|
}
|
|
638
|
-
|
|
669
|
+
return true;
|
|
670
|
+
};
|
|
639
671
|
|
|
640
|
-
|
|
641
|
-
|
|
672
|
+
streamLoop: for await (const sourceBatch of iter) {
|
|
673
|
+
this.abortSignal.throwIfAborted();
|
|
674
|
+
for (const { event: changeDocument } of sourceBatch.events) {
|
|
675
|
+
this.abortSignal.throwIfAborted();
|
|
642
676
|
const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
|
|
643
677
|
|
|
644
678
|
if (ns?.coll == CHECKPOINTS_COLLECTION && 'documentKey' in changeDocument) {
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
//
|
|
649
|
-
|
|
679
|
+
// Standalone events still feed the implementation's coordinate via
|
|
680
|
+
// event.observe; we only resolve on our own barrier.
|
|
681
|
+
if (this.checkpointImplementation.event.observe(changeDocument) == 'own-barrier') {
|
|
682
|
+
// Observing our own barrier has set the coordinate (the barrier event
|
|
683
|
+
// carries the sentinel counter directly), so the LSN is ready.
|
|
684
|
+
// This is checked before the timeout, so a barrier we already hold is never discarded.
|
|
685
|
+
return this.checkpointImplementation.event.lsn(changeDocument);
|
|
650
686
|
}
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
return this.checkpointImplementation.event.lsn(changeDocument);
|
|
687
|
+
} else {
|
|
688
|
+
eventsSeen += 1;
|
|
654
689
|
}
|
|
655
690
|
|
|
656
|
-
|
|
691
|
+
if (!(await shouldKeepWaiting())) {
|
|
692
|
+
break streamLoop;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const resumeToken = sourceBatch.getResumeToken();
|
|
696
|
+
if (resumeToken != null) {
|
|
697
|
+
batchesSeen++;
|
|
698
|
+
}
|
|
699
|
+
// Empty batches have no events to trigger the per-event check.
|
|
700
|
+
if (!(await shouldKeepWaiting())) {
|
|
701
|
+
break;
|
|
657
702
|
}
|
|
658
703
|
}
|
|
659
704
|
|
|
@@ -668,13 +713,19 @@ export class MongoSnapshotter {
|
|
|
668
713
|
* Given a snapshot LSN, validate that we can read from it, by opening a change stream.
|
|
669
714
|
*/
|
|
670
715
|
private async validateSnapshotLsn(lsn: string) {
|
|
671
|
-
const
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
716
|
+
const filters = this.getSourceNamespaceFilters();
|
|
717
|
+
const stream = this.openChangeStream({ lsn, maxAwaitTimeMS: 0, filters, signal: this.abortSignal });
|
|
718
|
+
for await (const batch of stream) {
|
|
719
|
+
this.abortSignal.throwIfAborted();
|
|
720
|
+
// Reading one complete event validates the position; closing the stream abandons batch progress.
|
|
721
|
+
for (const _change of batch.events) {
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
// An empty or fully filtered batch can also validate the position through safe progress.
|
|
725
|
+
// If a split event is unfinished, progress is undefined: keep reading until it is complete.
|
|
726
|
+
if (batch.getResumeToken() != null) {
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
678
729
|
}
|
|
679
730
|
}
|
|
680
731
|
|
|
@@ -722,73 +773,31 @@ export class MongoSnapshotter {
|
|
|
722
773
|
return { $match: nsFilter, multipleDatabases };
|
|
723
774
|
}
|
|
724
775
|
|
|
725
|
-
private
|
|
776
|
+
private openChangeStream(options: {
|
|
726
777
|
lsn: string | null;
|
|
727
778
|
maxAwaitTimeMS?: number;
|
|
728
779
|
batchSize?: number;
|
|
729
780
|
filters: { $match: any; multipleDatabases: boolean };
|
|
730
781
|
signal?: AbortSignal;
|
|
731
782
|
tracer?: PerformanceTracer<'changestream'>;
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
fullDocument
|
|
751
|
-
};
|
|
752
|
-
if (!this.isDocumentDb) {
|
|
753
|
-
// DocumentDB does not support showExpandedEvents.
|
|
754
|
-
streamOptions.showExpandedEvents = true;
|
|
755
|
-
}
|
|
756
|
-
const pipeline: mongo.Document[] = [{ $changeStream: streamOptions }, { $match: options.filters.$match }];
|
|
757
|
-
if (!this.isDocumentDb) {
|
|
758
|
-
// DocumentDB does not support $changeStreamSplitLargeEvent.
|
|
759
|
-
pipeline.push({ $changeStreamSplitLargeEvent: {} });
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
// Only one of these options can be supplied at a time.
|
|
763
|
-
if (resumeAfter) {
|
|
764
|
-
streamOptions.resumeAfter = resumeAfter;
|
|
765
|
-
} else if (startAfter != null) {
|
|
766
|
-
// Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
|
|
767
|
-
// case if we have an old one. The sentinel implementation never produces a startAfter,
|
|
768
|
-
// and a fresh DocumentDB stream opens from "now" with neither option set.
|
|
769
|
-
streamOptions.startAtOperationTime = startAfter;
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
let watchDb: mongo.Db;
|
|
773
|
-
if (this.isDocumentDb || options.filters.multipleDatabases) {
|
|
774
|
-
// DocumentDB only supports cluster-level change streams.
|
|
775
|
-
// Requires readAnyDatabase@admin on Atlas.
|
|
776
|
-
watchDb = this.client.db('admin');
|
|
777
|
-
streamOptions.allChangesForCluster = true;
|
|
778
|
-
} else {
|
|
779
|
-
// Same general result, but requires less permissions than the above
|
|
780
|
-
watchDb = this.defaultDb;
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
|
|
784
|
-
|
|
785
|
-
return rawChangeStream(watchDb, pipeline, {
|
|
786
|
-
batchSize: options.batchSize ?? this.snapshotChunkLength,
|
|
787
|
-
maxAwaitTimeMS,
|
|
788
|
-
maxTimeMS: this.changeStreamTimeout,
|
|
789
|
-
signal: options.signal,
|
|
790
|
-
logger: this.logger,
|
|
791
|
-
tracer: options.tracer
|
|
783
|
+
onBatch?: (batch: ChangeStreamBatch) => Disposable | void;
|
|
784
|
+
}): AsyncIterableIterator<MongoReplicationStreamBatch> {
|
|
785
|
+
return openMongoReplicationStream({
|
|
786
|
+
db: this.defaultDb,
|
|
787
|
+
queryProvider: this.queryProvider,
|
|
788
|
+
namespaceFilter: options.filters,
|
|
789
|
+
isDocumentDb: this.isDocumentDb,
|
|
790
|
+
usePostImages: this.usePostImages,
|
|
791
|
+
position: options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null,
|
|
792
|
+
onBatch: options.onBatch,
|
|
793
|
+
options: {
|
|
794
|
+
batchSize: options.batchSize ?? this.snapshotChunkLength,
|
|
795
|
+
maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
|
|
796
|
+
maxTimeMS: this.changeStreamTimeout,
|
|
797
|
+
signal: options.signal,
|
|
798
|
+
logger: this.logger,
|
|
799
|
+
tracer: options.tracer
|
|
800
|
+
}
|
|
792
801
|
});
|
|
793
802
|
}
|
|
794
803
|
|
|
@@ -364,7 +364,7 @@ type MapRawDocument<T> = T extends unknown ? { [K in keyof T]: Rawify<T[K]> } :
|
|
|
364
364
|
export type ProjectedChangeStreamDocument =
|
|
365
365
|
| Omit<mongo.ChangeStreamDropDocument, 'wallTime' | 'collectionUUID'>
|
|
366
366
|
| Omit<mongo.ChangeStreamRenameDocument, 'wallTime'>
|
|
367
|
-
| Omit<mongo.ChangeStreamDeleteDocument<Buffer>, 'wallTime'>
|
|
367
|
+
| Omit<mongo.ChangeStreamDeleteDocument<Buffer>, 'wallTime' | 'collectionUUID'>
|
|
368
368
|
| Omit<mongo.ChangeStreamInsertDocument<Buffer>, 'wallTime'>
|
|
369
369
|
| Omit<mongo.ChangeStreamUpdateDocument<Buffer>, 'wallTime' | 'updateDescription'>
|
|
370
370
|
| Omit<mongo.ChangeStreamReplaceDocument<Buffer>, 'wallTime'>;
|
|
@@ -432,7 +432,7 @@ export function mapChangeStreamError(e: unknown) {
|
|
|
432
432
|
} else if (
|
|
433
433
|
isMongoServerError(e) &&
|
|
434
434
|
e.codeName == 'NoMatchingDocument' &&
|
|
435
|
-
e.errmsg?.includes('post-image was not found')
|
|
435
|
+
(e.errmsg?.includes('post-image was not found') || e.errmsg?.includes('pre-image was not found'))
|
|
436
436
|
) {
|
|
437
437
|
throw new ChangeStreamInvalidatedError(e.errmsg, e);
|
|
438
438
|
} else if (isMongoServerError(e) && e.hasErrorLabel('NonResumableChangeStreamError')) {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { mongo } from '@powersync/lib-service-mongodb';
|
|
2
|
+
import type { Logger } from '@powersync/lib-services-framework';
|
|
3
|
+
import { performance } from 'node:perf_hooks';
|
|
4
|
+
|
|
5
|
+
const COUNT_TIMEOUT_MS = 10_000;
|
|
6
|
+
/**
|
|
7
|
+
* Keep below 5% for MongoDB's random-cursor optimization on large collections.
|
|
8
|
+
* https://www.mongodb.com/docs/manual/reference/operator/aggregation/sample/
|
|
9
|
+
*/
|
|
10
|
+
const SAMPLE_FRACTION = 0.01;
|
|
11
|
+
const MAX_SAMPLE_SIZE = 10_000;
|
|
12
|
+
|
|
13
|
+
interface SampleCounts {
|
|
14
|
+
sampled: { count: number }[];
|
|
15
|
+
matches: { count: number }[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Try an exact filtered count, then estimate selectivity from a bounded sample.
|
|
20
|
+
* Each attempt has a separate 10-second budget. Failures leave progress unknown (-1).
|
|
21
|
+
*/
|
|
22
|
+
export async function estimateFilteredSnapshotCount(options: {
|
|
23
|
+
collection: mongo.Collection;
|
|
24
|
+
filter: mongo.Filter<mongo.Document>;
|
|
25
|
+
signal: AbortSignal;
|
|
26
|
+
logger: Logger;
|
|
27
|
+
}): Promise<number> {
|
|
28
|
+
const { collection, filter, signal, logger } = options;
|
|
29
|
+
signal.throwIfAborted();
|
|
30
|
+
try {
|
|
31
|
+
return await collection.countDocuments(filter, {
|
|
32
|
+
collation: { locale: 'simple' },
|
|
33
|
+
readConcern: 'majority',
|
|
34
|
+
timeoutMS: COUNT_TIMEOUT_MS,
|
|
35
|
+
signal
|
|
36
|
+
});
|
|
37
|
+
} catch (error) {
|
|
38
|
+
signal.throwIfAborted();
|
|
39
|
+
logger.debug(`Filtered snapshot count unavailable for ${collection.namespace}; trying a sample strategy`, error);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sampleStartedAt = performance.now();
|
|
43
|
+
try {
|
|
44
|
+
const metadataOptions = { timeoutMS: COUNT_TIMEOUT_MS, signal };
|
|
45
|
+
const totalCount = await collection.estimatedDocumentCount(metadataOptions);
|
|
46
|
+
signal.throwIfAborted();
|
|
47
|
+
if (totalCount <= 0) {
|
|
48
|
+
// Metadata alone cannot establish that a filtered result is empty after the count failed.
|
|
49
|
+
return -1;
|
|
50
|
+
}
|
|
51
|
+
const remainingTimeMS = Math.floor(COUNT_TIMEOUT_MS - (performance.now() - sampleStartedAt));
|
|
52
|
+
if (remainingTimeMS <= 0) {
|
|
53
|
+
return -1;
|
|
54
|
+
}
|
|
55
|
+
// Keep $sample first and below 5% for MongoDB's random-cursor optimization on large collections.
|
|
56
|
+
// https://www.mongodb.com/docs/manual/reference/operator/aggregation/sample/
|
|
57
|
+
const sampleSize = Math.min(MAX_SAMPLE_SIZE, Math.max(1, Math.floor(totalCount * SAMPLE_FRACTION)));
|
|
58
|
+
const cursor = collection.aggregate<SampleCounts>(
|
|
59
|
+
[
|
|
60
|
+
{ $sample: { size: sampleSize } },
|
|
61
|
+
{
|
|
62
|
+
$facet: {
|
|
63
|
+
sampled: [{ $count: 'count' }],
|
|
64
|
+
matches: [{ $match: filter }, { $count: 'count' }]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
],
|
|
68
|
+
{
|
|
69
|
+
collation: { locale: 'simple' },
|
|
70
|
+
readConcern: 'majority',
|
|
71
|
+
timeoutMS: remainingTimeMS,
|
|
72
|
+
signal,
|
|
73
|
+
allowDiskUse: false
|
|
74
|
+
}
|
|
75
|
+
);
|
|
76
|
+
try {
|
|
77
|
+
const sample = await cursor.next();
|
|
78
|
+
const sampledCount = sample?.sampled[0]?.count ?? 0;
|
|
79
|
+
const matchedCount = sample?.matches[0]?.count ?? 0;
|
|
80
|
+
if (sampledCount == 0 || matchedCount == 0) {
|
|
81
|
+
// A rare predicate can miss the whole sample; that does not establish an empty result.
|
|
82
|
+
return -1;
|
|
83
|
+
}
|
|
84
|
+
return Math.round((matchedCount / sampledCount) * totalCount);
|
|
85
|
+
} finally {
|
|
86
|
+
await cursor.close();
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
signal.throwIfAborted();
|
|
90
|
+
logger.debug(`Filtered snapshot sample unavailable for ${collection.namespace}; total remains unknown`, error);
|
|
91
|
+
return -1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
export * from './ChangeStream.js';
|
|
2
2
|
export * from './ChangeStreamReplicationJob.js';
|
|
3
3
|
export * from './ChangeStreamReplicator.js';
|
|
4
|
+
export * from './ConnectionManagerFactory.js';
|
|
4
5
|
export * from './internal-mongodb-utils.js';
|
|
6
|
+
export * from './MongoErrorRateLimiter.js';
|
|
5
7
|
export * from './MongoRelation.js';
|
|
8
|
+
export * from './MongoReplicationQueryProvider.js';
|
|
9
|
+
export * from './MongoReplicationStream.js';
|
|
10
|
+
export * from './MongoReplicationStreamTypes.js';
|
|
11
|
+
export * from './MongoSnapshotter.js';
|
|
6
12
|
export * from './RawChangeStream.js';
|
|
13
|
+
export { CHECKPOINTS_COLLECTION } from './replication-utils.js';
|
|
7
14
|
export * from './SourceRowConverter.js';
|