@powersync/service-module-mongodb 0.18.2 → 0.19.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 +16 -0
- package/dist/api/MongoRouteAPIAdapter.d.ts +2 -0
- package/dist/api/MongoRouteAPIAdapter.js +23 -32
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/common/SentinelLSN.d.ts +37 -0
- package/dist/common/SentinelLSN.js +59 -0
- package/dist/common/SentinelLSN.js.map +1 -0
- package/dist/replication/ChangeStream.d.ts +15 -0
- package/dist/replication/ChangeStream.js +105 -44
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/MongoRelation.d.ts +36 -1
- package/dist/replication/MongoRelation.js +102 -6
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/MongoSnapshotter.d.ts +9 -0
- package/dist/replication/MongoSnapshotter.js +108 -42
- package/dist/replication/MongoSnapshotter.js.map +1 -1
- package/dist/replication/RawChangeStream.d.ts +12 -1
- package/dist/replication/RawChangeStream.js +30 -7
- package/dist/replication/RawChangeStream.js.map +1 -1
- package/dist/replication/checkpoints/CheckpointImplementation.d.ts +132 -0
- package/dist/replication/checkpoints/CheckpointImplementation.js +22 -0
- package/dist/replication/checkpoints/CheckpointImplementation.js.map +1 -0
- package/dist/replication/checkpoints/SentinelCheckpointImplementation.d.ts +55 -0
- package/dist/replication/checkpoints/SentinelCheckpointImplementation.js +222 -0
- package/dist/replication/checkpoints/SentinelCheckpointImplementation.js.map +1 -0
- package/dist/replication/checkpoints/TimestampCheckpointImplementation.d.ts +24 -0
- package/dist/replication/checkpoints/TimestampCheckpointImplementation.js +113 -0
- package/dist/replication/checkpoints/TimestampCheckpointImplementation.js.map +1 -0
- package/dist/replication/checkpoints/create-checkpoint-implementation.d.ts +6 -0
- package/dist/replication/checkpoints/create-checkpoint-implementation.js +10 -0
- package/dist/replication/checkpoints/create-checkpoint-implementation.js.map +1 -0
- package/dist/replication/replication-utils.d.ts +6 -0
- package/dist/replication/replication-utils.js +19 -2
- package/dist/replication/replication-utils.js.map +1 -1
- package/package.json +8 -8
- package/src/api/MongoRouteAPIAdapter.ts +26 -37
- package/src/common/SentinelLSN.ts +78 -0
- package/src/replication/ChangeStream.ts +118 -50
- package/src/replication/MongoRelation.ts +124 -14
- package/src/replication/MongoSnapshotter.ts +122 -47
- package/src/replication/RawChangeStream.ts +64 -28
- package/src/replication/checkpoints/CheckpointImplementation.ts +167 -0
- package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +267 -0
- package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +142 -0
- package/src/replication/checkpoints/create-checkpoint-implementation.ts +14 -0
- package/src/replication/replication-utils.ts +23 -2
- package/test/DOCUMENTDB_TESTING.md +115 -0
- package/test/src/DatabaseType.ts +25 -0
- package/test/src/change_stream.test.ts +97 -65
- package/test/src/change_stream_utils.ts +83 -7
- package/test/src/checkpoint_retry.test.ts +5 -2
- package/test/src/documentdb_helpers.test.ts +124 -0
- package/test/src/documentdb_mode.test.ts +1040 -0
- package/test/src/mongo_test.test.ts +15 -5
- package/test/src/raw_change_stream.test.ts +209 -125
- package/test/src/resume_token.test.ts +30 -0
- package/test/src/slow_tests.test.ts +4 -1
- package/test/src/test-timeouts.ts +23 -0
- package/test/src/util.ts +1 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -2,13 +2,30 @@ import { ErrorCode, ServiceError } from '@powersync/lib-services-framework';
|
|
|
2
2
|
import { PostImagesOption } from '../types/types.js';
|
|
3
3
|
export const CHECKPOINTS_COLLECTION = '_powersync_checkpoints';
|
|
4
4
|
const REQUIRED_CHECKPOINT_PERMISSIONS = ['find', 'insert', 'update', 'remove', 'changeStream', 'createCollection'];
|
|
5
|
+
/**
|
|
6
|
+
* Whether a `hello` response indicates Azure DocumentDB (formerly Azure Cosmos
|
|
7
|
+
* DB for MongoDB vCore), which reports `documentdb_versions` in the `internal`
|
|
8
|
+
* section.
|
|
9
|
+
*/
|
|
10
|
+
function isDocumentDbHello(hello) {
|
|
11
|
+
return hello.internal?.documentdb_versions != null;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Detect whether the connected server is DocumentDB. DocumentDB lacks usable
|
|
15
|
+
* clusterTime/operationTime and uses the sentinel checkpoint implementation.
|
|
16
|
+
*/
|
|
17
|
+
export async function detectDocumentDb(db) {
|
|
18
|
+
const hello = await db.command({ hello: 1 });
|
|
19
|
+
return isDocumentDbHello(hello);
|
|
20
|
+
}
|
|
5
21
|
export async function checkSourceConfiguration(connectionManager) {
|
|
6
22
|
const db = connectionManager.db;
|
|
7
23
|
const hello = await db.command({ hello: 1 });
|
|
8
|
-
|
|
24
|
+
const isDocumentDb = isDocumentDbHello(hello);
|
|
25
|
+
if (hello.msg == 'isdbgrid' && !isDocumentDb) {
|
|
9
26
|
throw new ServiceError(ErrorCode.PSYNC_S1341, 'Sharded MongoDB Clusters are not supported yet (including MongoDB Serverless instances).');
|
|
10
27
|
}
|
|
11
|
-
else if (hello.setName == null) {
|
|
28
|
+
else if (hello.setName == null && !isDocumentDb) {
|
|
12
29
|
throw new ServiceError(ErrorCode.PSYNC_S1342, 'Standalone MongoDB instances are not supported - use a replicaset.');
|
|
13
30
|
}
|
|
14
31
|
// https://www.mongodb.com/docs/manual/reference/command/connectionStatus/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"replication-utils.js","sourceRoot":"","sources":["../../src/replication/replication-utils.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"replication-utils.js","sourceRoot":"","sources":["../../src/replication/replication-utils.ts"],"names":[],"mappings":"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"}
|
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.19.0",
|
|
5
5
|
"license": "FSL-1.1-ALv2",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"publishConfig": {
|
|
@@ -15,17 +15,17 @@
|
|
|
15
15
|
"bson": "^6.10.4",
|
|
16
16
|
"ts-codec": "^1.3.0",
|
|
17
17
|
"uuid": "^14.0.0",
|
|
18
|
-
"@powersync/lib-
|
|
19
|
-
"@powersync/lib-
|
|
20
|
-
"@powersync/service-core": "1.23.
|
|
18
|
+
"@powersync/lib-services-framework": "0.9.8",
|
|
19
|
+
"@powersync/lib-service-mongodb": "0.6.29",
|
|
20
|
+
"@powersync/service-core": "1.23.3",
|
|
21
21
|
"@powersync/service-jsonbig": "0.17.13",
|
|
22
|
-
"@powersync/service-sync-rules": "0.
|
|
22
|
+
"@powersync/service-sync-rules": "0.39.0",
|
|
23
23
|
"@powersync/service-types": "0.16.1"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@powersync/service-core-tests": "0.17.
|
|
27
|
-
"@powersync/service-module-mongodb-storage": "0.18.
|
|
28
|
-
"@powersync/service-module-postgres-storage": "0.16.
|
|
26
|
+
"@powersync/service-core-tests": "0.17.2",
|
|
27
|
+
"@powersync/service-module-mongodb-storage": "0.18.3",
|
|
28
|
+
"@powersync/service-module-postgres-storage": "0.16.3"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"build": "tsc -b",
|
|
@@ -4,11 +4,12 @@ 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 {
|
|
8
|
-
import {
|
|
7
|
+
import { logger } from '@powersync/lib-services-framework';
|
|
8
|
+
import { CheckpointImplementation } from '../replication/checkpoints/CheckpointImplementation.js';
|
|
9
|
+
import { createCheckpointImplementation } from '../replication/checkpoints/create-checkpoint-implementation.js';
|
|
9
10
|
import { MongoManager } from '../replication/MongoManager.js';
|
|
10
|
-
import { constructAfterRecord
|
|
11
|
-
import { CHECKPOINTS_COLLECTION } from '../replication/replication-utils.js';
|
|
11
|
+
import { constructAfterRecord } from '../replication/MongoRelation.js';
|
|
12
|
+
import { CHECKPOINTS_COLLECTION, detectDocumentDb } from '../replication/replication-utils.js';
|
|
12
13
|
import * as types from '../types/types.js';
|
|
13
14
|
import { escapeRegExp } from '../utils.js';
|
|
14
15
|
|
|
@@ -19,6 +20,8 @@ export class MongoRouteAPIAdapter implements api.RouteAPI {
|
|
|
19
20
|
connectionTag: string;
|
|
20
21
|
defaultSchema: string;
|
|
21
22
|
|
|
23
|
+
private checkpointImplementation: CheckpointImplementation | null = null;
|
|
24
|
+
|
|
22
25
|
constructor(protected config: types.ResolvedConnectionConfig) {
|
|
23
26
|
const manager = new MongoManager(config);
|
|
24
27
|
this.client = manager.client;
|
|
@@ -199,41 +202,27 @@ export class MongoRouteAPIAdapter implements api.RouteAPI {
|
|
|
199
202
|
}
|
|
200
203
|
|
|
201
204
|
async createReplicationHead<T>(callback: ReplicationHeadCallback<T>): Promise<T> {
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const head = session.clusterTime?.clusterTime;
|
|
206
|
-
if (head == null) {
|
|
207
|
-
throw new ServiceAssertionError(`clusterTime not available for write checkpoint`);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const r = await callback(new MongoLSN({ timestamp: head }).comparable);
|
|
211
|
-
|
|
212
|
-
// Trigger a change on the changestream.
|
|
213
|
-
await this.db.collection(CHECKPOINTS_COLLECTION).findOneAndUpdate(
|
|
214
|
-
{
|
|
215
|
-
_id: STANDALONE_CHECKPOINT_ID as any
|
|
216
|
-
},
|
|
217
|
-
{
|
|
218
|
-
$inc: { i: 1 }
|
|
219
|
-
},
|
|
220
|
-
{
|
|
221
|
-
upsert: true,
|
|
222
|
-
returnDocument: 'after',
|
|
223
|
-
session
|
|
224
|
-
}
|
|
225
|
-
);
|
|
226
|
-
const time = session.operationTime!;
|
|
227
|
-
if (time == null) {
|
|
228
|
-
throw new ServiceAssertionError(`operationTime not available for write checkpoint`);
|
|
229
|
-
} else if (time.lt(head)) {
|
|
230
|
-
throw new ServiceAssertionError(`operationTime must be > clusterTime`);
|
|
231
|
-
}
|
|
205
|
+
const checkpointImplementation = await this.getCheckpointImplementation();
|
|
206
|
+
return checkpointImplementation.createReplicationHead(callback);
|
|
207
|
+
}
|
|
232
208
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
await
|
|
209
|
+
private async getCheckpointImplementation(): Promise<CheckpointImplementation> {
|
|
210
|
+
if (this.checkpointImplementation == null) {
|
|
211
|
+
const isDocumentDb = await detectDocumentDb(this.db);
|
|
212
|
+
this.checkpointImplementation = createCheckpointImplementation(isDocumentDb, {
|
|
213
|
+
client: this.client,
|
|
214
|
+
db: this.db,
|
|
215
|
+
// The adapter never streams, so it has no real barrier document. This
|
|
216
|
+
// random id is only safe because the adapter only ever calls
|
|
217
|
+
// createReplicationHead, which produces a standalone (stream_id = null)
|
|
218
|
+
// head that every real ChangeStream observes. It must NOT be used for a
|
|
219
|
+
// batch barrier (createBatchCheckpoint stamps this id), or the head would
|
|
220
|
+
// become an own-barrier of a phantom stream that nothing resolves.
|
|
221
|
+
checkpointStreamId: new mongo.ObjectId(),
|
|
222
|
+
logger
|
|
223
|
+
});
|
|
236
224
|
}
|
|
225
|
+
return this.checkpointImplementation;
|
|
237
226
|
}
|
|
238
227
|
|
|
239
228
|
async getConnectionSchema(): Promise<service_types.DatabaseSchema[]> {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
2
|
+
import { storage } from '@powersync/service-core';
|
|
3
|
+
|
|
4
|
+
export type SentinelLSNSpecification = {
|
|
5
|
+
sentinel: bigint;
|
|
6
|
+
/**
|
|
7
|
+
* Resume tokens are opaque on sentinel-based sources (e.g. DocumentDB). The
|
|
8
|
+
* sentinel component is the comparable position; this token is only used to
|
|
9
|
+
* resume the change stream.
|
|
10
|
+
*/
|
|
11
|
+
resume_token?: mongo.ResumeToken | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const DELIMINATOR = '|';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Width of the sentinel coordinate, matching a MongoDB cluster timestamp
|
|
18
|
+
* (64-bit → 16 hex chars). The sentinel counter is a 64-bit `Long`, so this is
|
|
19
|
+
* sufficient and never truncates.
|
|
20
|
+
*/
|
|
21
|
+
const SENTINEL_HEX_LENGTH = 16;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* LSN for sentinel-based checkpointing (sources without a usable clusterTime,
|
|
25
|
+
* e.g. DocumentDB). The ordered coordinate is a monotonic sentinel counter; the
|
|
26
|
+
* opaque resume token is carried alongside it only for `resumeAfter`.
|
|
27
|
+
*
|
|
28
|
+
* The coordinate is serialized as a 16-hex-char value with the **same shape as
|
|
29
|
+
* a MongoDB timestamp LSN** ({@link MongoLSN}): the high 32 bits resemble epoch
|
|
30
|
+
* seconds and the low 32 bits an increment. This is deliberate — it makes
|
|
31
|
+
* sentinel LSNs directly string-comparable with timestamp LSNs, so a sentinel
|
|
32
|
+
* coordinate (seeded at the current epoch seconds; see createSentinelCheckpointLsn)
|
|
33
|
+
* always sorts **above** any real-timestamp LSN issued in the past.
|
|
34
|
+
*
|
|
35
|
+
* It only *resembles* a timestamp. The value is a synthetic monotonic counter,
|
|
36
|
+
* not a real cluster time, and must never be used as one (e.g. it is never fed
|
|
37
|
+
* to `startAtOperationTime`; the sentinel implementation resumes purely via the
|
|
38
|
+
* resume token).
|
|
39
|
+
*/
|
|
40
|
+
export class SentinelLSN {
|
|
41
|
+
static ZERO = new SentinelLSN({ sentinel: 0n });
|
|
42
|
+
|
|
43
|
+
static fromSerialized(comparable: string): SentinelLSN {
|
|
44
|
+
const [sentinelString, resumeString] = comparable.split(DELIMINATOR);
|
|
45
|
+
|
|
46
|
+
return new SentinelLSN({
|
|
47
|
+
sentinel: BigInt(`0x${sentinelString}`),
|
|
48
|
+
resume_token: resumeString ? storage.deserializeBson(Buffer.from(resumeString, 'base64')).resumeToken : null
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
constructor(protected options: SentinelLSNSpecification) {}
|
|
53
|
+
|
|
54
|
+
get sentinel() {
|
|
55
|
+
return this.options.sentinel;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get resumeToken() {
|
|
59
|
+
return this.options.resume_token;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get comparable() {
|
|
63
|
+
// padStart(16) of the combined 64-bit value yields the identical string to
|
|
64
|
+
// MongoLSN's high(8)+low(8) formatting, keeping the two formats comparable.
|
|
65
|
+
const sentinel = this.sentinel.toString(16).padStart(SENTINEL_HEX_LENGTH, '0');
|
|
66
|
+
const segments = [sentinel];
|
|
67
|
+
|
|
68
|
+
if (this.resumeToken) {
|
|
69
|
+
segments.push(storage.serializeBson({ resumeToken: this.resumeToken }).toString('base64'));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return segments.join(DELIMINATOR);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
toString() {
|
|
76
|
+
return this.comparable;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -21,11 +21,12 @@ import {
|
|
|
21
21
|
import { HydratedSyncConfig } from '@powersync/service-sync-rules';
|
|
22
22
|
import { ReplicationMetric } from '@powersync/service-types';
|
|
23
23
|
import { performance } from 'node:perf_hooks';
|
|
24
|
-
import { MongoLSN } from '../common/MongoLSN.js';
|
|
25
24
|
import { PostImagesOption } from '../types/types.js';
|
|
26
25
|
import { escapeRegExp } from '../utils.js';
|
|
26
|
+
import { CheckpointImplementation } from './checkpoints/CheckpointImplementation.js';
|
|
27
|
+
import { createCheckpointImplementation } from './checkpoints/create-checkpoint-implementation.js';
|
|
27
28
|
import { MongoManager } from './MongoManager.js';
|
|
28
|
-
import {
|
|
29
|
+
import { getCacheIdentifier, getMongoRelation } from './MongoRelation.js';
|
|
29
30
|
import { MongoSnapshotter, MongoSnapshotterHooks } from './MongoSnapshotter.js';
|
|
30
31
|
import {
|
|
31
32
|
ChangeStreamBatch,
|
|
@@ -33,7 +34,7 @@ import {
|
|
|
33
34
|
ProjectedChangeStreamDocument,
|
|
34
35
|
rawChangeStream
|
|
35
36
|
} from './RawChangeStream.js';
|
|
36
|
-
import { CHECKPOINTS_COLLECTION, timestampToDate } from './replication-utils.js';
|
|
37
|
+
import { CHECKPOINTS_COLLECTION, detectDocumentDb, timestampToDate } from './replication-utils.js';
|
|
37
38
|
import { DirectSourceRowConverter, SourceRowConverter } from './SourceRowConverter.js';
|
|
38
39
|
export interface ChangeStreamOptions {
|
|
39
40
|
connections: MongoManager;
|
|
@@ -53,6 +54,11 @@ export interface ChangeStreamOptions {
|
|
|
53
54
|
*/
|
|
54
55
|
snapshotChunkLength?: number;
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Override keepalive interval for testing (defaults to 60_000ms).
|
|
59
|
+
*/
|
|
60
|
+
keepaliveIntervalMs?: number;
|
|
61
|
+
|
|
56
62
|
storageHooks?: storage.StorageHooks;
|
|
57
63
|
snapshotHooks?: MongoSnapshotterHooks;
|
|
58
64
|
|
|
@@ -113,6 +119,11 @@ export class ChangeStream {
|
|
|
113
119
|
|
|
114
120
|
private readonly sourceRowConverter: SourceRowConverter;
|
|
115
121
|
|
|
122
|
+
private keepaliveIntervalMs: number;
|
|
123
|
+
|
|
124
|
+
private isDocumentDb = false;
|
|
125
|
+
private _checkpointImplementation: CheckpointImplementation | null = null;
|
|
126
|
+
|
|
116
127
|
constructor(options: ChangeStreamOptions) {
|
|
117
128
|
this.storage = options.storage;
|
|
118
129
|
this.metrics = options.metrics;
|
|
@@ -120,6 +131,7 @@ export class ChangeStream {
|
|
|
120
131
|
this.connections = options.connections;
|
|
121
132
|
this.maxAwaitTimeMS = options.maxAwaitTimeMS ?? 10_000;
|
|
122
133
|
this.snapshotChunkLength = options.snapshotChunkLength ?? 6_000;
|
|
134
|
+
this.keepaliveIntervalMs = options.keepaliveIntervalMs ?? 60_000;
|
|
123
135
|
this.storageHooks = options.storageHooks;
|
|
124
136
|
this.client = this.connections.client;
|
|
125
137
|
this.defaultDb = this.connections.db;
|
|
@@ -169,6 +181,37 @@ export class ChangeStream {
|
|
|
169
181
|
return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
|
|
170
182
|
}
|
|
171
183
|
|
|
184
|
+
/** The active checkpoint strategy. Only valid after ensureDetected(). */
|
|
185
|
+
private get checkpointImplementation(): CheckpointImplementation {
|
|
186
|
+
if (this._checkpointImplementation == null) {
|
|
187
|
+
throw new ReplicationAssertionError('Checkpoint implementation not initialized - call ensureDetected() first');
|
|
188
|
+
}
|
|
189
|
+
return this._checkpointImplementation;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Detect DocumentDB and select the checkpoint implementation for the streaming
|
|
194
|
+
* loop. Idempotent. The snapshotter detects independently; the two coordinate
|
|
195
|
+
* through stored LSNs, not shared in-memory state.
|
|
196
|
+
*/
|
|
197
|
+
private async ensureDetected(): Promise<void> {
|
|
198
|
+
if (this._checkpointImplementation != null) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
this.isDocumentDb = await detectDocumentDb(this.defaultDb);
|
|
202
|
+
if (this.isDocumentDb) {
|
|
203
|
+
this.logger.warn(
|
|
204
|
+
'Azure DocumentDB support is experimental. APIs and behavior may change, and long-term stability is not yet guaranteed.'
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
|
|
208
|
+
client: this.client,
|
|
209
|
+
db: this.defaultDb,
|
|
210
|
+
checkpointStreamId: this.checkpointStreamId,
|
|
211
|
+
logger: this.logger
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
172
215
|
private getSourceNamespaceFilters(): { $match: any; multipleDatabases: boolean } {
|
|
173
216
|
const sourceTables = this.sync_rules.getSourceTables();
|
|
174
217
|
|
|
@@ -209,7 +252,15 @@ export class ChangeStream {
|
|
|
209
252
|
// For details, see:
|
|
210
253
|
// https://github.com/powersync-ja/powersync-service/pull/417
|
|
211
254
|
// https://jira.mongodb.org/browse/SERVER-114532
|
|
212
|
-
|
|
255
|
+
//
|
|
256
|
+
// DocumentDB always opens a cluster-level change stream (admin +
|
|
257
|
+
// allChangesForCluster), even in single-database mode. A coll-only filter
|
|
258
|
+
// would then match same-named collections (including _powersync_checkpoints)
|
|
259
|
+
// in other databases of the cluster, letting a foreign standalone-checkpoint
|
|
260
|
+
// event advance/resolve checkpoints against the wrong source database. So we
|
|
261
|
+
// must filter on the full namespace whenever the stream is cluster-scoped.
|
|
262
|
+
const useFullNamespaceFilter = this.isDocumentDb || multipleDatabases;
|
|
263
|
+
const nsFilter = useFullNamespaceFilter
|
|
213
264
|
? // cluster-level: filter on the entire namespace
|
|
214
265
|
{ ns: { $in: $inFilters } }
|
|
215
266
|
: // collection-level: filter on coll only
|
|
@@ -465,15 +516,18 @@ export class ChangeStream {
|
|
|
465
516
|
signal?: AbortSignal;
|
|
466
517
|
tracer?: PerformanceTracer<'changestream'>;
|
|
467
518
|
}): AsyncIterableIterator<ChangeStreamBatch> {
|
|
468
|
-
const
|
|
469
|
-
const startAfter =
|
|
470
|
-
const resumeAfter =
|
|
519
|
+
const position = options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null;
|
|
520
|
+
const startAfter = position?.startAfter ?? undefined;
|
|
521
|
+
const resumeAfter = position?.resumeAfter ?? undefined;
|
|
471
522
|
|
|
472
523
|
const filters = options.filters;
|
|
473
524
|
|
|
474
525
|
let fullDocument: 'required' | 'updateLookup';
|
|
475
526
|
|
|
476
|
-
if (this.
|
|
527
|
+
if (this.isDocumentDb) {
|
|
528
|
+
// DocumentDB does not support changeStreamPreAndPostImages, so 'required' won't work.
|
|
529
|
+
fullDocument = 'updateLookup';
|
|
530
|
+
} else if (this.usePostImages) {
|
|
477
531
|
// 'read_only' or 'auto_configure'
|
|
478
532
|
// Configuration happens during snapshot, or when we see new
|
|
479
533
|
// collections.
|
|
@@ -482,42 +536,56 @@ export class ChangeStream {
|
|
|
482
536
|
fullDocument = 'updateLookup';
|
|
483
537
|
}
|
|
484
538
|
const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
|
|
485
|
-
showExpandedEvents: true,
|
|
486
539
|
fullDocument: fullDocument
|
|
487
540
|
};
|
|
541
|
+
if (!this.isDocumentDb) {
|
|
542
|
+
// DocumentDB does not support showExpandedEvents.
|
|
543
|
+
streamOptions.showExpandedEvents = true;
|
|
544
|
+
}
|
|
488
545
|
const pipeline: mongo.Document[] = [
|
|
489
546
|
{
|
|
490
547
|
$changeStream: streamOptions
|
|
491
548
|
},
|
|
492
549
|
{
|
|
493
550
|
$match: filters.$match
|
|
494
|
-
}
|
|
495
|
-
{ $changeStreamSplitLargeEvent: {} }
|
|
551
|
+
}
|
|
496
552
|
];
|
|
553
|
+
if (!this.isDocumentDb) {
|
|
554
|
+
// DocumentDB does not support $changeStreamSplitLargeEvent.
|
|
555
|
+
pipeline.push({ $changeStreamSplitLargeEvent: {} });
|
|
556
|
+
}
|
|
497
557
|
|
|
498
558
|
/**
|
|
499
559
|
* Only one of these options can be supplied at a time.
|
|
500
560
|
*/
|
|
501
561
|
if (resumeAfter) {
|
|
502
562
|
streamOptions.resumeAfter = resumeAfter;
|
|
503
|
-
} else {
|
|
563
|
+
} else if (startAfter != null) {
|
|
504
564
|
// Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
|
|
505
565
|
// case if we have an old one.
|
|
506
566
|
// This is also relevant for getSnapshotLSN().
|
|
567
|
+
// The sentinel implementation never produces a startAfter, and a fresh DocumentDB stream
|
|
568
|
+
// opens from "now" with neither option set.
|
|
507
569
|
streamOptions.startAtOperationTime = startAfter;
|
|
508
570
|
}
|
|
509
571
|
|
|
510
572
|
let watchDb: mongo.Db;
|
|
511
|
-
if (filters.multipleDatabases) {
|
|
573
|
+
if (this.isDocumentDb || filters.multipleDatabases) {
|
|
574
|
+
// DocumentDB only supports cluster-level change streams.
|
|
512
575
|
watchDb = this.client.db('admin');
|
|
513
576
|
streamOptions.allChangesForCluster = true;
|
|
514
577
|
} else {
|
|
515
578
|
watchDb = this.defaultDb;
|
|
516
579
|
}
|
|
517
580
|
|
|
581
|
+
const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
|
|
582
|
+
|
|
518
583
|
return rawChangeStream(watchDb, pipeline, {
|
|
519
584
|
batchSize: options.batchSize ?? this.snapshotChunkLength,
|
|
520
|
-
maxAwaitTimeMS
|
|
585
|
+
maxAwaitTimeMS,
|
|
586
|
+
// maxAwaitTimeMS can be 0 for probe-style streams that do not want an idle wait.
|
|
587
|
+
// In that case there is no client-side wait to emulate for DocumentDB.
|
|
588
|
+
clientSideMaxAwaitTimeMS: this.isDocumentDb && maxAwaitTimeMS > 0,
|
|
521
589
|
maxTimeMS: this.changeStreamTimeout,
|
|
522
590
|
|
|
523
591
|
signal: options.signal,
|
|
@@ -531,6 +599,7 @@ export class ChangeStream {
|
|
|
531
599
|
}
|
|
532
600
|
|
|
533
601
|
async streamChangesInternal() {
|
|
602
|
+
await this.ensureDetected();
|
|
534
603
|
const transactionsReplicatedMetric = this.metrics.getCounter(ReplicationMetric.TRANSACTIONS_REPLICATED);
|
|
535
604
|
const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
|
|
536
605
|
const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
|
|
@@ -541,7 +610,7 @@ export class ChangeStream {
|
|
|
541
610
|
await this.storage.startBatch(
|
|
542
611
|
{
|
|
543
612
|
logger: this.logger,
|
|
544
|
-
zeroLSN:
|
|
613
|
+
zeroLSN: this.checkpointImplementation.zeroLsn,
|
|
545
614
|
defaultSchema: this.defaultDb.databaseName,
|
|
546
615
|
// We get a complete postimage for every change, so we don't need to store the current data.
|
|
547
616
|
storeCurrentData: false,
|
|
@@ -553,15 +622,14 @@ export class ChangeStream {
|
|
|
553
622
|
if (resumeFromLsn == null) {
|
|
554
623
|
throw new ReplicationAssertionError(`No LSN found to resume from`);
|
|
555
624
|
}
|
|
556
|
-
|
|
557
|
-
|
|
625
|
+
// Seed the implementation's coordinate state from the stored LSN, and parse
|
|
626
|
+
// the legacy startAfter timestamp (timestamp implementation only) for the
|
|
627
|
+
// resume-boundary dedupe guard below.
|
|
628
|
+
this.checkpointImplementation.seedPosition(resumeFromLsn);
|
|
629
|
+
const { startAfter } = this.checkpointImplementation.parseResumePosition(resumeFromLsn);
|
|
558
630
|
let outerSpan = tracer.span('batch');
|
|
559
631
|
|
|
560
|
-
|
|
561
|
-
// of ChangeStream events.
|
|
562
|
-
const tokenAgeSeconds = Math.round((Date.now() - timestampToDate(startAfter).getTime()) / 1000);
|
|
563
|
-
|
|
564
|
-
this.logger.info(`Resume streaming at ${startAfter?.inspect()} / ${lastLsn} | Token age: ${tokenAgeSeconds}s`);
|
|
632
|
+
this.checkpointImplementation.logResume(resumeFromLsn);
|
|
565
633
|
|
|
566
634
|
const filters = this.getSourceNamespaceFilters();
|
|
567
635
|
// This is closed when the for loop below returns/breaks/throws
|
|
@@ -575,11 +643,7 @@ export class ChangeStream {
|
|
|
575
643
|
// Always start with a checkpoint.
|
|
576
644
|
// This helps us to clear errors when restarting, even if there is
|
|
577
645
|
// no data to replicate.
|
|
578
|
-
let waitForCheckpointLsn: string | null = await
|
|
579
|
-
this.client,
|
|
580
|
-
this.defaultDb,
|
|
581
|
-
this.checkpointStreamId
|
|
582
|
-
);
|
|
646
|
+
let waitForCheckpointLsn: string | null = await this.checkpointImplementation.createBatchCheckpoint();
|
|
583
647
|
|
|
584
648
|
let splitDocument: ProjectedChangeStreamDocument | null = null;
|
|
585
649
|
|
|
@@ -603,22 +667,19 @@ export class ChangeStream {
|
|
|
603
667
|
// We do this by persisting a keepalive checkpoint.
|
|
604
668
|
// If we don't update it on empty events, we do keep consistency, but resuming the stream
|
|
605
669
|
// with old tokens may cause connection timeouts.
|
|
606
|
-
if (waitForCheckpointLsn == null && performance.now() - lastEmptyResume >
|
|
607
|
-
|
|
608
|
-
|
|
670
|
+
if (waitForCheckpointLsn == null && performance.now() - lastEmptyResume > this.keepaliveIntervalMs) {
|
|
671
|
+
// The implementation persists a keepalive (timestamp) or bumps the
|
|
672
|
+
// sentinel so a later event commits (sentinel). Logging is handled
|
|
673
|
+
// inside the implementation.
|
|
674
|
+
await this.checkpointImplementation.keepalive(batch, resumeToken);
|
|
609
675
|
this.touch();
|
|
610
676
|
lastEmptyResume = performance.now();
|
|
611
|
-
// Log the token update. This helps as a general "replication is still active" message in the logs.
|
|
612
|
-
// This token would typically be around 10s behind.
|
|
613
|
-
this.logger.info(
|
|
614
|
-
`Idle change stream. Persisted resumeToken for ${timestampToDate(timestamp).toISOString()}`
|
|
615
|
-
);
|
|
616
677
|
this.replicationLag.markStarted();
|
|
617
678
|
}
|
|
618
679
|
|
|
619
680
|
// If we have no changes, we can just persist the keepalive.
|
|
620
|
-
// This is throttled to once per
|
|
621
|
-
if (performance.now() - lastEmptyResume <
|
|
681
|
+
// This is throttled to once per interval.
|
|
682
|
+
if (performance.now() - lastEmptyResume < this.keepaliveIntervalMs) {
|
|
622
683
|
continue;
|
|
623
684
|
}
|
|
624
685
|
}
|
|
@@ -726,9 +787,12 @@ export class ChangeStream {
|
|
|
726
787
|
// It may be useful to also throttle commits due to standalone checkpoints in the future.
|
|
727
788
|
// However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
|
|
728
789
|
|
|
729
|
-
const
|
|
790
|
+
const kind = this.checkpointImplementation.event.observe(changeDocument);
|
|
730
791
|
|
|
731
|
-
if (
|
|
792
|
+
if (kind == 'foreign') {
|
|
793
|
+
// Another stream's barrier - ignore.
|
|
794
|
+
continue;
|
|
795
|
+
} else if (kind == 'standalone') {
|
|
732
796
|
// Standalone / write checkpoint received.
|
|
733
797
|
// When we are caught up, commit immediately to keep write checkpoint latency low.
|
|
734
798
|
// Once there is already a batch checkpoint pending, or the driver has buffered more
|
|
@@ -738,7 +802,7 @@ export class ChangeStream {
|
|
|
738
802
|
if (hasBufferedChanges && waitForCheckpointLsn == null) {
|
|
739
803
|
// Buffered changes - create a new batch checkpoint to rate limit commits
|
|
740
804
|
using _ = tracer.span('source_checkpoint');
|
|
741
|
-
waitForCheckpointLsn = await
|
|
805
|
+
waitForCheckpointLsn = await this.checkpointImplementation.createBatchCheckpoint();
|
|
742
806
|
continue;
|
|
743
807
|
} else if (waitForCheckpointLsn != null) {
|
|
744
808
|
// Skip this checkpoint - wait for the batch checkpoint.
|
|
@@ -746,15 +810,15 @@ export class ChangeStream {
|
|
|
746
810
|
} else {
|
|
747
811
|
// No buffered changes, and no batch checkpoint pending - commit immediately.
|
|
748
812
|
}
|
|
749
|
-
} else if (!this.checkpointStreamId.equals(checkpointId)) {
|
|
750
|
-
continue;
|
|
751
813
|
}
|
|
752
|
-
|
|
753
|
-
timestamp: changeDocument.clusterTime!,
|
|
754
|
-
resume_token: changeDocument._id
|
|
755
|
-
});
|
|
814
|
+
// kind == 'own-barrier' falls through to commit.
|
|
756
815
|
|
|
757
|
-
|
|
816
|
+
const lsn = this.checkpointImplementation.event.lsn(changeDocument);
|
|
817
|
+
|
|
818
|
+
if (
|
|
819
|
+
waitForCheckpointLsn != null &&
|
|
820
|
+
this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)
|
|
821
|
+
) {
|
|
758
822
|
waitForCheckpointLsn = null;
|
|
759
823
|
}
|
|
760
824
|
const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
|
|
@@ -772,7 +836,7 @@ export class ChangeStream {
|
|
|
772
836
|
) {
|
|
773
837
|
if (waitForCheckpointLsn == null) {
|
|
774
838
|
using _ = tracer.span('source_checkpoint');
|
|
775
|
-
waitForCheckpointLsn = await
|
|
839
|
+
waitForCheckpointLsn = await this.checkpointImplementation.createBatchCheckpoint();
|
|
776
840
|
}
|
|
777
841
|
|
|
778
842
|
const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
@@ -786,7 +850,11 @@ export class ChangeStream {
|
|
|
786
850
|
const tablesToReplicate = tables.filter((table) => table.syncAny);
|
|
787
851
|
if (tablesToReplicate.length > 0) {
|
|
788
852
|
this.replicationLag.trackUncommittedChange(
|
|
789
|
-
|
|
853
|
+
// Standard MongoDB uses clusterTime, unchanged. DocumentDB has no
|
|
854
|
+
// clusterTime, so fall back to wallTime there for the lag metric.
|
|
855
|
+
changeDocument.clusterTime != null
|
|
856
|
+
? timestampToDate(changeDocument.clusterTime)
|
|
857
|
+
: ((changeDocument as any).wallTime ?? null)
|
|
790
858
|
);
|
|
791
859
|
|
|
792
860
|
const transactionKeyValue = transactionKey(changeDocument);
|
|
@@ -841,7 +909,7 @@ export class ChangeStream {
|
|
|
841
909
|
// Batches are generally large (64MB or 6000 events, whichever comes first),
|
|
842
910
|
// so this is a good natural point to flush and mark progress.
|
|
843
911
|
// We avoid this when splitDocument is set, since we cannot resume in the middle of a split event.
|
|
844
|
-
const
|
|
912
|
+
const lsn = this.checkpointImplementation.lsnFromResumeToken(resumeToken);
|
|
845
913
|
await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
|
|
846
914
|
// TODO: We should consider making this standard behavior of flush().
|
|
847
915
|
await batch.setResumeLsn(lsn);
|