@powersync/service-module-mongodb 0.0.0-dev-20260225160713 → 0.0.0-dev-20260511080634
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +119 -6
- package/dist/api/MongoRouteAPIAdapter.js +4 -4
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +8 -16
- package/dist/replication/ChangeStream.js +291 -373
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.d.ts +1 -1
- package/dist/replication/ChangeStreamReplicationJob.js +3 -3
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/ChangeStreamReplicator.d.ts +1 -2
- package/dist/replication/ChangeStreamReplicator.js +1 -22
- package/dist/replication/ChangeStreamReplicator.js.map +1 -1
- package/dist/replication/JsonBufferWriter.d.ts +80 -0
- package/dist/replication/JsonBufferWriter.js +342 -0
- package/dist/replication/JsonBufferWriter.js.map +1 -0
- package/dist/replication/MongoManager.d.ts +1 -1
- package/dist/replication/MongoManager.js +1 -1
- package/dist/replication/MongoManager.js.map +1 -1
- package/dist/replication/MongoRelation.js +4 -0
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/MongoSnapshotQuery.d.ts +3 -1
- package/dist/replication/MongoSnapshotQuery.js +9 -4
- package/dist/replication/MongoSnapshotQuery.js.map +1 -1
- package/dist/replication/RawChangeStream.d.ts +55 -0
- package/dist/replication/RawChangeStream.js +322 -0
- package/dist/replication/RawChangeStream.js.map +1 -0
- package/dist/replication/SourceRowConverter.d.ts +46 -0
- package/dist/replication/SourceRowConverter.js +42 -0
- package/dist/replication/SourceRowConverter.js.map +1 -0
- package/dist/replication/bufferToSqlite.d.ts +43 -0
- package/dist/replication/bufferToSqlite.js +740 -0
- package/dist/replication/bufferToSqlite.js.map +1 -0
- package/dist/replication/internal-mongodb-utils.d.ts +9 -0
- package/dist/replication/internal-mongodb-utils.js +16 -0
- package/dist/replication/internal-mongodb-utils.js.map +1 -0
- package/dist/replication/replication-index.d.ts +5 -2
- package/dist/replication/replication-index.js +5 -2
- package/dist/replication/replication-index.js.map +1 -1
- package/dist/replication/replication-utils.d.ts +1 -1
- package/dist/types/types.js.map +1 -1
- package/package.json +11 -11
- package/scripts/benchmark-change-document-json.mts +358 -0
- package/scripts/benchmark-change-document.mts +370 -0
- package/src/api/MongoRouteAPIAdapter.ts +4 -4
- package/src/replication/ChangeStream.ts +388 -352
- package/src/replication/ChangeStreamReplicationJob.ts +3 -3
- package/src/replication/ChangeStreamReplicator.ts +2 -26
- package/src/replication/JsonBufferWriter.ts +390 -0
- package/src/replication/MongoManager.ts +2 -2
- package/src/replication/MongoRelation.ts +5 -2
- package/src/replication/MongoSnapshotQuery.ts +13 -6
- package/src/replication/RawChangeStream.ts +460 -0
- package/src/replication/SourceRowConverter.ts +65 -0
- package/src/replication/bufferToSqlite.ts +944 -0
- package/src/replication/internal-mongodb-utils.ts +25 -0
- package/src/replication/replication-index.ts +5 -2
- package/src/replication/replication-utils.ts +2 -2
- package/src/types/types.ts +1 -1
- package/test/src/buffer_to_sqlite.test.ts +1146 -0
- package/test/src/change_stream.test.ts +105 -3
- package/test/src/change_stream_utils.ts +39 -36
- package/test/src/env.ts +1 -1
- package/test/src/mongo_test.test.ts +66 -64
- package/test/src/parse_document_id.test.ts +54 -0
- package/test/src/raw_change_stream.test.ts +547 -0
- package/test/src/resume.test.ts +15 -4
- package/test/src/util.ts +62 -9
- package/test/tsconfig.json +0 -1
- package/tsconfig.scripts.json +13 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,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() {
|
|
@@ -264,47 +255,55 @@ export class ChangeStream {
|
|
|
264
255
|
const LSN_TIMEOUT_SECONDS = 60;
|
|
265
256
|
const LSN_CREATE_INTERVAL_SECONDS = 1;
|
|
266
257
|
|
|
267
|
-
|
|
268
|
-
const
|
|
258
|
+
// Create a checkpoint, and open a change stream using startAtOperationTime with the checkpoint's operationTime.
|
|
259
|
+
const firstCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
260
|
+
|
|
269
261
|
const startTime = performance.now();
|
|
270
|
-
let lastCheckpointCreated =
|
|
262
|
+
let lastCheckpointCreated = performance.now();
|
|
271
263
|
let eventsSeen = 0;
|
|
264
|
+
let batchesSeen = 0;
|
|
272
265
|
|
|
273
|
-
|
|
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
|
+
}
|
|
274
277
|
if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
|
|
275
278
|
await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
|
|
276
279
|
lastCheckpointCreated = performance.now();
|
|
277
280
|
}
|
|
281
|
+
batchesSeen += 1;
|
|
278
282
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
});
|
|
283
|
-
if (changeDocument == null) {
|
|
284
|
-
continue;
|
|
285
|
-
}
|
|
283
|
+
for (let rawChangeDocument of events) {
|
|
284
|
+
const changeDocument = parseChangeDocument(rawChangeDocument);
|
|
285
|
+
const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
|
|
286
286
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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;
|
|
293
297
|
}
|
|
294
|
-
const { comparable: lsn } = new MongoLSN({
|
|
295
|
-
timestamp: changeDocument.clusterTime!,
|
|
296
|
-
resume_token: changeDocument._id
|
|
297
|
-
});
|
|
298
|
-
return lsn;
|
|
299
|
-
}
|
|
300
298
|
|
|
301
|
-
|
|
299
|
+
eventsSeen += 1;
|
|
300
|
+
}
|
|
302
301
|
}
|
|
303
302
|
|
|
304
303
|
// Could happen if there is a very large replication lag?
|
|
305
304
|
throw new ServiceError(
|
|
306
305
|
ErrorCode.PSYNC_S1301,
|
|
307
|
-
`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}`
|
|
308
307
|
);
|
|
309
308
|
}
|
|
310
309
|
|
|
@@ -312,21 +311,24 @@ export class ChangeStream {
|
|
|
312
311
|
* Given a snapshot LSN, validate that we can read from it, by opening a change stream.
|
|
313
312
|
*/
|
|
314
313
|
private async validateSnapshotLsn(lsn: string) {
|
|
315
|
-
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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;
|
|
324
325
|
}
|
|
325
326
|
}
|
|
326
327
|
|
|
327
328
|
async initialReplication(snapshotLsn: string | null) {
|
|
328
329
|
const sourceTables = this.sync_rules.getSourceTables();
|
|
329
330
|
await this.client.connect();
|
|
331
|
+
const tracer = new PerformanceTracer('MongoDB initial replication');
|
|
330
332
|
|
|
331
333
|
const flushResult = await this.storage.startBatch(
|
|
332
334
|
{
|
|
@@ -334,7 +336,8 @@ export class ChangeStream {
|
|
|
334
336
|
zeroLSN: MongoLSN.ZERO.comparable,
|
|
335
337
|
defaultSchema: this.defaultDb.databaseName,
|
|
336
338
|
storeCurrentData: false,
|
|
337
|
-
skipExistingRows: true
|
|
339
|
+
skipExistingRows: true,
|
|
340
|
+
tracer
|
|
338
341
|
},
|
|
339
342
|
async (batch) => {
|
|
340
343
|
if (snapshotLsn == null) {
|
|
@@ -376,7 +379,7 @@ export class ChangeStream {
|
|
|
376
379
|
|
|
377
380
|
for (let table of tablesWithStatus) {
|
|
378
381
|
await this.snapshotTable(batch, table);
|
|
379
|
-
await batch.
|
|
382
|
+
await batch.markTableSnapshotDone([table]);
|
|
380
383
|
|
|
381
384
|
this.touch();
|
|
382
385
|
}
|
|
@@ -385,7 +388,7 @@ export class ChangeStream {
|
|
|
385
388
|
// point before the data can be considered consistent.
|
|
386
389
|
// We could do this for each individual table, but may as well just do it once for the entire snapshot.
|
|
387
390
|
const checkpoint = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
|
|
388
|
-
await batch.
|
|
391
|
+
await batch.markAllSnapshotDone(checkpoint);
|
|
389
392
|
|
|
390
393
|
// This will not create a consistent checkpoint yet, but will persist the op.
|
|
391
394
|
// Actual checkpoint will be created when streaming replication caught up.
|
|
@@ -469,13 +472,11 @@ export class ChangeStream {
|
|
|
469
472
|
return { $match: nsFilter, multipleDatabases };
|
|
470
473
|
}
|
|
471
474
|
|
|
472
|
-
static *getQueryData(results: Iterable<DatabaseInputRow>): Generator<SqliteInputRow> {
|
|
473
|
-
for (let row of results) {
|
|
474
|
-
yield constructAfterRecord(row);
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
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
|
+
|
|
479
480
|
const totalEstimatedCount = await this.estimatedCountNumber(table);
|
|
480
481
|
let at = table.snapshotStatus?.replicatedCount ?? 0;
|
|
481
482
|
const db = this.client.db(table.schema);
|
|
@@ -496,20 +497,22 @@ export class ChangeStream {
|
|
|
496
497
|
let lastBatch = performance.now();
|
|
497
498
|
let nextChunkPromise = query.nextChunk();
|
|
498
499
|
while (true) {
|
|
499
|
-
const { docs: docBatch, lastKey } = await nextChunkPromise;
|
|
500
|
+
const { docs: docBatch, lastKey, bytes: chunkBytes } = await nextChunkPromise;
|
|
500
501
|
if (docBatch.length == 0) {
|
|
501
502
|
// No more data - stop iterating
|
|
502
503
|
break;
|
|
503
504
|
}
|
|
505
|
+
bytesReplicatedMetric.add(chunkBytes);
|
|
506
|
+
chunksReplicatedMetric.add(1);
|
|
504
507
|
|
|
505
508
|
if (this.abort_signal.aborted) {
|
|
506
|
-
throw new ReplicationAbortedError(`Aborted initial replication
|
|
509
|
+
throw new ReplicationAbortedError(`Aborted initial replication`, this.abort_signal.reason);
|
|
507
510
|
}
|
|
508
511
|
|
|
509
512
|
// Pre-fetch next batch, so that we can read and write concurrently
|
|
510
513
|
nextChunkPromise = query.nextChunk();
|
|
511
|
-
for (let
|
|
512
|
-
const record = this.
|
|
514
|
+
for (let buffer of docBatch) {
|
|
515
|
+
const { row: record, replicaId: replicaId } = this.rawToSqliteRow(buffer);
|
|
513
516
|
|
|
514
517
|
// This auto-flushes when the batch reaches its size limit
|
|
515
518
|
await batch.save({
|
|
@@ -518,14 +521,14 @@ export class ChangeStream {
|
|
|
518
521
|
before: undefined,
|
|
519
522
|
beforeReplicaId: undefined,
|
|
520
523
|
after: record,
|
|
521
|
-
afterReplicaId:
|
|
524
|
+
afterReplicaId: replicaId
|
|
522
525
|
});
|
|
523
526
|
}
|
|
524
527
|
|
|
525
528
|
// Important: flush before marking progress
|
|
526
529
|
await batch.flush();
|
|
527
530
|
at += docBatch.length;
|
|
528
|
-
|
|
531
|
+
rowsReplicatedMetric.add(docBatch.length);
|
|
529
532
|
|
|
530
533
|
table = await batch.updateTableProgress(table, {
|
|
531
534
|
lastKey,
|
|
@@ -630,7 +633,7 @@ export class ChangeStream {
|
|
|
630
633
|
// Snapshot if:
|
|
631
634
|
// 1. Snapshot is requested (false for initial snapshot, since that process handles it elsewhere)
|
|
632
635
|
// 2. Snapshot is not already done, AND:
|
|
633
|
-
// 3. The table is used in sync
|
|
636
|
+
// 3. The table is used in sync config.
|
|
634
637
|
const shouldSnapshot = snapshot && !result.table.snapshotComplete && result.table.syncAny;
|
|
635
638
|
if (shouldSnapshot) {
|
|
636
639
|
this.logger.info(`New collection: ${descriptor.schema}.${descriptor.name}`);
|
|
@@ -640,37 +643,35 @@ export class ChangeStream {
|
|
|
640
643
|
await this.snapshotTable(batch, result.table);
|
|
641
644
|
const no_checkpoint_before_lsn = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
|
|
642
645
|
|
|
643
|
-
const [table] = await batch.
|
|
646
|
+
const [table] = await batch.markTableSnapshotDone([result.table], no_checkpoint_before_lsn);
|
|
644
647
|
return table;
|
|
645
648
|
}
|
|
646
649
|
|
|
647
650
|
return result.table;
|
|
648
651
|
}
|
|
649
652
|
|
|
650
|
-
private constructAfterRecord(document: mongo.Document): SqliteRow {
|
|
651
|
-
const inputRow = constructAfterRecord(document);
|
|
652
|
-
return this.sync_rules.applyRowContext<never>(inputRow);
|
|
653
|
-
}
|
|
654
|
-
|
|
655
653
|
async writeChange(
|
|
656
654
|
batch: storage.BucketStorageBatch,
|
|
657
655
|
table: storage.SourceTable,
|
|
658
|
-
change:
|
|
656
|
+
change: ProjectedChangeStreamDocument
|
|
659
657
|
): Promise<storage.FlushedResult | null> {
|
|
660
658
|
if (!table.syncAny) {
|
|
661
|
-
this.logger.debug(`Collection ${table.qualifiedName} not used in sync
|
|
659
|
+
this.logger.debug(`Collection ${table.qualifiedName} not used in sync config - skipping`);
|
|
662
660
|
return null;
|
|
663
661
|
}
|
|
664
662
|
|
|
665
663
|
this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(1);
|
|
666
664
|
if (change.operationType == 'insert') {
|
|
667
|
-
const baseRecord = this.
|
|
665
|
+
const { row: baseRecord, replicaId: _replicaId } = this.rawToSqliteRow(change.fullDocument);
|
|
668
666
|
return await batch.save({
|
|
669
667
|
tag: SaveOperationTag.INSERT,
|
|
670
668
|
sourceTable: table,
|
|
671
669
|
before: undefined,
|
|
672
670
|
beforeReplicaId: undefined,
|
|
673
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.
|
|
674
675
|
afterReplicaId: change.documentKey._id
|
|
675
676
|
});
|
|
676
677
|
} else if (change.operationType == 'update' || change.operationType == 'replace') {
|
|
@@ -683,14 +684,14 @@ export class ChangeStream {
|
|
|
683
684
|
beforeReplicaId: change.documentKey._id
|
|
684
685
|
});
|
|
685
686
|
}
|
|
686
|
-
const after = this.
|
|
687
|
+
const { row: after, replicaId: _replicaId } = this.rawToSqliteRow(change.fullDocument!);
|
|
687
688
|
return await batch.save({
|
|
688
689
|
tag: SaveOperationTag.UPDATE,
|
|
689
690
|
sourceTable: table,
|
|
690
691
|
before: undefined,
|
|
691
692
|
beforeReplicaId: undefined,
|
|
692
693
|
after: after,
|
|
693
|
-
afterReplicaId: change.documentKey._id
|
|
694
|
+
afterReplicaId: change.documentKey._id // Same as _replicaId
|
|
694
695
|
});
|
|
695
696
|
} else if (change.operationType == 'delete') {
|
|
696
697
|
return await batch.save({
|
|
@@ -726,7 +727,7 @@ export class ChangeStream {
|
|
|
726
727
|
}
|
|
727
728
|
const { lastOpId } = await this.initialReplication(result.snapshotLsn);
|
|
728
729
|
if (lastOpId != null) {
|
|
729
|
-
// 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.
|
|
730
731
|
await this.storage.populatePersistentChecksumCache({
|
|
731
732
|
signal: this.abort_signal,
|
|
732
733
|
// No checkpoint yet, but we do have the opId.
|
|
@@ -751,19 +752,19 @@ export class ChangeStream {
|
|
|
751
752
|
}
|
|
752
753
|
}
|
|
753
754
|
|
|
754
|
-
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> {
|
|
755
763
|
const lastLsn = options.lsn ? MongoLSN.fromSerialized(options.lsn) : null;
|
|
756
764
|
const startAfter = lastLsn?.timestamp;
|
|
757
765
|
const resumeAfter = lastLsn?.resumeToken;
|
|
758
766
|
|
|
759
|
-
const filters =
|
|
760
|
-
|
|
761
|
-
const pipeline: mongo.Document[] = [
|
|
762
|
-
{
|
|
763
|
-
$match: filters.$match
|
|
764
|
-
},
|
|
765
|
-
{ $changeStreamSplitLargeEvent: {} }
|
|
766
|
-
];
|
|
767
|
+
const filters = options.filters;
|
|
767
768
|
|
|
768
769
|
let fullDocument: 'required' | 'updateLookup';
|
|
769
770
|
|
|
@@ -775,12 +776,19 @@ export class ChangeStream {
|
|
|
775
776
|
} else {
|
|
776
777
|
fullDocument = 'updateLookup';
|
|
777
778
|
}
|
|
778
|
-
const streamOptions: mongo.ChangeStreamOptions = {
|
|
779
|
+
const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
|
|
779
780
|
showExpandedEvents: true,
|
|
780
|
-
|
|
781
|
-
fullDocument: fullDocument,
|
|
782
|
-
maxTimeMS: this.changeStreamTimeout
|
|
781
|
+
fullDocument: fullDocument
|
|
783
782
|
};
|
|
783
|
+
const pipeline: mongo.Document[] = [
|
|
784
|
+
{
|
|
785
|
+
$changeStream: streamOptions
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
$match: filters.$match
|
|
789
|
+
},
|
|
790
|
+
{ $changeStreamSplitLargeEvent: {} }
|
|
791
|
+
];
|
|
784
792
|
|
|
785
793
|
/**
|
|
786
794
|
* Only one of these options can be supplied at a time.
|
|
@@ -790,39 +798,47 @@ export class ChangeStream {
|
|
|
790
798
|
} else {
|
|
791
799
|
// Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
|
|
792
800
|
// case if we have an old one.
|
|
801
|
+
// This is also relevant for getSnapshotLSN().
|
|
793
802
|
streamOptions.startAtOperationTime = startAfter;
|
|
794
803
|
}
|
|
795
804
|
|
|
796
|
-
let
|
|
805
|
+
let watchDb: mongo.Db;
|
|
797
806
|
if (filters.multipleDatabases) {
|
|
798
|
-
|
|
799
|
-
|
|
807
|
+
watchDb = this.client.db('admin');
|
|
808
|
+
streamOptions.allChangesForCluster = true;
|
|
800
809
|
} else {
|
|
801
|
-
|
|
802
|
-
stream = this.defaultDb.watch(pipeline, streamOptions);
|
|
810
|
+
watchDb = this.defaultDb;
|
|
803
811
|
}
|
|
804
812
|
|
|
805
|
-
|
|
806
|
-
|
|
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
|
|
807
821
|
});
|
|
822
|
+
}
|
|
808
823
|
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
filters,
|
|
812
|
-
[Symbol.asyncDispose]: async () => {
|
|
813
|
-
return stream.close();
|
|
814
|
-
}
|
|
815
|
-
};
|
|
824
|
+
private rawToSqliteRow(row: Buffer) {
|
|
825
|
+
return this.sourceRowConverter.rawToSqliteRow(row);
|
|
816
826
|
}
|
|
817
827
|
|
|
818
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');
|
|
819
834
|
await this.storage.startBatch(
|
|
820
835
|
{
|
|
821
836
|
logger: this.logger,
|
|
822
837
|
zeroLSN: MongoLSN.ZERO.comparable,
|
|
823
838
|
defaultSchema: this.defaultDb.databaseName,
|
|
824
839
|
// We get a complete postimage for every change, so we don't need to store the current data.
|
|
825
|
-
storeCurrentData: false
|
|
840
|
+
storeCurrentData: false,
|
|
841
|
+
tracer
|
|
826
842
|
},
|
|
827
843
|
async (batch) => {
|
|
828
844
|
const { resumeFromLsn } = batch;
|
|
@@ -831,6 +847,7 @@ export class ChangeStream {
|
|
|
831
847
|
}
|
|
832
848
|
const lastLsn = MongoLSN.fromSerialized(resumeFromLsn);
|
|
833
849
|
const startAfter = lastLsn?.timestamp;
|
|
850
|
+
let outerSpan = tracer.span('batch');
|
|
834
851
|
|
|
835
852
|
// It is normal for this to be a minute or two old when there is a low volume
|
|
836
853
|
// of ChangeStream events.
|
|
@@ -838,12 +855,14 @@ export class ChangeStream {
|
|
|
838
855
|
|
|
839
856
|
this.logger.info(`Resume streaming at ${startAfter?.inspect()} / ${lastLsn} | Token age: ${tokenAgeSeconds}s`);
|
|
840
857
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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
|
+
});
|
|
847
866
|
|
|
848
867
|
// Always start with a checkpoint.
|
|
849
868
|
// This helps us to clear errors when restarting, even if there is
|
|
@@ -854,43 +873,30 @@ export class ChangeStream {
|
|
|
854
873
|
this.checkpointStreamId
|
|
855
874
|
);
|
|
856
875
|
|
|
857
|
-
let splitDocument:
|
|
876
|
+
let splitDocument: ProjectedChangeStreamDocument | null = null;
|
|
858
877
|
|
|
859
878
|
let flexDbNameWorkaroundLogged = false;
|
|
860
|
-
let changesSinceLastCheckpoint = 0;
|
|
861
879
|
|
|
862
880
|
let lastEmptyResume = performance.now();
|
|
881
|
+
let lastTxnKey: string | null = null;
|
|
863
882
|
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
const originalChangeDocument = await stream.tryNext().catch((e) => {
|
|
870
|
-
throw mapChangeStreamError(e);
|
|
871
|
-
});
|
|
872
|
-
// The stream was closed, we will only ever receive `null` from it
|
|
873
|
-
if (!originalChangeDocument && stream.closed) {
|
|
874
|
-
break;
|
|
875
|
-
}
|
|
883
|
+
for await (let eventBatch of batchStream) {
|
|
884
|
+
const { events, resumeToken } = eventBatch;
|
|
885
|
+
using batchSpan = tracer.span('processing');
|
|
876
886
|
|
|
887
|
+
bytesReplicatedMetric.add(eventBatch.byteSize);
|
|
888
|
+
chunksReplicatedMetric.add(1);
|
|
877
889
|
if (this.abort_signal.aborted) {
|
|
878
890
|
break;
|
|
879
891
|
}
|
|
880
|
-
|
|
881
|
-
if (
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
// stream.resumeToken is not updated if stream.tryNext() returns data, while stream.next()
|
|
885
|
-
// does update it.
|
|
886
|
-
// 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.
|
|
887
896
|
// If we don't update it on empty events, we do keep consistency, but resuming the stream
|
|
888
897
|
// with old tokens may cause connection timeouts.
|
|
889
|
-
// We throttle this further by only persisting a keepalive once a minute.
|
|
890
|
-
// We add an additional check for waitForCheckpointLsn == null, to make sure we're not
|
|
891
|
-
// doing a keepalive in the middle of a transaction.
|
|
892
898
|
if (waitForCheckpointLsn == null && performance.now() - lastEmptyResume > 60_000) {
|
|
893
|
-
const { comparable: lsn, timestamp } = MongoLSN.fromResumeToken(
|
|
899
|
+
const { comparable: lsn, timestamp } = MongoLSN.fromResumeToken(resumeToken);
|
|
894
900
|
await batch.keepalive(lsn);
|
|
895
901
|
this.touch();
|
|
896
902
|
lastEmptyResume = performance.now();
|
|
@@ -899,218 +905,260 @@ export class ChangeStream {
|
|
|
899
905
|
this.logger.info(
|
|
900
906
|
`Idle change stream. Persisted resumeToken for ${timestampToDate(timestamp).toISOString()}`
|
|
901
907
|
);
|
|
902
|
-
this.
|
|
908
|
+
this.replicationLag.markStarted();
|
|
903
909
|
}
|
|
904
|
-
continue;
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
this.touch();
|
|
908
910
|
|
|
909
|
-
|
|
910
|
-
|
|
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
|
+
}
|
|
911
916
|
}
|
|
912
917
|
|
|
913
|
-
|
|
914
|
-
if (originalChangeDocument?.splitEvent != null) {
|
|
915
|
-
// Handle split events from $changeStreamSplitLargeEvent.
|
|
916
|
-
// This is only relevant for very large update operations.
|
|
917
|
-
const splitEvent = originalChangeDocument?.splitEvent;
|
|
918
|
+
this.touch();
|
|
918
919
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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;
|
|
923
925
|
}
|
|
924
926
|
|
|
925
|
-
if (
|
|
926
|
-
// Got all fragments
|
|
927
|
-
changeDocument = splitDocument;
|
|
928
|
-
splitDocument = null;
|
|
929
|
-
} else {
|
|
930
|
-
// Wait for more fragments
|
|
927
|
+
if (startAfter != null && originalChangeDocument.clusterTime?.lte(startAfter)) {
|
|
931
928
|
continue;
|
|
932
929
|
}
|
|
933
|
-
} else if (splitDocument != null) {
|
|
934
|
-
// We were waiting for fragments, but got a different event
|
|
935
|
-
throw new ReplicationAssertionError(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
|
|
936
|
-
}
|
|
937
930
|
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
) {
|
|
944
|
-
// When all of the following conditions are met:
|
|
945
|
-
// 1. We're replicating from an Atlas Flex instance.
|
|
946
|
-
// 2. There were changestream events recorded while the PowerSync service is paused.
|
|
947
|
-
// 3. We're only replicating from a single database.
|
|
948
|
-
// Then we've observed an ns with for example {db: '67b83e86cd20730f1e766dde_ps'},
|
|
949
|
-
// instead of the expected {db: 'ps'}.
|
|
950
|
-
// We correct this.
|
|
951
|
-
changeDocument.ns.db = this.defaultDb.databaseName;
|
|
952
|
-
|
|
953
|
-
if (!flexDbNameWorkaroundLogged) {
|
|
954
|
-
flexDbNameWorkaroundLogged = true;
|
|
955
|
-
this.logger.warn(
|
|
956
|
-
`Incorrect DB name in change stream: ${changeDocument.ns.db}. Changed to ${this.defaultDb.databaseName}.`
|
|
957
|
-
);
|
|
958
|
-
}
|
|
959
|
-
}
|
|
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;
|
|
960
936
|
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
throw new ChangeStreamInvalidatedError(
|
|
979
|
-
'Internal collections have been dropped',
|
|
980
|
-
new Error('_checkpoints collection was dropped')
|
|
981
|
-
);
|
|
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)}`);
|
|
982
954
|
}
|
|
983
955
|
|
|
984
956
|
if (
|
|
985
|
-
!
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
)
|
|
957
|
+
!filters.multipleDatabases &&
|
|
958
|
+
'ns' in changeDocument &&
|
|
959
|
+
changeDocument.ns.db != this.defaultDb.databaseName &&
|
|
960
|
+
changeDocument.ns.db.endsWith(`_${this.defaultDb.databaseName}`)
|
|
990
961
|
) {
|
|
991
|
-
|
|
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
|
+
}
|
|
992
977
|
}
|
|
993
978
|
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
// This has been fixed in the driver in the meantime, but we still keep this as a safety-check.
|
|
1017
|
-
throw new ReplicationAssertionError(
|
|
1018
|
-
`Change resumeToken ${(changeDocument._id as any)._data} (${timestampToDate(changeDocument.clusterTime!).toISOString()}) is less than last checkpoint LSN ${batch.lastCheckpointLsn}. Restarting replication.`
|
|
1019
|
-
);
|
|
1020
|
-
}
|
|
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
|
+
}
|
|
1021
1001
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1002
|
+
if (
|
|
1003
|
+
!(
|
|
1004
|
+
changeDocument.operationType == 'insert' ||
|
|
1005
|
+
changeDocument.operationType == 'update' ||
|
|
1006
|
+
changeDocument.operationType == 'replace'
|
|
1007
|
+
)
|
|
1008
|
+
) {
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1026
1011
|
|
|
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
|
-
|
|
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;
|
|
1052
1038
|
}
|
|
1053
|
-
const
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
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
|
+
);
|
|
1067
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
|
+
});
|
|
1068
1127
|
}
|
|
1069
|
-
} else if (changeDocument.operationType == 'drop') {
|
|
1070
|
-
const rel = getMongoRelation(changeDocument.ns);
|
|
1071
|
-
const table = await this.getRelation(batch, rel, {
|
|
1072
|
-
// We're "dropping" this collection, so never snapshot it.
|
|
1073
|
-
snapshot: false
|
|
1074
|
-
});
|
|
1075
|
-
if (table.syncAny) {
|
|
1076
|
-
await batch.drop([table]);
|
|
1077
|
-
this.relationCache.delete(table);
|
|
1078
|
-
}
|
|
1079
|
-
} else if (changeDocument.operationType == 'rename') {
|
|
1080
|
-
const relFrom = getMongoRelation(changeDocument.ns);
|
|
1081
|
-
const relTo = getMongoRelation(changeDocument.to);
|
|
1082
|
-
const tableFrom = await this.getRelation(batch, relFrom, {
|
|
1083
|
-
// We're "dropping" this collection, so never snapshot it.
|
|
1084
|
-
snapshot: false
|
|
1085
|
-
});
|
|
1086
|
-
if (tableFrom.syncAny) {
|
|
1087
|
-
await batch.drop([tableFrom]);
|
|
1088
|
-
this.relationCache.delete(relFrom);
|
|
1089
|
-
}
|
|
1090
|
-
// Here we do need to snapshot the new table
|
|
1091
|
-
const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
|
|
1092
|
-
await this.handleRelation(batch, relTo, {
|
|
1093
|
-
// This is a new (renamed) collection, so always snapshot it.
|
|
1094
|
-
snapshot: true,
|
|
1095
|
-
collectionInfo: collection
|
|
1096
|
-
});
|
|
1097
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');
|
|
1098
1155
|
}
|
|
1099
1156
|
}
|
|
1100
1157
|
);
|
|
1101
1158
|
}
|
|
1102
1159
|
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
if (this.isStartingReplication) {
|
|
1106
|
-
// We don't have anything to compute replication lag with yet.
|
|
1107
|
-
return undefined;
|
|
1108
|
-
} else {
|
|
1109
|
-
// We don't have any uncommitted changes, so replication is up-to-date.
|
|
1110
|
-
return 0;
|
|
1111
|
-
}
|
|
1112
|
-
}
|
|
1113
|
-
return Date.now() - this.oldestUncommittedChange.getTime();
|
|
1160
|
+
getReplicationLagMillis(): number | undefined {
|
|
1161
|
+
return this.replicationLag.getLagMillis();
|
|
1114
1162
|
}
|
|
1115
1163
|
|
|
1116
1164
|
private lastTouchedAt = performance.now();
|
|
@@ -1126,24 +1174,12 @@ export class ChangeStream {
|
|
|
1126
1174
|
}
|
|
1127
1175
|
}
|
|
1128
1176
|
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
// maxTimeMS was reached. Example message:
|
|
1136
|
-
// MongoServerError: Executor error during aggregate command on namespace: powersync_test_data.$cmd.aggregate :: caused by :: operation exceeded time limit
|
|
1137
|
-
throw new DatabaseConnectionError(ErrorCode.PSYNC_S1345, `Timeout while reading MongoDB ChangeStream`, e);
|
|
1138
|
-
} else if (
|
|
1139
|
-
isMongoServerError(e) &&
|
|
1140
|
-
e.codeName == 'NoMatchingDocument' &&
|
|
1141
|
-
e.errmsg?.includes('post-image was not found')
|
|
1142
|
-
) {
|
|
1143
|
-
throw new ChangeStreamInvalidatedError(e.errmsg, e);
|
|
1144
|
-
} else if (isMongoServerError(e) && e.hasErrorLabel('NonResumableChangeStreamError')) {
|
|
1145
|
-
throw new ChangeStreamInvalidatedError(e.message, e);
|
|
1146
|
-
} else {
|
|
1147
|
-
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;
|
|
1148
1183
|
}
|
|
1184
|
+
return `${doc.lsid.id.toString('hex')}:${doc.txnNumber}`;
|
|
1149
1185
|
}
|