@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,8 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
2
2
|
import {
|
|
3
3
|
container,
|
|
4
4
|
DatabaseConnectionError,
|
|
5
|
-
logger as defaultLogger,
|
|
6
5
|
ErrorCode,
|
|
7
6
|
Logger,
|
|
8
7
|
ReplicationAbortedError,
|
|
@@ -11,34 +10,31 @@ import {
|
|
|
11
10
|
} from '@powersync/lib-services-framework';
|
|
12
11
|
import {
|
|
13
12
|
MetricsEngine,
|
|
13
|
+
PerformanceTracer,
|
|
14
14
|
RelationCache,
|
|
15
|
+
ReplicationLagTracker,
|
|
15
16
|
SaveOperationTag,
|
|
16
17
|
SourceEntityDescriptor,
|
|
17
18
|
SourceTable,
|
|
18
19
|
storage
|
|
19
20
|
} from '@powersync/service-core';
|
|
20
|
-
import {
|
|
21
|
-
DatabaseInputRow,
|
|
22
|
-
SqliteInputRow,
|
|
23
|
-
SqliteRow,
|
|
24
|
-
HydratedSyncRules,
|
|
25
|
-
TablePattern
|
|
26
|
-
} from '@powersync/service-sync-rules';
|
|
21
|
+
import { HydratedSyncRules, TablePattern } from '@powersync/service-sync-rules';
|
|
27
22
|
import { ReplicationMetric } from '@powersync/service-types';
|
|
23
|
+
import { performance } from 'node:perf_hooks';
|
|
28
24
|
import { MongoLSN } from '../common/MongoLSN.js';
|
|
29
25
|
import { PostImagesOption } from '../types/types.js';
|
|
30
26
|
import { escapeRegExp } from '../utils.js';
|
|
31
27
|
import { MongoManager } from './MongoManager.js';
|
|
32
|
-
import {
|
|
33
|
-
constructAfterRecord,
|
|
34
|
-
createCheckpoint,
|
|
35
|
-
getCacheIdentifier,
|
|
36
|
-
getMongoRelation,
|
|
37
|
-
STANDALONE_CHECKPOINT_ID
|
|
38
|
-
} from './MongoRelation.js';
|
|
28
|
+
import { createCheckpoint, getCacheIdentifier, getMongoRelation, STANDALONE_CHECKPOINT_ID } from './MongoRelation.js';
|
|
39
29
|
import { ChunkedSnapshotQuery } from './MongoSnapshotQuery.js';
|
|
30
|
+
import {
|
|
31
|
+
ChangeStreamBatch,
|
|
32
|
+
parseChangeDocument,
|
|
33
|
+
ProjectedChangeStreamDocument,
|
|
34
|
+
rawChangeStream
|
|
35
|
+
} from './RawChangeStream.js';
|
|
40
36
|
import { CHECKPOINTS_COLLECTION, timestampToDate } from './replication-utils.js';
|
|
41
|
-
|
|
37
|
+
import { DirectSourceRowConverter, SourceRowConverter } from './SourceRowConverter.js';
|
|
42
38
|
export interface ChangeStreamOptions {
|
|
43
39
|
connections: MongoManager;
|
|
44
40
|
storage: storage.SyncRulesBucketStorage;
|
|
@@ -98,16 +94,7 @@ export class ChangeStream {
|
|
|
98
94
|
|
|
99
95
|
private relationCache = new RelationCache(getCacheIdentifier);
|
|
100
96
|
|
|
101
|
-
|
|
102
|
-
* Time of the oldest uncommitted change, according to the source db.
|
|
103
|
-
* This is used to determine the replication lag.
|
|
104
|
-
*/
|
|
105
|
-
private oldestUncommittedChange: Date | null = null;
|
|
106
|
-
/**
|
|
107
|
-
* Keep track of whether we have done a commit or keepalive yet.
|
|
108
|
-
* We can only compute replication lag if isStartingReplication == false, or oldestUncommittedChange is present.
|
|
109
|
-
*/
|
|
110
|
-
private isStartingReplication = true;
|
|
97
|
+
private replicationLag = new ReplicationLagTracker();
|
|
111
98
|
|
|
112
99
|
private checkpointStreamId = new mongo.ObjectId();
|
|
113
100
|
|
|
@@ -117,6 +104,8 @@ export class ChangeStream {
|
|
|
117
104
|
|
|
118
105
|
private changeStreamTimeout: number;
|
|
119
106
|
|
|
107
|
+
private readonly sourceRowConverter: SourceRowConverter;
|
|
108
|
+
|
|
120
109
|
constructor(options: ChangeStreamOptions) {
|
|
121
110
|
this.storage = options.storage;
|
|
122
111
|
this.metrics = options.metrics;
|
|
@@ -129,6 +118,8 @@ export class ChangeStream {
|
|
|
129
118
|
this.sync_rules = options.storage.getParsedSyncRules({
|
|
130
119
|
defaultSchema: this.defaultDb.databaseName
|
|
131
120
|
});
|
|
121
|
+
this.sourceRowConverter = new DirectSourceRowConverter(this.sync_rules.compatibility);
|
|
122
|
+
|
|
132
123
|
// The change stream aggregation command should timeout before the socket times out,
|
|
133
124
|
// so we use 90% of the socket timeout value.
|
|
134
125
|
this.changeStreamTimeout = Math.ceil(this.client.options.socketTimeoutMS * 0.9);
|
|
@@ -142,7 +133,7 @@ export class ChangeStream {
|
|
|
142
133
|
{ once: true }
|
|
143
134
|
);
|
|
144
135
|
|
|
145
|
-
this.logger = options.logger ??
|
|
136
|
+
this.logger = options.logger ?? this.storage.logger;
|
|
146
137
|
}
|
|
147
138
|
|
|
148
139
|
get stopped() {
|
|
@@ -266,48 +257,53 @@ export class ChangeStream {
|
|
|
266
257
|
|
|
267
258
|
// Create a checkpoint, and open a change stream using startAtOperationTime with the checkpoint's operationTime.
|
|
268
259
|
const firstCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
269
|
-
await using streamManager = this.openChangeStream({ lsn: firstCheckpointLsn, maxAwaitTimeMs: 0 });
|
|
270
260
|
|
|
271
|
-
const { stream } = streamManager;
|
|
272
261
|
const startTime = performance.now();
|
|
273
262
|
let lastCheckpointCreated = performance.now();
|
|
274
263
|
let eventsSeen = 0;
|
|
264
|
+
let batchesSeen = 0;
|
|
275
265
|
|
|
276
|
-
|
|
266
|
+
const filters = this.getSourceNamespaceFilters();
|
|
267
|
+
const iter = this.rawChangeStreamBatches({
|
|
268
|
+
lsn: firstCheckpointLsn,
|
|
269
|
+
maxAwaitTimeMS: 0,
|
|
270
|
+
signal: this.abort_signal,
|
|
271
|
+
filters
|
|
272
|
+
});
|
|
273
|
+
for await (let { events } of iter) {
|
|
274
|
+
if (performance.now() - startTime >= LSN_TIMEOUT_SECONDS * 1000) {
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
277
|
if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
|
|
278
278
|
await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
279
279
|
lastCheckpointCreated = performance.now();
|
|
280
280
|
}
|
|
281
|
+
batchesSeen += 1;
|
|
281
282
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
});
|
|
286
|
-
if (changeDocument == null) {
|
|
287
|
-
continue;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
|
|
283
|
+
for (let rawChangeDocument of events) {
|
|
284
|
+
const changeDocument = parseChangeDocument(rawChangeDocument);
|
|
285
|
+
const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
|
|
291
286
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
287
|
+
if (ns?.coll == CHECKPOINTS_COLLECTION && 'documentKey' in changeDocument) {
|
|
288
|
+
const checkpointId = changeDocument.documentKey._id as string | mongo.ObjectId;
|
|
289
|
+
if (!this.checkpointStreamId.equals(checkpointId)) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const { comparable: lsn } = new MongoLSN({
|
|
293
|
+
timestamp: changeDocument.clusterTime!,
|
|
294
|
+
resume_token: changeDocument._id
|
|
295
|
+
});
|
|
296
|
+
return lsn;
|
|
296
297
|
}
|
|
297
|
-
const { comparable: lsn } = new MongoLSN({
|
|
298
|
-
timestamp: changeDocument.clusterTime!,
|
|
299
|
-
resume_token: changeDocument._id
|
|
300
|
-
});
|
|
301
|
-
return lsn;
|
|
302
|
-
}
|
|
303
298
|
|
|
304
|
-
|
|
299
|
+
eventsSeen += 1;
|
|
300
|
+
}
|
|
305
301
|
}
|
|
306
302
|
|
|
307
303
|
// Could happen if there is a very large replication lag?
|
|
308
304
|
throw new ServiceError(
|
|
309
305
|
ErrorCode.PSYNC_S1301,
|
|
310
|
-
`Timeout after while waiting for checkpoint document for ${LSN_TIMEOUT_SECONDS}s. Streamed events = ${eventsSeen}`
|
|
306
|
+
`Timeout after while waiting for checkpoint document for ${LSN_TIMEOUT_SECONDS}s. Streamed events = ${eventsSeen}, batches = ${batchesSeen}`
|
|
311
307
|
);
|
|
312
308
|
}
|
|
313
309
|
|
|
@@ -315,21 +311,24 @@ export class ChangeStream {
|
|
|
315
311
|
* Given a snapshot LSN, validate that we can read from it, by opening a change stream.
|
|
316
312
|
*/
|
|
317
313
|
private async validateSnapshotLsn(lsn: string) {
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
314
|
+
const filters = this.getSourceNamespaceFilters();
|
|
315
|
+
const stream = this.rawChangeStreamBatches({
|
|
316
|
+
lsn: lsn,
|
|
317
|
+
// maxAwaitTimeMS should never actually be used here
|
|
318
|
+
maxAwaitTimeMS: 0,
|
|
319
|
+
filters
|
|
320
|
+
});
|
|
321
|
+
for await (let _batch of stream) {
|
|
322
|
+
// We got a response from the aggregate command, so consider the LSN valid.
|
|
323
|
+
// Close the stream immediately.
|
|
324
|
+
break;
|
|
327
325
|
}
|
|
328
326
|
}
|
|
329
327
|
|
|
330
328
|
async initialReplication(snapshotLsn: string | null) {
|
|
331
329
|
const sourceTables = this.sync_rules.getSourceTables();
|
|
332
330
|
await this.client.connect();
|
|
331
|
+
const tracer = new PerformanceTracer('MongoDB initial replication');
|
|
333
332
|
|
|
334
333
|
const flushResult = await this.storage.startBatch(
|
|
335
334
|
{
|
|
@@ -337,7 +336,8 @@ export class ChangeStream {
|
|
|
337
336
|
zeroLSN: MongoLSN.ZERO.comparable,
|
|
338
337
|
defaultSchema: this.defaultDb.databaseName,
|
|
339
338
|
storeCurrentData: false,
|
|
340
|
-
skipExistingRows: true
|
|
339
|
+
skipExistingRows: true,
|
|
340
|
+
tracer
|
|
341
341
|
},
|
|
342
342
|
async (batch) => {
|
|
343
343
|
if (snapshotLsn == null) {
|
|
@@ -472,13 +472,11 @@ export class ChangeStream {
|
|
|
472
472
|
return { $match: nsFilter, multipleDatabases };
|
|
473
473
|
}
|
|
474
474
|
|
|
475
|
-
static *getQueryData(results: Iterable<DatabaseInputRow>): Generator<SqliteInputRow> {
|
|
476
|
-
for (let row of results) {
|
|
477
|
-
yield constructAfterRecord(row);
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
475
|
private async snapshotTable(batch: storage.BucketStorageBatch, table: storage.SourceTable) {
|
|
476
|
+
const rowsReplicatedMetric = this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED);
|
|
477
|
+
const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
|
|
478
|
+
const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
|
|
479
|
+
|
|
482
480
|
const totalEstimatedCount = await this.estimatedCountNumber(table);
|
|
483
481
|
let at = table.snapshotStatus?.replicatedCount ?? 0;
|
|
484
482
|
const db = this.client.db(table.schema);
|
|
@@ -499,11 +497,13 @@ export class ChangeStream {
|
|
|
499
497
|
let lastBatch = performance.now();
|
|
500
498
|
let nextChunkPromise = query.nextChunk();
|
|
501
499
|
while (true) {
|
|
502
|
-
const { docs: docBatch, lastKey } = await nextChunkPromise;
|
|
500
|
+
const { docs: docBatch, lastKey, bytes: chunkBytes } = await nextChunkPromise;
|
|
503
501
|
if (docBatch.length == 0) {
|
|
504
502
|
// No more data - stop iterating
|
|
505
503
|
break;
|
|
506
504
|
}
|
|
505
|
+
bytesReplicatedMetric.add(chunkBytes);
|
|
506
|
+
chunksReplicatedMetric.add(1);
|
|
507
507
|
|
|
508
508
|
if (this.abort_signal.aborted) {
|
|
509
509
|
throw new ReplicationAbortedError(`Aborted initial replication`, this.abort_signal.reason);
|
|
@@ -511,8 +511,8 @@ export class ChangeStream {
|
|
|
511
511
|
|
|
512
512
|
// Pre-fetch next batch, so that we can read and write concurrently
|
|
513
513
|
nextChunkPromise = query.nextChunk();
|
|
514
|
-
for (let
|
|
515
|
-
const record = this.
|
|
514
|
+
for (let buffer of docBatch) {
|
|
515
|
+
const { row: record, replicaId: replicaId } = this.rawToSqliteRow(buffer);
|
|
516
516
|
|
|
517
517
|
// This auto-flushes when the batch reaches its size limit
|
|
518
518
|
await batch.save({
|
|
@@ -521,14 +521,14 @@ export class ChangeStream {
|
|
|
521
521
|
before: undefined,
|
|
522
522
|
beforeReplicaId: undefined,
|
|
523
523
|
after: record,
|
|
524
|
-
afterReplicaId:
|
|
524
|
+
afterReplicaId: replicaId
|
|
525
525
|
});
|
|
526
526
|
}
|
|
527
527
|
|
|
528
528
|
// Important: flush before marking progress
|
|
529
529
|
await batch.flush();
|
|
530
530
|
at += docBatch.length;
|
|
531
|
-
|
|
531
|
+
rowsReplicatedMetric.add(docBatch.length);
|
|
532
532
|
|
|
533
533
|
table = await batch.updateTableProgress(table, {
|
|
534
534
|
lastKey,
|
|
@@ -633,7 +633,7 @@ export class ChangeStream {
|
|
|
633
633
|
// Snapshot if:
|
|
634
634
|
// 1. Snapshot is requested (false for initial snapshot, since that process handles it elsewhere)
|
|
635
635
|
// 2. Snapshot is not already done, AND:
|
|
636
|
-
// 3. The table is used in sync
|
|
636
|
+
// 3. The table is used in sync config.
|
|
637
637
|
const shouldSnapshot = snapshot && !result.table.snapshotComplete && result.table.syncAny;
|
|
638
638
|
if (shouldSnapshot) {
|
|
639
639
|
this.logger.info(`New collection: ${descriptor.schema}.${descriptor.name}`);
|
|
@@ -650,30 +650,28 @@ export class ChangeStream {
|
|
|
650
650
|
return result.table;
|
|
651
651
|
}
|
|
652
652
|
|
|
653
|
-
private constructAfterRecord(document: mongo.Document): SqliteRow {
|
|
654
|
-
const inputRow = constructAfterRecord(document);
|
|
655
|
-
return this.sync_rules.applyRowContext<never>(inputRow);
|
|
656
|
-
}
|
|
657
|
-
|
|
658
653
|
async writeChange(
|
|
659
654
|
batch: storage.BucketStorageBatch,
|
|
660
655
|
table: storage.SourceTable,
|
|
661
|
-
change:
|
|
656
|
+
change: ProjectedChangeStreamDocument
|
|
662
657
|
): Promise<storage.FlushedResult | null> {
|
|
663
658
|
if (!table.syncAny) {
|
|
664
|
-
this.logger.debug(`Collection ${table.qualifiedName} not used in sync
|
|
659
|
+
this.logger.debug(`Collection ${table.qualifiedName} not used in sync config - skipping`);
|
|
665
660
|
return null;
|
|
666
661
|
}
|
|
667
662
|
|
|
668
663
|
this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(1);
|
|
669
664
|
if (change.operationType == 'insert') {
|
|
670
|
-
const baseRecord = this.
|
|
665
|
+
const { row: baseRecord, replicaId: _replicaId } = this.rawToSqliteRow(change.fullDocument);
|
|
671
666
|
return await batch.save({
|
|
672
667
|
tag: SaveOperationTag.INSERT,
|
|
673
668
|
sourceTable: table,
|
|
674
669
|
before: undefined,
|
|
675
670
|
beforeReplicaId: undefined,
|
|
676
671
|
after: baseRecord,
|
|
672
|
+
// Same as _replicaId
|
|
673
|
+
// We specifically need to use the source _id, not the converted one in baseRecord,
|
|
674
|
+
// to preserve _id uniqueness properties.
|
|
677
675
|
afterReplicaId: change.documentKey._id
|
|
678
676
|
});
|
|
679
677
|
} else if (change.operationType == 'update' || change.operationType == 'replace') {
|
|
@@ -686,14 +684,14 @@ export class ChangeStream {
|
|
|
686
684
|
beforeReplicaId: change.documentKey._id
|
|
687
685
|
});
|
|
688
686
|
}
|
|
689
|
-
const after = this.
|
|
687
|
+
const { row: after, replicaId: _replicaId } = this.rawToSqliteRow(change.fullDocument!);
|
|
690
688
|
return await batch.save({
|
|
691
689
|
tag: SaveOperationTag.UPDATE,
|
|
692
690
|
sourceTable: table,
|
|
693
691
|
before: undefined,
|
|
694
692
|
beforeReplicaId: undefined,
|
|
695
693
|
after: after,
|
|
696
|
-
afterReplicaId: change.documentKey._id
|
|
694
|
+
afterReplicaId: change.documentKey._id // Same as _replicaId
|
|
697
695
|
});
|
|
698
696
|
} else if (change.operationType == 'delete') {
|
|
699
697
|
return await batch.save({
|
|
@@ -729,7 +727,7 @@ export class ChangeStream {
|
|
|
729
727
|
}
|
|
730
728
|
const { lastOpId } = await this.initialReplication(result.snapshotLsn);
|
|
731
729
|
if (lastOpId != null) {
|
|
732
|
-
// Populate the cache _after_ initial replication, but _before_ we switch to this
|
|
730
|
+
// Populate the cache _after_ initial replication, but _before_ we switch to this replication stream.
|
|
733
731
|
await this.storage.populatePersistentChecksumCache({
|
|
734
732
|
signal: this.abort_signal,
|
|
735
733
|
// No checkpoint yet, but we do have the opId.
|
|
@@ -754,19 +752,19 @@ export class ChangeStream {
|
|
|
754
752
|
}
|
|
755
753
|
}
|
|
756
754
|
|
|
757
|
-
private
|
|
755
|
+
private rawChangeStreamBatches(options: {
|
|
756
|
+
lsn: string | null;
|
|
757
|
+
maxAwaitTimeMS?: number;
|
|
758
|
+
batchSize?: number;
|
|
759
|
+
filters: { $match: any; multipleDatabases: boolean };
|
|
760
|
+
signal?: AbortSignal;
|
|
761
|
+
tracer?: PerformanceTracer<'changestream'>;
|
|
762
|
+
}): AsyncIterableIterator<ChangeStreamBatch> {
|
|
758
763
|
const lastLsn = options.lsn ? MongoLSN.fromSerialized(options.lsn) : null;
|
|
759
764
|
const startAfter = lastLsn?.timestamp;
|
|
760
765
|
const resumeAfter = lastLsn?.resumeToken;
|
|
761
766
|
|
|
762
|
-
const filters =
|
|
763
|
-
|
|
764
|
-
const pipeline: mongo.Document[] = [
|
|
765
|
-
{
|
|
766
|
-
$match: filters.$match
|
|
767
|
-
},
|
|
768
|
-
{ $changeStreamSplitLargeEvent: {} }
|
|
769
|
-
];
|
|
767
|
+
const filters = options.filters;
|
|
770
768
|
|
|
771
769
|
let fullDocument: 'required' | 'updateLookup';
|
|
772
770
|
|
|
@@ -778,12 +776,19 @@ export class ChangeStream {
|
|
|
778
776
|
} else {
|
|
779
777
|
fullDocument = 'updateLookup';
|
|
780
778
|
}
|
|
781
|
-
const streamOptions: mongo.ChangeStreamOptions = {
|
|
779
|
+
const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
|
|
782
780
|
showExpandedEvents: true,
|
|
783
|
-
|
|
784
|
-
fullDocument: fullDocument,
|
|
785
|
-
maxTimeMS: this.changeStreamTimeout
|
|
781
|
+
fullDocument: fullDocument
|
|
786
782
|
};
|
|
783
|
+
const pipeline: mongo.Document[] = [
|
|
784
|
+
{
|
|
785
|
+
$changeStream: streamOptions
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
$match: filters.$match
|
|
789
|
+
},
|
|
790
|
+
{ $changeStreamSplitLargeEvent: {} }
|
|
791
|
+
];
|
|
787
792
|
|
|
788
793
|
/**
|
|
789
794
|
* Only one of these options can be supplied at a time.
|
|
@@ -797,36 +802,43 @@ export class ChangeStream {
|
|
|
797
802
|
streamOptions.startAtOperationTime = startAfter;
|
|
798
803
|
}
|
|
799
804
|
|
|
800
|
-
let
|
|
805
|
+
let watchDb: mongo.Db;
|
|
801
806
|
if (filters.multipleDatabases) {
|
|
802
|
-
|
|
803
|
-
|
|
807
|
+
watchDb = this.client.db('admin');
|
|
808
|
+
streamOptions.allChangesForCluster = true;
|
|
804
809
|
} else {
|
|
805
|
-
|
|
806
|
-
stream = this.defaultDb.watch(pipeline, streamOptions);
|
|
810
|
+
watchDb = this.defaultDb;
|
|
807
811
|
}
|
|
808
812
|
|
|
809
|
-
|
|
810
|
-
|
|
813
|
+
return rawChangeStream(watchDb, pipeline, {
|
|
814
|
+
batchSize: options.batchSize ?? this.snapshotChunkLength,
|
|
815
|
+
maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
|
|
816
|
+
maxTimeMS: this.changeStreamTimeout,
|
|
817
|
+
|
|
818
|
+
signal: options.signal,
|
|
819
|
+
logger: this.logger,
|
|
820
|
+
tracer: options.tracer
|
|
811
821
|
});
|
|
822
|
+
}
|
|
812
823
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
filters,
|
|
816
|
-
[Symbol.asyncDispose]: async () => {
|
|
817
|
-
return stream.close();
|
|
818
|
-
}
|
|
819
|
-
};
|
|
824
|
+
private rawToSqliteRow(row: Buffer) {
|
|
825
|
+
return this.sourceRowConverter.rawToSqliteRow(row);
|
|
820
826
|
}
|
|
821
827
|
|
|
822
828
|
async streamChangesInternal() {
|
|
829
|
+
const transactionsReplicatedMetric = this.metrics.getCounter(ReplicationMetric.TRANSACTIONS_REPLICATED);
|
|
830
|
+
const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
|
|
831
|
+
const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
|
|
832
|
+
|
|
833
|
+
const tracer = new PerformanceTracer('MongoDB streaming replication');
|
|
823
834
|
await this.storage.startBatch(
|
|
824
835
|
{
|
|
825
836
|
logger: this.logger,
|
|
826
837
|
zeroLSN: MongoLSN.ZERO.comparable,
|
|
827
838
|
defaultSchema: this.defaultDb.databaseName,
|
|
828
839
|
// We get a complete postimage for every change, so we don't need to store the current data.
|
|
829
|
-
storeCurrentData: false
|
|
840
|
+
storeCurrentData: false,
|
|
841
|
+
tracer
|
|
830
842
|
},
|
|
831
843
|
async (batch) => {
|
|
832
844
|
const { resumeFromLsn } = batch;
|
|
@@ -835,6 +847,7 @@ export class ChangeStream {
|
|
|
835
847
|
}
|
|
836
848
|
const lastLsn = MongoLSN.fromSerialized(resumeFromLsn);
|
|
837
849
|
const startAfter = lastLsn?.timestamp;
|
|
850
|
+
let outerSpan = tracer.span('batch');
|
|
838
851
|
|
|
839
852
|
// It is normal for this to be a minute or two old when there is a low volume
|
|
840
853
|
// of ChangeStream events.
|
|
@@ -842,12 +855,14 @@ export class ChangeStream {
|
|
|
842
855
|
|
|
843
856
|
this.logger.info(`Resume streaming at ${startAfter?.inspect()} / ${lastLsn} | Token age: ${tokenAgeSeconds}s`);
|
|
844
857
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
858
|
+
const filters = this.getSourceNamespaceFilters();
|
|
859
|
+
// This is closed when the for loop below returns/breaks/throws
|
|
860
|
+
const batchStream = this.rawChangeStreamBatches({
|
|
861
|
+
lsn: resumeFromLsn,
|
|
862
|
+
filters,
|
|
863
|
+
signal: this.abort_signal,
|
|
864
|
+
tracer
|
|
865
|
+
});
|
|
851
866
|
|
|
852
867
|
// Always start with a checkpoint.
|
|
853
868
|
// This helps us to clear errors when restarting, even if there is
|
|
@@ -858,43 +873,30 @@ export class ChangeStream {
|
|
|
858
873
|
this.checkpointStreamId
|
|
859
874
|
);
|
|
860
875
|
|
|
861
|
-
let splitDocument:
|
|
876
|
+
let splitDocument: ProjectedChangeStreamDocument | null = null;
|
|
862
877
|
|
|
863
878
|
let flexDbNameWorkaroundLogged = false;
|
|
864
|
-
let changesSinceLastCheckpoint = 0;
|
|
865
879
|
|
|
866
880
|
let lastEmptyResume = performance.now();
|
|
881
|
+
let lastTxnKey: string | null = null;
|
|
867
882
|
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
const originalChangeDocument = await stream.tryNext().catch((e) => {
|
|
874
|
-
throw mapChangeStreamError(e);
|
|
875
|
-
});
|
|
876
|
-
// The stream was closed, we will only ever receive `null` from it
|
|
877
|
-
if (!originalChangeDocument && stream.closed) {
|
|
878
|
-
break;
|
|
879
|
-
}
|
|
883
|
+
for await (let eventBatch of batchStream) {
|
|
884
|
+
const { events, resumeToken } = eventBatch;
|
|
885
|
+
using batchSpan = tracer.span('processing');
|
|
880
886
|
|
|
887
|
+
bytesReplicatedMetric.add(eventBatch.byteSize);
|
|
888
|
+
chunksReplicatedMetric.add(1);
|
|
881
889
|
if (this.abort_signal.aborted) {
|
|
882
890
|
break;
|
|
883
891
|
}
|
|
884
|
-
|
|
885
|
-
if (
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
// stream.resumeToken is not updated if stream.tryNext() returns data, while stream.next()
|
|
889
|
-
// does update it.
|
|
890
|
-
// From observed behavior, the actual resumeToken changes around once every 10 seconds.
|
|
892
|
+
this.touch();
|
|
893
|
+
if (events.length == 0) {
|
|
894
|
+
// No changes in this batch, but we still want to keep the connection alive.
|
|
895
|
+
// We do this by persisting a keepalive checkpoint.
|
|
891
896
|
// If we don't update it on empty events, we do keep consistency, but resuming the stream
|
|
892
897
|
// with old tokens may cause connection timeouts.
|
|
893
|
-
// We throttle this further by only persisting a keepalive once a minute.
|
|
894
|
-
// We add an additional check for waitForCheckpointLsn == null, to make sure we're not
|
|
895
|
-
// doing a keepalive in the middle of a transaction.
|
|
896
898
|
if (waitForCheckpointLsn == null && performance.now() - lastEmptyResume > 60_000) {
|
|
897
|
-
const { comparable: lsn, timestamp } = MongoLSN.fromResumeToken(
|
|
899
|
+
const { comparable: lsn, timestamp } = MongoLSN.fromResumeToken(resumeToken);
|
|
898
900
|
await batch.keepalive(lsn);
|
|
899
901
|
this.touch();
|
|
900
902
|
lastEmptyResume = performance.now();
|
|
@@ -903,220 +905,260 @@ export class ChangeStream {
|
|
|
903
905
|
this.logger.info(
|
|
904
906
|
`Idle change stream. Persisted resumeToken for ${timestampToDate(timestamp).toISOString()}`
|
|
905
907
|
);
|
|
906
|
-
this.
|
|
908
|
+
this.replicationLag.markStarted();
|
|
907
909
|
}
|
|
908
|
-
continue;
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
this.touch();
|
|
912
910
|
|
|
913
|
-
|
|
914
|
-
|
|
911
|
+
// If we have no changes, we can just persist the keepalive.
|
|
912
|
+
// This is throttled to once per minute.
|
|
913
|
+
if (performance.now() - lastEmptyResume < 60_000) {
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
915
916
|
}
|
|
916
917
|
|
|
917
|
-
|
|
918
|
-
if (originalChangeDocument?.splitEvent != null) {
|
|
919
|
-
// Handle split events from $changeStreamSplitLargeEvent.
|
|
920
|
-
// This is only relevant for very large update operations.
|
|
921
|
-
const splitEvent = originalChangeDocument?.splitEvent;
|
|
918
|
+
this.touch();
|
|
922
919
|
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
920
|
+
for (let eventIndex = 0; eventIndex < events.length; eventIndex++) {
|
|
921
|
+
const rawChangeDocument = events[eventIndex];
|
|
922
|
+
const originalChangeDocument = parseChangeDocument(rawChangeDocument);
|
|
923
|
+
if (this.abort_signal.aborted) {
|
|
924
|
+
break;
|
|
927
925
|
}
|
|
928
926
|
|
|
929
|
-
if (
|
|
930
|
-
// Got all fragments
|
|
931
|
-
changeDocument = splitDocument;
|
|
932
|
-
splitDocument = null;
|
|
933
|
-
} else {
|
|
934
|
-
// Wait for more fragments
|
|
927
|
+
if (startAfter != null && originalChangeDocument.clusterTime?.lte(startAfter)) {
|
|
935
928
|
continue;
|
|
936
929
|
}
|
|
937
|
-
} else if (splitDocument != null) {
|
|
938
|
-
// We were waiting for fragments, but got a different event
|
|
939
|
-
throw new ReplicationAssertionError(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
|
|
940
|
-
}
|
|
941
930
|
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
) {
|
|
948
|
-
// When all of the following conditions are met:
|
|
949
|
-
// 1. We're replicating from an Atlas Flex instance.
|
|
950
|
-
// 2. There were changestream events recorded while the PowerSync service is paused.
|
|
951
|
-
// 3. We're only replicating from a single database.
|
|
952
|
-
// Then we've observed an ns with for example {db: '67b83e86cd20730f1e766dde_ps'},
|
|
953
|
-
// instead of the expected {db: 'ps'}.
|
|
954
|
-
// We correct this.
|
|
955
|
-
changeDocument.ns.db = this.defaultDb.databaseName;
|
|
956
|
-
|
|
957
|
-
if (!flexDbNameWorkaroundLogged) {
|
|
958
|
-
flexDbNameWorkaroundLogged = true;
|
|
959
|
-
this.logger.warn(
|
|
960
|
-
`Incorrect DB name in change stream: ${changeDocument.ns.db}. Changed to ${this.defaultDb.databaseName}.`
|
|
961
|
-
);
|
|
962
|
-
}
|
|
963
|
-
}
|
|
931
|
+
let changeDocument = originalChangeDocument;
|
|
932
|
+
if (originalChangeDocument?.splitEvent != null) {
|
|
933
|
+
// Handle split events from $changeStreamSplitLargeEvent.
|
|
934
|
+
// This is only relevant for very large update operations.
|
|
935
|
+
const splitEvent = originalChangeDocument?.splitEvent;
|
|
964
936
|
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
throw new ChangeStreamInvalidatedError(
|
|
983
|
-
'Internal collections have been dropped',
|
|
984
|
-
new Error('_checkpoints collection was dropped')
|
|
985
|
-
);
|
|
937
|
+
if (splitDocument == null) {
|
|
938
|
+
splitDocument = originalChangeDocument;
|
|
939
|
+
} else {
|
|
940
|
+
splitDocument = Object.assign(splitDocument, originalChangeDocument);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (splitEvent.fragment == splitEvent.of) {
|
|
944
|
+
// Got all fragments
|
|
945
|
+
changeDocument = splitDocument;
|
|
946
|
+
splitDocument = null;
|
|
947
|
+
} else {
|
|
948
|
+
// Wait for more fragments
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
} else if (splitDocument != null) {
|
|
952
|
+
// We were waiting for fragments, but got a different event
|
|
953
|
+
throw new ReplicationAssertionError(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
|
|
986
954
|
}
|
|
987
955
|
|
|
988
956
|
if (
|
|
989
|
-
!
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
)
|
|
957
|
+
!filters.multipleDatabases &&
|
|
958
|
+
'ns' in changeDocument &&
|
|
959
|
+
changeDocument.ns.db != this.defaultDb.databaseName &&
|
|
960
|
+
changeDocument.ns.db.endsWith(`_${this.defaultDb.databaseName}`)
|
|
994
961
|
) {
|
|
995
|
-
|
|
962
|
+
// When all of the following conditions are met:
|
|
963
|
+
// 1. We're replicating from an Atlas Flex instance.
|
|
964
|
+
// 2. There were changestream events recorded while the PowerSync service is paused.
|
|
965
|
+
// 3. We're only replicating from a single database.
|
|
966
|
+
// Then we've observed an ns with for example {db: '67b83e86cd20730f1e766dde_ps'},
|
|
967
|
+
// instead of the expected {db: 'ps'}.
|
|
968
|
+
// We correct this.
|
|
969
|
+
changeDocument.ns.db = this.defaultDb.databaseName;
|
|
970
|
+
|
|
971
|
+
if (!flexDbNameWorkaroundLogged) {
|
|
972
|
+
flexDbNameWorkaroundLogged = true;
|
|
973
|
+
this.logger.warn(
|
|
974
|
+
`Incorrect DB name in change stream: ${changeDocument.ns.db}. Changed to ${this.defaultDb.databaseName}.`
|
|
975
|
+
);
|
|
976
|
+
}
|
|
996
977
|
}
|
|
997
978
|
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
// This has been fixed in the driver in the meantime, but we still keep this as a safety-check.
|
|
1021
|
-
throw new ReplicationAssertionError(
|
|
1022
|
-
`Change resumeToken ${(changeDocument._id as any)._data} (${timestampToDate(changeDocument.clusterTime!).toISOString()}) is less than last checkpoint LSN ${batch.lastCheckpointLsn}. Restarting replication.`
|
|
1023
|
-
);
|
|
1024
|
-
}
|
|
979
|
+
const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
|
|
980
|
+
|
|
981
|
+
if (ns?.coll == CHECKPOINTS_COLLECTION) {
|
|
982
|
+
/**
|
|
983
|
+
* Dropping the database does not provide an `invalidate` event.
|
|
984
|
+
* We typically would receive `drop` events for the collection which we
|
|
985
|
+
* would process below.
|
|
986
|
+
*
|
|
987
|
+
* However we don't commit the LSN after collections are dropped.
|
|
988
|
+
* This prevents the `startAfter` or `resumeToken` from advancing past the drop events.
|
|
989
|
+
* The stream also closes after the drop events.
|
|
990
|
+
* This causes an infinite loop of processing the collection drop events.
|
|
991
|
+
*
|
|
992
|
+
* This check here invalidates the change stream if our `_powersync_checkpoints` collection
|
|
993
|
+
* is dropped. This allows for detecting when the DB is dropped.
|
|
994
|
+
*/
|
|
995
|
+
if (changeDocument.operationType == 'drop') {
|
|
996
|
+
throw new ChangeStreamInvalidatedError(
|
|
997
|
+
'Internal collections have been dropped',
|
|
998
|
+
new Error('_powersync_checkpoints collection was dropped')
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1025
1001
|
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1002
|
+
if (
|
|
1003
|
+
!(
|
|
1004
|
+
changeDocument.operationType == 'insert' ||
|
|
1005
|
+
changeDocument.operationType == 'update' ||
|
|
1006
|
+
changeDocument.operationType == 'replace'
|
|
1007
|
+
)
|
|
1008
|
+
) {
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// We handle two types of checkpoint events:
|
|
1013
|
+
// 1. "Standalone" checkpoints, typically write checkpoints. We want to process these
|
|
1014
|
+
// immediately, regardless of where they were created.
|
|
1015
|
+
// 2. "Batch" checkpoints for the current stream. This is used as a form of dynamic rate
|
|
1016
|
+
// limiting of commits, so we specifically want to exclude checkpoints from other streams.
|
|
1017
|
+
//
|
|
1018
|
+
// It may be useful to also throttle commits due to standalone checkpoints in the future.
|
|
1019
|
+
// However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
|
|
1020
|
+
|
|
1021
|
+
const checkpointId = changeDocument.documentKey._id as string | mongo.ObjectId;
|
|
1022
|
+
|
|
1023
|
+
if (checkpointId == STANDALONE_CHECKPOINT_ID) {
|
|
1024
|
+
// Standalone / write checkpoint received.
|
|
1025
|
+
// When we are caught up, commit immediately to keep write checkpoint latency low.
|
|
1026
|
+
// Once there is already a batch checkpoint pending, or the driver has buffered more
|
|
1027
|
+
// change stream events, collapse standalone checkpoints into the normal batch
|
|
1028
|
+
// checkpoint flow to avoid commit churn under sustained load.
|
|
1029
|
+
const hasBufferedChanges = eventIndex < events.length - 1;
|
|
1030
|
+
if (waitForCheckpointLsn != null || hasBufferedChanges) {
|
|
1031
|
+
if (waitForCheckpointLsn == null) {
|
|
1032
|
+
waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
1033
|
+
}
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
} else if (!this.checkpointStreamId.equals(checkpointId)) {
|
|
1037
|
+
continue;
|
|
1058
1038
|
}
|
|
1059
|
-
const
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
//
|
|
1065
|
-
//
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
changesSinceLastCheckpoint = 0;
|
|
1039
|
+
const { comparable: lsn } = new MongoLSN({
|
|
1040
|
+
timestamp: changeDocument.clusterTime!,
|
|
1041
|
+
resume_token: changeDocument._id
|
|
1042
|
+
});
|
|
1043
|
+
if (batch.lastCheckpointLsn != null && lsn < batch.lastCheckpointLsn) {
|
|
1044
|
+
// Checkpoint out of order - should never happen with MongoDB.
|
|
1045
|
+
// If it does happen, we throw an error to stop the replication - restarting should recover.
|
|
1046
|
+
// Since we use batch.lastCheckpointLsn for the next resumeAfter, this should not result in an infinite loop.
|
|
1047
|
+
// Originally a workaround for https://jira.mongodb.org/browse/NODE-7042.
|
|
1048
|
+
// This has been fixed in the driver in the meantime, but we still keep this as a safety-check.
|
|
1049
|
+
throw new ReplicationAssertionError(
|
|
1050
|
+
`Change resumeToken ${(changeDocument._id as any)._data} (${timestampToDate(changeDocument.clusterTime!).toISOString()}) is less than last checkpoint LSN ${batch.lastCheckpointLsn}. Restarting replication.`
|
|
1051
|
+
);
|
|
1073
1052
|
}
|
|
1053
|
+
|
|
1054
|
+
if (waitForCheckpointLsn != null && lsn >= waitForCheckpointLsn) {
|
|
1055
|
+
waitForCheckpointLsn = null;
|
|
1056
|
+
}
|
|
1057
|
+
const { checkpointBlocked } = await batch.commit(lsn, {
|
|
1058
|
+
oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
if (!checkpointBlocked) {
|
|
1062
|
+
this.replicationLag.markCommitted();
|
|
1063
|
+
}
|
|
1064
|
+
} else if (
|
|
1065
|
+
changeDocument.operationType == 'insert' ||
|
|
1066
|
+
changeDocument.operationType == 'update' ||
|
|
1067
|
+
changeDocument.operationType == 'replace' ||
|
|
1068
|
+
changeDocument.operationType == 'delete'
|
|
1069
|
+
) {
|
|
1070
|
+
if (waitForCheckpointLsn == null) {
|
|
1071
|
+
waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const rel = getMongoRelation(changeDocument.ns);
|
|
1075
|
+
const table = await this.getRelation(batch, rel, {
|
|
1076
|
+
// In most cases, we should not need to snapshot this. But if this is the first time we see the collection
|
|
1077
|
+
// for whatever reason, then we do need to snapshot it.
|
|
1078
|
+
// This may result in some duplicate operations when a collection is created for the first time after
|
|
1079
|
+
// sync config was deployed.
|
|
1080
|
+
snapshot: true
|
|
1081
|
+
});
|
|
1082
|
+
if (table.syncAny) {
|
|
1083
|
+
this.replicationLag.trackUncommittedChange(
|
|
1084
|
+
changeDocument.clusterTime == null ? null : timestampToDate(changeDocument.clusterTime)
|
|
1085
|
+
);
|
|
1086
|
+
|
|
1087
|
+
const transactionKeyValue = transactionKey(changeDocument);
|
|
1088
|
+
|
|
1089
|
+
if (transactionKeyValue == null || lastTxnKey != transactionKeyValue) {
|
|
1090
|
+
// Very crude metric for counting transactions replicated.
|
|
1091
|
+
// We ignore operations other than basic CRUD, and ignore changes to _powersync_checkpoints.
|
|
1092
|
+
// Individual writes may not have a txnNumber, in which case we count them as separate transactions.
|
|
1093
|
+
lastTxnKey = transactionKeyValue;
|
|
1094
|
+
transactionsReplicatedMetric.add(1);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
await this.writeChange(batch, table, changeDocument);
|
|
1098
|
+
}
|
|
1099
|
+
} else if (changeDocument.operationType == 'drop') {
|
|
1100
|
+
const rel = getMongoRelation(changeDocument.ns);
|
|
1101
|
+
const table = await this.getRelation(batch, rel, {
|
|
1102
|
+
// We're "dropping" this collection, so never snapshot it.
|
|
1103
|
+
snapshot: false
|
|
1104
|
+
});
|
|
1105
|
+
if (table.syncAny) {
|
|
1106
|
+
await batch.drop([table]);
|
|
1107
|
+
this.relationCache.delete(table);
|
|
1108
|
+
}
|
|
1109
|
+
} else if (changeDocument.operationType == 'rename') {
|
|
1110
|
+
const relFrom = getMongoRelation(changeDocument.ns);
|
|
1111
|
+
const relTo = getMongoRelation(changeDocument.to);
|
|
1112
|
+
const tableFrom = await this.getRelation(batch, relFrom, {
|
|
1113
|
+
// We're "dropping" this collection, so never snapshot it.
|
|
1114
|
+
snapshot: false
|
|
1115
|
+
});
|
|
1116
|
+
if (tableFrom.syncAny) {
|
|
1117
|
+
await batch.drop([tableFrom]);
|
|
1118
|
+
this.relationCache.delete(relFrom);
|
|
1119
|
+
}
|
|
1120
|
+
// Here we do need to snapshot the new table
|
|
1121
|
+
const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
|
|
1122
|
+
await this.handleRelation(batch, relTo, {
|
|
1123
|
+
// This is a new (renamed) collection, so always snapshot it.
|
|
1124
|
+
snapshot: true,
|
|
1125
|
+
collectionInfo: collection
|
|
1126
|
+
});
|
|
1074
1127
|
}
|
|
1075
|
-
} else if (changeDocument.operationType == 'drop') {
|
|
1076
|
-
const rel = getMongoRelation(changeDocument.ns);
|
|
1077
|
-
const table = await this.getRelation(batch, rel, {
|
|
1078
|
-
// We're "dropping" this collection, so never snapshot it.
|
|
1079
|
-
snapshot: false
|
|
1080
|
-
});
|
|
1081
|
-
if (table.syncAny) {
|
|
1082
|
-
await batch.drop([table]);
|
|
1083
|
-
this.relationCache.delete(table);
|
|
1084
|
-
}
|
|
1085
|
-
} else if (changeDocument.operationType == 'rename') {
|
|
1086
|
-
const relFrom = getMongoRelation(changeDocument.ns);
|
|
1087
|
-
const relTo = getMongoRelation(changeDocument.to);
|
|
1088
|
-
const tableFrom = await this.getRelation(batch, relFrom, {
|
|
1089
|
-
// We're "dropping" this collection, so never snapshot it.
|
|
1090
|
-
snapshot: false
|
|
1091
|
-
});
|
|
1092
|
-
if (tableFrom.syncAny) {
|
|
1093
|
-
await batch.drop([tableFrom]);
|
|
1094
|
-
this.relationCache.delete(relFrom);
|
|
1095
|
-
}
|
|
1096
|
-
// Here we do need to snapshot the new table
|
|
1097
|
-
const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
|
|
1098
|
-
await this.handleRelation(batch, relTo, {
|
|
1099
|
-
// This is a new (renamed) collection, so always snapshot it.
|
|
1100
|
-
snapshot: true,
|
|
1101
|
-
collectionInfo: collection
|
|
1102
|
-
});
|
|
1103
1128
|
}
|
|
1129
|
+
|
|
1130
|
+
if (splitDocument == null) {
|
|
1131
|
+
// We flush and mark progress on every batch of data we receive.
|
|
1132
|
+
// Batches are generally large (64MB or 6000 events, whichever comes first),
|
|
1133
|
+
// so this is a good natural point to flush and mark progress.
|
|
1134
|
+
// We avoid this when splitDocument is set, since we cannot resume in the middle of a split event.
|
|
1135
|
+
const { comparable: lsn } = MongoLSN.fromResumeToken(resumeToken);
|
|
1136
|
+
await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
|
|
1137
|
+
// TODO: We should consider making this standard behavior of flush().
|
|
1138
|
+
await batch.setResumeLsn(lsn);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
batchSpan.end();
|
|
1142
|
+
const durations = outerSpan.end();
|
|
1143
|
+
const duration = batchSpan.endAt - batchSpan.startAt;
|
|
1144
|
+
|
|
1145
|
+
this.logger.info(
|
|
1146
|
+
`Processed batch of ${events.length} changes / ${eventBatch.byteSize} bytes in ${duration}ms`,
|
|
1147
|
+
{
|
|
1148
|
+
count: events.length,
|
|
1149
|
+
bytes: eventBatch.byteSize,
|
|
1150
|
+
duration,
|
|
1151
|
+
t: durations
|
|
1152
|
+
}
|
|
1153
|
+
);
|
|
1154
|
+
outerSpan = tracer.span('batch');
|
|
1104
1155
|
}
|
|
1105
1156
|
}
|
|
1106
1157
|
);
|
|
1107
1158
|
}
|
|
1108
1159
|
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
if (this.isStartingReplication) {
|
|
1112
|
-
// We don't have anything to compute replication lag with yet.
|
|
1113
|
-
return undefined;
|
|
1114
|
-
} else {
|
|
1115
|
-
// We don't have any uncommitted changes, so replication is up-to-date.
|
|
1116
|
-
return 0;
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
return Date.now() - this.oldestUncommittedChange.getTime();
|
|
1160
|
+
getReplicationLagMillis(): number | undefined {
|
|
1161
|
+
return this.replicationLag.getLagMillis();
|
|
1120
1162
|
}
|
|
1121
1163
|
|
|
1122
1164
|
private lastTouchedAt = performance.now();
|
|
@@ -1132,24 +1174,12 @@ export class ChangeStream {
|
|
|
1132
1174
|
}
|
|
1133
1175
|
}
|
|
1134
1176
|
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
// maxTimeMS was reached. Example message:
|
|
1142
|
-
// MongoServerError: Executor error during aggregate command on namespace: powersync_test_data.$cmd.aggregate :: caused by :: operation exceeded time limit
|
|
1143
|
-
throw new DatabaseConnectionError(ErrorCode.PSYNC_S1345, `Timeout while reading MongoDB ChangeStream`, e);
|
|
1144
|
-
} else if (
|
|
1145
|
-
isMongoServerError(e) &&
|
|
1146
|
-
e.codeName == 'NoMatchingDocument' &&
|
|
1147
|
-
e.errmsg?.includes('post-image was not found')
|
|
1148
|
-
) {
|
|
1149
|
-
throw new ChangeStreamInvalidatedError(e.errmsg, e);
|
|
1150
|
-
} else if (isMongoServerError(e) && e.hasErrorLabel('NonResumableChangeStreamError')) {
|
|
1151
|
-
throw new ChangeStreamInvalidatedError(e.message, e);
|
|
1152
|
-
} else {
|
|
1153
|
-
throw new DatabaseConnectionError(ErrorCode.PSYNC_S1346, `Error reading MongoDB ChangeStream`, e);
|
|
1177
|
+
/**
|
|
1178
|
+
* Transaction key for a change stream event, used to detect transaction boundaries. Returns null if the event is not part of a transaction.
|
|
1179
|
+
*/
|
|
1180
|
+
function transactionKey(doc: Pick<mongo.ChangeStreamDocument, 'lsid' | 'txnNumber'>): string | null {
|
|
1181
|
+
if (doc.txnNumber == null || doc.lsid == null) {
|
|
1182
|
+
return null;
|
|
1154
1183
|
}
|
|
1184
|
+
return `${doc.lsid.id.toString('hex')}:${doc.txnNumber}`;
|
|
1155
1185
|
}
|