@powersync/service-module-mongodb 0.0.0-dev-20260602073133 → 0.0.0-dev-20260803144812
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 +97 -5
- package/dist/api/MongoRouteAPIAdapter.d.ts +4 -2
- 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 +16 -0
- package/dist/replication/ChangeStream.js +143 -64
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.js +1 -1
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/ChangeStreamReplicator.js +1 -1
- package/dist/replication/ChangeStreamReplicator.js.map +1 -1
- package/dist/replication/MongoRelation.d.ts +36 -1
- package/dist/replication/MongoRelation.js +133 -18
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/MongoSnapshotter.d.ts +9 -1
- package/dist/replication/MongoSnapshotter.js +128 -66
- package/dist/replication/MongoSnapshotter.js.map +1 -1
- package/dist/replication/RawChangeStream.d.ts +12 -1
- package/dist/replication/RawChangeStream.js +23 -10
- 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 +224 -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 +115 -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/dist/types/types.d.ts +2 -2
- package/package.json +11 -21
- package/src/api/MongoRouteAPIAdapter.ts +28 -39
- package/src/common/SentinelLSN.ts +78 -0
- package/src/replication/ChangeStream.ts +159 -73
- package/src/replication/ChangeStreamReplicationJob.ts +1 -1
- package/src/replication/ChangeStreamReplicator.ts +1 -1
- package/src/replication/MongoRelation.ts +153 -21
- package/src/replication/MongoSnapshotter.ts +147 -72
- package/src/replication/RawChangeStream.ts +54 -32
- package/src/replication/checkpoints/CheckpointImplementation.ts +167 -0
- package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +269 -0
- package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +144 -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 +107 -71
- package/test/src/change_stream_utils.ts +117 -29
- package/test/src/checkpoint_retry.test.ts +134 -0
- 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.test.ts +2 -2
- 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 +3 -3
- package/tsconfig.json +1 -4
- package/tsconfig.scripts.json +1 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import * as lib_mongo from '@powersync/lib-service-mongodb';
|
|
2
2
|
import { mongo } from '@powersync/lib-service-mongodb';
|
|
3
|
-
import { api,
|
|
3
|
+
import { api, ParseSyncConfigOptions, ReplicationHeadCallback } from '@powersync/service-core';
|
|
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;
|
|
@@ -27,7 +30,7 @@ export class MongoRouteAPIAdapter implements api.RouteAPI {
|
|
|
27
30
|
this.connectionTag = config.tag ?? sync_rules.DEFAULT_TAG;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
getParseSyncRulesOptions():
|
|
33
|
+
getParseSyncRulesOptions(): ParseSyncConfigOptions {
|
|
31
34
|
return {
|
|
32
35
|
defaultSchema: this.defaultSchema
|
|
33
36
|
};
|
|
@@ -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,13 +119,19 @@ 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;
|
|
119
|
-
this.group_id = options.storage.
|
|
130
|
+
this.group_id = options.storage.replicationStreamId;
|
|
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;
|
|
@@ -132,11 +144,16 @@ export class ChangeStream {
|
|
|
132
144
|
// so we use 90% of the socket timeout value.
|
|
133
145
|
this.changeStreamTimeout = Math.ceil(this.client.options.socketTimeoutMS * 0.9);
|
|
134
146
|
|
|
135
|
-
|
|
147
|
+
const baseLogger = options.logger ?? this.storage.logger;
|
|
148
|
+
// Unfortunately the Winston APIs don't have a nice way to append to the prefix,
|
|
149
|
+
// so we replace it here.
|
|
150
|
+
this.logger = baseLogger.child({ prefix: `[${this.storage.replicationStreamName}] [stream] ` });
|
|
151
|
+
const snapshotLogger = baseLogger.child({ prefix: `[${this.storage.replicationStreamName}] [snapshot] ` });
|
|
152
|
+
|
|
136
153
|
this.snapshotter = new MongoSnapshotter({
|
|
137
154
|
...options,
|
|
138
155
|
abortSignal: this.abortSignal,
|
|
139
|
-
logger:
|
|
156
|
+
logger: snapshotLogger,
|
|
140
157
|
checkpointStreamId: this.checkpointStreamId
|
|
141
158
|
});
|
|
142
159
|
|
|
@@ -164,6 +181,37 @@ export class ChangeStream {
|
|
|
164
181
|
return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
|
|
165
182
|
}
|
|
166
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
|
+
|
|
167
215
|
private getSourceNamespaceFilters(): { $match: any; multipleDatabases: boolean } {
|
|
168
216
|
const sourceTables = this.sync_rules.getSourceTables();
|
|
169
217
|
|
|
@@ -204,7 +252,15 @@ export class ChangeStream {
|
|
|
204
252
|
// For details, see:
|
|
205
253
|
// https://github.com/powersync-ja/powersync-service/pull/417
|
|
206
254
|
// https://jira.mongodb.org/browse/SERVER-114532
|
|
207
|
-
|
|
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
|
|
208
264
|
? // cluster-level: filter on the entire namespace
|
|
209
265
|
{ ns: { $in: $inFilters } }
|
|
210
266
|
: // collection-level: filter on coll only
|
|
@@ -366,6 +422,7 @@ export class ChangeStream {
|
|
|
366
422
|
async replicate() {
|
|
367
423
|
let streamPromise: Promise<void> | null = null;
|
|
368
424
|
let loopPromise: Promise<void> | null = null;
|
|
425
|
+
let cleanupPromise: Promise<void> | null = null;
|
|
369
426
|
try {
|
|
370
427
|
// If anything errors here, the entire replication process is halted, and
|
|
371
428
|
// all connections automatically closed, including this one.
|
|
@@ -383,6 +440,11 @@ export class ChangeStream {
|
|
|
383
440
|
if (!this.snapshotter.supportsConcurrentSnapshots) {
|
|
384
441
|
await Promise.race([this.snapshotter.waitForInitialSnapshot(), loopPromise]);
|
|
385
442
|
}
|
|
443
|
+
// Unlike the other two, this resolves on completion, not an indefinite loop.
|
|
444
|
+
cleanupPromise = this.cleanupStoppedSyncConfigs().catch((e) => {
|
|
445
|
+
this.abortController.abort(e);
|
|
446
|
+
throw e;
|
|
447
|
+
});
|
|
386
448
|
streamPromise = this.streamChanges()
|
|
387
449
|
.then(() => {
|
|
388
450
|
throw new ReplicationAssertionError(`Replication stream exited unexpectedly`);
|
|
@@ -392,7 +454,7 @@ export class ChangeStream {
|
|
|
392
454
|
throw e;
|
|
393
455
|
});
|
|
394
456
|
|
|
395
|
-
const results = await Promise.allSettled([loopPromise, streamPromise]);
|
|
457
|
+
const results = await Promise.allSettled([loopPromise, streamPromise, cleanupPromise]);
|
|
396
458
|
throw replicationLoopError(results);
|
|
397
459
|
} catch (e) {
|
|
398
460
|
await this.storage.reportError(e);
|
|
@@ -422,6 +484,15 @@ export class ChangeStream {
|
|
|
422
484
|
}
|
|
423
485
|
}
|
|
424
486
|
|
|
487
|
+
private async cleanupStoppedSyncConfigs() {
|
|
488
|
+
await this.storage.cleanupStoppedSyncConfigs?.({
|
|
489
|
+
signal: this.abortSignal,
|
|
490
|
+
logger: this.logger,
|
|
491
|
+
defaultSchema: this.defaultDb.databaseName,
|
|
492
|
+
sourceConnectionTag: this.connections.connectionTag
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
425
496
|
private async streamChanges() {
|
|
426
497
|
try {
|
|
427
498
|
await this.streamChangesInternal();
|
|
@@ -445,15 +516,18 @@ export class ChangeStream {
|
|
|
445
516
|
signal?: AbortSignal;
|
|
446
517
|
tracer?: PerformanceTracer<'changestream'>;
|
|
447
518
|
}): AsyncIterableIterator<ChangeStreamBatch> {
|
|
448
|
-
const
|
|
449
|
-
const startAfter =
|
|
450
|
-
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;
|
|
451
522
|
|
|
452
523
|
const filters = options.filters;
|
|
453
524
|
|
|
454
525
|
let fullDocument: 'required' | 'updateLookup';
|
|
455
526
|
|
|
456
|
-
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) {
|
|
457
531
|
// 'read_only' or 'auto_configure'
|
|
458
532
|
// Configuration happens during snapshot, or when we see new
|
|
459
533
|
// collections.
|
|
@@ -462,42 +536,56 @@ export class ChangeStream {
|
|
|
462
536
|
fullDocument = 'updateLookup';
|
|
463
537
|
}
|
|
464
538
|
const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
|
|
465
|
-
showExpandedEvents: true,
|
|
466
539
|
fullDocument: fullDocument
|
|
467
540
|
};
|
|
541
|
+
if (!this.isDocumentDb) {
|
|
542
|
+
// DocumentDB does not support showExpandedEvents.
|
|
543
|
+
streamOptions.showExpandedEvents = true;
|
|
544
|
+
}
|
|
468
545
|
const pipeline: mongo.Document[] = [
|
|
469
546
|
{
|
|
470
547
|
$changeStream: streamOptions
|
|
471
548
|
},
|
|
472
549
|
{
|
|
473
550
|
$match: filters.$match
|
|
474
|
-
}
|
|
475
|
-
{ $changeStreamSplitLargeEvent: {} }
|
|
551
|
+
}
|
|
476
552
|
];
|
|
553
|
+
if (!this.isDocumentDb) {
|
|
554
|
+
// DocumentDB does not support $changeStreamSplitLargeEvent.
|
|
555
|
+
pipeline.push({ $changeStreamSplitLargeEvent: {} });
|
|
556
|
+
}
|
|
477
557
|
|
|
478
558
|
/**
|
|
479
559
|
* Only one of these options can be supplied at a time.
|
|
480
560
|
*/
|
|
481
561
|
if (resumeAfter) {
|
|
482
562
|
streamOptions.resumeAfter = resumeAfter;
|
|
483
|
-
} else {
|
|
563
|
+
} else if (startAfter != null) {
|
|
484
564
|
// Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
|
|
485
565
|
// case if we have an old one.
|
|
486
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.
|
|
487
569
|
streamOptions.startAtOperationTime = startAfter;
|
|
488
570
|
}
|
|
489
571
|
|
|
490
572
|
let watchDb: mongo.Db;
|
|
491
|
-
if (filters.multipleDatabases) {
|
|
573
|
+
if (this.isDocumentDb || filters.multipleDatabases) {
|
|
574
|
+
// DocumentDB only supports cluster-level change streams.
|
|
492
575
|
watchDb = this.client.db('admin');
|
|
493
576
|
streamOptions.allChangesForCluster = true;
|
|
494
577
|
} else {
|
|
495
578
|
watchDb = this.defaultDb;
|
|
496
579
|
}
|
|
497
580
|
|
|
581
|
+
const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
|
|
582
|
+
|
|
498
583
|
return rawChangeStream(watchDb, pipeline, {
|
|
499
584
|
batchSize: options.batchSize ?? this.snapshotChunkLength,
|
|
500
|
-
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,
|
|
501
589
|
maxTimeMS: this.changeStreamTimeout,
|
|
502
590
|
|
|
503
591
|
signal: options.signal,
|
|
@@ -511,15 +599,18 @@ export class ChangeStream {
|
|
|
511
599
|
}
|
|
512
600
|
|
|
513
601
|
async streamChangesInternal() {
|
|
602
|
+
await this.ensureDetected();
|
|
514
603
|
const transactionsReplicatedMetric = this.metrics.getCounter(ReplicationMetric.TRANSACTIONS_REPLICATED);
|
|
515
604
|
const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
|
|
516
605
|
const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
|
|
517
606
|
|
|
518
|
-
const tracer = new PerformanceTracer
|
|
607
|
+
const tracer = new PerformanceTracer<
|
|
608
|
+
'storage' | 'evaluate' | 'batch' | 'source_checkpoint' | 'changestream' | 'processing'
|
|
609
|
+
>('MongoDB streaming replication');
|
|
519
610
|
await this.storage.startBatch(
|
|
520
611
|
{
|
|
521
612
|
logger: this.logger,
|
|
522
|
-
zeroLSN:
|
|
613
|
+
zeroLSN: this.checkpointImplementation.zeroLsn,
|
|
523
614
|
defaultSchema: this.defaultDb.databaseName,
|
|
524
615
|
// We get a complete postimage for every change, so we don't need to store the current data.
|
|
525
616
|
storeCurrentData: false,
|
|
@@ -531,15 +622,14 @@ export class ChangeStream {
|
|
|
531
622
|
if (resumeFromLsn == null) {
|
|
532
623
|
throw new ReplicationAssertionError(`No LSN found to resume from`);
|
|
533
624
|
}
|
|
534
|
-
|
|
535
|
-
|
|
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);
|
|
536
630
|
let outerSpan = tracer.span('batch');
|
|
537
631
|
|
|
538
|
-
|
|
539
|
-
// of ChangeStream events.
|
|
540
|
-
const tokenAgeSeconds = Math.round((Date.now() - timestampToDate(startAfter).getTime()) / 1000);
|
|
541
|
-
|
|
542
|
-
this.logger.info(`Resume streaming at ${startAfter?.inspect()} / ${lastLsn} | Token age: ${tokenAgeSeconds}s`);
|
|
632
|
+
this.checkpointImplementation.logResume(resumeFromLsn);
|
|
543
633
|
|
|
544
634
|
const filters = this.getSourceNamespaceFilters();
|
|
545
635
|
// This is closed when the for loop below returns/breaks/throws
|
|
@@ -553,11 +643,7 @@ export class ChangeStream {
|
|
|
553
643
|
// Always start with a checkpoint.
|
|
554
644
|
// This helps us to clear errors when restarting, even if there is
|
|
555
645
|
// no data to replicate.
|
|
556
|
-
let waitForCheckpointLsn: string | null = await
|
|
557
|
-
this.client,
|
|
558
|
-
this.defaultDb,
|
|
559
|
-
this.checkpointStreamId
|
|
560
|
-
);
|
|
646
|
+
let waitForCheckpointLsn: string | null = await this.checkpointImplementation.createBatchCheckpoint();
|
|
561
647
|
|
|
562
648
|
let splitDocument: ProjectedChangeStreamDocument | null = null;
|
|
563
649
|
|
|
@@ -581,22 +667,19 @@ export class ChangeStream {
|
|
|
581
667
|
// We do this by persisting a keepalive checkpoint.
|
|
582
668
|
// If we don't update it on empty events, we do keep consistency, but resuming the stream
|
|
583
669
|
// with old tokens may cause connection timeouts.
|
|
584
|
-
if (waitForCheckpointLsn == null && performance.now() - lastEmptyResume >
|
|
585
|
-
|
|
586
|
-
|
|
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);
|
|
587
675
|
this.touch();
|
|
588
676
|
lastEmptyResume = performance.now();
|
|
589
|
-
// Log the token update. This helps as a general "replication is still active" message in the logs.
|
|
590
|
-
// This token would typically be around 10s behind.
|
|
591
|
-
this.logger.info(
|
|
592
|
-
`Idle change stream. Persisted resumeToken for ${timestampToDate(timestamp).toISOString()}`
|
|
593
|
-
);
|
|
594
677
|
this.replicationLag.markStarted();
|
|
595
678
|
}
|
|
596
679
|
|
|
597
680
|
// If we have no changes, we can just persist the keepalive.
|
|
598
|
-
// This is throttled to once per
|
|
599
|
-
if (performance.now() - lastEmptyResume <
|
|
681
|
+
// This is throttled to once per interval.
|
|
682
|
+
if (performance.now() - lastEmptyResume < this.keepaliveIntervalMs) {
|
|
600
683
|
continue;
|
|
601
684
|
}
|
|
602
685
|
}
|
|
@@ -704,47 +787,45 @@ export class ChangeStream {
|
|
|
704
787
|
// It may be useful to also throttle commits due to standalone checkpoints in the future.
|
|
705
788
|
// However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
|
|
706
789
|
|
|
707
|
-
const
|
|
790
|
+
const kind = this.checkpointImplementation.event.observe(changeDocument);
|
|
708
791
|
|
|
709
|
-
if (
|
|
792
|
+
if (kind == 'foreign') {
|
|
793
|
+
// Another stream's barrier - ignore.
|
|
794
|
+
continue;
|
|
795
|
+
} else if (kind == 'standalone') {
|
|
710
796
|
// Standalone / write checkpoint received.
|
|
711
797
|
// When we are caught up, commit immediately to keep write checkpoint latency low.
|
|
712
798
|
// Once there is already a batch checkpoint pending, or the driver has buffered more
|
|
713
799
|
// change stream events, collapse standalone checkpoints into the normal batch
|
|
714
800
|
// checkpoint flow to avoid commit churn under sustained load.
|
|
715
801
|
const hasBufferedChanges = eventIndex < events.length - 1;
|
|
716
|
-
if (waitForCheckpointLsn
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
802
|
+
if (hasBufferedChanges && waitForCheckpointLsn == null) {
|
|
803
|
+
// Buffered changes - create a new batch checkpoint to rate limit commits
|
|
804
|
+
using _ = tracer.span('source_checkpoint');
|
|
805
|
+
waitForCheckpointLsn = await this.checkpointImplementation.createBatchCheckpoint();
|
|
806
|
+
continue;
|
|
807
|
+
} else if (waitForCheckpointLsn != null) {
|
|
808
|
+
// Skip this checkpoint - wait for the batch checkpoint.
|
|
720
809
|
continue;
|
|
810
|
+
} else {
|
|
811
|
+
// No buffered changes, and no batch checkpoint pending - commit immediately.
|
|
721
812
|
}
|
|
722
|
-
} else if (!this.checkpointStreamId.equals(checkpointId)) {
|
|
723
|
-
continue;
|
|
724
|
-
}
|
|
725
|
-
const { comparable: lsn } = new MongoLSN({
|
|
726
|
-
timestamp: changeDocument.clusterTime!,
|
|
727
|
-
resume_token: changeDocument._id
|
|
728
|
-
});
|
|
729
|
-
if (batch.lastCheckpointLsn != null && lsn < batch.lastCheckpointLsn) {
|
|
730
|
-
// Checkpoint out of order - should never happen with MongoDB.
|
|
731
|
-
// If it does happen, we throw an error to stop the replication - restarting should recover.
|
|
732
|
-
// Since we use batch.lastCheckpointLsn for the next resumeAfter, this should not result in an infinite loop.
|
|
733
|
-
// Originally a workaround for https://jira.mongodb.org/browse/NODE-7042.
|
|
734
|
-
// This has been fixed in the driver in the meantime, but we still keep this as a safety-check.
|
|
735
|
-
throw new ReplicationAssertionError(
|
|
736
|
-
`Change resumeToken ${(changeDocument._id as any)._data} (${timestampToDate(changeDocument.clusterTime!).toISOString()}) is less than last checkpoint LSN ${batch.lastCheckpointLsn}. Restarting replication.`
|
|
737
|
-
);
|
|
738
813
|
}
|
|
814
|
+
// kind == 'own-barrier' falls through to commit.
|
|
815
|
+
|
|
816
|
+
const lsn = this.checkpointImplementation.event.lsn(changeDocument);
|
|
739
817
|
|
|
740
|
-
if (
|
|
818
|
+
if (
|
|
819
|
+
waitForCheckpointLsn != null &&
|
|
820
|
+
this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)
|
|
821
|
+
) {
|
|
741
822
|
waitForCheckpointLsn = null;
|
|
742
823
|
}
|
|
743
|
-
const { checkpointBlocked } = await batch.commit(lsn, {
|
|
824
|
+
const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
|
|
744
825
|
oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
|
|
745
826
|
});
|
|
746
827
|
|
|
747
|
-
if (!checkpointBlocked) {
|
|
828
|
+
if (!checkpointBlocked || checkpointCreated) {
|
|
748
829
|
this.replicationLag.markCommitted();
|
|
749
830
|
}
|
|
750
831
|
} else if (
|
|
@@ -754,7 +835,8 @@ export class ChangeStream {
|
|
|
754
835
|
changeDocument.operationType == 'delete'
|
|
755
836
|
) {
|
|
756
837
|
if (waitForCheckpointLsn == null) {
|
|
757
|
-
|
|
838
|
+
using _ = tracer.span('source_checkpoint');
|
|
839
|
+
waitForCheckpointLsn = await this.checkpointImplementation.createBatchCheckpoint();
|
|
758
840
|
}
|
|
759
841
|
|
|
760
842
|
const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
@@ -768,7 +850,11 @@ export class ChangeStream {
|
|
|
768
850
|
const tablesToReplicate = tables.filter((table) => table.syncAny);
|
|
769
851
|
if (tablesToReplicate.length > 0) {
|
|
770
852
|
this.replicationLag.trackUncommittedChange(
|
|
771
|
-
|
|
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)
|
|
772
858
|
);
|
|
773
859
|
|
|
774
860
|
const transactionKeyValue = transactionKey(changeDocument);
|
|
@@ -823,15 +909,15 @@ export class ChangeStream {
|
|
|
823
909
|
// Batches are generally large (64MB or 6000 events, whichever comes first),
|
|
824
910
|
// so this is a good natural point to flush and mark progress.
|
|
825
911
|
// We avoid this when splitDocument is set, since we cannot resume in the middle of a split event.
|
|
826
|
-
const
|
|
912
|
+
const lsn = this.checkpointImplementation.lsnFromResumeToken(resumeToken);
|
|
827
913
|
await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
|
|
828
914
|
// TODO: We should consider making this standard behavior of flush().
|
|
829
915
|
await batch.setResumeLsn(lsn);
|
|
830
916
|
}
|
|
831
917
|
|
|
832
918
|
batchSpan.end();
|
|
833
|
-
const
|
|
834
|
-
const duration = batchSpan.
|
|
919
|
+
const durationsMicroseconds = outerSpan.end();
|
|
920
|
+
const duration = batchSpan.durationMillis;
|
|
835
921
|
|
|
836
922
|
this.logger.info(
|
|
837
923
|
`Processed batch of ${events.length} changes / ${eventBatch.byteSize} bytes in ${duration}ms`,
|
|
@@ -839,7 +925,7 @@ export class ChangeStream {
|
|
|
839
925
|
count: events.length,
|
|
840
926
|
bytes: eventBatch.byteSize,
|
|
841
927
|
duration,
|
|
842
|
-
t:
|
|
928
|
+
t: durationsMicroseconds
|
|
843
929
|
}
|
|
844
930
|
);
|
|
845
931
|
outerSpan = tracer.span('batch');
|
|
@@ -47,7 +47,7 @@ export class ChangeStreamReplicationJob extends replication.AbstractReplicationJ
|
|
|
47
47
|
|
|
48
48
|
if (e instanceof ChangeStreamInvalidatedError) {
|
|
49
49
|
// This stops replication and restarts with a new instance
|
|
50
|
-
await this.options.storage.factory.restartReplication(this.storage.
|
|
50
|
+
await this.options.storage.factory.restartReplication(this.storage.replicationStreamId);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
// No need to rethrow - the error is already logged, and retry behavior is the same on error
|
|
@@ -17,7 +17,7 @@ export class ChangeStreamReplicator extends replication.AbstractReplicator<Chang
|
|
|
17
17
|
|
|
18
18
|
createJob(options: replication.CreateJobOptions): ChangeStreamReplicationJob {
|
|
19
19
|
return new ChangeStreamReplicationJob({
|
|
20
|
-
id: this.createJobId(options.storage.
|
|
20
|
+
id: this.createJobId(options.storage.replicationStreamId),
|
|
21
21
|
storage: options.storage,
|
|
22
22
|
metrics: this.metrics,
|
|
23
23
|
connectionFactory: this.connectionFactory,
|