@powersync/service-module-mongodb 0.0.0-dev-20260909133214 → 0.0.0-dev-20260929095716
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 +38 -6
- package/ci/test-connection.yaml +12 -0
- package/dist/api/MongoRouteAPIAdapter.d.ts +2 -2
- package/dist/api/MongoRouteAPIAdapter.js +73 -143
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/api/infer-collection-schema.d.ts +12 -0
- package/dist/api/infer-collection-schema.js +142 -0
- package/dist/api/infer-collection-schema.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/module/MongoModule.d.ts +1 -2
- package/dist/module/MongoModule.js +0 -1
- package/dist/module/MongoModule.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +10 -2
- package/dist/replication/ChangeStream.js +228 -317
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
- package/dist/replication/ChangeStreamReplicationJob.js +4 -1
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/ChangeStreamReplicator.d.ts +3 -0
- package/dist/replication/ChangeStreamReplicator.js +3 -0
- package/dist/replication/ChangeStreamReplicator.js.map +1 -1
- package/dist/replication/MongoRelation.js +5 -2
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/MongoReplicationQueryProvider.d.ts +87 -0
- package/dist/replication/MongoReplicationQueryProvider.js +23 -0
- package/dist/replication/MongoReplicationQueryProvider.js.map +1 -0
- package/dist/replication/MongoReplicationStream.d.ts +85 -0
- package/dist/replication/MongoReplicationStream.js +101 -0
- package/dist/replication/MongoReplicationStream.js.map +1 -0
- package/dist/replication/MongoSnapshotQuery.d.ts +2 -0
- package/dist/replication/MongoSnapshotQuery.js +12 -2
- package/dist/replication/MongoSnapshotQuery.js.map +1 -1
- package/dist/replication/MongoSnapshotter.d.ts +7 -2
- package/dist/replication/MongoSnapshotter.js +58 -85
- package/dist/replication/MongoSnapshotter.js.map +1 -1
- package/dist/replication/RawChangeStream.d.ts +1 -1
- package/dist/replication/RawChangeStream.js +1 -1
- package/dist/replication/RawChangeStream.js.map +1 -1
- package/dist/replication/SourceRowConverter.d.ts +1 -0
- package/dist/replication/SourceRowConverter.js +4 -2
- package/dist/replication/SourceRowConverter.js.map +1 -1
- package/dist/replication/bufferToSqlite.d.ts +3 -1
- package/dist/replication/bufferToSqlite.js +35 -23
- package/dist/replication/bufferToSqlite.js.map +1 -1
- package/dist/replication/replication-index.d.ts +6 -0
- package/dist/replication/replication-index.js +6 -0
- package/dist/replication/replication-index.js.map +1 -1
- package/dist/test-utils/ChangeStreamTestContext.d.ts +131 -0
- package/dist/test-utils/ChangeStreamTestContext.js +391 -0
- package/dist/test-utils/ChangeStreamTestContext.js.map +1 -0
- package/dist/test-utils/test-utils-index.d.ts +1 -0
- package/dist/test-utils/test-utils-index.js +2 -0
- package/dist/test-utils/test-utils-index.js.map +1 -0
- package/package.json +8 -8
- package/src/api/MongoRouteAPIAdapter.ts +79 -127
- package/src/api/infer-collection-schema.ts +153 -0
- package/src/index.ts +1 -0
- package/src/module/MongoModule.ts +1 -3
- package/src/replication/ChangeStream.ts +253 -359
- package/src/replication/ChangeStreamReplicationJob.ts +7 -2
- package/src/replication/ChangeStreamReplicator.ts +5 -0
- package/src/replication/MongoRelation.ts +8 -1
- package/src/replication/MongoReplicationQueryProvider.ts +108 -0
- package/src/replication/MongoReplicationStream.ts +201 -0
- package/src/replication/MongoSnapshotQuery.ts +19 -3
- package/src/replication/MongoSnapshotter.ts +70 -92
- package/src/replication/RawChangeStream.ts +2 -2
- package/src/replication/SourceRowConverter.ts +4 -2
- package/src/replication/bufferToSqlite.ts +43 -23
- package/src/replication/replication-index.ts +6 -0
- package/src/test-utils/ChangeStreamTestContext.ts +520 -0
- package/src/test-utils/test-utils-index.ts +1 -0
- package/test/src/buffer_to_sqlite.test.ts +9 -0
- package/test/src/change_stream.test.ts +3 -3
- package/test/src/change_stream_test_setup.ts +38 -0
- package/test/src/chunked_snapshot.test.ts +3 -3
- package/test/src/documentdb_mode.test.ts +3 -3
- package/test/src/replication_stream.test.ts +311 -0
- package/test/src/resume.test.ts +10 -4
- package/test/src/resuming_snapshots.test.ts +3 -3
- package/test/src/schema.test.ts +223 -0
- package/test/src/slow_tests.test.ts +3 -3
- package/test/src/snapshot_query_provider.test.ts +95 -0
- package/test/src/stream_progress.test.ts +312 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/test/src/change_stream_utils.ts +0 -375
|
@@ -7,8 +7,9 @@ import { PostImagesOption } from '../types/types.js';
|
|
|
7
7
|
import { escapeRegExp } from '../utils.js';
|
|
8
8
|
import { createCheckpointImplementation } from './checkpoints/create-checkpoint-implementation.js';
|
|
9
9
|
import { getCacheIdentifier, getMongoRelation } from './MongoRelation.js';
|
|
10
|
+
import { DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER } from './MongoReplicationQueryProvider.js';
|
|
11
|
+
import { openMongoReplicationStream } from './MongoReplicationStream.js';
|
|
10
12
|
import { MongoSnapshotter } from './MongoSnapshotter.js';
|
|
11
|
-
import { parseChangeDocument, rawChangeStream } from './RawChangeStream.js';
|
|
12
13
|
import { CHECKPOINTS_COLLECTION, detectDocumentDb, timestampToDate } from './replication-utils.js';
|
|
13
14
|
import { DirectSourceRowConverter } from './SourceRowConverter.js';
|
|
14
15
|
/**
|
|
@@ -49,6 +50,7 @@ export class ChangeStream {
|
|
|
49
50
|
changeStreamTimeout;
|
|
50
51
|
storageHooks;
|
|
51
52
|
sourceRowConverter;
|
|
53
|
+
queryProvider;
|
|
52
54
|
keepaliveIntervalMs;
|
|
53
55
|
isDocumentDb = false;
|
|
54
56
|
_checkpointImplementation = null;
|
|
@@ -79,6 +81,12 @@ export class ChangeStream {
|
|
|
79
81
|
this.sync_rules = options.storage.getParsedSyncRules({
|
|
80
82
|
defaultSchema: this.defaultDb.databaseName
|
|
81
83
|
});
|
|
84
|
+
this.queryProvider =
|
|
85
|
+
options.createReplicationQueryProvider?.({
|
|
86
|
+
syncConfig: this.sync_rules,
|
|
87
|
+
connectionTag: this.connections.connectionTag,
|
|
88
|
+
defaultSchema: this.defaultDb.databaseName
|
|
89
|
+
}) ?? DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER;
|
|
82
90
|
this.sourceRowConverter = new DirectSourceRowConverter(this.sync_rules.compatibility);
|
|
83
91
|
// The change stream aggregation command should timeout before the socket times out,
|
|
84
92
|
// so we use 90% of the socket timeout value.
|
|
@@ -92,7 +100,8 @@ export class ChangeStream {
|
|
|
92
100
|
...options,
|
|
93
101
|
abortSignal: this.abortSignal,
|
|
94
102
|
logger: snapshotLogger,
|
|
95
|
-
checkpointStreamId: this.checkpointStreamId
|
|
103
|
+
checkpointStreamId: this.checkpointStreamId,
|
|
104
|
+
queryProvider: this.queryProvider
|
|
96
105
|
});
|
|
97
106
|
options.abort_signal.addEventListener('abort', () => {
|
|
98
107
|
this.abortController.abort(options.abort_signal.reason);
|
|
@@ -110,7 +119,9 @@ export class ChangeStream {
|
|
|
110
119
|
get configurePostImages() {
|
|
111
120
|
return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
|
|
112
121
|
}
|
|
113
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* The active checkpoint strategy. Only valid after ensureDetected().
|
|
124
|
+
*/
|
|
114
125
|
get checkpointImplementation() {
|
|
115
126
|
if (this._checkpointImplementation == null) {
|
|
116
127
|
throw new ReplicationAssertionError('Checkpoint implementation not initialized - call ensureDetected() first');
|
|
@@ -130,6 +141,7 @@ export class ChangeStream {
|
|
|
130
141
|
if (this.isDocumentDb) {
|
|
131
142
|
this.logger.warn('Azure DocumentDB support is in alpha. APIs and behavior may change, and long-term stability is not yet guaranteed.');
|
|
132
143
|
}
|
|
144
|
+
await this.queryProvider.validateSource?.({ connectionManager: this.connections, isDocumentDb: this.isDocumentDb });
|
|
133
145
|
this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
|
|
134
146
|
client: this.client,
|
|
135
147
|
db: this.defaultDb,
|
|
@@ -390,81 +402,30 @@ export class ChangeStream {
|
|
|
390
402
|
catch (e) {
|
|
391
403
|
if (e instanceof mongo.MongoServerError &&
|
|
392
404
|
e.codeName == 'NoMatchingDocument' &&
|
|
393
|
-
e.errmsg?.includes('post-image was not found')) {
|
|
405
|
+
(e.errmsg?.includes('post-image was not found') || e.errmsg?.includes('pre-image was not found'))) {
|
|
394
406
|
throw new ChangeStreamInvalidatedError(e.errmsg, e);
|
|
395
407
|
}
|
|
396
408
|
throw e;
|
|
397
409
|
}
|
|
398
410
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
fullDocument = 'updateLookup';
|
|
417
|
-
}
|
|
418
|
-
const streamOptions = {
|
|
419
|
-
fullDocument: fullDocument
|
|
420
|
-
};
|
|
421
|
-
if (!this.isDocumentDb) {
|
|
422
|
-
// DocumentDB does not support showExpandedEvents.
|
|
423
|
-
streamOptions.showExpandedEvents = true;
|
|
424
|
-
}
|
|
425
|
-
const pipeline = [
|
|
426
|
-
{
|
|
427
|
-
$changeStream: streamOptions
|
|
428
|
-
},
|
|
429
|
-
{
|
|
430
|
-
$match: filters.$match
|
|
411
|
+
openChangeStream(options) {
|
|
412
|
+
return openMongoReplicationStream({
|
|
413
|
+
db: this.defaultDb,
|
|
414
|
+
queryProvider: this.queryProvider,
|
|
415
|
+
namespaceFilter: options.filters,
|
|
416
|
+
isDocumentDb: this.isDocumentDb,
|
|
417
|
+
usePostImages: this.usePostImages,
|
|
418
|
+
position: options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null,
|
|
419
|
+
skipInitialTimestamp: true,
|
|
420
|
+
onBatch: options.onBatch,
|
|
421
|
+
options: {
|
|
422
|
+
batchSize: options.batchSize ?? this.snapshotChunkLength,
|
|
423
|
+
maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
|
|
424
|
+
maxTimeMS: this.changeStreamTimeout,
|
|
425
|
+
signal: options.signal,
|
|
426
|
+
logger: this.logger,
|
|
427
|
+
tracer: options.tracer
|
|
431
428
|
}
|
|
432
|
-
];
|
|
433
|
-
if (!this.isDocumentDb) {
|
|
434
|
-
// DocumentDB does not support $changeStreamSplitLargeEvent.
|
|
435
|
-
pipeline.push({ $changeStreamSplitLargeEvent: {} });
|
|
436
|
-
}
|
|
437
|
-
/**
|
|
438
|
-
* Only one of these options can be supplied at a time.
|
|
439
|
-
*/
|
|
440
|
-
if (resumeAfter) {
|
|
441
|
-
streamOptions.resumeAfter = resumeAfter;
|
|
442
|
-
}
|
|
443
|
-
else if (startAfter != null) {
|
|
444
|
-
// Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
|
|
445
|
-
// case if we have an old one.
|
|
446
|
-
// This is also relevant for getSnapshotLSN().
|
|
447
|
-
// The sentinel implementation never produces a startAfter, and a fresh DocumentDB stream
|
|
448
|
-
// opens from "now" with neither option set.
|
|
449
|
-
streamOptions.startAtOperationTime = startAfter;
|
|
450
|
-
}
|
|
451
|
-
let watchDb;
|
|
452
|
-
if (this.isDocumentDb || filters.multipleDatabases) {
|
|
453
|
-
// DocumentDB only supports cluster-level change streams.
|
|
454
|
-
watchDb = this.client.db('admin');
|
|
455
|
-
streamOptions.allChangesForCluster = true;
|
|
456
|
-
}
|
|
457
|
-
else {
|
|
458
|
-
watchDb = this.defaultDb;
|
|
459
|
-
}
|
|
460
|
-
const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
|
|
461
|
-
return rawChangeStream(watchDb, pipeline, {
|
|
462
|
-
batchSize: options.batchSize ?? this.snapshotChunkLength,
|
|
463
|
-
maxAwaitTimeMS,
|
|
464
|
-
maxTimeMS: this.changeStreamTimeout,
|
|
465
|
-
signal: options.signal,
|
|
466
|
-
logger: this.logger,
|
|
467
|
-
tracer: options.tracer
|
|
468
429
|
});
|
|
469
430
|
}
|
|
470
431
|
rawToSqliteRow(row) {
|
|
@@ -495,288 +456,238 @@ export class ChangeStream {
|
|
|
495
456
|
if (resumeFromLsn == null) {
|
|
496
457
|
throw new ReplicationAssertionError(`No LSN found to resume from`);
|
|
497
458
|
}
|
|
498
|
-
// Seed the
|
|
499
|
-
// the legacy startAfter timestamp (timestamp implementation only) for the
|
|
500
|
-
// resume-boundary dedupe guard below.
|
|
459
|
+
// Seed the checkpoint strategy's coordinate from the durable source position.
|
|
501
460
|
this.checkpointImplementation.seedPosition(resumeFromLsn);
|
|
502
|
-
const { startAfter } = this.checkpointImplementation.parseResumePosition(resumeFromLsn);
|
|
503
461
|
let outerSpan = tracer.span('batch');
|
|
504
462
|
this.checkpointImplementation.logResume(resumeFromLsn);
|
|
505
463
|
const filters = this.getSourceNamespaceFilters();
|
|
506
464
|
// This is closed when the for loop below returns/breaks/throws
|
|
507
|
-
|
|
465
|
+
let processingSpan;
|
|
466
|
+
let receivedBytes = 0;
|
|
467
|
+
let changesSinceProgress = 0;
|
|
468
|
+
const stream = this.openChangeStream({
|
|
508
469
|
lsn: resumeFromLsn,
|
|
509
470
|
filters,
|
|
510
471
|
signal: this.abortSignal,
|
|
511
|
-
tracer
|
|
472
|
+
tracer,
|
|
473
|
+
onBatch: (batch) => {
|
|
474
|
+
// Count actual transport, even if the adapter drops every envelope or reassembles fragments.
|
|
475
|
+
bytesReplicatedMetric.add(batch.byteSize);
|
|
476
|
+
chunksReplicatedMetric.add(1);
|
|
477
|
+
receivedBytes += batch.byteSize;
|
|
478
|
+
processingSpan = tracer.span('processing');
|
|
479
|
+
return processingSpan;
|
|
480
|
+
}
|
|
512
481
|
});
|
|
513
482
|
// Always start with a checkpoint.
|
|
514
483
|
// This helps us to clear errors when restarting, even if there is
|
|
515
484
|
// no data to replicate.
|
|
516
485
|
let waitForCheckpointLsn = await this.createBatchCheckpoint();
|
|
517
|
-
let
|
|
518
|
-
let flexDbNameWorkaroundLogged = false;
|
|
519
|
-
let lastEmptyResume = performance.now();
|
|
486
|
+
let lastKeepalive = performance.now();
|
|
520
487
|
let lastTxnKey = null;
|
|
521
|
-
for await (
|
|
522
|
-
|
|
523
|
-
using batchSpan = tracer.span('processing');
|
|
524
|
-
bytesReplicatedMetric.add(eventBatch.byteSize);
|
|
525
|
-
chunksReplicatedMetric.add(1);
|
|
526
|
-
if (this.abortSignal.aborted) {
|
|
488
|
+
for await (const item of stream) {
|
|
489
|
+
if (this.abortSignal.aborted)
|
|
527
490
|
break;
|
|
528
|
-
}
|
|
529
|
-
this.touch();
|
|
530
|
-
if (events.length == 0) {
|
|
531
|
-
// No changes in this batch, but we still want to persist progress.
|
|
532
|
-
// We do this by persisting a keepalive checkpoint.
|
|
533
|
-
// If we don't update it on empty events, we do keep consistency, but resuming the stream
|
|
534
|
-
// with old tokens may cause connection timeouts.
|
|
535
|
-
const hadRecentKeepalive = performance.now() - lastEmptyResume < this.keepaliveIntervalMs;
|
|
536
|
-
if (waitForCheckpointLsn == null && !hadRecentKeepalive) {
|
|
537
|
-
// Case 1: We have no changes, and we are not waiting for a checkpoint to be created,
|
|
538
|
-
// and we have not recently persisted a keepalive. Persist one now, and call setResumeLsn() below.
|
|
539
|
-
// This is the normal case for an idle stream.
|
|
540
|
-
// The implementation persists a keepalive (timestamp) or bumps the
|
|
541
|
-
// sentinel so a later event commits (sentinel). Logging is handled
|
|
542
|
-
// inside the implementation.
|
|
543
|
-
await this.checkpointImplementation.keepalive(batch, resumeToken);
|
|
544
|
-
this.touch();
|
|
545
|
-
lastEmptyResume = performance.now();
|
|
546
|
-
this.replicationLag.markStarted();
|
|
547
|
-
}
|
|
548
|
-
else if (hadRecentKeepalive) {
|
|
549
|
-
// Case 2: We have no changes, and may or may not be waiting for a checkpoint to be created.
|
|
550
|
-
// We have recently persisted a keepalive.
|
|
551
|
-
// Continue waiting.
|
|
552
|
-
continue;
|
|
553
|
-
}
|
|
554
|
-
else {
|
|
555
|
-
// Case 3: Waiting for a checkpoint; have not had a recent keepalive.
|
|
556
|
-
// We cannot call checkpointImplementation.keepalive() here, but we do call
|
|
557
|
-
// setResumeLsn() below.
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
491
|
this.touch();
|
|
561
|
-
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
}
|
|
581
|
-
if (splitEvent.fragment == splitEvent.of) {
|
|
582
|
-
// Got all fragments
|
|
583
|
-
changeDocument = splitDocument;
|
|
584
|
-
splitDocument = null;
|
|
585
|
-
}
|
|
586
|
-
else {
|
|
587
|
-
// Wait for more fragments
|
|
588
|
-
continue;
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
else if (splitDocument != null) {
|
|
592
|
-
// We were waiting for fragments, but got a different event
|
|
593
|
-
throw new ReplicationAssertionError(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
|
|
594
|
-
}
|
|
595
|
-
if (!filters.multipleDatabases &&
|
|
596
|
-
'ns' in changeDocument &&
|
|
597
|
-
changeDocument.ns.db != this.defaultDb.databaseName &&
|
|
598
|
-
changeDocument.ns.db.endsWith(`_${this.defaultDb.databaseName}`)) {
|
|
599
|
-
// When all of the following conditions are met:
|
|
600
|
-
// 1. We're replicating from an Atlas Flex instance.
|
|
601
|
-
// 2. There were changestream events recorded while the PowerSync service is paused.
|
|
602
|
-
// 3. We're only replicating from a single database.
|
|
603
|
-
// Then we've observed an ns with for example {db: '67b83e86cd20730f1e766dde_ps'},
|
|
604
|
-
// instead of the expected {db: 'ps'}.
|
|
605
|
-
// We correct this.
|
|
606
|
-
changeDocument.ns.db = this.defaultDb.databaseName;
|
|
607
|
-
if (!flexDbNameWorkaroundLogged) {
|
|
608
|
-
flexDbNameWorkaroundLogged = true;
|
|
609
|
-
this.logger.warn(`Incorrect DB name in change stream: ${changeDocument.ns.db}. Changed to ${this.defaultDb.databaseName}.`);
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
|
|
613
|
-
if (ns?.coll == CHECKPOINTS_COLLECTION) {
|
|
614
|
-
/**
|
|
615
|
-
* Dropping the database does not provide an `invalidate` event.
|
|
616
|
-
* We typically would receive `drop` events for the collection which we
|
|
617
|
-
* would process below.
|
|
618
|
-
*
|
|
619
|
-
* However we don't commit the LSN after collections are dropped.
|
|
620
|
-
* This prevents the `startAfter` or `resumeToken` from advancing past the drop events.
|
|
621
|
-
* The stream also closes after the drop events.
|
|
622
|
-
* This causes an infinite loop of processing the collection drop events.
|
|
623
|
-
*
|
|
624
|
-
* This check here invalidates the change stream if our `_powersync_checkpoints` collection
|
|
625
|
-
* is dropped. This allows for detecting when the DB is dropped.
|
|
626
|
-
*/
|
|
627
|
-
if (changeDocument.operationType == 'drop') {
|
|
628
|
-
throw new ChangeStreamInvalidatedError('Internal collections have been dropped', new Error('_powersync_checkpoints collection was dropped'));
|
|
629
|
-
}
|
|
630
|
-
if (!(changeDocument.operationType == 'insert' ||
|
|
631
|
-
changeDocument.operationType == 'update' ||
|
|
632
|
-
changeDocument.operationType == 'replace')) {
|
|
633
|
-
continue;
|
|
634
|
-
}
|
|
635
|
-
// We handle two types of checkpoint events:
|
|
636
|
-
// 1. "Standalone" checkpoints, typically write checkpoints. We want to process these
|
|
637
|
-
// immediately, regardless of where they were created.
|
|
638
|
-
// 2. "Batch" checkpoints for the current stream. This is used as a form of dynamic rate
|
|
639
|
-
// limiting of commits, so we specifically want to exclude checkpoints from other streams.
|
|
640
|
-
//
|
|
641
|
-
// It may be useful to also throttle commits due to standalone checkpoints in the future.
|
|
642
|
-
// However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
|
|
643
|
-
const kind = this.checkpointImplementation.event.observe(changeDocument);
|
|
644
|
-
if (kind == 'foreign') {
|
|
645
|
-
// Another stream's barrier - ignore.
|
|
646
|
-
continue;
|
|
647
|
-
}
|
|
648
|
-
else if (kind == 'standalone') {
|
|
649
|
-
// Standalone / write checkpoint received.
|
|
650
|
-
// When we are caught up, commit immediately to keep write checkpoint latency low.
|
|
651
|
-
// Once there is already a batch checkpoint pending, or the driver has buffered more
|
|
652
|
-
// change stream events, collapse standalone checkpoints into the normal batch
|
|
653
|
-
// checkpoint flow to avoid commit churn under sustained load.
|
|
654
|
-
const hasBufferedChanges = eventIndex < events.length - 1;
|
|
655
|
-
if (hasBufferedChanges && waitForCheckpointLsn == null) {
|
|
656
|
-
// Buffered changes - create a new batch checkpoint to rate limit commits
|
|
492
|
+
if (item.type == 'progress') {
|
|
493
|
+
const { resumeToken, filteredCount } = item;
|
|
494
|
+
if (changesSinceProgress == 0) {
|
|
495
|
+
// No retained changes since the previous progress item: this is either idle or filtered-only traffic.
|
|
496
|
+
// Case 1: No pending barrier and the keepalive interval has elapsed. Advance checkpoints as below,
|
|
497
|
+
// then flush and save the resume token.
|
|
498
|
+
// Case 2: Idle traffic with a recent keepalive. Skip this resume update, whether or not a barrier
|
|
499
|
+
// is pending, to preserve the idle throttle.
|
|
500
|
+
// Case 3: All other combinations. Flush and save the resume token without requesting a checkpoint.
|
|
501
|
+
// A pending barrier will provide the checkpoint boundary once replication reaches it. Filtered
|
|
502
|
+
// progress still saves its token even during the keepalive interval, so excluded work is resumable.
|
|
503
|
+
const hadRecentKeepalive = performance.now() - lastKeepalive < this.keepaliveIntervalMs;
|
|
504
|
+
if (waitForCheckpointLsn == null && !hadRecentKeepalive) {
|
|
505
|
+
if (filteredCount > 0) {
|
|
506
|
+
// Case 1a: A transaction can span batches: this batch may contain only filtered changes,
|
|
507
|
+
// while retained changes from the same transaction are still unread. They share a timestamp,
|
|
508
|
+
// so publishing a checkpoint here could acknowledge the transaction before its data is saved.
|
|
509
|
+
// Request a source barrier; reaching it ensures those retained changes have been processed.
|
|
510
|
+
// The progress token can still be saved below for recovery without publishing a checkpoint.
|
|
657
511
|
using _ = tracer.span('source_checkpoint');
|
|
658
512
|
waitForCheckpointLsn = await this.createBatchCheckpoint();
|
|
659
|
-
continue;
|
|
660
|
-
}
|
|
661
|
-
else if (waitForCheckpointLsn != null) {
|
|
662
|
-
// Skip this checkpoint - wait for the batch checkpoint.
|
|
663
|
-
continue;
|
|
664
513
|
}
|
|
665
514
|
else {
|
|
666
|
-
//
|
|
515
|
+
// Case 1b: Idle traffic. The timestamp implementation persists a keepalive directly;
|
|
516
|
+
// the sentinel implementation writes a source marker whose event will commit later.
|
|
517
|
+
await this.checkpointImplementation.keepalive(batch, resumeToken);
|
|
518
|
+
this.replicationLag.markStarted();
|
|
667
519
|
}
|
|
520
|
+
this.touch();
|
|
521
|
+
lastKeepalive = performance.now();
|
|
668
522
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)) {
|
|
673
|
-
waitForCheckpointLsn = null;
|
|
674
|
-
}
|
|
675
|
-
const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
|
|
676
|
-
oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
|
|
677
|
-
});
|
|
678
|
-
if (!checkpointBlocked || checkpointCreated) {
|
|
679
|
-
this.replicationLag.markCommitted();
|
|
523
|
+
else if (filteredCount == 0 && hadRecentKeepalive) {
|
|
524
|
+
// Case 2: Only idle progress skips resume persistence. Cases 1 and 3 fall through below.
|
|
525
|
+
continue;
|
|
680
526
|
}
|
|
681
527
|
}
|
|
682
|
-
|
|
528
|
+
const { lsn, timestamp } = this.checkpointImplementation.lsnFromResumeToken(resumeToken);
|
|
529
|
+
// Row writes must be durable before their source token. This advances recovery without
|
|
530
|
+
// publishing a checkpoint ahead of an outstanding barrier, transaction or snapshot.
|
|
531
|
+
await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
|
|
532
|
+
await batch.setResumeLsn(lsn);
|
|
533
|
+
// MongoDB's token timestamp may lag by about 10 seconds. DocumentDB tokens have no timestamp.
|
|
534
|
+
this.lastPersistedResumeTimestamp = timestamp?.getTime() ?? Date.now();
|
|
535
|
+
processingSpan?.end();
|
|
536
|
+
const durationsMicroseconds = outerSpan.end();
|
|
537
|
+
this.logger.info(`Processed ${changesSinceProgress} changes and ${filteredCount} filtered events`, {
|
|
538
|
+
count: changesSinceProgress,
|
|
539
|
+
filteredCount,
|
|
540
|
+
bytes: receivedBytes,
|
|
541
|
+
duration: processingSpan?.durationMillis,
|
|
542
|
+
t: durationsMicroseconds
|
|
543
|
+
});
|
|
544
|
+
changesSinceProgress = 0;
|
|
545
|
+
receivedBytes = 0;
|
|
546
|
+
outerSpan = tracer.span('batch');
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
// The shared reader has already reassembled complete events and normalized their namespaces.
|
|
550
|
+
const changeDocument = item.event;
|
|
551
|
+
changesSinceProgress++;
|
|
552
|
+
const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
|
|
553
|
+
if (ns?.coll == CHECKPOINTS_COLLECTION) {
|
|
554
|
+
/**
|
|
555
|
+
* Dropping the database does not provide an `invalidate` event.
|
|
556
|
+
* We typically would receive `drop` events for the collection which we
|
|
557
|
+
* would process below.
|
|
558
|
+
*
|
|
559
|
+
* However we don't commit the LSN after collections are dropped.
|
|
560
|
+
* This prevents the `startAfter` or `resumeToken` from advancing past the drop events.
|
|
561
|
+
* The stream also closes after the drop events.
|
|
562
|
+
* This causes an infinite loop of processing the collection drop events.
|
|
563
|
+
*
|
|
564
|
+
* This check here invalidates the change stream if our `_powersync_checkpoints` collection
|
|
565
|
+
* is dropped. This allows for detecting when the DB is dropped.
|
|
566
|
+
*/
|
|
567
|
+
if (changeDocument.operationType == 'drop') {
|
|
568
|
+
throw new ChangeStreamInvalidatedError('Internal collections have been dropped', new Error('_powersync_checkpoints collection was dropped'));
|
|
569
|
+
}
|
|
570
|
+
if (!(changeDocument.operationType == 'insert' ||
|
|
683
571
|
changeDocument.operationType == 'update' ||
|
|
684
|
-
changeDocument.operationType == 'replace'
|
|
685
|
-
|
|
686
|
-
|
|
572
|
+
changeDocument.operationType == 'replace')) {
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
// We handle two types of checkpoint events:
|
|
576
|
+
// 1. "Standalone" checkpoints, typically write checkpoints. We want to process these
|
|
577
|
+
// immediately, regardless of where they were created.
|
|
578
|
+
// 2. "Batch" checkpoints for the current stream. This is used as a form of dynamic rate
|
|
579
|
+
// limiting of commits, so we specifically want to exclude checkpoints from other streams.
|
|
580
|
+
//
|
|
581
|
+
// It may be useful to also throttle commits due to standalone checkpoints in the future.
|
|
582
|
+
// However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
|
|
583
|
+
const kind = this.checkpointImplementation.event.observe(changeDocument);
|
|
584
|
+
if (kind == 'foreign') {
|
|
585
|
+
// Another stream's barrier - ignore.
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
else if (kind == 'standalone') {
|
|
589
|
+
// Standalone / write checkpoint received.
|
|
590
|
+
// When we are caught up, commit immediately to keep write checkpoint latency low.
|
|
591
|
+
// Once there is already a batch checkpoint pending, or the driver has buffered more
|
|
592
|
+
// change stream events, collapse standalone checkpoints into the normal batch
|
|
593
|
+
// checkpoint flow to avoid commit churn under sustained load.
|
|
594
|
+
const hasBufferedChanges = item.hasBufferedChanges;
|
|
595
|
+
if (hasBufferedChanges && waitForCheckpointLsn == null) {
|
|
596
|
+
// Buffered changes - create a new batch checkpoint to rate limit commits
|
|
687
597
|
using _ = tracer.span('source_checkpoint');
|
|
688
598
|
waitForCheckpointLsn = await this.createBatchCheckpoint();
|
|
599
|
+
continue;
|
|
689
600
|
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
// for whatever reason, then we do need to snapshot it.
|
|
694
|
-
// This may result in some duplicate operations when a collection is created for the first time after
|
|
695
|
-
// sync config was deployed.
|
|
696
|
-
snapshot: true
|
|
697
|
-
});
|
|
698
|
-
const tablesToReplicate = tables.filter((table) => table.syncAny);
|
|
699
|
-
if (tablesToReplicate.length > 0) {
|
|
700
|
-
this.replicationLag.trackUncommittedChange(
|
|
701
|
-
// Standard MongoDB uses clusterTime, unchanged. DocumentDB has no
|
|
702
|
-
// clusterTime, so fall back to wallTime there for the lag metric.
|
|
703
|
-
changeDocument.clusterTime != null
|
|
704
|
-
? timestampToDate(changeDocument.clusterTime)
|
|
705
|
-
: (changeDocument.wallTime ?? null));
|
|
706
|
-
const transactionKeyValue = transactionKey(changeDocument);
|
|
707
|
-
if (transactionKeyValue == null || lastTxnKey != transactionKeyValue) {
|
|
708
|
-
// Very crude metric for counting transactions replicated.
|
|
709
|
-
// We ignore operations other than basic CRUD, and ignore changes to _powersync_checkpoints.
|
|
710
|
-
// Individual writes may not have a txnNumber, in which case we count them as separate transactions.
|
|
711
|
-
lastTxnKey = transactionKeyValue;
|
|
712
|
-
transactionsReplicatedMetric.add(1);
|
|
713
|
-
}
|
|
714
|
-
for (const table of tablesToReplicate) {
|
|
715
|
-
await this.writeChange(batch, table, changeDocument);
|
|
716
|
-
}
|
|
601
|
+
else if (waitForCheckpointLsn != null) {
|
|
602
|
+
// Skip this checkpoint - wait for the batch checkpoint.
|
|
603
|
+
continue;
|
|
717
604
|
}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
721
|
-
const tables = await this.getRelations(batch, rel, {
|
|
722
|
-
// We're "dropping" this collection, so never snapshot it.
|
|
723
|
-
snapshot: false
|
|
724
|
-
});
|
|
725
|
-
const tablesToDrop = tables.filter((table) => table.syncAny);
|
|
726
|
-
if (tablesToDrop.length > 0) {
|
|
727
|
-
await batch.drop(tablesToDrop);
|
|
605
|
+
else {
|
|
606
|
+
// No buffered changes, and no batch checkpoint pending - commit immediately.
|
|
728
607
|
}
|
|
729
|
-
this.relationCache.delete(rel);
|
|
730
608
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
609
|
+
// kind == 'own-barrier' falls through to commit.
|
|
610
|
+
const lsn = this.checkpointImplementation.event.lsn(changeDocument);
|
|
611
|
+
if (waitForCheckpointLsn != null &&
|
|
612
|
+
this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)) {
|
|
613
|
+
waitForCheckpointLsn = null;
|
|
614
|
+
}
|
|
615
|
+
const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
|
|
616
|
+
oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
|
|
617
|
+
});
|
|
618
|
+
if (!checkpointBlocked || checkpointCreated) {
|
|
619
|
+
this.replicationLag.markCommitted();
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
else if (changeDocument.operationType == 'insert' ||
|
|
623
|
+
changeDocument.operationType == 'update' ||
|
|
624
|
+
changeDocument.operationType == 'replace' ||
|
|
625
|
+
changeDocument.operationType == 'delete') {
|
|
626
|
+
if (waitForCheckpointLsn == null) {
|
|
627
|
+
using _ = tracer.span('source_checkpoint');
|
|
628
|
+
waitForCheckpointLsn = await this.createBatchCheckpoint();
|
|
629
|
+
}
|
|
630
|
+
const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
631
|
+
const tables = await this.getRelations(batch, rel, {
|
|
632
|
+
// In most cases, we should not need to snapshot this. But if this is the first time we see the collection
|
|
633
|
+
// for whatever reason, then we do need to snapshot it.
|
|
634
|
+
// This may result in some duplicate operations when a collection is created for the first time after
|
|
635
|
+
// sync config was deployed.
|
|
636
|
+
snapshot: true
|
|
637
|
+
});
|
|
638
|
+
const tablesToReplicate = tables.filter((table) => table.syncAny);
|
|
639
|
+
if (tablesToReplicate.length > 0) {
|
|
640
|
+
this.replicationLag.trackUncommittedChange(
|
|
641
|
+
// Standard MongoDB uses clusterTime, unchanged. DocumentDB has no
|
|
642
|
+
// clusterTime, so fall back to wallTime there for the lag metric.
|
|
643
|
+
changeDocument.clusterTime != null
|
|
644
|
+
? timestampToDate(changeDocument.clusterTime)
|
|
645
|
+
: (changeDocument.wallTime ?? null));
|
|
646
|
+
const transactionKeyValue = transactionKey(changeDocument);
|
|
647
|
+
if (transactionKeyValue == null || lastTxnKey != transactionKeyValue) {
|
|
648
|
+
// Very crude metric for counting transactions replicated.
|
|
649
|
+
// We ignore operations other than basic CRUD, and ignore changes to _powersync_checkpoints.
|
|
650
|
+
// Individual writes may not have a txnNumber, in which case we count them as separate transactions.
|
|
651
|
+
lastTxnKey = transactionKeyValue;
|
|
652
|
+
transactionsReplicatedMetric.add(1);
|
|
653
|
+
}
|
|
654
|
+
for (const table of tablesToReplicate) {
|
|
655
|
+
await this.writeChange(batch, table, changeDocument);
|
|
741
656
|
}
|
|
742
|
-
this.relationCache.delete(relFrom);
|
|
743
|
-
// Here we do need to snapshot the new table
|
|
744
|
-
const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
|
|
745
|
-
await this.handleRelation(batch, relTo, {
|
|
746
|
-
// This is a new (renamed) collection, so always snapshot it.
|
|
747
|
-
snapshot: true,
|
|
748
|
-
collectionInfo: collection
|
|
749
|
-
});
|
|
750
657
|
}
|
|
751
658
|
}
|
|
752
|
-
if (
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
if (timestamp != null) {
|
|
762
|
-
// Note that this timestamp provided by MongoDB is not exact - it can be around 10s behind.
|
|
763
|
-
this.lastPersistedResumeTimestamp = timestamp.getTime();
|
|
659
|
+
else if (changeDocument.operationType == 'drop') {
|
|
660
|
+
const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
661
|
+
const tables = await this.getRelations(batch, rel, {
|
|
662
|
+
// We're "dropping" this collection, so never snapshot it.
|
|
663
|
+
snapshot: false
|
|
664
|
+
});
|
|
665
|
+
const tablesToDrop = tables.filter((table) => table.syncAny);
|
|
666
|
+
if (tablesToDrop.length > 0) {
|
|
667
|
+
await batch.drop(tablesToDrop);
|
|
764
668
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
669
|
+
this.relationCache.delete(rel);
|
|
670
|
+
}
|
|
671
|
+
else if (changeDocument.operationType == 'rename') {
|
|
672
|
+
const relFrom = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
673
|
+
const relTo = getMongoRelation(changeDocument.to, this.connections.connectionTag);
|
|
674
|
+
const tablesFrom = await this.getRelations(batch, relFrom, {
|
|
675
|
+
// We're "dropping" this collection, so never snapshot it.
|
|
676
|
+
snapshot: false
|
|
677
|
+
});
|
|
678
|
+
const tablesToDrop = tablesFrom.filter((table) => table.syncAny);
|
|
679
|
+
if (tablesToDrop.length > 0) {
|
|
680
|
+
await batch.drop(tablesToDrop);
|
|
768
681
|
}
|
|
682
|
+
this.relationCache.delete(relFrom);
|
|
683
|
+
// Here we do need to snapshot the new table
|
|
684
|
+
const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
|
|
685
|
+
await this.handleRelation(batch, relTo, {
|
|
686
|
+
// This is a new (renamed) collection, so always snapshot it.
|
|
687
|
+
snapshot: true,
|
|
688
|
+
collectionInfo: collection
|
|
689
|
+
});
|
|
769
690
|
}
|
|
770
|
-
batchSpan.end();
|
|
771
|
-
const durationsMicroseconds = outerSpan.end();
|
|
772
|
-
const duration = batchSpan.durationMillis;
|
|
773
|
-
this.logger.info(`Processed batch of ${events.length} changes / ${eventBatch.byteSize} bytes in ${duration}ms`, {
|
|
774
|
-
count: events.length,
|
|
775
|
-
bytes: eventBatch.byteSize,
|
|
776
|
-
duration,
|
|
777
|
-
t: durationsMicroseconds
|
|
778
|
-
});
|
|
779
|
-
outerSpan = tracer.span('batch');
|
|
780
691
|
}
|
|
781
692
|
});
|
|
782
693
|
throw new ReplicationAbortedError(`Replication stream aborted`, this.abortSignal.reason);
|