@powersync/service-module-mongodb 0.15.4 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/dist/replication/ChangeStream.d.ts +6 -6
- package/dist/replication/ChangeStream.js +300 -322
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.js +2 -2
- package/dist/replication/ChangeStreamReplicationJob.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/MongoRelation.js +4 -0
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/MongoSnapshotQuery.d.ts +1 -1
- package/dist/replication/MongoSnapshotQuery.js +6 -3
- 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 +0 -12
- package/dist/replication/internal-mongodb-utils.js +0 -54
- package/dist/replication/internal-mongodb-utils.js.map +1 -1
- package/dist/replication/replication-index.d.ts +2 -0
- package/dist/replication/replication-index.js +2 -0
- package/dist/replication/replication-index.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/replication/ChangeStream.ts +348 -371
- package/src/replication/ChangeStreamReplicationJob.ts +2 -2
- package/src/replication/JsonBufferWriter.ts +390 -0
- package/src/replication/MongoRelation.ts +3 -0
- package/src/replication/MongoSnapshotQuery.ts +8 -5
- 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 +0 -65
- package/src/replication/replication-index.ts +2 -0
- package/test/src/buffer_to_sqlite.test.ts +1146 -0
- package/test/src/change_stream.test.ts +49 -2
- 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
- package/test/src/internal_mongodb_utils.test.ts +0 -103
|
@@ -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,6 +10,7 @@ import {
|
|
|
11
10
|
} from '@powersync/lib-services-framework';
|
|
12
11
|
import {
|
|
13
12
|
MetricsEngine,
|
|
13
|
+
PerformanceTracer,
|
|
14
14
|
RelationCache,
|
|
15
15
|
ReplicationLagTracker,
|
|
16
16
|
SaveOperationTag,
|
|
@@ -18,29 +18,23 @@ import {
|
|
|
18
18
|
SourceTable,
|
|
19
19
|
storage
|
|
20
20
|
} from '@powersync/service-core';
|
|
21
|
-
import {
|
|
22
|
-
DatabaseInputRow,
|
|
23
|
-
HydratedSyncRules,
|
|
24
|
-
SqliteInputRow,
|
|
25
|
-
SqliteRow,
|
|
26
|
-
TablePattern
|
|
27
|
-
} from '@powersync/service-sync-rules';
|
|
21
|
+
import { HydratedSyncRules, TablePattern } from '@powersync/service-sync-rules';
|
|
28
22
|
import { ReplicationMetric } from '@powersync/service-types';
|
|
23
|
+
import { performance } from 'node:perf_hooks';
|
|
29
24
|
import { MongoLSN } from '../common/MongoLSN.js';
|
|
30
25
|
import { PostImagesOption } from '../types/types.js';
|
|
31
26
|
import { escapeRegExp } from '../utils.js';
|
|
32
|
-
import { trackChangeStreamBsonBytes } from './internal-mongodb-utils.js';
|
|
33
27
|
import { MongoManager } from './MongoManager.js';
|
|
34
|
-
import {
|
|
35
|
-
constructAfterRecord,
|
|
36
|
-
createCheckpoint,
|
|
37
|
-
getCacheIdentifier,
|
|
38
|
-
getMongoRelation,
|
|
39
|
-
STANDALONE_CHECKPOINT_ID
|
|
40
|
-
} from './MongoRelation.js';
|
|
28
|
+
import { createCheckpoint, getCacheIdentifier, getMongoRelation, STANDALONE_CHECKPOINT_ID } from './MongoRelation.js';
|
|
41
29
|
import { ChunkedSnapshotQuery } from './MongoSnapshotQuery.js';
|
|
30
|
+
import {
|
|
31
|
+
ChangeStreamBatch,
|
|
32
|
+
parseChangeDocument,
|
|
33
|
+
ProjectedChangeStreamDocument,
|
|
34
|
+
rawChangeStream
|
|
35
|
+
} from './RawChangeStream.js';
|
|
42
36
|
import { CHECKPOINTS_COLLECTION, timestampToDate } from './replication-utils.js';
|
|
43
|
-
|
|
37
|
+
import { DirectSourceRowConverter, SourceRowConverter } from './SourceRowConverter.js';
|
|
44
38
|
export interface ChangeStreamOptions {
|
|
45
39
|
connections: MongoManager;
|
|
46
40
|
storage: storage.SyncRulesBucketStorage;
|
|
@@ -110,6 +104,8 @@ export class ChangeStream {
|
|
|
110
104
|
|
|
111
105
|
private changeStreamTimeout: number;
|
|
112
106
|
|
|
107
|
+
private readonly sourceRowConverter: SourceRowConverter;
|
|
108
|
+
|
|
113
109
|
constructor(options: ChangeStreamOptions) {
|
|
114
110
|
this.storage = options.storage;
|
|
115
111
|
this.metrics = options.metrics;
|
|
@@ -122,6 +118,8 @@ export class ChangeStream {
|
|
|
122
118
|
this.sync_rules = options.storage.getParsedSyncRules({
|
|
123
119
|
defaultSchema: this.defaultDb.databaseName
|
|
124
120
|
});
|
|
121
|
+
this.sourceRowConverter = new DirectSourceRowConverter(this.sync_rules.compatibility);
|
|
122
|
+
|
|
125
123
|
// The change stream aggregation command should timeout before the socket times out,
|
|
126
124
|
// so we use 90% of the socket timeout value.
|
|
127
125
|
this.changeStreamTimeout = Math.ceil(this.client.options.socketTimeoutMS * 0.9);
|
|
@@ -135,7 +133,7 @@ export class ChangeStream {
|
|
|
135
133
|
{ once: true }
|
|
136
134
|
);
|
|
137
135
|
|
|
138
|
-
this.logger = options.logger ??
|
|
136
|
+
this.logger = options.logger ?? this.storage.logger;
|
|
139
137
|
}
|
|
140
138
|
|
|
141
139
|
get stopped() {
|
|
@@ -259,48 +257,53 @@ export class ChangeStream {
|
|
|
259
257
|
|
|
260
258
|
// Create a checkpoint, and open a change stream using startAtOperationTime with the checkpoint's operationTime.
|
|
261
259
|
const firstCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
262
|
-
await using streamManager = this.openChangeStream({ lsn: firstCheckpointLsn, maxAwaitTimeMs: 0 });
|
|
263
260
|
|
|
264
|
-
const { stream } = streamManager;
|
|
265
261
|
const startTime = performance.now();
|
|
266
262
|
let lastCheckpointCreated = performance.now();
|
|
267
263
|
let eventsSeen = 0;
|
|
264
|
+
let batchesSeen = 0;
|
|
268
265
|
|
|
269
|
-
|
|
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
|
+
}
|
|
270
277
|
if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
|
|
271
278
|
await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
272
279
|
lastCheckpointCreated = performance.now();
|
|
273
280
|
}
|
|
281
|
+
batchesSeen += 1;
|
|
274
282
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
});
|
|
279
|
-
if (changeDocument == null) {
|
|
280
|
-
continue;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
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;
|
|
284
286
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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;
|
|
289
297
|
}
|
|
290
|
-
const { comparable: lsn } = new MongoLSN({
|
|
291
|
-
timestamp: changeDocument.clusterTime!,
|
|
292
|
-
resume_token: changeDocument._id
|
|
293
|
-
});
|
|
294
|
-
return lsn;
|
|
295
|
-
}
|
|
296
298
|
|
|
297
|
-
|
|
299
|
+
eventsSeen += 1;
|
|
300
|
+
}
|
|
298
301
|
}
|
|
299
302
|
|
|
300
303
|
// Could happen if there is a very large replication lag?
|
|
301
304
|
throw new ServiceError(
|
|
302
305
|
ErrorCode.PSYNC_S1301,
|
|
303
|
-
`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}`
|
|
304
307
|
);
|
|
305
308
|
}
|
|
306
309
|
|
|
@@ -308,21 +311,24 @@ export class ChangeStream {
|
|
|
308
311
|
* Given a snapshot LSN, validate that we can read from it, by opening a change stream.
|
|
309
312
|
*/
|
|
310
313
|
private async validateSnapshotLsn(lsn: string) {
|
|
311
|
-
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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;
|
|
320
325
|
}
|
|
321
326
|
}
|
|
322
327
|
|
|
323
328
|
async initialReplication(snapshotLsn: string | null) {
|
|
324
329
|
const sourceTables = this.sync_rules.getSourceTables();
|
|
325
330
|
await this.client.connect();
|
|
331
|
+
const tracer = new PerformanceTracer('MongoDB initial replication');
|
|
326
332
|
|
|
327
333
|
const flushResult = await this.storage.startBatch(
|
|
328
334
|
{
|
|
@@ -330,7 +336,8 @@ export class ChangeStream {
|
|
|
330
336
|
zeroLSN: MongoLSN.ZERO.comparable,
|
|
331
337
|
defaultSchema: this.defaultDb.databaseName,
|
|
332
338
|
storeCurrentData: false,
|
|
333
|
-
skipExistingRows: true
|
|
339
|
+
skipExistingRows: true,
|
|
340
|
+
tracer
|
|
334
341
|
},
|
|
335
342
|
async (batch) => {
|
|
336
343
|
if (snapshotLsn == null) {
|
|
@@ -465,12 +472,6 @@ export class ChangeStream {
|
|
|
465
472
|
return { $match: nsFilter, multipleDatabases };
|
|
466
473
|
}
|
|
467
474
|
|
|
468
|
-
static *getQueryData(results: Iterable<DatabaseInputRow>): Generator<SqliteInputRow> {
|
|
469
|
-
for (let row of results) {
|
|
470
|
-
yield constructAfterRecord(row);
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
475
|
private async snapshotTable(batch: storage.BucketStorageBatch, table: storage.SourceTable) {
|
|
475
476
|
const rowsReplicatedMetric = this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED);
|
|
476
477
|
const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
|
|
@@ -510,8 +511,8 @@ export class ChangeStream {
|
|
|
510
511
|
|
|
511
512
|
// Pre-fetch next batch, so that we can read and write concurrently
|
|
512
513
|
nextChunkPromise = query.nextChunk();
|
|
513
|
-
for (let
|
|
514
|
-
const record = this.
|
|
514
|
+
for (let buffer of docBatch) {
|
|
515
|
+
const { row: record, replicaId: replicaId } = this.rawToSqliteRow(buffer);
|
|
515
516
|
|
|
516
517
|
// This auto-flushes when the batch reaches its size limit
|
|
517
518
|
await batch.save({
|
|
@@ -520,7 +521,7 @@ export class ChangeStream {
|
|
|
520
521
|
before: undefined,
|
|
521
522
|
beforeReplicaId: undefined,
|
|
522
523
|
after: record,
|
|
523
|
-
afterReplicaId:
|
|
524
|
+
afterReplicaId: replicaId
|
|
524
525
|
});
|
|
525
526
|
}
|
|
526
527
|
|
|
@@ -632,7 +633,7 @@ export class ChangeStream {
|
|
|
632
633
|
// Snapshot if:
|
|
633
634
|
// 1. Snapshot is requested (false for initial snapshot, since that process handles it elsewhere)
|
|
634
635
|
// 2. Snapshot is not already done, AND:
|
|
635
|
-
// 3. The table is used in sync
|
|
636
|
+
// 3. The table is used in sync config.
|
|
636
637
|
const shouldSnapshot = snapshot && !result.table.snapshotComplete && result.table.syncAny;
|
|
637
638
|
if (shouldSnapshot) {
|
|
638
639
|
this.logger.info(`New collection: ${descriptor.schema}.${descriptor.name}`);
|
|
@@ -649,30 +650,28 @@ export class ChangeStream {
|
|
|
649
650
|
return result.table;
|
|
650
651
|
}
|
|
651
652
|
|
|
652
|
-
private constructAfterRecord(document: mongo.Document): SqliteRow {
|
|
653
|
-
const inputRow = constructAfterRecord(document);
|
|
654
|
-
return this.sync_rules.applyRowContext<never>(inputRow);
|
|
655
|
-
}
|
|
656
|
-
|
|
657
653
|
async writeChange(
|
|
658
654
|
batch: storage.BucketStorageBatch,
|
|
659
655
|
table: storage.SourceTable,
|
|
660
|
-
change:
|
|
656
|
+
change: ProjectedChangeStreamDocument
|
|
661
657
|
): Promise<storage.FlushedResult | null> {
|
|
662
658
|
if (!table.syncAny) {
|
|
663
|
-
this.logger.debug(`Collection ${table.qualifiedName} not used in sync
|
|
659
|
+
this.logger.debug(`Collection ${table.qualifiedName} not used in sync config - skipping`);
|
|
664
660
|
return null;
|
|
665
661
|
}
|
|
666
662
|
|
|
667
663
|
this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(1);
|
|
668
664
|
if (change.operationType == 'insert') {
|
|
669
|
-
const baseRecord = this.
|
|
665
|
+
const { row: baseRecord, replicaId: _replicaId } = this.rawToSqliteRow(change.fullDocument);
|
|
670
666
|
return await batch.save({
|
|
671
667
|
tag: SaveOperationTag.INSERT,
|
|
672
668
|
sourceTable: table,
|
|
673
669
|
before: undefined,
|
|
674
670
|
beforeReplicaId: undefined,
|
|
675
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.
|
|
676
675
|
afterReplicaId: change.documentKey._id
|
|
677
676
|
});
|
|
678
677
|
} else if (change.operationType == 'update' || change.operationType == 'replace') {
|
|
@@ -685,14 +684,14 @@ export class ChangeStream {
|
|
|
685
684
|
beforeReplicaId: change.documentKey._id
|
|
686
685
|
});
|
|
687
686
|
}
|
|
688
|
-
const after = this.
|
|
687
|
+
const { row: after, replicaId: _replicaId } = this.rawToSqliteRow(change.fullDocument!);
|
|
689
688
|
return await batch.save({
|
|
690
689
|
tag: SaveOperationTag.UPDATE,
|
|
691
690
|
sourceTable: table,
|
|
692
691
|
before: undefined,
|
|
693
692
|
beforeReplicaId: undefined,
|
|
694
693
|
after: after,
|
|
695
|
-
afterReplicaId: change.documentKey._id
|
|
694
|
+
afterReplicaId: change.documentKey._id // Same as _replicaId
|
|
696
695
|
});
|
|
697
696
|
} else if (change.operationType == 'delete') {
|
|
698
697
|
return await batch.save({
|
|
@@ -728,7 +727,7 @@ export class ChangeStream {
|
|
|
728
727
|
}
|
|
729
728
|
const { lastOpId } = await this.initialReplication(result.snapshotLsn);
|
|
730
729
|
if (lastOpId != null) {
|
|
731
|
-
// 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.
|
|
732
731
|
await this.storage.populatePersistentChecksumCache({
|
|
733
732
|
signal: this.abort_signal,
|
|
734
733
|
// No checkpoint yet, but we do have the opId.
|
|
@@ -753,19 +752,19 @@ export class ChangeStream {
|
|
|
753
752
|
}
|
|
754
753
|
}
|
|
755
754
|
|
|
756
|
-
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> {
|
|
757
763
|
const lastLsn = options.lsn ? MongoLSN.fromSerialized(options.lsn) : null;
|
|
758
764
|
const startAfter = lastLsn?.timestamp;
|
|
759
765
|
const resumeAfter = lastLsn?.resumeToken;
|
|
760
766
|
|
|
761
|
-
const filters =
|
|
762
|
-
|
|
763
|
-
const pipeline: mongo.Document[] = [
|
|
764
|
-
{
|
|
765
|
-
$match: filters.$match
|
|
766
|
-
},
|
|
767
|
-
{ $changeStreamSplitLargeEvent: {} }
|
|
768
|
-
];
|
|
767
|
+
const filters = options.filters;
|
|
769
768
|
|
|
770
769
|
let fullDocument: 'required' | 'updateLookup';
|
|
771
770
|
|
|
@@ -777,12 +776,19 @@ export class ChangeStream {
|
|
|
777
776
|
} else {
|
|
778
777
|
fullDocument = 'updateLookup';
|
|
779
778
|
}
|
|
780
|
-
const streamOptions: mongo.ChangeStreamOptions = {
|
|
779
|
+
const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
|
|
781
780
|
showExpandedEvents: true,
|
|
782
|
-
|
|
783
|
-
fullDocument: fullDocument,
|
|
784
|
-
maxTimeMS: this.changeStreamTimeout
|
|
781
|
+
fullDocument: fullDocument
|
|
785
782
|
};
|
|
783
|
+
const pipeline: mongo.Document[] = [
|
|
784
|
+
{
|
|
785
|
+
$changeStream: streamOptions
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
$match: filters.$match
|
|
789
|
+
},
|
|
790
|
+
{ $changeStreamSplitLargeEvent: {} }
|
|
791
|
+
];
|
|
786
792
|
|
|
787
793
|
/**
|
|
788
794
|
* Only one of these options can be supplied at a time.
|
|
@@ -796,45 +802,27 @@ export class ChangeStream {
|
|
|
796
802
|
streamOptions.startAtOperationTime = startAfter;
|
|
797
803
|
}
|
|
798
804
|
|
|
799
|
-
let
|
|
805
|
+
let watchDb: mongo.Db;
|
|
800
806
|
if (filters.multipleDatabases) {
|
|
801
|
-
|
|
802
|
-
|
|
807
|
+
watchDb = this.client.db('admin');
|
|
808
|
+
streamOptions.allChangesForCluster = true;
|
|
803
809
|
} else {
|
|
804
|
-
|
|
805
|
-
stream = this.defaultDb.watch(pipeline, streamOptions);
|
|
810
|
+
watchDb = this.defaultDb;
|
|
806
811
|
}
|
|
807
812
|
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
813
|
+
return rawChangeStream(watchDb, pipeline, {
|
|
814
|
+
batchSize: options.batchSize ?? this.snapshotChunkLength,
|
|
815
|
+
maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
|
|
816
|
+
maxTimeMS: this.changeStreamTimeout,
|
|
811
817
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
return stream.close();
|
|
817
|
-
}
|
|
818
|
-
};
|
|
818
|
+
signal: options.signal,
|
|
819
|
+
logger: this.logger,
|
|
820
|
+
tracer: options.tracer
|
|
821
|
+
});
|
|
819
822
|
}
|
|
820
823
|
|
|
821
|
-
private
|
|
822
|
-
|
|
823
|
-
// not expose that through the public ChangeStream API. We use this to detect backlog
|
|
824
|
-
// building up before we have processed the corresponding source changes locally.
|
|
825
|
-
// If the driver API changes, we'll have a hard error here.
|
|
826
|
-
// We specifically want to avoid a silent performance regression if the driver behavior changes.
|
|
827
|
-
const cursor = (
|
|
828
|
-
stream as mongo.ChangeStream<mongo.Document> & {
|
|
829
|
-
cursor: mongo.AbstractCursor<mongo.ChangeStreamDocument<mongo.Document>>;
|
|
830
|
-
}
|
|
831
|
-
).cursor;
|
|
832
|
-
if (cursor == null || typeof cursor.bufferedCount != 'function') {
|
|
833
|
-
throw new ReplicationAssertionError(
|
|
834
|
-
'MongoDB ChangeStream no longer exposes an internal cursor with bufferedCount'
|
|
835
|
-
);
|
|
836
|
-
}
|
|
837
|
-
return cursor.bufferedCount();
|
|
824
|
+
private rawToSqliteRow(row: Buffer) {
|
|
825
|
+
return this.sourceRowConverter.rawToSqliteRow(row);
|
|
838
826
|
}
|
|
839
827
|
|
|
840
828
|
async streamChangesInternal() {
|
|
@@ -842,13 +830,15 @@ export class ChangeStream {
|
|
|
842
830
|
const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
|
|
843
831
|
const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
|
|
844
832
|
|
|
833
|
+
const tracer = new PerformanceTracer('MongoDB streaming replication');
|
|
845
834
|
await this.storage.startBatch(
|
|
846
835
|
{
|
|
847
836
|
logger: this.logger,
|
|
848
837
|
zeroLSN: MongoLSN.ZERO.comparable,
|
|
849
838
|
defaultSchema: this.defaultDb.databaseName,
|
|
850
839
|
// We get a complete postimage for every change, so we don't need to store the current data.
|
|
851
|
-
storeCurrentData: false
|
|
840
|
+
storeCurrentData: false,
|
|
841
|
+
tracer
|
|
852
842
|
},
|
|
853
843
|
async (batch) => {
|
|
854
844
|
const { resumeFromLsn } = batch;
|
|
@@ -857,6 +847,7 @@ export class ChangeStream {
|
|
|
857
847
|
}
|
|
858
848
|
const lastLsn = MongoLSN.fromSerialized(resumeFromLsn);
|
|
859
849
|
const startAfter = lastLsn?.timestamp;
|
|
850
|
+
let outerSpan = tracer.span('batch');
|
|
860
851
|
|
|
861
852
|
// It is normal for this to be a minute or two old when there is a low volume
|
|
862
853
|
// of ChangeStream events.
|
|
@@ -864,16 +855,13 @@ export class ChangeStream {
|
|
|
864
855
|
|
|
865
856
|
this.logger.info(`Resume streaming at ${startAfter?.inspect()} / ${lastLsn} | Token age: ${tokenAgeSeconds}s`);
|
|
866
857
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
bytesReplicatedMetric.add(bytes);
|
|
875
|
-
// Each of these represent a single response message from MongoDB.
|
|
876
|
-
chunksReplicatedMetric.add(1);
|
|
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
|
|
877
865
|
});
|
|
878
866
|
|
|
879
867
|
// Always start with a checkpoint.
|
|
@@ -885,44 +873,30 @@ export class ChangeStream {
|
|
|
885
873
|
this.checkpointStreamId
|
|
886
874
|
);
|
|
887
875
|
|
|
888
|
-
let splitDocument:
|
|
876
|
+
let splitDocument: ProjectedChangeStreamDocument | null = null;
|
|
889
877
|
|
|
890
878
|
let flexDbNameWorkaroundLogged = false;
|
|
891
|
-
let changesSinceLastCheckpoint = 0;
|
|
892
879
|
|
|
893
880
|
let lastEmptyResume = performance.now();
|
|
894
881
|
let lastTxnKey: string | null = null;
|
|
895
882
|
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
const originalChangeDocument = await stream.tryNext().catch((e) => {
|
|
902
|
-
throw mapChangeStreamError(e);
|
|
903
|
-
});
|
|
904
|
-
// The stream was closed, we will only ever receive `null` from it
|
|
905
|
-
if (!originalChangeDocument && stream.closed) {
|
|
906
|
-
break;
|
|
907
|
-
}
|
|
883
|
+
for await (let eventBatch of batchStream) {
|
|
884
|
+
const { events, resumeToken } = eventBatch;
|
|
885
|
+
using batchSpan = tracer.span('processing');
|
|
908
886
|
|
|
887
|
+
bytesReplicatedMetric.add(eventBatch.byteSize);
|
|
888
|
+
chunksReplicatedMetric.add(1);
|
|
909
889
|
if (this.abort_signal.aborted) {
|
|
910
890
|
break;
|
|
911
891
|
}
|
|
912
|
-
|
|
913
|
-
if (
|
|
914
|
-
//
|
|
915
|
-
//
|
|
916
|
-
// stream.resumeToken is not updated if stream.tryNext() returns data, while stream.next()
|
|
917
|
-
// does update it.
|
|
918
|
-
// 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.
|
|
919
896
|
// If we don't update it on empty events, we do keep consistency, but resuming the stream
|
|
920
897
|
// with old tokens may cause connection timeouts.
|
|
921
|
-
// We throttle this further by only persisting a keepalive once a minute.
|
|
922
|
-
// We add an additional check for waitForCheckpointLsn == null, to make sure we're not
|
|
923
|
-
// doing a keepalive in the middle of a transaction.
|
|
924
898
|
if (waitForCheckpointLsn == null && performance.now() - lastEmptyResume > 60_000) {
|
|
925
|
-
const { comparable: lsn, timestamp } = MongoLSN.fromResumeToken(
|
|
899
|
+
const { comparable: lsn, timestamp } = MongoLSN.fromResumeToken(resumeToken);
|
|
926
900
|
await batch.keepalive(lsn);
|
|
927
901
|
this.touch();
|
|
928
902
|
lastEmptyResume = performance.now();
|
|
@@ -933,226 +907,251 @@ export class ChangeStream {
|
|
|
933
907
|
);
|
|
934
908
|
this.replicationLag.markStarted();
|
|
935
909
|
}
|
|
936
|
-
continue;
|
|
937
|
-
}
|
|
938
910
|
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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
|
+
}
|
|
943
916
|
}
|
|
944
917
|
|
|
945
|
-
|
|
946
|
-
if (originalChangeDocument?.splitEvent != null) {
|
|
947
|
-
// Handle split events from $changeStreamSplitLargeEvent.
|
|
948
|
-
// This is only relevant for very large update operations.
|
|
949
|
-
const splitEvent = originalChangeDocument?.splitEvent;
|
|
918
|
+
this.touch();
|
|
950
919
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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;
|
|
955
925
|
}
|
|
956
926
|
|
|
957
|
-
if (
|
|
958
|
-
// Got all fragments
|
|
959
|
-
changeDocument = splitDocument;
|
|
960
|
-
splitDocument = null;
|
|
961
|
-
} else {
|
|
962
|
-
// Wait for more fragments
|
|
927
|
+
if (startAfter != null && originalChangeDocument.clusterTime?.lte(startAfter)) {
|
|
963
928
|
continue;
|
|
964
929
|
}
|
|
965
|
-
} else if (splitDocument != null) {
|
|
966
|
-
// We were waiting for fragments, but got a different event
|
|
967
|
-
throw new ReplicationAssertionError(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
|
|
968
|
-
}
|
|
969
930
|
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
) {
|
|
976
|
-
// When all of the following conditions are met:
|
|
977
|
-
// 1. We're replicating from an Atlas Flex instance.
|
|
978
|
-
// 2. There were changestream events recorded while the PowerSync service is paused.
|
|
979
|
-
// 3. We're only replicating from a single database.
|
|
980
|
-
// Then we've observed an ns with for example {db: '67b83e86cd20730f1e766dde_ps'},
|
|
981
|
-
// instead of the expected {db: 'ps'}.
|
|
982
|
-
// We correct this.
|
|
983
|
-
changeDocument.ns.db = this.defaultDb.databaseName;
|
|
984
|
-
|
|
985
|
-
if (!flexDbNameWorkaroundLogged) {
|
|
986
|
-
flexDbNameWorkaroundLogged = true;
|
|
987
|
-
this.logger.warn(
|
|
988
|
-
`Incorrect DB name in change stream: ${changeDocument.ns.db}. Changed to ${this.defaultDb.databaseName}.`
|
|
989
|
-
);
|
|
990
|
-
}
|
|
991
|
-
}
|
|
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;
|
|
992
936
|
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
throw new ChangeStreamInvalidatedError(
|
|
1011
|
-
'Internal collections have been dropped',
|
|
1012
|
-
new Error('_checkpoints collection was dropped')
|
|
1013
|
-
);
|
|
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)}`);
|
|
1014
954
|
}
|
|
1015
955
|
|
|
1016
956
|
if (
|
|
1017
|
-
!
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
)
|
|
957
|
+
!filters.multipleDatabases &&
|
|
958
|
+
'ns' in changeDocument &&
|
|
959
|
+
changeDocument.ns.db != this.defaultDb.databaseName &&
|
|
960
|
+
changeDocument.ns.db.endsWith(`_${this.defaultDb.databaseName}`)
|
|
1022
961
|
) {
|
|
1023
|
-
|
|
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
|
+
}
|
|
1024
977
|
}
|
|
1025
978
|
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
+
}
|
|
1001
|
+
|
|
1002
|
+
if (
|
|
1003
|
+
!(
|
|
1004
|
+
changeDocument.operationType == 'insert' ||
|
|
1005
|
+
changeDocument.operationType == 'update' ||
|
|
1006
|
+
changeDocument.operationType == 'replace'
|
|
1007
|
+
)
|
|
1008
|
+
) {
|
|
1047
1009
|
continue;
|
|
1048
1010
|
}
|
|
1049
|
-
} else if (!this.checkpointStreamId.equals(checkpointId)) {
|
|
1050
|
-
continue;
|
|
1051
|
-
}
|
|
1052
|
-
const { comparable: lsn } = new MongoLSN({
|
|
1053
|
-
timestamp: changeDocument.clusterTime!,
|
|
1054
|
-
resume_token: changeDocument._id
|
|
1055
|
-
});
|
|
1056
|
-
if (batch.lastCheckpointLsn != null && lsn < batch.lastCheckpointLsn) {
|
|
1057
|
-
// Checkpoint out of order - should never happen with MongoDB.
|
|
1058
|
-
// If it does happen, we throw an error to stop the replication - restarting should recover.
|
|
1059
|
-
// Since we use batch.lastCheckpointLsn for the next resumeAfter, this should not result in an infinite loop.
|
|
1060
|
-
// Originally a workaround for https://jira.mongodb.org/browse/NODE-7042.
|
|
1061
|
-
// This has been fixed in the driver in the meantime, but we still keep this as a safety-check.
|
|
1062
|
-
throw new ReplicationAssertionError(
|
|
1063
|
-
`Change resumeToken ${(changeDocument._id as any)._data} (${timestampToDate(changeDocument.clusterTime!).toISOString()}) is less than last checkpoint LSN ${batch.lastCheckpointLsn}. Restarting replication.`
|
|
1064
|
-
);
|
|
1065
|
-
}
|
|
1066
1011
|
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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;
|
|
1038
|
+
}
|
|
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
|
+
);
|
|
1052
|
+
}
|
|
1073
1053
|
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
changeDocument.operationType == 'update' ||
|
|
1081
|
-
changeDocument.operationType == 'replace' ||
|
|
1082
|
-
changeDocument.operationType == 'delete'
|
|
1083
|
-
) {
|
|
1084
|
-
if (waitForCheckpointLsn == null) {
|
|
1085
|
-
waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
1086
|
-
}
|
|
1054
|
+
if (waitForCheckpointLsn != null && lsn >= waitForCheckpointLsn) {
|
|
1055
|
+
waitForCheckpointLsn = null;
|
|
1056
|
+
}
|
|
1057
|
+
const { checkpointBlocked } = await batch.commit(lsn, {
|
|
1058
|
+
oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
|
|
1059
|
+
});
|
|
1087
1060
|
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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
|
+
}
|
|
1100
1073
|
|
|
1101
|
-
const
|
|
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
|
+
}
|
|
1102
1096
|
|
|
1103
|
-
|
|
1104
|
-
// Very crude metric for counting transactions replicated.
|
|
1105
|
-
// We ignore operations other than basic CRUD, and ignore changes to _powersync_checkpoints.
|
|
1106
|
-
// Individual writes may not have a txnNumber, in which case we count them as separate transactions.
|
|
1107
|
-
lastTxnKey = transactionKeyValue;
|
|
1108
|
-
transactionsReplicatedMetric.add(1);
|
|
1097
|
+
await this.writeChange(batch, table, changeDocument);
|
|
1109
1098
|
}
|
|
1110
|
-
|
|
1111
|
-
const
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
timestamp: changeDocument.clusterTime!,
|
|
1120
|
-
resume_token: changeDocument._id
|
|
1121
|
-
});
|
|
1122
|
-
this.logger.info(`Updating resume LSN to ${lsn} after ${changesSinceLastCheckpoint} changes`);
|
|
1123
|
-
await batch.setResumeLsn(lsn);
|
|
1124
|
-
changesSinceLastCheckpoint = 0;
|
|
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);
|
|
1125
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
|
+
});
|
|
1126
1127
|
}
|
|
1127
|
-
} else if (changeDocument.operationType == 'drop') {
|
|
1128
|
-
const rel = getMongoRelation(changeDocument.ns);
|
|
1129
|
-
const table = await this.getRelation(batch, rel, {
|
|
1130
|
-
// We're "dropping" this collection, so never snapshot it.
|
|
1131
|
-
snapshot: false
|
|
1132
|
-
});
|
|
1133
|
-
if (table.syncAny) {
|
|
1134
|
-
await batch.drop([table]);
|
|
1135
|
-
this.relationCache.delete(table);
|
|
1136
|
-
}
|
|
1137
|
-
} else if (changeDocument.operationType == 'rename') {
|
|
1138
|
-
const relFrom = getMongoRelation(changeDocument.ns);
|
|
1139
|
-
const relTo = getMongoRelation(changeDocument.to);
|
|
1140
|
-
const tableFrom = await this.getRelation(batch, relFrom, {
|
|
1141
|
-
// We're "dropping" this collection, so never snapshot it.
|
|
1142
|
-
snapshot: false
|
|
1143
|
-
});
|
|
1144
|
-
if (tableFrom.syncAny) {
|
|
1145
|
-
await batch.drop([tableFrom]);
|
|
1146
|
-
this.relationCache.delete(relFrom);
|
|
1147
|
-
}
|
|
1148
|
-
// Here we do need to snapshot the new table
|
|
1149
|
-
const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
|
|
1150
|
-
await this.handleRelation(batch, relTo, {
|
|
1151
|
-
// This is a new (renamed) collection, so always snapshot it.
|
|
1152
|
-
snapshot: true,
|
|
1153
|
-
collectionInfo: collection
|
|
1154
|
-
});
|
|
1155
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');
|
|
1156
1155
|
}
|
|
1157
1156
|
}
|
|
1158
1157
|
);
|
|
@@ -1175,32 +1174,10 @@ export class ChangeStream {
|
|
|
1175
1174
|
}
|
|
1176
1175
|
}
|
|
1177
1176
|
|
|
1178
|
-
function mapChangeStreamError(e: any) {
|
|
1179
|
-
if (isMongoNetworkTimeoutError(e)) {
|
|
1180
|
-
// This typically has an unhelpful message like "connection 2 to 159.41.94.47:27017 timed out".
|
|
1181
|
-
// We wrap the error to make it more useful.
|
|
1182
|
-
throw new DatabaseConnectionError(ErrorCode.PSYNC_S1345, `Timeout while reading MongoDB ChangeStream`, e);
|
|
1183
|
-
} else if (isMongoServerError(e) && e.codeName == 'MaxTimeMSExpired') {
|
|
1184
|
-
// maxTimeMS was reached. Example message:
|
|
1185
|
-
// MongoServerError: Executor error during aggregate command on namespace: powersync_test_data.$cmd.aggregate :: caused by :: operation exceeded time limit
|
|
1186
|
-
throw new DatabaseConnectionError(ErrorCode.PSYNC_S1345, `Timeout while reading MongoDB ChangeStream`, e);
|
|
1187
|
-
} else if (
|
|
1188
|
-
isMongoServerError(e) &&
|
|
1189
|
-
e.codeName == 'NoMatchingDocument' &&
|
|
1190
|
-
e.errmsg?.includes('post-image was not found')
|
|
1191
|
-
) {
|
|
1192
|
-
throw new ChangeStreamInvalidatedError(e.errmsg, e);
|
|
1193
|
-
} else if (isMongoServerError(e) && e.hasErrorLabel('NonResumableChangeStreamError')) {
|
|
1194
|
-
throw new ChangeStreamInvalidatedError(e.message, e);
|
|
1195
|
-
} else {
|
|
1196
|
-
throw new DatabaseConnectionError(ErrorCode.PSYNC_S1346, `Error reading MongoDB ChangeStream`, e);
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
1177
|
/**
|
|
1201
1178
|
* Transaction key for a change stream event, used to detect transaction boundaries. Returns null if the event is not part of a transaction.
|
|
1202
1179
|
*/
|
|
1203
|
-
function transactionKey(doc: mongo.ChangeStreamDocument): string | null {
|
|
1180
|
+
function transactionKey(doc: Pick<mongo.ChangeStreamDocument, 'lsid' | 'txnNumber'>): string | null {
|
|
1204
1181
|
if (doc.txnNumber == null || doc.lsid == null) {
|
|
1205
1182
|
return null;
|
|
1206
1183
|
}
|