@powersync/service-module-mongodb 0.0.0-dev-20260225160713 → 0.0.0-dev-20260511080634
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 +119 -6
- package/dist/api/MongoRouteAPIAdapter.js +4 -4
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +8 -16
- package/dist/replication/ChangeStream.js +291 -373
- 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 +4 -4
- package/src/replication/ChangeStream.ts +388 -352
- 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 +39 -36
- package/test/src/env.ts +1 -1
- 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 +15 -4
- package/test/src/util.ts +62 -9
- 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,46 +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
|
-
const streamManager = __addDisposableResource(env_1, this.openChangeStream({ lsn: null, maxAwaitTimeMs: 0 }), true);
|
|
215
|
-
const { stream } = streamManager;
|
|
216
|
-
const startTime = performance.now();
|
|
217
|
-
let lastCheckpointCreated = -10_000;
|
|
218
|
-
let eventsSeen = 0;
|
|
219
|
-
while (performance.now() - startTime < LSN_TIMEOUT_SECONDS * 1000) {
|
|
220
|
-
if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
|
|
221
|
-
await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
222
|
-
lastCheckpointCreated = performance.now();
|
|
223
|
-
}
|
|
224
|
-
// tryNext() doesn't block, while next() / hasNext() does block until there is data on the stream
|
|
225
|
-
const changeDocument = await stream.tryNext().catch((e) => {
|
|
226
|
-
throw mapChangeStreamError(e);
|
|
227
|
-
});
|
|
228
|
-
if (changeDocument == null) {
|
|
229
|
-
continue;
|
|
230
|
-
}
|
|
177
|
+
batchesSeen += 1;
|
|
178
|
+
for (let rawChangeDocument of events) {
|
|
179
|
+
const changeDocument = parseChangeDocument(rawChangeDocument);
|
|
231
180
|
const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
|
|
232
181
|
if (ns?.coll == CHECKPOINTS_COLLECTION && 'documentKey' in changeDocument) {
|
|
233
182
|
const checkpointId = changeDocument.documentKey._id;
|
|
@@ -242,56 +191,38 @@ export class ChangeStream {
|
|
|
242
191
|
}
|
|
243
192
|
eventsSeen += 1;
|
|
244
193
|
}
|
|
245
|
-
// Could happen if there is a very large replication lag?
|
|
246
|
-
throw new ServiceError(ErrorCode.PSYNC_S1301, `Timeout after while waiting for checkpoint document for ${LSN_TIMEOUT_SECONDS}s. Streamed events = ${eventsSeen}`);
|
|
247
|
-
}
|
|
248
|
-
catch (e_1) {
|
|
249
|
-
env_1.error = e_1;
|
|
250
|
-
env_1.hasError = true;
|
|
251
|
-
}
|
|
252
|
-
finally {
|
|
253
|
-
const result_1 = __disposeResources(env_1);
|
|
254
|
-
if (result_1)
|
|
255
|
-
await result_1;
|
|
256
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}`);
|
|
257
197
|
}
|
|
258
198
|
/**
|
|
259
199
|
* Given a snapshot LSN, validate that we can read from it, by opening a change stream.
|
|
260
200
|
*/
|
|
261
201
|
async validateSnapshotLsn(lsn) {
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
throw mapChangeStreamError(e);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
catch (e_2) {
|
|
277
|
-
env_2.error = e_2;
|
|
278
|
-
env_2.hasError = true;
|
|
279
|
-
}
|
|
280
|
-
finally {
|
|
281
|
-
const result_2 = __disposeResources(env_2);
|
|
282
|
-
if (result_2)
|
|
283
|
-
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;
|
|
284
213
|
}
|
|
285
214
|
}
|
|
286
215
|
async initialReplication(snapshotLsn) {
|
|
287
216
|
const sourceTables = this.sync_rules.getSourceTables();
|
|
288
217
|
await this.client.connect();
|
|
218
|
+
const tracer = new PerformanceTracer('MongoDB initial replication');
|
|
289
219
|
const flushResult = await this.storage.startBatch({
|
|
290
220
|
logger: this.logger,
|
|
291
221
|
zeroLSN: MongoLSN.ZERO.comparable,
|
|
292
222
|
defaultSchema: this.defaultDb.databaseName,
|
|
293
223
|
storeCurrentData: false,
|
|
294
|
-
skipExistingRows: true
|
|
224
|
+
skipExistingRows: true,
|
|
225
|
+
tracer
|
|
295
226
|
}, async (batch) => {
|
|
296
227
|
if (snapshotLsn == null) {
|
|
297
228
|
// First replication attempt - get a snapshot and store the timestamp
|
|
@@ -328,14 +259,14 @@ export class ChangeStream {
|
|
|
328
259
|
}
|
|
329
260
|
for (let table of tablesWithStatus) {
|
|
330
261
|
await this.snapshotTable(batch, table);
|
|
331
|
-
await batch.
|
|
262
|
+
await batch.markTableSnapshotDone([table]);
|
|
332
263
|
this.touch();
|
|
333
264
|
}
|
|
334
265
|
// The checkpoint here is a marker - we need to replicate up to at least this
|
|
335
266
|
// point before the data can be considered consistent.
|
|
336
267
|
// We could do this for each individual table, but may as well just do it once for the entire snapshot.
|
|
337
268
|
const checkpoint = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
|
|
338
|
-
await batch.
|
|
269
|
+
await batch.markAllSnapshotDone(checkpoint);
|
|
339
270
|
// This will not create a consistent checkpoint yet, but will persist the op.
|
|
340
271
|
// Actual checkpoint will be created when streaming replication caught up.
|
|
341
272
|
await batch.commit(snapshotLsn);
|
|
@@ -412,81 +343,69 @@ export class ChangeStream {
|
|
|
412
343
|
}
|
|
413
344
|
return { $match: nsFilter, multipleDatabases };
|
|
414
345
|
}
|
|
415
|
-
static *getQueryData(results) {
|
|
416
|
-
for (let row of results) {
|
|
417
|
-
yield constructAfterRecord(row);
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
346
|
async snapshotTable(batch, table) {
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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;
|
|
434
372
|
}
|
|
435
|
-
|
|
436
|
-
|
|
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);
|
|
437
377
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
nextChunkPromise = query.nextChunk();
|
|
451
|
-
for (let document of docBatch) {
|
|
452
|
-
const record = this.constructAfterRecord(document);
|
|
453
|
-
// This auto-flushes when the batch reaches its size limit
|
|
454
|
-
await batch.save({
|
|
455
|
-
tag: SaveOperationTag.INSERT,
|
|
456
|
-
sourceTable: table,
|
|
457
|
-
before: undefined,
|
|
458
|
-
beforeReplicaId: undefined,
|
|
459
|
-
after: record,
|
|
460
|
-
afterReplicaId: document._id
|
|
461
|
-
});
|
|
462
|
-
}
|
|
463
|
-
// Important: flush before marking progress
|
|
464
|
-
await batch.flush();
|
|
465
|
-
at += docBatch.length;
|
|
466
|
-
this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(docBatch.length);
|
|
467
|
-
table = await batch.updateTableProgress(table, {
|
|
468
|
-
lastKey,
|
|
469
|
-
replicatedCount: at,
|
|
470
|
-
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
|
|
471
390
|
});
|
|
472
|
-
this.relationCache.update(table);
|
|
473
|
-
const duration = performance.now() - lastBatch;
|
|
474
|
-
lastBatch = performance.now();
|
|
475
|
-
this.logger.info(`Replicating ${table.qualifiedName} ${table.formatSnapshotProgress()} in ${duration.toFixed(0)}ms`);
|
|
476
|
-
this.touch();
|
|
477
391
|
}
|
|
478
|
-
//
|
|
479
|
-
await
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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();
|
|
489
406
|
}
|
|
407
|
+
// In case the loop was interrupted, make sure we await the last promise.
|
|
408
|
+
await nextChunkPromise;
|
|
490
409
|
}
|
|
491
410
|
async getRelation(batch, descriptor, options) {
|
|
492
411
|
const existing = this.relationCache.get(descriptor);
|
|
@@ -550,7 +469,7 @@ export class ChangeStream {
|
|
|
550
469
|
// Snapshot if:
|
|
551
470
|
// 1. Snapshot is requested (false for initial snapshot, since that process handles it elsewhere)
|
|
552
471
|
// 2. Snapshot is not already done, AND:
|
|
553
|
-
// 3. The table is used in sync
|
|
472
|
+
// 3. The table is used in sync config.
|
|
554
473
|
const shouldSnapshot = snapshot && !result.table.snapshotComplete && result.table.syncAny;
|
|
555
474
|
if (shouldSnapshot) {
|
|
556
475
|
this.logger.info(`New collection: ${descriptor.schema}.${descriptor.name}`);
|
|
@@ -558,29 +477,28 @@ export class ChangeStream {
|
|
|
558
477
|
await batch.truncate([result.table]);
|
|
559
478
|
await this.snapshotTable(batch, result.table);
|
|
560
479
|
const no_checkpoint_before_lsn = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
|
|
561
|
-
const [table] = await batch.
|
|
480
|
+
const [table] = await batch.markTableSnapshotDone([result.table], no_checkpoint_before_lsn);
|
|
562
481
|
return table;
|
|
563
482
|
}
|
|
564
483
|
return result.table;
|
|
565
484
|
}
|
|
566
|
-
constructAfterRecord(document) {
|
|
567
|
-
const inputRow = constructAfterRecord(document);
|
|
568
|
-
return this.sync_rules.applyRowContext(inputRow);
|
|
569
|
-
}
|
|
570
485
|
async writeChange(batch, table, change) {
|
|
571
486
|
if (!table.syncAny) {
|
|
572
|
-
this.logger.debug(`Collection ${table.qualifiedName} not used in sync
|
|
487
|
+
this.logger.debug(`Collection ${table.qualifiedName} not used in sync config - skipping`);
|
|
573
488
|
return null;
|
|
574
489
|
}
|
|
575
490
|
this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(1);
|
|
576
491
|
if (change.operationType == 'insert') {
|
|
577
|
-
const baseRecord = this.
|
|
492
|
+
const { row: baseRecord, replicaId: _replicaId } = this.rawToSqliteRow(change.fullDocument);
|
|
578
493
|
return await batch.save({
|
|
579
494
|
tag: SaveOperationTag.INSERT,
|
|
580
495
|
sourceTable: table,
|
|
581
496
|
before: undefined,
|
|
582
497
|
beforeReplicaId: undefined,
|
|
583
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.
|
|
584
502
|
afterReplicaId: change.documentKey._id
|
|
585
503
|
});
|
|
586
504
|
}
|
|
@@ -594,14 +512,14 @@ export class ChangeStream {
|
|
|
594
512
|
beforeReplicaId: change.documentKey._id
|
|
595
513
|
});
|
|
596
514
|
}
|
|
597
|
-
const after = this.
|
|
515
|
+
const { row: after, replicaId: _replicaId } = this.rawToSqliteRow(change.fullDocument);
|
|
598
516
|
return await batch.save({
|
|
599
517
|
tag: SaveOperationTag.UPDATE,
|
|
600
518
|
sourceTable: table,
|
|
601
519
|
before: undefined,
|
|
602
520
|
beforeReplicaId: undefined,
|
|
603
521
|
after: after,
|
|
604
|
-
afterReplicaId: change.documentKey._id
|
|
522
|
+
afterReplicaId: change.documentKey._id // Same as _replicaId
|
|
605
523
|
});
|
|
606
524
|
}
|
|
607
525
|
else if (change.operationType == 'delete') {
|
|
@@ -638,7 +556,7 @@ export class ChangeStream {
|
|
|
638
556
|
}
|
|
639
557
|
const { lastOpId } = await this.initialReplication(result.snapshotLsn);
|
|
640
558
|
if (lastOpId != null) {
|
|
641
|
-
// 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.
|
|
642
560
|
await this.storage.populatePersistentChecksumCache({
|
|
643
561
|
signal: this.abort_signal,
|
|
644
562
|
// No checkpoint yet, but we do have the opId.
|
|
@@ -660,17 +578,11 @@ export class ChangeStream {
|
|
|
660
578
|
throw e;
|
|
661
579
|
}
|
|
662
580
|
}
|
|
663
|
-
|
|
581
|
+
rawChangeStreamBatches(options) {
|
|
664
582
|
const lastLsn = options.lsn ? MongoLSN.fromSerialized(options.lsn) : null;
|
|
665
583
|
const startAfter = lastLsn?.timestamp;
|
|
666
584
|
const resumeAfter = lastLsn?.resumeToken;
|
|
667
|
-
const filters =
|
|
668
|
-
const pipeline = [
|
|
669
|
-
{
|
|
670
|
-
$match: filters.$match
|
|
671
|
-
},
|
|
672
|
-
{ $changeStreamSplitLargeEvent: {} }
|
|
673
|
-
];
|
|
585
|
+
const filters = options.filters;
|
|
674
586
|
let fullDocument;
|
|
675
587
|
if (this.usePostImages) {
|
|
676
588
|
// 'read_only' or 'auto_configure'
|
|
@@ -683,10 +595,17 @@ export class ChangeStream {
|
|
|
683
595
|
}
|
|
684
596
|
const streamOptions = {
|
|
685
597
|
showExpandedEvents: true,
|
|
686
|
-
|
|
687
|
-
fullDocument: fullDocument,
|
|
688
|
-
maxTimeMS: this.changeStreamTimeout
|
|
598
|
+
fullDocument: fullDocument
|
|
689
599
|
};
|
|
600
|
+
const pipeline = [
|
|
601
|
+
{
|
|
602
|
+
$changeStream: streamOptions
|
|
603
|
+
},
|
|
604
|
+
{
|
|
605
|
+
$match: filters.$match
|
|
606
|
+
},
|
|
607
|
+
{ $changeStreamSplitLargeEvent: {} }
|
|
608
|
+
];
|
|
690
609
|
/**
|
|
691
610
|
* Only one of these options can be supplied at a time.
|
|
692
611
|
*/
|
|
@@ -696,100 +615,106 @@ export class ChangeStream {
|
|
|
696
615
|
else {
|
|
697
616
|
// Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
|
|
698
617
|
// case if we have an old one.
|
|
618
|
+
// This is also relevant for getSnapshotLSN().
|
|
699
619
|
streamOptions.startAtOperationTime = startAfter;
|
|
700
620
|
}
|
|
701
|
-
let
|
|
621
|
+
let watchDb;
|
|
702
622
|
if (filters.multipleDatabases) {
|
|
703
|
-
|
|
704
|
-
|
|
623
|
+
watchDb = this.client.db('admin');
|
|
624
|
+
streamOptions.allChangesForCluster = true;
|
|
705
625
|
}
|
|
706
626
|
else {
|
|
707
|
-
|
|
708
|
-
stream = this.defaultDb.watch(pipeline, streamOptions);
|
|
627
|
+
watchDb = this.defaultDb;
|
|
709
628
|
}
|
|
710
|
-
|
|
711
|
-
|
|
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
|
|
712
636
|
});
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
[Symbol.asyncDispose]: async () => {
|
|
717
|
-
return stream.close();
|
|
718
|
-
}
|
|
719
|
-
};
|
|
637
|
+
}
|
|
638
|
+
rawToSqliteRow(row) {
|
|
639
|
+
return this.sourceRowConverter.rawToSqliteRow(row);
|
|
720
640
|
}
|
|
721
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');
|
|
722
646
|
await this.storage.startBatch({
|
|
723
647
|
logger: this.logger,
|
|
724
648
|
zeroLSN: MongoLSN.ZERO.comparable,
|
|
725
649
|
defaultSchema: this.defaultDb.databaseName,
|
|
726
650
|
// We get a complete postimage for every change, so we don't need to store the current data.
|
|
727
|
-
storeCurrentData: false
|
|
651
|
+
storeCurrentData: false,
|
|
652
|
+
tracer
|
|
728
653
|
}, async (batch) => {
|
|
729
|
-
const
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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);
|
|
743
686
|
if (this.abort_signal.aborted) {
|
|
744
|
-
|
|
745
|
-
return;
|
|
687
|
+
break;
|
|
746
688
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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();
|
|
758
704
|
}
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
if (!originalChangeDocument && stream.closed) {
|
|
764
|
-
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;
|
|
765
709
|
}
|
|
710
|
+
}
|
|
711
|
+
this.touch();
|
|
712
|
+
for (let eventIndex = 0; eventIndex < events.length; eventIndex++) {
|
|
713
|
+
const rawChangeDocument = events[eventIndex];
|
|
714
|
+
const originalChangeDocument = parseChangeDocument(rawChangeDocument);
|
|
766
715
|
if (this.abort_signal.aborted) {
|
|
767
716
|
break;
|
|
768
717
|
}
|
|
769
|
-
if (originalChangeDocument == null) {
|
|
770
|
-
// We get a new null document after `maxAwaitTimeMS` if there were no other events.
|
|
771
|
-
// In this case, stream.resumeToken is the resume token associated with the last response.
|
|
772
|
-
// stream.resumeToken is not updated if stream.tryNext() returns data, while stream.next()
|
|
773
|
-
// does update it.
|
|
774
|
-
// From observed behavior, the actual resumeToken changes around once every 10 seconds.
|
|
775
|
-
// If we don't update it on empty events, we do keep consistency, but resuming the stream
|
|
776
|
-
// with old tokens may cause connection timeouts.
|
|
777
|
-
// We throttle this further by only persisting a keepalive once a minute.
|
|
778
|
-
// We add an additional check for waitForCheckpointLsn == null, to make sure we're not
|
|
779
|
-
// doing a keepalive in the middle of a transaction.
|
|
780
|
-
if (waitForCheckpointLsn == null && performance.now() - lastEmptyResume > 60_000) {
|
|
781
|
-
const { comparable: lsn, timestamp } = MongoLSN.fromResumeToken(stream.resumeToken);
|
|
782
|
-
await batch.keepalive(lsn);
|
|
783
|
-
this.touch();
|
|
784
|
-
lastEmptyResume = performance.now();
|
|
785
|
-
// Log the token update. This helps as a general "replication is still active" message in the logs.
|
|
786
|
-
// This token would typically be around 10s behind.
|
|
787
|
-
this.logger.info(`Idle change stream. Persisted resumeToken for ${timestampToDate(timestamp).toISOString()}`);
|
|
788
|
-
this.isStartingReplication = false;
|
|
789
|
-
}
|
|
790
|
-
continue;
|
|
791
|
-
}
|
|
792
|
-
this.touch();
|
|
793
718
|
if (startAfter != null && originalChangeDocument.clusterTime?.lte(startAfter)) {
|
|
794
719
|
continue;
|
|
795
720
|
}
|
|
@@ -843,15 +768,15 @@ export class ChangeStream {
|
|
|
843
768
|
* would process below.
|
|
844
769
|
*
|
|
845
770
|
* However we don't commit the LSN after collections are dropped.
|
|
846
|
-
*
|
|
771
|
+
* This prevents the `startAfter` or `resumeToken` from advancing past the drop events.
|
|
847
772
|
* The stream also closes after the drop events.
|
|
848
773
|
* This causes an infinite loop of processing the collection drop events.
|
|
849
774
|
*
|
|
850
|
-
* This check here invalidates the change stream if our `
|
|
775
|
+
* This check here invalidates the change stream if our `_powersync_checkpoints` collection
|
|
851
776
|
* is dropped. This allows for detecting when the DB is dropped.
|
|
852
777
|
*/
|
|
853
778
|
if (changeDocument.operationType == 'drop') {
|
|
854
|
-
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'));
|
|
855
780
|
}
|
|
856
781
|
if (!(changeDocument.operationType == 'insert' ||
|
|
857
782
|
changeDocument.operationType == 'update' ||
|
|
@@ -867,7 +792,21 @@ export class ChangeStream {
|
|
|
867
792
|
// It may be useful to also throttle commits due to standalone checkpoints in the future.
|
|
868
793
|
// However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
|
|
869
794
|
const checkpointId = changeDocument.documentKey._id;
|
|
870
|
-
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)) {
|
|
871
810
|
continue;
|
|
872
811
|
}
|
|
873
812
|
const { comparable: lsn } = new MongoLSN({
|
|
@@ -885,11 +824,11 @@ export class ChangeStream {
|
|
|
885
824
|
if (waitForCheckpointLsn != null && lsn >= waitForCheckpointLsn) {
|
|
886
825
|
waitForCheckpointLsn = null;
|
|
887
826
|
}
|
|
888
|
-
const
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
827
|
+
const { checkpointBlocked } = await batch.commit(lsn, {
|
|
828
|
+
oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
|
|
829
|
+
});
|
|
830
|
+
if (!checkpointBlocked) {
|
|
831
|
+
this.replicationLag.markCommitted();
|
|
893
832
|
}
|
|
894
833
|
}
|
|
895
834
|
else if (changeDocument.operationType == 'insert' ||
|
|
@@ -904,28 +843,20 @@ export class ChangeStream {
|
|
|
904
843
|
// In most cases, we should not need to snapshot this. But if this is the first time we see the collection
|
|
905
844
|
// for whatever reason, then we do need to snapshot it.
|
|
906
845
|
// This may result in some duplicate operations when a collection is created for the first time after
|
|
907
|
-
// sync
|
|
846
|
+
// sync config was deployed.
|
|
908
847
|
snapshot: true
|
|
909
848
|
});
|
|
910
849
|
if (table.syncAny) {
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
// we don't restart from scratch if we restart replication.
|
|
920
|
-
// The same could apply if we need to catch up on replication after some downtime.
|
|
921
|
-
const { comparable: lsn } = new MongoLSN({
|
|
922
|
-
timestamp: changeDocument.clusterTime,
|
|
923
|
-
resume_token: changeDocument._id
|
|
924
|
-
});
|
|
925
|
-
this.logger.info(`Updating resume LSN to ${lsn} after ${changesSinceLastCheckpoint} changes`);
|
|
926
|
-
await batch.setResumeLsn(lsn);
|
|
927
|
-
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);
|
|
928
858
|
}
|
|
859
|
+
await this.writeChange(batch, table, changeDocument);
|
|
929
860
|
}
|
|
930
861
|
}
|
|
931
862
|
else if (changeDocument.operationType == 'drop') {
|
|
@@ -959,30 +890,31 @@ export class ChangeStream {
|
|
|
959
890
|
});
|
|
960
891
|
}
|
|
961
892
|
}
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
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');
|
|
971
913
|
}
|
|
972
914
|
});
|
|
973
915
|
}
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
if (this.isStartingReplication) {
|
|
977
|
-
// We don't have anything to compute replication lag with yet.
|
|
978
|
-
return undefined;
|
|
979
|
-
}
|
|
980
|
-
else {
|
|
981
|
-
// We don't have any uncommitted changes, so replication is up-to-date.
|
|
982
|
-
return 0;
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
return Date.now() - this.oldestUncommittedChange.getTime();
|
|
916
|
+
getReplicationLagMillis() {
|
|
917
|
+
return this.replicationLag.getLagMillis();
|
|
986
918
|
}
|
|
987
919
|
lastTouchedAt = performance.now();
|
|
988
920
|
touch() {
|
|
@@ -995,27 +927,13 @@ export class ChangeStream {
|
|
|
995
927
|
}
|
|
996
928
|
}
|
|
997
929
|
}
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
else if (isMongoServerError(e) && e.codeName == 'MaxTimeMSExpired') {
|
|
1005
|
-
// maxTimeMS was reached. Example message:
|
|
1006
|
-
// MongoServerError: Executor error during aggregate command on namespace: powersync_test_data.$cmd.aggregate :: caused by :: operation exceeded time limit
|
|
1007
|
-
throw new DatabaseConnectionError(ErrorCode.PSYNC_S1345, `Timeout while reading MongoDB ChangeStream`, e);
|
|
1008
|
-
}
|
|
1009
|
-
else if (isMongoServerError(e) &&
|
|
1010
|
-
e.codeName == 'NoMatchingDocument' &&
|
|
1011
|
-
e.errmsg?.includes('post-image was not found')) {
|
|
1012
|
-
throw new ChangeStreamInvalidatedError(e.errmsg, e);
|
|
1013
|
-
}
|
|
1014
|
-
else if (isMongoServerError(e) && e.hasErrorLabel('NonResumableChangeStreamError')) {
|
|
1015
|
-
throw new ChangeStreamInvalidatedError(e.message, e);
|
|
1016
|
-
}
|
|
1017
|
-
else {
|
|
1018
|
-
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;
|
|
1019
936
|
}
|
|
937
|
+
return `${doc.lsid.id.toString('hex')}:${doc.txnNumber}`;
|
|
1020
938
|
}
|
|
1021
939
|
//# sourceMappingURL=ChangeStream.js.map
|