@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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './ChangeStreamTestContext.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"test-utils-index.js","sourceRoot":"","sources":["../../src/test-utils/test-utils-index.ts"],"names":[],"mappings":"AAAA,cAAc,8BAA8B,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@powersync/service-module-mongodb",
|
|
3
3
|
"repository": "https://github.com/powersync-ja/powersync-service",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.22.0",
|
|
5
5
|
"license": "FSL-1.1-ALv2",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"publishConfig": {
|
|
@@ -15,22 +15,23 @@
|
|
|
15
15
|
"bson": "^6.10.4",
|
|
16
16
|
"ts-codec": "^1.3.0",
|
|
17
17
|
"uuid": "^14.0.0",
|
|
18
|
-
"@powersync/lib-service-mongodb": "0.6.
|
|
19
|
-
"@powersync/lib-services-framework": "0.10.
|
|
20
|
-
"@powersync/service-core": "1.27.
|
|
18
|
+
"@powersync/lib-service-mongodb": "0.6.34",
|
|
19
|
+
"@powersync/lib-services-framework": "0.10.4",
|
|
20
|
+
"@powersync/service-core": "1.27.1",
|
|
21
21
|
"@powersync/service-jsonbig": "0.17.13",
|
|
22
|
-
"@powersync/service-sync-rules": "0.
|
|
23
|
-
"@powersync/service-types": "0.
|
|
22
|
+
"@powersync/service-sync-rules": "0.44.0",
|
|
23
|
+
"@powersync/service-types": "0.20.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@powersync/service-core-tests": "0.18.
|
|
27
|
-
"@powersync/service-module-mongodb-storage": "0.23.
|
|
28
|
-
"@powersync/service-module-postgres-storage": "0.19.
|
|
26
|
+
"@powersync/service-core-tests": "0.18.4",
|
|
27
|
+
"@powersync/service-module-mongodb-storage": "0.23.1",
|
|
28
|
+
"@powersync/service-module-postgres-storage": "0.19.3"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"build": "tsc -b",
|
|
32
32
|
"build:tests": "tsc -b test/tsconfig.json",
|
|
33
33
|
"clean": "rm -rf ./dist && tsc -b --clean",
|
|
34
|
-
"test": "vitest"
|
|
34
|
+
"test": "vitest",
|
|
35
|
+
"benchmark:replication": "vitest run --config vitest.benchmark.config.ts"
|
|
35
36
|
}
|
|
36
37
|
}
|
|
@@ -4,7 +4,7 @@ import { api, ParseSyncConfigOptions, ReplicationHeadCallback } from '@powersync
|
|
|
4
4
|
import * as sync_rules from '@powersync/service-sync-rules';
|
|
5
5
|
import * as service_types from '@powersync/service-types';
|
|
6
6
|
|
|
7
|
-
import { logger } from '@powersync/lib-services-framework';
|
|
7
|
+
import { ErrorCode, logger } from '@powersync/lib-services-framework';
|
|
8
8
|
import { CheckpointImplementation } from '../replication/checkpoints/CheckpointImplementation.js';
|
|
9
9
|
import { createCheckpointImplementation } from '../replication/checkpoints/create-checkpoint-implementation.js';
|
|
10
10
|
import { MongoManager } from '../replication/MongoManager.js';
|
|
@@ -35,10 +35,34 @@ export class MongoRouteAPIAdapter implements api.RouteAPI {
|
|
|
35
35
|
|
|
36
36
|
getParseSyncRulesOptions(): ParseSyncConfigOptions {
|
|
37
37
|
return {
|
|
38
|
-
defaultSchema: this.defaultSchema
|
|
38
|
+
defaultSchema: this.defaultSchema,
|
|
39
|
+
connections: { [this.connectionTag]: { type: this.config.type } }
|
|
39
40
|
};
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
async validateSourceCapabilities(config: sync_rules.SyncConfig): Promise<service_types.ValidationDiagnostic[]> {
|
|
44
|
+
return this.validateMongoFilters(config);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Validate MongoDB pre-filtering support. External modules can override this method to account for
|
|
49
|
+
* their plan parameters and filter capabilities while retaining other source-capability checks.
|
|
50
|
+
*/
|
|
51
|
+
protected async validateMongoFilters(config: sync_rules.SyncConfig): Promise<service_types.ValidationDiagnostic[]> {
|
|
52
|
+
const filtered = sync_rules.hasMongoFilterExpressions(
|
|
53
|
+
config instanceof sync_rules.PrecompiledSyncConfig ? (config.plan.sourceTableConfig ?? {}) : {},
|
|
54
|
+
this.connectionTag
|
|
55
|
+
);
|
|
56
|
+
return filtered
|
|
57
|
+
? [
|
|
58
|
+
{
|
|
59
|
+
level: 'fatal',
|
|
60
|
+
message: `[${ErrorCode.PSYNC_S1348}] MongoDB replication pre-filtering is not supported in this edition of the PowerSync service.`
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
: [];
|
|
64
|
+
}
|
|
65
|
+
|
|
42
66
|
async shutdown(): Promise<void> {
|
|
43
67
|
await this.client.close();
|
|
44
68
|
}
|
package/src/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ export class MongoModule extends replication.ReplicationModule<types.MongoConnec
|
|
|
37
37
|
id: this.getDefaultId(normalisedConfig.database ?? ''),
|
|
38
38
|
syncRuleProvider: syncRuleProvider,
|
|
39
39
|
storageEngine: context.storageEngine,
|
|
40
|
+
configuration: context.configuration,
|
|
40
41
|
metricsEngine: context.metricsEngine,
|
|
41
42
|
connectionFactory: connectionFactory,
|
|
42
43
|
rateLimiter: new MongoErrorRateLimiter(),
|
|
@@ -47,7 +48,7 @@ export class MongoModule extends replication.ReplicationModule<types.MongoConnec
|
|
|
47
48
|
/**
|
|
48
49
|
* Combines base config with normalized connection settings
|
|
49
50
|
*/
|
|
50
|
-
|
|
51
|
+
protected resolveConfig(config: types.MongoConnectionConfig): types.ResolvedConnectionConfig {
|
|
51
52
|
return {
|
|
52
53
|
...config,
|
|
53
54
|
...types.normalizeConnectionConfig(config)
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
SourceTable,
|
|
19
19
|
storage
|
|
20
20
|
} from '@powersync/service-core';
|
|
21
|
-
import { HydratedSyncConfig } from '@powersync/service-sync-rules';
|
|
21
|
+
import { hasMongoFilterExpressions, HydratedSyncConfig } from '@powersync/service-sync-rules';
|
|
22
22
|
import { ReplicationMetric } from '@powersync/service-types';
|
|
23
23
|
import { performance } from 'node:perf_hooks';
|
|
24
24
|
import { PostImagesOption } from '../types/types.js';
|
|
@@ -27,18 +27,20 @@ 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 { openMongoReplicationStream } from './MongoReplicationStream.js';
|
|
36
|
+
import type { MongoReplicationStreamBatch } from './MongoReplicationStreamTypes.js';
|
|
37
|
+
import { MongoSnapshotter, MongoSnapshotterHooks } from './MongoSnapshotter.js';
|
|
38
|
+
import { ChangeStreamBatch, ProjectedChangeStreamDocument } from './RawChangeStream.js';
|
|
37
39
|
import { CHECKPOINTS_COLLECTION, detectDocumentDb, timestampToDate } from './replication-utils.js';
|
|
38
40
|
import { DirectSourceRowConverter, SourceRowConverter } from './SourceRowConverter.js';
|
|
39
41
|
export interface ChangeStreamOptions {
|
|
40
|
-
connections: MongoManager;
|
|
41
42
|
storage: storage.SyncRulesBucketStorage;
|
|
43
|
+
connections: MongoManager;
|
|
42
44
|
metrics: MetricsEngine;
|
|
43
45
|
abort_signal: AbortSignal;
|
|
44
46
|
/**
|
|
@@ -61,6 +63,10 @@ export interface ChangeStreamOptions {
|
|
|
61
63
|
|
|
62
64
|
storageHooks?: storage.StorageHooks;
|
|
63
65
|
snapshotHooks?: MongoSnapshotterHooks;
|
|
66
|
+
/**
|
|
67
|
+
* Create one adapter per replication attempt, shared with its snapshotter and source probes.
|
|
68
|
+
*/
|
|
69
|
+
createReplicationQueryProvider?: MongoReplicationQueryProviderFactory;
|
|
64
70
|
|
|
65
71
|
logger?: Logger;
|
|
66
72
|
}
|
|
@@ -118,6 +124,8 @@ export class ChangeStream {
|
|
|
118
124
|
private storageHooks: storage.StorageHooks | undefined;
|
|
119
125
|
|
|
120
126
|
private readonly sourceRowConverter: SourceRowConverter;
|
|
127
|
+
private readonly queryProvider: MongoReplicationQueryProvider;
|
|
128
|
+
private readonly hasQueryProviderFactory: boolean;
|
|
121
129
|
|
|
122
130
|
private keepaliveIntervalMs: number;
|
|
123
131
|
|
|
@@ -130,7 +138,7 @@ export class ChangeStream {
|
|
|
130
138
|
private lastPersistedResumeTimestamp = 0;
|
|
131
139
|
private lastBatchCheckpoint = 0;
|
|
132
140
|
|
|
133
|
-
constructor(options: ChangeStreamOptions) {
|
|
141
|
+
constructor(private readonly options: ChangeStreamOptions) {
|
|
134
142
|
this.storage = options.storage;
|
|
135
143
|
this.metrics = options.metrics;
|
|
136
144
|
this.group_id = options.storage.replicationStreamId;
|
|
@@ -150,6 +158,13 @@ export class ChangeStream {
|
|
|
150
158
|
this.sync_rules = options.storage.getParsedSyncRules({
|
|
151
159
|
defaultSchema: this.defaultDb.databaseName
|
|
152
160
|
});
|
|
161
|
+
this.hasQueryProviderFactory = options.createReplicationQueryProvider != null;
|
|
162
|
+
this.queryProvider =
|
|
163
|
+
options.createReplicationQueryProvider?.({
|
|
164
|
+
syncConfig: this.sync_rules,
|
|
165
|
+
connectionTag: this.connections.connectionTag,
|
|
166
|
+
defaultSchema: this.defaultDb.databaseName
|
|
167
|
+
}) ?? DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER;
|
|
153
168
|
this.sourceRowConverter = new DirectSourceRowConverter(this.sync_rules.compatibility);
|
|
154
169
|
|
|
155
170
|
// The change stream aggregation command should timeout before the socket times out,
|
|
@@ -166,7 +181,8 @@ export class ChangeStream {
|
|
|
166
181
|
...options,
|
|
167
182
|
abortSignal: this.abortSignal,
|
|
168
183
|
logger: snapshotLogger,
|
|
169
|
-
checkpointStreamId: this.checkpointStreamId
|
|
184
|
+
checkpointStreamId: this.checkpointStreamId,
|
|
185
|
+
queryProvider: this.queryProvider
|
|
170
186
|
});
|
|
171
187
|
|
|
172
188
|
options.abort_signal.addEventListener(
|
|
@@ -193,7 +209,9 @@ export class ChangeStream {
|
|
|
193
209
|
return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
|
|
194
210
|
}
|
|
195
211
|
|
|
196
|
-
/**
|
|
212
|
+
/**
|
|
213
|
+
* The active checkpoint strategy. Only valid after ensureDetected().
|
|
214
|
+
*/
|
|
197
215
|
private get checkpointImplementation(): CheckpointImplementation {
|
|
198
216
|
if (this._checkpointImplementation == null) {
|
|
199
217
|
throw new ReplicationAssertionError('Checkpoint implementation not initialized - call ensureDetected() first');
|
|
@@ -216,6 +234,7 @@ export class ChangeStream {
|
|
|
216
234
|
'Azure DocumentDB support is in alpha. APIs and behavior may change, and long-term stability is not yet guaranteed.'
|
|
217
235
|
);
|
|
218
236
|
}
|
|
237
|
+
await this.queryProvider.validateSource?.({ connectionManager: this.connections, isDocumentDb: this.isDocumentDb });
|
|
219
238
|
this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
|
|
220
239
|
client: this.client,
|
|
221
240
|
db: this.defaultDb,
|
|
@@ -485,6 +504,17 @@ export class ChangeStream {
|
|
|
485
504
|
}
|
|
486
505
|
|
|
487
506
|
private async initReplication() {
|
|
507
|
+
// A previously deployed config may outlive the module that provided pre-filtering.
|
|
508
|
+
// Throw inside replicate() so the failure is persisted in replication diagnostics.
|
|
509
|
+
if (
|
|
510
|
+
!this.hasQueryProviderFactory &&
|
|
511
|
+
hasMongoFilterExpressions(this.sync_rules.sourceTableConfig, this.connections.connectionTag)
|
|
512
|
+
) {
|
|
513
|
+
throw new ServiceError(
|
|
514
|
+
ErrorCode.PSYNC_S1348,
|
|
515
|
+
'This sync config requires MongoDB pre-filtering, but no query provider factory is registered. Restore the required module, or remove the filters and deploy a new sync config to restart replication.'
|
|
516
|
+
);
|
|
517
|
+
}
|
|
488
518
|
const result = await this.snapshotter.checkSlot();
|
|
489
519
|
await this.snapshotter.setupCheckpointsCollection();
|
|
490
520
|
if (result.needsInitialSync) {
|
|
@@ -512,7 +542,7 @@ export class ChangeStream {
|
|
|
512
542
|
if (
|
|
513
543
|
e instanceof mongo.MongoServerError &&
|
|
514
544
|
e.codeName == 'NoMatchingDocument' &&
|
|
515
|
-
e.errmsg?.includes('post-image was not found')
|
|
545
|
+
(e.errmsg?.includes('post-image was not found') || e.errmsg?.includes('pre-image was not found'))
|
|
516
546
|
) {
|
|
517
547
|
throw new ChangeStreamInvalidatedError(e.errmsg, e);
|
|
518
548
|
}
|
|
@@ -520,86 +550,32 @@ export class ChangeStream {
|
|
|
520
550
|
}
|
|
521
551
|
}
|
|
522
552
|
|
|
523
|
-
private
|
|
553
|
+
private openChangeStream(options: {
|
|
524
554
|
lsn: string | null;
|
|
525
555
|
maxAwaitTimeMS?: number;
|
|
526
556
|
batchSize?: number;
|
|
527
557
|
filters: { $match: any; multipleDatabases: boolean };
|
|
528
558
|
signal?: AbortSignal;
|
|
529
559
|
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
|
|
560
|
+
onBatch?: (batch: ChangeStreamBatch) => Disposable | void;
|
|
561
|
+
}): AsyncIterableIterator<MongoReplicationStreamBatch> {
|
|
562
|
+
return openMongoReplicationStream({
|
|
563
|
+
db: this.defaultDb,
|
|
564
|
+
queryProvider: this.queryProvider,
|
|
565
|
+
namespaceFilter: options.filters,
|
|
566
|
+
isDocumentDb: this.isDocumentDb,
|
|
567
|
+
usePostImages: this.usePostImages,
|
|
568
|
+
position: options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null,
|
|
569
|
+
skipInitialTimestamp: true,
|
|
570
|
+
onBatch: options.onBatch,
|
|
571
|
+
options: {
|
|
572
|
+
batchSize: options.batchSize ?? this.snapshotChunkLength,
|
|
573
|
+
maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
|
|
574
|
+
maxTimeMS: this.changeStreamTimeout,
|
|
575
|
+
signal: options.signal,
|
|
576
|
+
logger: this.logger,
|
|
577
|
+
tracer: options.tracer
|
|
563
578
|
}
|
|
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
579
|
});
|
|
604
580
|
}
|
|
605
581
|
|
|
@@ -638,22 +614,31 @@ export class ChangeStream {
|
|
|
638
614
|
if (resumeFromLsn == null) {
|
|
639
615
|
throw new ReplicationAssertionError(`No LSN found to resume from`);
|
|
640
616
|
}
|
|
641
|
-
// Seed the
|
|
642
|
-
// the legacy startAfter timestamp (timestamp implementation only) for the
|
|
643
|
-
// resume-boundary dedupe guard below.
|
|
617
|
+
// Seed the checkpoint strategy's coordinate from the durable source position.
|
|
644
618
|
this.checkpointImplementation.seedPosition(resumeFromLsn);
|
|
645
|
-
const { startAfter } = this.checkpointImplementation.parseResumePosition(resumeFromLsn);
|
|
646
619
|
let outerSpan = tracer.span('batch');
|
|
647
620
|
|
|
648
621
|
this.checkpointImplementation.logResume(resumeFromLsn);
|
|
649
622
|
|
|
650
623
|
const filters = this.getSourceNamespaceFilters();
|
|
651
624
|
// This is closed when the for loop below returns/breaks/throws
|
|
652
|
-
|
|
625
|
+
let processingSpan: ReturnType<typeof tracer.span> | undefined;
|
|
626
|
+
let receivedBytes = 0;
|
|
627
|
+
let changesSinceProgress = 0;
|
|
628
|
+
let filteredSinceProgress: number | null = null;
|
|
629
|
+
const stream = this.openChangeStream({
|
|
653
630
|
lsn: resumeFromLsn,
|
|
654
631
|
filters,
|
|
655
632
|
signal: this.abortSignal,
|
|
656
|
-
tracer
|
|
633
|
+
tracer,
|
|
634
|
+
onBatch: (batch) => {
|
|
635
|
+
// Count actual transport, even if the adapter drops every envelope or reassembles fragments.
|
|
636
|
+
bytesReplicatedMetric.add(batch.byteSize);
|
|
637
|
+
chunksReplicatedMetric.add(1);
|
|
638
|
+
receivedBytes += batch.byteSize;
|
|
639
|
+
processingSpan = tracer.span('processing');
|
|
640
|
+
return processingSpan;
|
|
641
|
+
}
|
|
657
642
|
});
|
|
658
643
|
|
|
659
644
|
// Always start with a checkpoint.
|
|
@@ -661,113 +646,15 @@ export class ChangeStream {
|
|
|
661
646
|
// no data to replicate.
|
|
662
647
|
let waitForCheckpointLsn: string | null = await this.createBatchCheckpoint();
|
|
663
648
|
|
|
664
|
-
let
|
|
665
|
-
|
|
666
|
-
let flexDbNameWorkaroundLogged = false;
|
|
667
|
-
|
|
668
|
-
let lastEmptyResume = performance.now();
|
|
649
|
+
let lastKeepalive = performance.now();
|
|
669
650
|
let lastTxnKey: string | null = null;
|
|
670
651
|
|
|
671
|
-
for await (
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
if (this.abortSignal.aborted) {
|
|
678
|
-
break;
|
|
679
|
-
}
|
|
680
|
-
this.touch();
|
|
681
|
-
if (events.length == 0) {
|
|
682
|
-
// No changes in this batch, but we still want to persist progress.
|
|
683
|
-
// We do this by persisting a keepalive checkpoint.
|
|
684
|
-
// If we don't update it on empty events, we do keep consistency, but resuming the stream
|
|
685
|
-
// with old tokens may cause connection timeouts.
|
|
686
|
-
const hadRecentKeepalive = performance.now() - lastEmptyResume < this.keepaliveIntervalMs;
|
|
687
|
-
if (waitForCheckpointLsn == null && !hadRecentKeepalive) {
|
|
688
|
-
// Case 1: We have no changes, and we are not waiting for a checkpoint to be created,
|
|
689
|
-
// and we have not recently persisted a keepalive. Persist one now, and call setResumeLsn() below.
|
|
690
|
-
// This is the normal case for an idle stream.
|
|
691
|
-
// The implementation persists a keepalive (timestamp) or bumps the
|
|
692
|
-
// sentinel so a later event commits (sentinel). Logging is handled
|
|
693
|
-
// inside the implementation.
|
|
694
|
-
await this.checkpointImplementation.keepalive(batch, resumeToken);
|
|
695
|
-
this.touch();
|
|
696
|
-
lastEmptyResume = performance.now();
|
|
697
|
-
this.replicationLag.markStarted();
|
|
698
|
-
} else if (hadRecentKeepalive) {
|
|
699
|
-
// Case 2: We have no changes, and may or may not be waiting for a checkpoint to be created.
|
|
700
|
-
// We have recently persisted a keepalive.
|
|
701
|
-
// Continue waiting.
|
|
702
|
-
continue;
|
|
703
|
-
} else {
|
|
704
|
-
// Case 3: Waiting for a checkpoint; have not had a recent keepalive.
|
|
705
|
-
// We cannot call checkpointImplementation.keepalive() here, but we do call
|
|
706
|
-
// setResumeLsn() below.
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
this.touch();
|
|
711
|
-
|
|
712
|
-
for (let eventIndex = 0; eventIndex < events.length; eventIndex++) {
|
|
713
|
-
const rawChangeDocument = events[eventIndex];
|
|
714
|
-
const originalChangeDocument = parseChangeDocument(rawChangeDocument);
|
|
715
|
-
if (this.abortSignal.aborted) {
|
|
716
|
-
break;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
if (startAfter != null && originalChangeDocument.clusterTime?.lte(startAfter)) {
|
|
720
|
-
continue;
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
let changeDocument = originalChangeDocument;
|
|
724
|
-
if (originalChangeDocument?.splitEvent != null) {
|
|
725
|
-
// Handle split events from $changeStreamSplitLargeEvent.
|
|
726
|
-
// This is only relevant for very large update operations.
|
|
727
|
-
const splitEvent = originalChangeDocument?.splitEvent;
|
|
728
|
-
|
|
729
|
-
if (splitDocument == null) {
|
|
730
|
-
splitDocument = originalChangeDocument;
|
|
731
|
-
} else {
|
|
732
|
-
splitDocument = Object.assign(splitDocument, originalChangeDocument);
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
if (splitEvent.fragment == splitEvent.of) {
|
|
736
|
-
// Got all fragments
|
|
737
|
-
changeDocument = splitDocument;
|
|
738
|
-
splitDocument = null;
|
|
739
|
-
} else {
|
|
740
|
-
// Wait for more fragments
|
|
741
|
-
continue;
|
|
742
|
-
}
|
|
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
|
-
}
|
|
747
|
-
|
|
748
|
-
if (
|
|
749
|
-
!filters.multipleDatabases &&
|
|
750
|
-
'ns' in changeDocument &&
|
|
751
|
-
changeDocument.ns.db != this.defaultDb.databaseName &&
|
|
752
|
-
changeDocument.ns.db.endsWith(`_${this.defaultDb.databaseName}`)
|
|
753
|
-
) {
|
|
754
|
-
// When all of the following conditions are met:
|
|
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
|
-
}
|
|
769
|
-
}
|
|
770
|
-
|
|
652
|
+
streamLoop: for await (const sourceBatch of stream) {
|
|
653
|
+
if (this.abortSignal.aborted) break;
|
|
654
|
+
for (const { event: changeDocument, hasBufferedChanges } of sourceBatch.events) {
|
|
655
|
+
if (this.abortSignal.aborted) break streamLoop;
|
|
656
|
+
this.touch();
|
|
657
|
+
changesSinceProgress++;
|
|
771
658
|
const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
|
|
772
659
|
|
|
773
660
|
if (ns?.coll == CHECKPOINTS_COLLECTION) {
|
|
@@ -821,7 +708,6 @@ export class ChangeStream {
|
|
|
821
708
|
// Once there is already a batch checkpoint pending, or the driver has buffered more
|
|
822
709
|
// change stream events, collapse standalone checkpoints into the normal batch
|
|
823
710
|
// checkpoint flow to avoid commit churn under sustained load.
|
|
824
|
-
const hasBufferedChanges = eventIndex < events.length - 1;
|
|
825
711
|
if (hasBufferedChanges && waitForCheckpointLsn == null) {
|
|
826
712
|
// Buffered changes - create a new batch checkpoint to rate limit commits
|
|
827
713
|
using _ = tracer.span('source_checkpoint');
|
|
@@ -926,40 +812,71 @@ export class ChangeStream {
|
|
|
926
812
|
});
|
|
927
813
|
}
|
|
928
814
|
}
|
|
815
|
+
if (this.abortSignal.aborted) break;
|
|
816
|
+
this.touch();
|
|
817
|
+
const batchFilteredCount = sourceBatch.metadata.filteredCount;
|
|
818
|
+
if (batchFilteredCount != null) {
|
|
819
|
+
filteredSinceProgress = (filteredSinceProgress ?? 0) + batchFilteredCount;
|
|
820
|
+
}
|
|
821
|
+
const resumeToken = sourceBatch.getResumeToken();
|
|
822
|
+
if (resumeToken != null) {
|
|
823
|
+
const filteredCount = filteredSinceProgress;
|
|
824
|
+
if (changesSinceProgress == 0) {
|
|
825
|
+
// No retained changes since the previous progress boundary: this is either idle or filtered-only traffic.
|
|
826
|
+
// Case 1: No pending barrier and the keepalive interval has elapsed. Advance checkpoints as below,
|
|
827
|
+
// then flush and save the resume token.
|
|
828
|
+
// Case 2: Idle traffic with a recent keepalive. Skip this resume update, whether or not a barrier
|
|
829
|
+
// is pending, to preserve the idle throttle.
|
|
830
|
+
// Case 3: All other combinations. Flush and save the resume token without requesting a checkpoint.
|
|
831
|
+
// A pending barrier will provide the checkpoint boundary once replication reaches it. Filtered
|
|
832
|
+
// progress still saves its token even during the keepalive interval, so excluded work is resumable.
|
|
833
|
+
const hadRecentKeepalive = performance.now() - lastKeepalive < this.keepaliveIntervalMs;
|
|
834
|
+
if (waitForCheckpointLsn == null && !hadRecentKeepalive) {
|
|
835
|
+
if ((filteredCount ?? 0) > 0) {
|
|
836
|
+
// Case 1a: A transaction can span batches: this batch may contain only filtered changes,
|
|
837
|
+
// while retained changes from the same transaction are still unread. They share a timestamp,
|
|
838
|
+
// so publishing a checkpoint here could acknowledge the transaction before its data is saved.
|
|
839
|
+
// Request a source barrier; reaching it ensures those retained changes have been processed.
|
|
840
|
+
// The progress token can still be saved below for recovery without publishing a checkpoint.
|
|
841
|
+
using _ = tracer.span('source_checkpoint');
|
|
842
|
+
waitForCheckpointLsn = await this.createBatchCheckpoint();
|
|
843
|
+
} else {
|
|
844
|
+
// Case 1b: Idle traffic. The timestamp implementation persists a keepalive directly;
|
|
845
|
+
// the sentinel implementation writes a source marker whose event will commit later.
|
|
846
|
+
await this.checkpointImplementation.keepalive(batch, resumeToken);
|
|
847
|
+
this.replicationLag.markStarted();
|
|
848
|
+
}
|
|
849
|
+
this.touch();
|
|
850
|
+
lastKeepalive = performance.now();
|
|
851
|
+
} else if ((filteredCount ?? 0) == 0 && hadRecentKeepalive) {
|
|
852
|
+
// Case 2: Only idle progress skips resume persistence. Cases 1 and 3 fall through below.
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
929
856
|
|
|
930
|
-
if (splitDocument == null) {
|
|
931
|
-
// We flush and mark progress on every batch of data we receive.
|
|
932
|
-
// Batches are generally large (64MB or 6000 events, whichever comes first),
|
|
933
|
-
// so this is a good natural point to flush and mark progress.
|
|
934
|
-
// We avoid this when splitDocument is set, since we cannot resume in the middle of a split event.
|
|
935
857
|
const { lsn, timestamp } = this.checkpointImplementation.lsnFromResumeToken(resumeToken);
|
|
858
|
+
// Row writes must be durable before their source token. This advances recovery without
|
|
859
|
+
// publishing a checkpoint ahead of an outstanding barrier, transaction or snapshot.
|
|
936
860
|
await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
|
|
937
|
-
// TODO: We should consider making this standard behavior of flush().
|
|
938
861
|
await batch.setResumeLsn(lsn);
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
batchSpan.end();
|
|
950
|
-
const durationsMicroseconds = outerSpan.end();
|
|
951
|
-
const duration = batchSpan.durationMillis;
|
|
952
|
-
|
|
953
|
-
this.logger.info(
|
|
954
|
-
`Processed batch of ${events.length} changes / ${eventBatch.byteSize} bytes in ${duration}ms`,
|
|
955
|
-
{
|
|
956
|
-
count: events.length,
|
|
957
|
-
bytes: eventBatch.byteSize,
|
|
958
|
-
duration,
|
|
862
|
+
// MongoDB's token timestamp may lag by about 10 seconds. DocumentDB tokens have no timestamp.
|
|
863
|
+
this.lastPersistedResumeTimestamp = timestamp?.getTime() ?? Date.now();
|
|
864
|
+
processingSpan?.end();
|
|
865
|
+
const durationsMicroseconds = outerSpan.end();
|
|
866
|
+
const filteringDescription = filteredCount == null ? '' : ` and ${filteredCount} filtered events`;
|
|
867
|
+
this.logger.info(`Processed ${changesSinceProgress} changes${filteringDescription}`, {
|
|
868
|
+
count: changesSinceProgress,
|
|
869
|
+
...(filteredCount == null ? {} : { filteredCount }),
|
|
870
|
+
bytes: receivedBytes,
|
|
871
|
+
duration: processingSpan?.durationMillis,
|
|
959
872
|
t: durationsMicroseconds
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
|
|
873
|
+
});
|
|
874
|
+
changesSinceProgress = 0;
|
|
875
|
+
filteredSinceProgress = null;
|
|
876
|
+
receivedBytes = 0;
|
|
877
|
+
outerSpan = tracer.span('batch');
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
963
880
|
}
|
|
964
881
|
}
|
|
965
882
|
);
|