@powersync/service-module-mongodb 0.0.0-dev-20260313100403 → 0.0.0-dev-20260515144844
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 +95 -7
- package/dist/api/MongoRouteAPIAdapter.js +2 -2
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +8 -16
- package/dist/replication/ChangeStream.js +284 -371
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.d.ts +1 -1
- package/dist/replication/ChangeStreamReplicationJob.js +3 -3
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/ChangeStreamReplicator.d.ts +1 -2
- package/dist/replication/ChangeStreamReplicator.js +1 -22
- package/dist/replication/ChangeStreamReplicator.js.map +1 -1
- package/dist/replication/JsonBufferWriter.d.ts +80 -0
- package/dist/replication/JsonBufferWriter.js +342 -0
- package/dist/replication/JsonBufferWriter.js.map +1 -0
- package/dist/replication/MongoManager.d.ts +1 -1
- package/dist/replication/MongoManager.js +1 -1
- package/dist/replication/MongoManager.js.map +1 -1
- package/dist/replication/MongoRelation.js +4 -0
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/MongoSnapshotQuery.d.ts +3 -1
- package/dist/replication/MongoSnapshotQuery.js +9 -4
- package/dist/replication/MongoSnapshotQuery.js.map +1 -1
- package/dist/replication/RawChangeStream.d.ts +55 -0
- package/dist/replication/RawChangeStream.js +322 -0
- package/dist/replication/RawChangeStream.js.map +1 -0
- package/dist/replication/SourceRowConverter.d.ts +46 -0
- package/dist/replication/SourceRowConverter.js +42 -0
- package/dist/replication/SourceRowConverter.js.map +1 -0
- package/dist/replication/bufferToSqlite.d.ts +43 -0
- package/dist/replication/bufferToSqlite.js +740 -0
- package/dist/replication/bufferToSqlite.js.map +1 -0
- package/dist/replication/internal-mongodb-utils.d.ts +9 -0
- package/dist/replication/internal-mongodb-utils.js +16 -0
- package/dist/replication/internal-mongodb-utils.js.map +1 -0
- package/dist/replication/replication-index.d.ts +5 -2
- package/dist/replication/replication-index.js +5 -2
- package/dist/replication/replication-index.js.map +1 -1
- package/dist/replication/replication-utils.d.ts +1 -1
- package/dist/types/types.js.map +1 -1
- package/package.json +11 -11
- package/scripts/benchmark-change-document-json.mts +358 -0
- package/scripts/benchmark-change-document.mts +370 -0
- package/src/api/MongoRouteAPIAdapter.ts +2 -2
- package/src/replication/ChangeStream.ts +380 -350
- package/src/replication/ChangeStreamReplicationJob.ts +3 -3
- package/src/replication/ChangeStreamReplicator.ts +2 -26
- package/src/replication/JsonBufferWriter.ts +390 -0
- package/src/replication/MongoManager.ts +2 -2
- package/src/replication/MongoRelation.ts +5 -2
- package/src/replication/MongoSnapshotQuery.ts +13 -6
- package/src/replication/RawChangeStream.ts +460 -0
- package/src/replication/SourceRowConverter.ts +65 -0
- package/src/replication/bufferToSqlite.ts +944 -0
- package/src/replication/internal-mongodb-utils.ts +25 -0
- package/src/replication/replication-index.ts +5 -2
- package/src/replication/replication-utils.ts +2 -2
- package/src/types/types.ts +1 -1
- package/test/src/buffer_to_sqlite.test.ts +1146 -0
- package/test/src/change_stream.test.ts +105 -3
- package/test/src/change_stream_utils.ts +4 -10
- package/test/src/mongo_test.test.ts +66 -64
- package/test/src/parse_document_id.test.ts +54 -0
- package/test/src/raw_change_stream.test.ts +547 -0
- package/test/src/resume.test.ts +12 -2
- package/test/src/util.ts +56 -3
- package/test/tsconfig.json +0 -1
- package/tsconfig.scripts.json +13 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,65 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
var dispose, inner;
|
|
5
|
-
if (async) {
|
|
6
|
-
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
|
|
7
|
-
dispose = value[Symbol.asyncDispose];
|
|
8
|
-
}
|
|
9
|
-
if (dispose === void 0) {
|
|
10
|
-
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
|
|
11
|
-
dispose = value[Symbol.dispose];
|
|
12
|
-
if (async) inner = dispose;
|
|
13
|
-
}
|
|
14
|
-
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
|
|
15
|
-
if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
|
|
16
|
-
env.stack.push({ value: value, dispose: dispose, async: async });
|
|
17
|
-
}
|
|
18
|
-
else if (async) {
|
|
19
|
-
env.stack.push({ async: true });
|
|
20
|
-
}
|
|
21
|
-
return value;
|
|
22
|
-
};
|
|
23
|
-
var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
|
|
24
|
-
return function (env) {
|
|
25
|
-
function fail(e) {
|
|
26
|
-
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
|
|
27
|
-
env.hasError = true;
|
|
28
|
-
}
|
|
29
|
-
var r, s = 0;
|
|
30
|
-
function next() {
|
|
31
|
-
while (r = env.stack.pop()) {
|
|
32
|
-
try {
|
|
33
|
-
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
|
|
34
|
-
if (r.dispose) {
|
|
35
|
-
var result = r.dispose.call(r.value);
|
|
36
|
-
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
|
|
37
|
-
}
|
|
38
|
-
else s |= 1;
|
|
39
|
-
}
|
|
40
|
-
catch (e) {
|
|
41
|
-
fail(e);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
|
|
45
|
-
if (env.hasError) throw env.error;
|
|
46
|
-
}
|
|
47
|
-
return next();
|
|
48
|
-
};
|
|
49
|
-
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
50
|
-
var e = new Error(message);
|
|
51
|
-
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
52
|
-
});
|
|
53
|
-
import { isMongoNetworkTimeoutError, isMongoServerError, mongo } from '@powersync/lib-service-mongodb';
|
|
54
|
-
import { container, DatabaseConnectionError, logger as defaultLogger, ErrorCode, ReplicationAbortedError, ReplicationAssertionError, ServiceError } from '@powersync/lib-services-framework';
|
|
55
|
-
import { RelationCache, SaveOperationTag } from '@powersync/service-core';
|
|
1
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
2
|
+
import { container, DatabaseConnectionError, ErrorCode, ReplicationAbortedError, ReplicationAssertionError, ServiceError } from '@powersync/lib-services-framework';
|
|
3
|
+
import { PerformanceTracer, RelationCache, ReplicationLagTracker, SaveOperationTag } from '@powersync/service-core';
|
|
56
4
|
import { ReplicationMetric } from '@powersync/service-types';
|
|
5
|
+
import { performance } from 'node:perf_hooks';
|
|
57
6
|
import { MongoLSN } from '../common/MongoLSN.js';
|
|
58
7
|
import { PostImagesOption } from '../types/types.js';
|
|
59
8
|
import { escapeRegExp } from '../utils.js';
|
|
60
|
-
import {
|
|
9
|
+
import { createCheckpoint, getCacheIdentifier, getMongoRelation, STANDALONE_CHECKPOINT_ID } from './MongoRelation.js';
|
|
61
10
|
import { ChunkedSnapshotQuery } from './MongoSnapshotQuery.js';
|
|
11
|
+
import { parseChangeDocument, rawChangeStream } from './RawChangeStream.js';
|
|
62
12
|
import { CHECKPOINTS_COLLECTION, timestampToDate } from './replication-utils.js';
|
|
13
|
+
import { DirectSourceRowConverter } from './SourceRowConverter.js';
|
|
63
14
|
/**
|
|
64
15
|
* Thrown when the change stream is not valid anymore, and replication
|
|
65
16
|
* must be restarted.
|
|
@@ -85,20 +36,12 @@ export class ChangeStream {
|
|
|
85
36
|
maxAwaitTimeMS;
|
|
86
37
|
abort_signal;
|
|
87
38
|
relationCache = new RelationCache(getCacheIdentifier);
|
|
88
|
-
|
|
89
|
-
* Time of the oldest uncommitted change, according to the source db.
|
|
90
|
-
* This is used to determine the replication lag.
|
|
91
|
-
*/
|
|
92
|
-
oldestUncommittedChange = null;
|
|
93
|
-
/**
|
|
94
|
-
* Keep track of whether we have done a commit or keepalive yet.
|
|
95
|
-
* We can only compute replication lag if isStartingReplication == false, or oldestUncommittedChange is present.
|
|
96
|
-
*/
|
|
97
|
-
isStartingReplication = true;
|
|
39
|
+
replicationLag = new ReplicationLagTracker();
|
|
98
40
|
checkpointStreamId = new mongo.ObjectId();
|
|
99
41
|
logger;
|
|
100
42
|
snapshotChunkLength;
|
|
101
43
|
changeStreamTimeout;
|
|
44
|
+
sourceRowConverter;
|
|
102
45
|
constructor(options) {
|
|
103
46
|
this.storage = options.storage;
|
|
104
47
|
this.metrics = options.metrics;
|
|
@@ -111,6 +54,7 @@ export class ChangeStream {
|
|
|
111
54
|
this.sync_rules = options.storage.getParsedSyncRules({
|
|
112
55
|
defaultSchema: this.defaultDb.databaseName
|
|
113
56
|
});
|
|
57
|
+
this.sourceRowConverter = new DirectSourceRowConverter(this.sync_rules.compatibility);
|
|
114
58
|
// The change stream aggregation command should timeout before the socket times out,
|
|
115
59
|
// so we use 90% of the socket timeout value.
|
|
116
60
|
this.changeStreamTimeout = Math.ceil(this.client.options.socketTimeoutMS * 0.9);
|
|
@@ -118,7 +62,7 @@ export class ChangeStream {
|
|
|
118
62
|
this.abort_signal.addEventListener('abort', () => {
|
|
119
63
|
// TODO: Fast abort?
|
|
120
64
|
}, { once: true });
|
|
121
|
-
this.logger = options.logger ??
|
|
65
|
+
this.logger = options.logger ?? this.storage.logger;
|
|
122
66
|
}
|
|
123
67
|
get stopped() {
|
|
124
68
|
return this.abort_signal.aborted;
|
|
@@ -188,48 +132,51 @@ export class ChangeStream {
|
|
|
188
132
|
* This LSN can survive initial replication restarts.
|
|
189
133
|
*/
|
|
190
134
|
async getSnapshotLsn() {
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
135
|
+
const hello = await this.defaultDb.command({ hello: 1 });
|
|
136
|
+
// Basic sanity check
|
|
137
|
+
if (hello.msg == 'isdbgrid') {
|
|
138
|
+
throw new ServiceError(ErrorCode.PSYNC_S1341, 'Sharded MongoDB Clusters are not supported yet (including MongoDB Serverless instances).');
|
|
139
|
+
}
|
|
140
|
+
else if (hello.setName == null) {
|
|
141
|
+
throw new ServiceError(ErrorCode.PSYNC_S1342, 'Standalone MongoDB instances are not supported - use a replicaset.');
|
|
142
|
+
}
|
|
143
|
+
// Open a change stream just to get a resume token for later use.
|
|
144
|
+
// We could use clusterTime from the hello command, but that won't tell us if the
|
|
145
|
+
// snapshot isn't valid anymore.
|
|
146
|
+
// If we just use the first resumeToken from the stream, we get two potential issues:
|
|
147
|
+
// 1. The resumeToken may just be a wrapped clusterTime, which does not detect changes
|
|
148
|
+
// in source db or other stream issues.
|
|
149
|
+
// 2. The first actual change we get may have the same clusterTime, causing us to incorrect
|
|
150
|
+
// skip that event.
|
|
151
|
+
// Instead, we create a new checkpoint document, and wait until we get that document back in the stream.
|
|
152
|
+
// To avoid potential race conditions with the checkpoint creation, we create a new checkpoint document
|
|
153
|
+
// periodically until the timeout is reached.
|
|
154
|
+
const LSN_TIMEOUT_SECONDS = 60;
|
|
155
|
+
const LSN_CREATE_INTERVAL_SECONDS = 1;
|
|
156
|
+
// Create a checkpoint, and open a change stream using startAtOperationTime with the checkpoint's operationTime.
|
|
157
|
+
const firstCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
158
|
+
const startTime = performance.now();
|
|
159
|
+
let lastCheckpointCreated = performance.now();
|
|
160
|
+
let eventsSeen = 0;
|
|
161
|
+
let batchesSeen = 0;
|
|
162
|
+
const filters = this.getSourceNamespaceFilters();
|
|
163
|
+
const iter = this.rawChangeStreamBatches({
|
|
164
|
+
lsn: firstCheckpointLsn,
|
|
165
|
+
maxAwaitTimeMS: 0,
|
|
166
|
+
signal: this.abort_signal,
|
|
167
|
+
filters
|
|
168
|
+
});
|
|
169
|
+
for await (let { events } of iter) {
|
|
170
|
+
if (performance.now() - startTime >= LSN_TIMEOUT_SECONDS * 1000) {
|
|
171
|
+
break;
|
|
197
172
|
}
|
|
198
|
-
|
|
199
|
-
|
|
173
|
+
if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
|
|
174
|
+
await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
175
|
+
lastCheckpointCreated = performance.now();
|
|
200
176
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
// If we just use the first resumeToken from the stream, we get two potential issues:
|
|
205
|
-
// 1. The resumeToken may just be a wrapped clusterTime, which does not detect changes
|
|
206
|
-
// in source db or other stream issues.
|
|
207
|
-
// 2. The first actual change we get may have the same clusterTime, causing us to incorrect
|
|
208
|
-
// skip that event.
|
|
209
|
-
// Instead, we create a new checkpoint document, and wait until we get that document back in the stream.
|
|
210
|
-
// To avoid potential race conditions with the checkpoint creation, we create a new checkpoint document
|
|
211
|
-
// periodically until the timeout is reached.
|
|
212
|
-
const LSN_TIMEOUT_SECONDS = 60;
|
|
213
|
-
const LSN_CREATE_INTERVAL_SECONDS = 1;
|
|
214
|
-
// Create a checkpoint, and open a change stream using startAtOperationTime with the checkpoint's operationTime.
|
|
215
|
-
const firstCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
216
|
-
const streamManager = __addDisposableResource(env_1, this.openChangeStream({ lsn: firstCheckpointLsn, maxAwaitTimeMs: 0 }), true);
|
|
217
|
-
const { stream } = streamManager;
|
|
218
|
-
const startTime = performance.now();
|
|
219
|
-
let lastCheckpointCreated = performance.now();
|
|
220
|
-
let eventsSeen = 0;
|
|
221
|
-
while (performance.now() - startTime < LSN_TIMEOUT_SECONDS * 1000) {
|
|
222
|
-
if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
|
|
223
|
-
await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
224
|
-
lastCheckpointCreated = performance.now();
|
|
225
|
-
}
|
|
226
|
-
// tryNext() doesn't block, while next() / hasNext() does block until there is data on the stream
|
|
227
|
-
const changeDocument = await stream.tryNext().catch((e) => {
|
|
228
|
-
throw mapChangeStreamError(e);
|
|
229
|
-
});
|
|
230
|
-
if (changeDocument == null) {
|
|
231
|
-
continue;
|
|
232
|
-
}
|
|
177
|
+
batchesSeen += 1;
|
|
178
|
+
for (let rawChangeDocument of events) {
|
|
179
|
+
const changeDocument = parseChangeDocument(rawChangeDocument);
|
|
233
180
|
const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
|
|
234
181
|
if (ns?.coll == CHECKPOINTS_COLLECTION && 'documentKey' in changeDocument) {
|
|
235
182
|
const checkpointId = changeDocument.documentKey._id;
|
|
@@ -244,56 +191,38 @@ export class ChangeStream {
|
|
|
244
191
|
}
|
|
245
192
|
eventsSeen += 1;
|
|
246
193
|
}
|
|
247
|
-
// Could happen if there is a very large replication lag?
|
|
248
|
-
throw new ServiceError(ErrorCode.PSYNC_S1301, `Timeout after while waiting for checkpoint document for ${LSN_TIMEOUT_SECONDS}s. Streamed events = ${eventsSeen}`);
|
|
249
|
-
}
|
|
250
|
-
catch (e_1) {
|
|
251
|
-
env_1.error = e_1;
|
|
252
|
-
env_1.hasError = true;
|
|
253
|
-
}
|
|
254
|
-
finally {
|
|
255
|
-
const result_1 = __disposeResources(env_1);
|
|
256
|
-
if (result_1)
|
|
257
|
-
await result_1;
|
|
258
194
|
}
|
|
195
|
+
// Could happen if there is a very large replication lag?
|
|
196
|
+
throw new ServiceError(ErrorCode.PSYNC_S1301, `Timeout after while waiting for checkpoint document for ${LSN_TIMEOUT_SECONDS}s. Streamed events = ${eventsSeen}, batches = ${batchesSeen}`);
|
|
259
197
|
}
|
|
260
198
|
/**
|
|
261
199
|
* Given a snapshot LSN, validate that we can read from it, by opening a change stream.
|
|
262
200
|
*/
|
|
263
201
|
async validateSnapshotLsn(lsn) {
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
throw mapChangeStreamError(e);
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
catch (e_2) {
|
|
279
|
-
env_2.error = e_2;
|
|
280
|
-
env_2.hasError = true;
|
|
281
|
-
}
|
|
282
|
-
finally {
|
|
283
|
-
const result_2 = __disposeResources(env_2);
|
|
284
|
-
if (result_2)
|
|
285
|
-
await result_2;
|
|
202
|
+
const filters = this.getSourceNamespaceFilters();
|
|
203
|
+
const stream = this.rawChangeStreamBatches({
|
|
204
|
+
lsn: lsn,
|
|
205
|
+
// maxAwaitTimeMS should never actually be used here
|
|
206
|
+
maxAwaitTimeMS: 0,
|
|
207
|
+
filters
|
|
208
|
+
});
|
|
209
|
+
for await (let _batch of stream) {
|
|
210
|
+
// We got a response from the aggregate command, so consider the LSN valid.
|
|
211
|
+
// Close the stream immediately.
|
|
212
|
+
break;
|
|
286
213
|
}
|
|
287
214
|
}
|
|
288
215
|
async initialReplication(snapshotLsn) {
|
|
289
216
|
const sourceTables = this.sync_rules.getSourceTables();
|
|
290
217
|
await this.client.connect();
|
|
218
|
+
const tracer = new PerformanceTracer('MongoDB initial replication');
|
|
291
219
|
const flushResult = await this.storage.startBatch({
|
|
292
220
|
logger: this.logger,
|
|
293
221
|
zeroLSN: MongoLSN.ZERO.comparable,
|
|
294
222
|
defaultSchema: this.defaultDb.databaseName,
|
|
295
223
|
storeCurrentData: false,
|
|
296
|
-
skipExistingRows: true
|
|
224
|
+
skipExistingRows: true,
|
|
225
|
+
tracer
|
|
297
226
|
}, async (batch) => {
|
|
298
227
|
if (snapshotLsn == null) {
|
|
299
228
|
// First replication attempt - get a snapshot and store the timestamp
|
|
@@ -414,81 +343,69 @@ export class ChangeStream {
|
|
|
414
343
|
}
|
|
415
344
|
return { $match: nsFilter, multipleDatabases };
|
|
416
345
|
}
|
|
417
|
-
static *getQueryData(results) {
|
|
418
|
-
for (let row of results) {
|
|
419
|
-
yield constructAfterRecord(row);
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
346
|
async snapshotTable(batch, table) {
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
347
|
+
const rowsReplicatedMetric = this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED);
|
|
348
|
+
const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
|
|
349
|
+
const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
|
|
350
|
+
const totalEstimatedCount = await this.estimatedCountNumber(table);
|
|
351
|
+
let at = table.snapshotStatus?.replicatedCount ?? 0;
|
|
352
|
+
const db = this.client.db(table.schema);
|
|
353
|
+
const collection = db.collection(table.name);
|
|
354
|
+
await using query = new ChunkedSnapshotQuery({
|
|
355
|
+
collection,
|
|
356
|
+
key: table.snapshotStatus?.lastKey,
|
|
357
|
+
batchSize: this.snapshotChunkLength
|
|
358
|
+
});
|
|
359
|
+
if (query.lastKey != null) {
|
|
360
|
+
this.logger.info(`Replicating ${table.qualifiedName} ${table.formatSnapshotProgress()} - resuming at _id > ${query.lastKey}`);
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
this.logger.info(`Replicating ${table.qualifiedName} ${table.formatSnapshotProgress()}`);
|
|
364
|
+
}
|
|
365
|
+
let lastBatch = performance.now();
|
|
366
|
+
let nextChunkPromise = query.nextChunk();
|
|
367
|
+
while (true) {
|
|
368
|
+
const { docs: docBatch, lastKey, bytes: chunkBytes } = await nextChunkPromise;
|
|
369
|
+
if (docBatch.length == 0) {
|
|
370
|
+
// No more data - stop iterating
|
|
371
|
+
break;
|
|
436
372
|
}
|
|
437
|
-
|
|
438
|
-
|
|
373
|
+
bytesReplicatedMetric.add(chunkBytes);
|
|
374
|
+
chunksReplicatedMetric.add(1);
|
|
375
|
+
if (this.abort_signal.aborted) {
|
|
376
|
+
throw new ReplicationAbortedError(`Aborted initial replication`, this.abort_signal.reason);
|
|
439
377
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
const {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
nextChunkPromise = query.nextChunk();
|
|
453
|
-
for (let document of docBatch) {
|
|
454
|
-
const record = this.constructAfterRecord(document);
|
|
455
|
-
// This auto-flushes when the batch reaches its size limit
|
|
456
|
-
await batch.save({
|
|
457
|
-
tag: SaveOperationTag.INSERT,
|
|
458
|
-
sourceTable: table,
|
|
459
|
-
before: undefined,
|
|
460
|
-
beforeReplicaId: undefined,
|
|
461
|
-
after: record,
|
|
462
|
-
afterReplicaId: document._id
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
// Important: flush before marking progress
|
|
466
|
-
await batch.flush();
|
|
467
|
-
at += docBatch.length;
|
|
468
|
-
this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(docBatch.length);
|
|
469
|
-
table = await batch.updateTableProgress(table, {
|
|
470
|
-
lastKey,
|
|
471
|
-
replicatedCount: at,
|
|
472
|
-
totalEstimatedCount: totalEstimatedCount
|
|
378
|
+
// Pre-fetch next batch, so that we can read and write concurrently
|
|
379
|
+
nextChunkPromise = query.nextChunk();
|
|
380
|
+
for (let buffer of docBatch) {
|
|
381
|
+
const { row: record, replicaId: replicaId } = this.rawToSqliteRow(buffer);
|
|
382
|
+
// This auto-flushes when the batch reaches its size limit
|
|
383
|
+
await batch.save({
|
|
384
|
+
tag: SaveOperationTag.INSERT,
|
|
385
|
+
sourceTable: table,
|
|
386
|
+
before: undefined,
|
|
387
|
+
beforeReplicaId: undefined,
|
|
388
|
+
after: record,
|
|
389
|
+
afterReplicaId: replicaId
|
|
473
390
|
});
|
|
474
|
-
this.relationCache.update(table);
|
|
475
|
-
const duration = performance.now() - lastBatch;
|
|
476
|
-
lastBatch = performance.now();
|
|
477
|
-
this.logger.info(`Replicating ${table.qualifiedName} ${table.formatSnapshotProgress()} in ${duration.toFixed(0)}ms`);
|
|
478
|
-
this.touch();
|
|
479
391
|
}
|
|
480
|
-
//
|
|
481
|
-
await
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
392
|
+
// Important: flush before marking progress
|
|
393
|
+
await batch.flush();
|
|
394
|
+
at += docBatch.length;
|
|
395
|
+
rowsReplicatedMetric.add(docBatch.length);
|
|
396
|
+
table = await batch.updateTableProgress(table, {
|
|
397
|
+
lastKey,
|
|
398
|
+
replicatedCount: at,
|
|
399
|
+
totalEstimatedCount: totalEstimatedCount
|
|
400
|
+
});
|
|
401
|
+
this.relationCache.update(table);
|
|
402
|
+
const duration = performance.now() - lastBatch;
|
|
403
|
+
lastBatch = performance.now();
|
|
404
|
+
this.logger.info(`Replicating ${table.qualifiedName} ${table.formatSnapshotProgress()} in ${duration.toFixed(0)}ms`);
|
|
405
|
+
this.touch();
|
|
491
406
|
}
|
|
407
|
+
// In case the loop was interrupted, make sure we await the last promise.
|
|
408
|
+
await nextChunkPromise;
|
|
492
409
|
}
|
|
493
410
|
async getRelation(batch, descriptor, options) {
|
|
494
411
|
const existing = this.relationCache.get(descriptor);
|
|
@@ -552,7 +469,7 @@ export class ChangeStream {
|
|
|
552
469
|
// Snapshot if:
|
|
553
470
|
// 1. Snapshot is requested (false for initial snapshot, since that process handles it elsewhere)
|
|
554
471
|
// 2. Snapshot is not already done, AND:
|
|
555
|
-
// 3. The table is used in sync
|
|
472
|
+
// 3. The table is used in sync config.
|
|
556
473
|
const shouldSnapshot = snapshot && !result.table.snapshotComplete && result.table.syncAny;
|
|
557
474
|
if (shouldSnapshot) {
|
|
558
475
|
this.logger.info(`New collection: ${descriptor.schema}.${descriptor.name}`);
|
|
@@ -565,24 +482,23 @@ export class ChangeStream {
|
|
|
565
482
|
}
|
|
566
483
|
return result.table;
|
|
567
484
|
}
|
|
568
|
-
constructAfterRecord(document) {
|
|
569
|
-
const inputRow = constructAfterRecord(document);
|
|
570
|
-
return this.sync_rules.applyRowContext(inputRow);
|
|
571
|
-
}
|
|
572
485
|
async writeChange(batch, table, change) {
|
|
573
486
|
if (!table.syncAny) {
|
|
574
|
-
this.logger.debug(`Collection ${table.qualifiedName} not used in sync
|
|
487
|
+
this.logger.debug(`Collection ${table.qualifiedName} not used in sync config - skipping`);
|
|
575
488
|
return null;
|
|
576
489
|
}
|
|
577
490
|
this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(1);
|
|
578
491
|
if (change.operationType == 'insert') {
|
|
579
|
-
const baseRecord = this.
|
|
492
|
+
const { row: baseRecord, replicaId: _replicaId } = this.rawToSqliteRow(change.fullDocument);
|
|
580
493
|
return await batch.save({
|
|
581
494
|
tag: SaveOperationTag.INSERT,
|
|
582
495
|
sourceTable: table,
|
|
583
496
|
before: undefined,
|
|
584
497
|
beforeReplicaId: undefined,
|
|
585
498
|
after: baseRecord,
|
|
499
|
+
// Same as _replicaId
|
|
500
|
+
// We specifically need to use the source _id, not the converted one in baseRecord,
|
|
501
|
+
// to preserve _id uniqueness properties.
|
|
586
502
|
afterReplicaId: change.documentKey._id
|
|
587
503
|
});
|
|
588
504
|
}
|
|
@@ -596,14 +512,14 @@ export class ChangeStream {
|
|
|
596
512
|
beforeReplicaId: change.documentKey._id
|
|
597
513
|
});
|
|
598
514
|
}
|
|
599
|
-
const after = this.
|
|
515
|
+
const { row: after, replicaId: _replicaId } = this.rawToSqliteRow(change.fullDocument);
|
|
600
516
|
return await batch.save({
|
|
601
517
|
tag: SaveOperationTag.UPDATE,
|
|
602
518
|
sourceTable: table,
|
|
603
519
|
before: undefined,
|
|
604
520
|
beforeReplicaId: undefined,
|
|
605
521
|
after: after,
|
|
606
|
-
afterReplicaId: change.documentKey._id
|
|
522
|
+
afterReplicaId: change.documentKey._id // Same as _replicaId
|
|
607
523
|
});
|
|
608
524
|
}
|
|
609
525
|
else if (change.operationType == 'delete') {
|
|
@@ -640,7 +556,7 @@ export class ChangeStream {
|
|
|
640
556
|
}
|
|
641
557
|
const { lastOpId } = await this.initialReplication(result.snapshotLsn);
|
|
642
558
|
if (lastOpId != null) {
|
|
643
|
-
// Populate the cache _after_ initial replication, but _before_ we switch to this
|
|
559
|
+
// Populate the cache _after_ initial replication, but _before_ we switch to this replication stream.
|
|
644
560
|
await this.storage.populatePersistentChecksumCache({
|
|
645
561
|
signal: this.abort_signal,
|
|
646
562
|
// No checkpoint yet, but we do have the opId.
|
|
@@ -662,17 +578,11 @@ export class ChangeStream {
|
|
|
662
578
|
throw e;
|
|
663
579
|
}
|
|
664
580
|
}
|
|
665
|
-
|
|
581
|
+
rawChangeStreamBatches(options) {
|
|
666
582
|
const lastLsn = options.lsn ? MongoLSN.fromSerialized(options.lsn) : null;
|
|
667
583
|
const startAfter = lastLsn?.timestamp;
|
|
668
584
|
const resumeAfter = lastLsn?.resumeToken;
|
|
669
|
-
const filters =
|
|
670
|
-
const pipeline = [
|
|
671
|
-
{
|
|
672
|
-
$match: filters.$match
|
|
673
|
-
},
|
|
674
|
-
{ $changeStreamSplitLargeEvent: {} }
|
|
675
|
-
];
|
|
585
|
+
const filters = options.filters;
|
|
676
586
|
let fullDocument;
|
|
677
587
|
if (this.usePostImages) {
|
|
678
588
|
// 'read_only' or 'auto_configure'
|
|
@@ -685,10 +595,17 @@ export class ChangeStream {
|
|
|
685
595
|
}
|
|
686
596
|
const streamOptions = {
|
|
687
597
|
showExpandedEvents: true,
|
|
688
|
-
|
|
689
|
-
fullDocument: fullDocument,
|
|
690
|
-
maxTimeMS: this.changeStreamTimeout
|
|
598
|
+
fullDocument: fullDocument
|
|
691
599
|
};
|
|
600
|
+
const pipeline = [
|
|
601
|
+
{
|
|
602
|
+
$changeStream: streamOptions
|
|
603
|
+
},
|
|
604
|
+
{
|
|
605
|
+
$match: filters.$match
|
|
606
|
+
},
|
|
607
|
+
{ $changeStreamSplitLargeEvent: {} }
|
|
608
|
+
];
|
|
692
609
|
/**
|
|
693
610
|
* Only one of these options can be supplied at a time.
|
|
694
611
|
*/
|
|
@@ -701,98 +618,103 @@ export class ChangeStream {
|
|
|
701
618
|
// This is also relevant for getSnapshotLSN().
|
|
702
619
|
streamOptions.startAtOperationTime = startAfter;
|
|
703
620
|
}
|
|
704
|
-
let
|
|
621
|
+
let watchDb;
|
|
705
622
|
if (filters.multipleDatabases) {
|
|
706
|
-
|
|
707
|
-
|
|
623
|
+
watchDb = this.client.db('admin');
|
|
624
|
+
streamOptions.allChangesForCluster = true;
|
|
708
625
|
}
|
|
709
626
|
else {
|
|
710
|
-
|
|
711
|
-
stream = this.defaultDb.watch(pipeline, streamOptions);
|
|
627
|
+
watchDb = this.defaultDb;
|
|
712
628
|
}
|
|
713
|
-
|
|
714
|
-
|
|
629
|
+
return rawChangeStream(watchDb, pipeline, {
|
|
630
|
+
batchSize: options.batchSize ?? this.snapshotChunkLength,
|
|
631
|
+
maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
|
|
632
|
+
maxTimeMS: this.changeStreamTimeout,
|
|
633
|
+
signal: options.signal,
|
|
634
|
+
logger: this.logger,
|
|
635
|
+
tracer: options.tracer
|
|
715
636
|
});
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
[Symbol.asyncDispose]: async () => {
|
|
720
|
-
return stream.close();
|
|
721
|
-
}
|
|
722
|
-
};
|
|
637
|
+
}
|
|
638
|
+
rawToSqliteRow(row) {
|
|
639
|
+
return this.sourceRowConverter.rawToSqliteRow(row);
|
|
723
640
|
}
|
|
724
641
|
async streamChangesInternal() {
|
|
642
|
+
const transactionsReplicatedMetric = this.metrics.getCounter(ReplicationMetric.TRANSACTIONS_REPLICATED);
|
|
643
|
+
const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
|
|
644
|
+
const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
|
|
645
|
+
const tracer = new PerformanceTracer('MongoDB streaming replication');
|
|
725
646
|
await this.storage.startBatch({
|
|
726
647
|
logger: this.logger,
|
|
727
648
|
zeroLSN: MongoLSN.ZERO.comparable,
|
|
728
649
|
defaultSchema: this.defaultDb.databaseName,
|
|
729
650
|
// We get a complete postimage for every change, so we don't need to store the current data.
|
|
730
|
-
storeCurrentData: false
|
|
651
|
+
storeCurrentData: false,
|
|
652
|
+
tracer
|
|
731
653
|
}, async (batch) => {
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
654
|
+
const { resumeFromLsn } = batch;
|
|
655
|
+
if (resumeFromLsn == null) {
|
|
656
|
+
throw new ReplicationAssertionError(`No LSN found to resume from`);
|
|
657
|
+
}
|
|
658
|
+
const lastLsn = MongoLSN.fromSerialized(resumeFromLsn);
|
|
659
|
+
const startAfter = lastLsn?.timestamp;
|
|
660
|
+
let outerSpan = tracer.span('batch');
|
|
661
|
+
// It is normal for this to be a minute or two old when there is a low volume
|
|
662
|
+
// of ChangeStream events.
|
|
663
|
+
const tokenAgeSeconds = Math.round((Date.now() - timestampToDate(startAfter).getTime()) / 1000);
|
|
664
|
+
this.logger.info(`Resume streaming at ${startAfter?.inspect()} / ${lastLsn} | Token age: ${tokenAgeSeconds}s`);
|
|
665
|
+
const filters = this.getSourceNamespaceFilters();
|
|
666
|
+
// This is closed when the for loop below returns/breaks/throws
|
|
667
|
+
const batchStream = this.rawChangeStreamBatches({
|
|
668
|
+
lsn: resumeFromLsn,
|
|
669
|
+
filters,
|
|
670
|
+
signal: this.abort_signal,
|
|
671
|
+
tracer
|
|
672
|
+
});
|
|
673
|
+
// Always start with a checkpoint.
|
|
674
|
+
// This helps us to clear errors when restarting, even if there is
|
|
675
|
+
// no data to replicate.
|
|
676
|
+
let waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
677
|
+
let splitDocument = null;
|
|
678
|
+
let flexDbNameWorkaroundLogged = false;
|
|
679
|
+
let lastEmptyResume = performance.now();
|
|
680
|
+
let lastTxnKey = null;
|
|
681
|
+
for await (let eventBatch of batchStream) {
|
|
682
|
+
const { events, resumeToken } = eventBatch;
|
|
683
|
+
using batchSpan = tracer.span('processing');
|
|
684
|
+
bytesReplicatedMetric.add(eventBatch.byteSize);
|
|
685
|
+
chunksReplicatedMetric.add(1);
|
|
746
686
|
if (this.abort_signal.aborted) {
|
|
747
|
-
|
|
748
|
-
return;
|
|
687
|
+
break;
|
|
749
688
|
}
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
689
|
+
this.touch();
|
|
690
|
+
if (events.length == 0) {
|
|
691
|
+
// No changes in this batch, but we still want to keep the connection alive.
|
|
692
|
+
// We do this by persisting a keepalive checkpoint.
|
|
693
|
+
// If we don't update it on empty events, we do keep consistency, but resuming the stream
|
|
694
|
+
// with old tokens may cause connection timeouts.
|
|
695
|
+
if (waitForCheckpointLsn == null && performance.now() - lastEmptyResume > 60_000) {
|
|
696
|
+
const { comparable: lsn, timestamp } = MongoLSN.fromResumeToken(resumeToken);
|
|
697
|
+
await batch.keepalive(lsn);
|
|
698
|
+
this.touch();
|
|
699
|
+
lastEmptyResume = performance.now();
|
|
700
|
+
// Log the token update. This helps as a general "replication is still active" message in the logs.
|
|
701
|
+
// This token would typically be around 10s behind.
|
|
702
|
+
this.logger.info(`Idle change stream. Persisted resumeToken for ${timestampToDate(timestamp).toISOString()}`);
|
|
703
|
+
this.replicationLag.markStarted();
|
|
761
704
|
}
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
if (!originalChangeDocument && stream.closed) {
|
|
767
|
-
break;
|
|
705
|
+
// If we have no changes, we can just persist the keepalive.
|
|
706
|
+
// This is throttled to once per minute.
|
|
707
|
+
if (performance.now() - lastEmptyResume < 60_000) {
|
|
708
|
+
continue;
|
|
768
709
|
}
|
|
710
|
+
}
|
|
711
|
+
this.touch();
|
|
712
|
+
for (let eventIndex = 0; eventIndex < events.length; eventIndex++) {
|
|
713
|
+
const rawChangeDocument = events[eventIndex];
|
|
714
|
+
const originalChangeDocument = parseChangeDocument(rawChangeDocument);
|
|
769
715
|
if (this.abort_signal.aborted) {
|
|
770
716
|
break;
|
|
771
717
|
}
|
|
772
|
-
if (originalChangeDocument == null) {
|
|
773
|
-
// We get a new null document after `maxAwaitTimeMS` if there were no other events.
|
|
774
|
-
// In this case, stream.resumeToken is the resume token associated with the last response.
|
|
775
|
-
// stream.resumeToken is not updated if stream.tryNext() returns data, while stream.next()
|
|
776
|
-
// does update it.
|
|
777
|
-
// From observed behavior, the actual resumeToken changes around once every 10 seconds.
|
|
778
|
-
// If we don't update it on empty events, we do keep consistency, but resuming the stream
|
|
779
|
-
// with old tokens may cause connection timeouts.
|
|
780
|
-
// We throttle this further by only persisting a keepalive once a minute.
|
|
781
|
-
// We add an additional check for waitForCheckpointLsn == null, to make sure we're not
|
|
782
|
-
// doing a keepalive in the middle of a transaction.
|
|
783
|
-
if (waitForCheckpointLsn == null && performance.now() - lastEmptyResume > 60_000) {
|
|
784
|
-
const { comparable: lsn, timestamp } = MongoLSN.fromResumeToken(stream.resumeToken);
|
|
785
|
-
await batch.keepalive(lsn);
|
|
786
|
-
this.touch();
|
|
787
|
-
lastEmptyResume = performance.now();
|
|
788
|
-
// Log the token update. This helps as a general "replication is still active" message in the logs.
|
|
789
|
-
// This token would typically be around 10s behind.
|
|
790
|
-
this.logger.info(`Idle change stream. Persisted resumeToken for ${timestampToDate(timestamp).toISOString()}`);
|
|
791
|
-
this.isStartingReplication = false;
|
|
792
|
-
}
|
|
793
|
-
continue;
|
|
794
|
-
}
|
|
795
|
-
this.touch();
|
|
796
718
|
if (startAfter != null && originalChangeDocument.clusterTime?.lte(startAfter)) {
|
|
797
719
|
continue;
|
|
798
720
|
}
|
|
@@ -846,15 +768,15 @@ export class ChangeStream {
|
|
|
846
768
|
* would process below.
|
|
847
769
|
*
|
|
848
770
|
* However we don't commit the LSN after collections are dropped.
|
|
849
|
-
*
|
|
771
|
+
* This prevents the `startAfter` or `resumeToken` from advancing past the drop events.
|
|
850
772
|
* The stream also closes after the drop events.
|
|
851
773
|
* This causes an infinite loop of processing the collection drop events.
|
|
852
774
|
*
|
|
853
|
-
* This check here invalidates the change stream if our `
|
|
775
|
+
* This check here invalidates the change stream if our `_powersync_checkpoints` collection
|
|
854
776
|
* is dropped. This allows for detecting when the DB is dropped.
|
|
855
777
|
*/
|
|
856
778
|
if (changeDocument.operationType == 'drop') {
|
|
857
|
-
throw new ChangeStreamInvalidatedError('Internal collections have been dropped', new Error('
|
|
779
|
+
throw new ChangeStreamInvalidatedError('Internal collections have been dropped', new Error('_powersync_checkpoints collection was dropped'));
|
|
858
780
|
}
|
|
859
781
|
if (!(changeDocument.operationType == 'insert' ||
|
|
860
782
|
changeDocument.operationType == 'update' ||
|
|
@@ -870,7 +792,21 @@ export class ChangeStream {
|
|
|
870
792
|
// It may be useful to also throttle commits due to standalone checkpoints in the future.
|
|
871
793
|
// However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
|
|
872
794
|
const checkpointId = changeDocument.documentKey._id;
|
|
873
|
-
if (
|
|
795
|
+
if (checkpointId == STANDALONE_CHECKPOINT_ID) {
|
|
796
|
+
// Standalone / write checkpoint received.
|
|
797
|
+
// When we are caught up, commit immediately to keep write checkpoint latency low.
|
|
798
|
+
// Once there is already a batch checkpoint pending, or the driver has buffered more
|
|
799
|
+
// change stream events, collapse standalone checkpoints into the normal batch
|
|
800
|
+
// checkpoint flow to avoid commit churn under sustained load.
|
|
801
|
+
const hasBufferedChanges = eventIndex < events.length - 1;
|
|
802
|
+
if (waitForCheckpointLsn != null || hasBufferedChanges) {
|
|
803
|
+
if (waitForCheckpointLsn == null) {
|
|
804
|
+
waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
805
|
+
}
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
else if (!this.checkpointStreamId.equals(checkpointId)) {
|
|
874
810
|
continue;
|
|
875
811
|
}
|
|
876
812
|
const { comparable: lsn } = new MongoLSN({
|
|
@@ -889,12 +825,10 @@ export class ChangeStream {
|
|
|
889
825
|
waitForCheckpointLsn = null;
|
|
890
826
|
}
|
|
891
827
|
const { checkpointBlocked } = await batch.commit(lsn, {
|
|
892
|
-
oldestUncommittedChange: this.oldestUncommittedChange
|
|
828
|
+
oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
|
|
893
829
|
});
|
|
894
830
|
if (!checkpointBlocked) {
|
|
895
|
-
this.
|
|
896
|
-
this.isStartingReplication = false;
|
|
897
|
-
changesSinceLastCheckpoint = 0;
|
|
831
|
+
this.replicationLag.markCommitted();
|
|
898
832
|
}
|
|
899
833
|
}
|
|
900
834
|
else if (changeDocument.operationType == 'insert' ||
|
|
@@ -909,28 +843,20 @@ export class ChangeStream {
|
|
|
909
843
|
// In most cases, we should not need to snapshot this. But if this is the first time we see the collection
|
|
910
844
|
// for whatever reason, then we do need to snapshot it.
|
|
911
845
|
// This may result in some duplicate operations when a collection is created for the first time after
|
|
912
|
-
// sync
|
|
846
|
+
// sync config was deployed.
|
|
913
847
|
snapshot: true
|
|
914
848
|
});
|
|
915
849
|
if (table.syncAny) {
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
// we don't restart from scratch if we restart replication.
|
|
925
|
-
// The same could apply if we need to catch up on replication after some downtime.
|
|
926
|
-
const { comparable: lsn } = new MongoLSN({
|
|
927
|
-
timestamp: changeDocument.clusterTime,
|
|
928
|
-
resume_token: changeDocument._id
|
|
929
|
-
});
|
|
930
|
-
this.logger.info(`Updating resume LSN to ${lsn} after ${changesSinceLastCheckpoint} changes`);
|
|
931
|
-
await batch.setResumeLsn(lsn);
|
|
932
|
-
changesSinceLastCheckpoint = 0;
|
|
850
|
+
this.replicationLag.trackUncommittedChange(changeDocument.clusterTime == null ? null : timestampToDate(changeDocument.clusterTime));
|
|
851
|
+
const transactionKeyValue = transactionKey(changeDocument);
|
|
852
|
+
if (transactionKeyValue == null || lastTxnKey != transactionKeyValue) {
|
|
853
|
+
// Very crude metric for counting transactions replicated.
|
|
854
|
+
// We ignore operations other than basic CRUD, and ignore changes to _powersync_checkpoints.
|
|
855
|
+
// Individual writes may not have a txnNumber, in which case we count them as separate transactions.
|
|
856
|
+
lastTxnKey = transactionKeyValue;
|
|
857
|
+
transactionsReplicatedMetric.add(1);
|
|
933
858
|
}
|
|
859
|
+
await this.writeChange(batch, table, changeDocument);
|
|
934
860
|
}
|
|
935
861
|
}
|
|
936
862
|
else if (changeDocument.operationType == 'drop') {
|
|
@@ -964,30 +890,31 @@ export class ChangeStream {
|
|
|
964
890
|
});
|
|
965
891
|
}
|
|
966
892
|
}
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
await
|
|
893
|
+
if (splitDocument == null) {
|
|
894
|
+
// We flush and mark progress on every batch of data we receive.
|
|
895
|
+
// Batches are generally large (64MB or 6000 events, whichever comes first),
|
|
896
|
+
// so this is a good natural point to flush and mark progress.
|
|
897
|
+
// We avoid this when splitDocument is set, since we cannot resume in the middle of a split event.
|
|
898
|
+
const { comparable: lsn } = MongoLSN.fromResumeToken(resumeToken);
|
|
899
|
+
await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
|
|
900
|
+
// TODO: We should consider making this standard behavior of flush().
|
|
901
|
+
await batch.setResumeLsn(lsn);
|
|
902
|
+
}
|
|
903
|
+
batchSpan.end();
|
|
904
|
+
const durations = outerSpan.end();
|
|
905
|
+
const duration = batchSpan.endAt - batchSpan.startAt;
|
|
906
|
+
this.logger.info(`Processed batch of ${events.length} changes / ${eventBatch.byteSize} bytes in ${duration}ms`, {
|
|
907
|
+
count: events.length,
|
|
908
|
+
bytes: eventBatch.byteSize,
|
|
909
|
+
duration,
|
|
910
|
+
t: durations
|
|
911
|
+
});
|
|
912
|
+
outerSpan = tracer.span('batch');
|
|
976
913
|
}
|
|
977
914
|
});
|
|
978
915
|
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
if (this.isStartingReplication) {
|
|
982
|
-
// We don't have anything to compute replication lag with yet.
|
|
983
|
-
return undefined;
|
|
984
|
-
}
|
|
985
|
-
else {
|
|
986
|
-
// We don't have any uncommitted changes, so replication is up-to-date.
|
|
987
|
-
return 0;
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
return Date.now() - this.oldestUncommittedChange.getTime();
|
|
916
|
+
getReplicationLagMillis() {
|
|
917
|
+
return this.replicationLag.getLagMillis();
|
|
991
918
|
}
|
|
992
919
|
lastTouchedAt = performance.now();
|
|
993
920
|
touch() {
|
|
@@ -1000,27 +927,13 @@ export class ChangeStream {
|
|
|
1000
927
|
}
|
|
1001
928
|
}
|
|
1002
929
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
else if (isMongoServerError(e) && e.codeName == 'MaxTimeMSExpired') {
|
|
1010
|
-
// maxTimeMS was reached. Example message:
|
|
1011
|
-
// MongoServerError: Executor error during aggregate command on namespace: powersync_test_data.$cmd.aggregate :: caused by :: operation exceeded time limit
|
|
1012
|
-
throw new DatabaseConnectionError(ErrorCode.PSYNC_S1345, `Timeout while reading MongoDB ChangeStream`, e);
|
|
1013
|
-
}
|
|
1014
|
-
else if (isMongoServerError(e) &&
|
|
1015
|
-
e.codeName == 'NoMatchingDocument' &&
|
|
1016
|
-
e.errmsg?.includes('post-image was not found')) {
|
|
1017
|
-
throw new ChangeStreamInvalidatedError(e.errmsg, e);
|
|
1018
|
-
}
|
|
1019
|
-
else if (isMongoServerError(e) && e.hasErrorLabel('NonResumableChangeStreamError')) {
|
|
1020
|
-
throw new ChangeStreamInvalidatedError(e.message, e);
|
|
1021
|
-
}
|
|
1022
|
-
else {
|
|
1023
|
-
throw new DatabaseConnectionError(ErrorCode.PSYNC_S1346, `Error reading MongoDB ChangeStream`, e);
|
|
930
|
+
/**
|
|
931
|
+
* Transaction key for a change stream event, used to detect transaction boundaries. Returns null if the event is not part of a transaction.
|
|
932
|
+
*/
|
|
933
|
+
function transactionKey(doc) {
|
|
934
|
+
if (doc.txnNumber == null || doc.lsid == null) {
|
|
935
|
+
return null;
|
|
1024
936
|
}
|
|
937
|
+
return `${doc.lsid.id.toString('hex')}:${doc.txnNumber}`;
|
|
1025
938
|
}
|
|
1026
939
|
//# sourceMappingURL=ChangeStream.js.map
|