@powersync/service-module-mongodb 0.18.2 → 0.20.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 +47 -0
- package/dist/api/MongoRouteAPIAdapter.d.ts +2 -0
- package/dist/api/MongoRouteAPIAdapter.js +23 -32
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/common/SentinelLSN.d.ts +37 -0
- package/dist/common/SentinelLSN.js +59 -0
- package/dist/common/SentinelLSN.js.map +1 -0
- package/dist/module/MongoModule.js +2 -1
- package/dist/module/MongoModule.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +23 -0
- package/dist/replication/ChangeStream.js +167 -47
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.js +2 -1
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/MongoRelation.d.ts +36 -1
- package/dist/replication/MongoRelation.js +102 -6
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/MongoSnapshotter.d.ts +9 -0
- package/dist/replication/MongoSnapshotter.js +113 -42
- package/dist/replication/MongoSnapshotter.js.map +1 -1
- package/dist/replication/RawChangeStream.d.ts +12 -1
- package/dist/replication/RawChangeStream.js +23 -10
- package/dist/replication/RawChangeStream.js.map +1 -1
- package/dist/replication/checkpoints/CheckpointImplementation.d.ts +135 -0
- package/dist/replication/checkpoints/CheckpointImplementation.js +22 -0
- package/dist/replication/checkpoints/CheckpointImplementation.js.map +1 -0
- package/dist/replication/checkpoints/SentinelCheckpointImplementation.d.ts +58 -0
- package/dist/replication/checkpoints/SentinelCheckpointImplementation.js +224 -0
- package/dist/replication/checkpoints/SentinelCheckpointImplementation.js.map +1 -0
- package/dist/replication/checkpoints/TimestampCheckpointImplementation.d.ts +27 -0
- package/dist/replication/checkpoints/TimestampCheckpointImplementation.js +116 -0
- package/dist/replication/checkpoints/TimestampCheckpointImplementation.js.map +1 -0
- package/dist/replication/checkpoints/create-checkpoint-implementation.d.ts +6 -0
- package/dist/replication/checkpoints/create-checkpoint-implementation.js +10 -0
- package/dist/replication/checkpoints/create-checkpoint-implementation.js.map +1 -0
- package/dist/replication/replication-utils.d.ts +6 -0
- package/dist/replication/replication-utils.js +19 -2
- package/dist/replication/replication-utils.js.map +1 -1
- package/dist/types/types.d.ts +5 -0
- package/dist/types/types.js +20 -2
- package/dist/types/types.js.map +1 -1
- package/package.json +9 -9
- package/src/api/MongoRouteAPIAdapter.ts +26 -37
- package/src/common/SentinelLSN.ts +78 -0
- package/src/module/MongoModule.ts +2 -1
- package/src/replication/ChangeStream.ts +184 -55
- package/src/replication/ChangeStreamReplicationJob.ts +2 -1
- package/src/replication/MongoRelation.ts +124 -14
- package/src/replication/MongoSnapshotter.ts +132 -47
- package/src/replication/RawChangeStream.ts +54 -32
- package/src/replication/checkpoints/CheckpointImplementation.ts +167 -0
- package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +269 -0
- package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +145 -0
- package/src/replication/checkpoints/create-checkpoint-implementation.ts +14 -0
- package/src/replication/replication-utils.ts +23 -2
- package/src/types/types.ts +27 -2
- package/test/DOCUMENTDB_TESTING.md +115 -0
- package/test/src/DatabaseType.ts +25 -0
- package/test/src/change_stream.test.ts +97 -65
- package/test/src/change_stream_utils.ts +99 -11
- package/test/src/checkpoint_retry.test.ts +5 -2
- package/test/src/config.test.ts +34 -0
- package/test/src/documentdb_helpers.test.ts +124 -0
- package/test/src/documentdb_mode.test.ts +1040 -0
- package/test/src/mongo_test.test.ts +15 -5
- package/test/src/raw_change_stream.test.ts +209 -125
- package/test/src/resume_token.test.ts +30 -0
- package/test/src/slow_tests.test.ts +4 -1
- package/test/src/test-timeouts.ts +23 -0
- package/test/src/util.ts +1 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -21,11 +21,12 @@ import {
|
|
|
21
21
|
import { HydratedSyncConfig } from '@powersync/service-sync-rules';
|
|
22
22
|
import { ReplicationMetric } from '@powersync/service-types';
|
|
23
23
|
import { performance } from 'node:perf_hooks';
|
|
24
|
-
import { MongoLSN } from '../common/MongoLSN.js';
|
|
25
24
|
import { PostImagesOption } from '../types/types.js';
|
|
26
25
|
import { escapeRegExp } from '../utils.js';
|
|
26
|
+
import { CheckpointImplementation } from './checkpoints/CheckpointImplementation.js';
|
|
27
|
+
import { createCheckpointImplementation } from './checkpoints/create-checkpoint-implementation.js';
|
|
27
28
|
import { MongoManager } from './MongoManager.js';
|
|
28
|
-
import {
|
|
29
|
+
import { getCacheIdentifier, getMongoRelation } from './MongoRelation.js';
|
|
29
30
|
import { MongoSnapshotter, MongoSnapshotterHooks } from './MongoSnapshotter.js';
|
|
30
31
|
import {
|
|
31
32
|
ChangeStreamBatch,
|
|
@@ -33,7 +34,7 @@ import {
|
|
|
33
34
|
ProjectedChangeStreamDocument,
|
|
34
35
|
rawChangeStream
|
|
35
36
|
} from './RawChangeStream.js';
|
|
36
|
-
import { CHECKPOINTS_COLLECTION, timestampToDate } from './replication-utils.js';
|
|
37
|
+
import { CHECKPOINTS_COLLECTION, detectDocumentDb, timestampToDate } from './replication-utils.js';
|
|
37
38
|
import { DirectSourceRowConverter, SourceRowConverter } from './SourceRowConverter.js';
|
|
38
39
|
export interface ChangeStreamOptions {
|
|
39
40
|
connections: MongoManager;
|
|
@@ -53,6 +54,11 @@ export interface ChangeStreamOptions {
|
|
|
53
54
|
*/
|
|
54
55
|
snapshotChunkLength?: number;
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Override keepalive interval for testing (defaults to 60_000ms).
|
|
59
|
+
*/
|
|
60
|
+
keepaliveIntervalMs?: number;
|
|
61
|
+
|
|
56
62
|
storageHooks?: storage.StorageHooks;
|
|
57
63
|
snapshotHooks?: MongoSnapshotterHooks;
|
|
58
64
|
|
|
@@ -113,13 +119,31 @@ export class ChangeStream {
|
|
|
113
119
|
|
|
114
120
|
private readonly sourceRowConverter: SourceRowConverter;
|
|
115
121
|
|
|
122
|
+
private keepaliveIntervalMs: number;
|
|
123
|
+
|
|
124
|
+
private isDocumentDb = false;
|
|
125
|
+
private _checkpointImplementation: CheckpointImplementation | null = null;
|
|
126
|
+
/**
|
|
127
|
+
* Last persisted resume timestamp as Date.now(). This represents the equivalent timestamp from the
|
|
128
|
+
* source database, rather than the time when we persisted it.
|
|
129
|
+
*/
|
|
130
|
+
private lastPersistedResumeTimestamp = 0;
|
|
131
|
+
private lastBatchCheckpoint = 0;
|
|
132
|
+
|
|
116
133
|
constructor(options: ChangeStreamOptions) {
|
|
117
134
|
this.storage = options.storage;
|
|
118
135
|
this.metrics = options.metrics;
|
|
119
136
|
this.group_id = options.storage.replicationStreamId;
|
|
120
137
|
this.connections = options.connections;
|
|
121
|
-
this.maxAwaitTimeMS = options.maxAwaitTimeMS ?? 10_000;
|
|
122
138
|
this.snapshotChunkLength = options.snapshotChunkLength ?? 6_000;
|
|
139
|
+
this.keepaliveIntervalMs = options.keepaliveIntervalMs ?? 60_000;
|
|
140
|
+
if (options.maxAwaitTimeMS) {
|
|
141
|
+
this.maxAwaitTimeMS = options.maxAwaitTimeMS;
|
|
142
|
+
} else {
|
|
143
|
+
// Default to 10s, unless keepaliveIntervalMs is shorter. In that case, use keepaliveIntervalMs,
|
|
144
|
+
// but add a bit of a margin, so that our keepalive logic is triggered on each empty change stream batch.
|
|
145
|
+
this.maxAwaitTimeMS = Math.min(10_000, this.keepaliveIntervalMs + 100);
|
|
146
|
+
}
|
|
123
147
|
this.storageHooks = options.storageHooks;
|
|
124
148
|
this.client = this.connections.client;
|
|
125
149
|
this.defaultDb = this.connections.db;
|
|
@@ -169,6 +193,37 @@ export class ChangeStream {
|
|
|
169
193
|
return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
|
|
170
194
|
}
|
|
171
195
|
|
|
196
|
+
/** The active checkpoint strategy. Only valid after ensureDetected(). */
|
|
197
|
+
private get checkpointImplementation(): CheckpointImplementation {
|
|
198
|
+
if (this._checkpointImplementation == null) {
|
|
199
|
+
throw new ReplicationAssertionError('Checkpoint implementation not initialized - call ensureDetected() first');
|
|
200
|
+
}
|
|
201
|
+
return this._checkpointImplementation;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Detect DocumentDB and select the checkpoint implementation for the streaming
|
|
206
|
+
* loop. Idempotent. The snapshotter detects independently; the two coordinate
|
|
207
|
+
* through stored LSNs, not shared in-memory state.
|
|
208
|
+
*/
|
|
209
|
+
private async ensureDetected(): Promise<void> {
|
|
210
|
+
if (this._checkpointImplementation != null) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
this.isDocumentDb = await detectDocumentDb(this.defaultDb);
|
|
214
|
+
if (this.isDocumentDb) {
|
|
215
|
+
this.logger.warn(
|
|
216
|
+
'Azure DocumentDB support is experimental. APIs and behavior may change, and long-term stability is not yet guaranteed.'
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
|
|
220
|
+
client: this.client,
|
|
221
|
+
db: this.defaultDb,
|
|
222
|
+
checkpointStreamId: this.checkpointStreamId,
|
|
223
|
+
logger: this.logger
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
172
227
|
private getSourceNamespaceFilters(): { $match: any; multipleDatabases: boolean } {
|
|
173
228
|
const sourceTables = this.sync_rules.getSourceTables();
|
|
174
229
|
|
|
@@ -209,7 +264,15 @@ export class ChangeStream {
|
|
|
209
264
|
// For details, see:
|
|
210
265
|
// https://github.com/powersync-ja/powersync-service/pull/417
|
|
211
266
|
// https://jira.mongodb.org/browse/SERVER-114532
|
|
212
|
-
|
|
267
|
+
//
|
|
268
|
+
// DocumentDB always opens a cluster-level change stream (admin +
|
|
269
|
+
// allChangesForCluster), even in single-database mode. A coll-only filter
|
|
270
|
+
// would then match same-named collections (including _powersync_checkpoints)
|
|
271
|
+
// in other databases of the cluster, letting a foreign standalone-checkpoint
|
|
272
|
+
// event advance/resolve checkpoints against the wrong source database. So we
|
|
273
|
+
// must filter on the full namespace whenever the stream is cluster-scoped.
|
|
274
|
+
const useFullNamespaceFilter = this.isDocumentDb || multipleDatabases;
|
|
275
|
+
const nsFilter = useFullNamespaceFilter
|
|
213
276
|
? // cluster-level: filter on the entire namespace
|
|
214
277
|
{ ns: { $in: $inFilters } }
|
|
215
278
|
: // collection-level: filter on coll only
|
|
@@ -465,15 +528,18 @@ export class ChangeStream {
|
|
|
465
528
|
signal?: AbortSignal;
|
|
466
529
|
tracer?: PerformanceTracer<'changestream'>;
|
|
467
530
|
}): AsyncIterableIterator<ChangeStreamBatch> {
|
|
468
|
-
const
|
|
469
|
-
const startAfter =
|
|
470
|
-
const resumeAfter =
|
|
531
|
+
const position = options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null;
|
|
532
|
+
const startAfter = position?.startAfter ?? undefined;
|
|
533
|
+
const resumeAfter = position?.resumeAfter ?? undefined;
|
|
471
534
|
|
|
472
535
|
const filters = options.filters;
|
|
473
536
|
|
|
474
537
|
let fullDocument: 'required' | 'updateLookup';
|
|
475
538
|
|
|
476
|
-
if (this.
|
|
539
|
+
if (this.isDocumentDb) {
|
|
540
|
+
// DocumentDB does not support changeStreamPreAndPostImages, so 'required' won't work.
|
|
541
|
+
fullDocument = 'updateLookup';
|
|
542
|
+
} else if (this.usePostImages) {
|
|
477
543
|
// 'read_only' or 'auto_configure'
|
|
478
544
|
// Configuration happens during snapshot, or when we see new
|
|
479
545
|
// collections.
|
|
@@ -482,42 +548,56 @@ export class ChangeStream {
|
|
|
482
548
|
fullDocument = 'updateLookup';
|
|
483
549
|
}
|
|
484
550
|
const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
|
|
485
|
-
showExpandedEvents: true,
|
|
486
551
|
fullDocument: fullDocument
|
|
487
552
|
};
|
|
553
|
+
if (!this.isDocumentDb) {
|
|
554
|
+
// DocumentDB does not support showExpandedEvents.
|
|
555
|
+
streamOptions.showExpandedEvents = true;
|
|
556
|
+
}
|
|
488
557
|
const pipeline: mongo.Document[] = [
|
|
489
558
|
{
|
|
490
559
|
$changeStream: streamOptions
|
|
491
560
|
},
|
|
492
561
|
{
|
|
493
562
|
$match: filters.$match
|
|
494
|
-
}
|
|
495
|
-
{ $changeStreamSplitLargeEvent: {} }
|
|
563
|
+
}
|
|
496
564
|
];
|
|
565
|
+
if (!this.isDocumentDb) {
|
|
566
|
+
// DocumentDB does not support $changeStreamSplitLargeEvent.
|
|
567
|
+
pipeline.push({ $changeStreamSplitLargeEvent: {} });
|
|
568
|
+
}
|
|
497
569
|
|
|
498
570
|
/**
|
|
499
571
|
* Only one of these options can be supplied at a time.
|
|
500
572
|
*/
|
|
501
573
|
if (resumeAfter) {
|
|
502
574
|
streamOptions.resumeAfter = resumeAfter;
|
|
503
|
-
} else {
|
|
575
|
+
} else if (startAfter != null) {
|
|
504
576
|
// Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
|
|
505
577
|
// case if we have an old one.
|
|
506
578
|
// This is also relevant for getSnapshotLSN().
|
|
579
|
+
// The sentinel implementation never produces a startAfter, and a fresh DocumentDB stream
|
|
580
|
+
// opens from "now" with neither option set.
|
|
507
581
|
streamOptions.startAtOperationTime = startAfter;
|
|
508
582
|
}
|
|
509
583
|
|
|
510
584
|
let watchDb: mongo.Db;
|
|
511
|
-
if (filters.multipleDatabases) {
|
|
585
|
+
if (this.isDocumentDb || filters.multipleDatabases) {
|
|
586
|
+
// DocumentDB only supports cluster-level change streams.
|
|
512
587
|
watchDb = this.client.db('admin');
|
|
513
588
|
streamOptions.allChangesForCluster = true;
|
|
514
589
|
} else {
|
|
515
590
|
watchDb = this.defaultDb;
|
|
516
591
|
}
|
|
517
592
|
|
|
593
|
+
const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
|
|
594
|
+
|
|
518
595
|
return rawChangeStream(watchDb, pipeline, {
|
|
519
596
|
batchSize: options.batchSize ?? this.snapshotChunkLength,
|
|
520
|
-
maxAwaitTimeMS
|
|
597
|
+
maxAwaitTimeMS,
|
|
598
|
+
// maxAwaitTimeMS can be 0 for probe-style streams that do not want an idle wait.
|
|
599
|
+
// In that case there is no client-side wait to emulate for DocumentDB.
|
|
600
|
+
clientSideMaxAwaitTimeMS: this.isDocumentDb && maxAwaitTimeMS > 0,
|
|
521
601
|
maxTimeMS: this.changeStreamTimeout,
|
|
522
602
|
|
|
523
603
|
signal: options.signal,
|
|
@@ -530,7 +610,14 @@ export class ChangeStream {
|
|
|
530
610
|
return this.sourceRowConverter.rawToSqliteRow(row);
|
|
531
611
|
}
|
|
532
612
|
|
|
613
|
+
private async createBatchCheckpoint() {
|
|
614
|
+
const checkpoint = await this.checkpointImplementation.createBatchCheckpoint();
|
|
615
|
+
this.lastBatchCheckpoint = Date.now();
|
|
616
|
+
return checkpoint;
|
|
617
|
+
}
|
|
618
|
+
|
|
533
619
|
async streamChangesInternal() {
|
|
620
|
+
await this.ensureDetected();
|
|
534
621
|
const transactionsReplicatedMetric = this.metrics.getCounter(ReplicationMetric.TRANSACTIONS_REPLICATED);
|
|
535
622
|
const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
|
|
536
623
|
const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
|
|
@@ -541,7 +628,7 @@ export class ChangeStream {
|
|
|
541
628
|
await this.storage.startBatch(
|
|
542
629
|
{
|
|
543
630
|
logger: this.logger,
|
|
544
|
-
zeroLSN:
|
|
631
|
+
zeroLSN: this.checkpointImplementation.zeroLsn,
|
|
545
632
|
defaultSchema: this.defaultDb.databaseName,
|
|
546
633
|
// We get a complete postimage for every change, so we don't need to store the current data.
|
|
547
634
|
storeCurrentData: false,
|
|
@@ -553,15 +640,14 @@ export class ChangeStream {
|
|
|
553
640
|
if (resumeFromLsn == null) {
|
|
554
641
|
throw new ReplicationAssertionError(`No LSN found to resume from`);
|
|
555
642
|
}
|
|
556
|
-
|
|
557
|
-
|
|
643
|
+
// Seed the implementation's coordinate state from the stored LSN, and parse
|
|
644
|
+
// the legacy startAfter timestamp (timestamp implementation only) for the
|
|
645
|
+
// resume-boundary dedupe guard below.
|
|
646
|
+
this.checkpointImplementation.seedPosition(resumeFromLsn);
|
|
647
|
+
const { startAfter } = this.checkpointImplementation.parseResumePosition(resumeFromLsn);
|
|
558
648
|
let outerSpan = tracer.span('batch');
|
|
559
649
|
|
|
560
|
-
|
|
561
|
-
// of ChangeStream events.
|
|
562
|
-
const tokenAgeSeconds = Math.round((Date.now() - timestampToDate(startAfter).getTime()) / 1000);
|
|
563
|
-
|
|
564
|
-
this.logger.info(`Resume streaming at ${startAfter?.inspect()} / ${lastLsn} | Token age: ${tokenAgeSeconds}s`);
|
|
650
|
+
this.checkpointImplementation.logResume(resumeFromLsn);
|
|
565
651
|
|
|
566
652
|
const filters = this.getSourceNamespaceFilters();
|
|
567
653
|
// This is closed when the for loop below returns/breaks/throws
|
|
@@ -575,11 +661,7 @@ export class ChangeStream {
|
|
|
575
661
|
// Always start with a checkpoint.
|
|
576
662
|
// This helps us to clear errors when restarting, even if there is
|
|
577
663
|
// no data to replicate.
|
|
578
|
-
let waitForCheckpointLsn: string | null = await
|
|
579
|
-
this.client,
|
|
580
|
-
this.defaultDb,
|
|
581
|
-
this.checkpointStreamId
|
|
582
|
-
);
|
|
664
|
+
let waitForCheckpointLsn: string | null = await this.createBatchCheckpoint();
|
|
583
665
|
|
|
584
666
|
let splitDocument: ProjectedChangeStreamDocument | null = null;
|
|
585
667
|
|
|
@@ -599,27 +681,31 @@ export class ChangeStream {
|
|
|
599
681
|
}
|
|
600
682
|
this.touch();
|
|
601
683
|
if (events.length == 0) {
|
|
602
|
-
// No changes in this batch, but we still want to
|
|
684
|
+
// No changes in this batch, but we still want to persist progress.
|
|
603
685
|
// We do this by persisting a keepalive checkpoint.
|
|
604
686
|
// If we don't update it on empty events, we do keep consistency, but resuming the stream
|
|
605
687
|
// with old tokens may cause connection timeouts.
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
688
|
+
const hadRecentKeepalive = performance.now() - lastEmptyResume < this.keepaliveIntervalMs;
|
|
689
|
+
if (waitForCheckpointLsn == null && !hadRecentKeepalive) {
|
|
690
|
+
// Case 1: We have no changes, and we are not waiting for a checkpoint to be created,
|
|
691
|
+
// and we have not recently persisted a keepalive. Persist one now, and call setResumeLsn() below.
|
|
692
|
+
// This is the normal case for an idle stream.
|
|
693
|
+
// The implementation persists a keepalive (timestamp) or bumps the
|
|
694
|
+
// sentinel so a later event commits (sentinel). Logging is handled
|
|
695
|
+
// inside the implementation.
|
|
696
|
+
await this.checkpointImplementation.keepalive(batch, resumeToken);
|
|
609
697
|
this.touch();
|
|
610
698
|
lastEmptyResume = performance.now();
|
|
611
|
-
// Log the token update. This helps as a general "replication is still active" message in the logs.
|
|
612
|
-
// This token would typically be around 10s behind.
|
|
613
|
-
this.logger.info(
|
|
614
|
-
`Idle change stream. Persisted resumeToken for ${timestampToDate(timestamp).toISOString()}`
|
|
615
|
-
);
|
|
616
699
|
this.replicationLag.markStarted();
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
if (performance.now() - lastEmptyResume < 60_000) {
|
|
700
|
+
} else if (hadRecentKeepalive) {
|
|
701
|
+
// Case 2: We have no changes, and may or may not be waiting for a checkpoint to be created.
|
|
702
|
+
// We have recently persisted a keepalive.
|
|
703
|
+
// Continue waiting.
|
|
622
704
|
continue;
|
|
705
|
+
} else {
|
|
706
|
+
// Case 3: Waiting for a checkpoint; have not had a recent keepalive.
|
|
707
|
+
// We cannot call checkpointImplementation.keepalive() here, but we do call
|
|
708
|
+
// setResumeLsn() below.
|
|
623
709
|
}
|
|
624
710
|
}
|
|
625
711
|
|
|
@@ -726,9 +812,12 @@ export class ChangeStream {
|
|
|
726
812
|
// It may be useful to also throttle commits due to standalone checkpoints in the future.
|
|
727
813
|
// However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
|
|
728
814
|
|
|
729
|
-
const
|
|
815
|
+
const kind = this.checkpointImplementation.event.observe(changeDocument);
|
|
730
816
|
|
|
731
|
-
if (
|
|
817
|
+
if (kind == 'foreign') {
|
|
818
|
+
// Another stream's barrier - ignore.
|
|
819
|
+
continue;
|
|
820
|
+
} else if (kind == 'standalone') {
|
|
732
821
|
// Standalone / write checkpoint received.
|
|
733
822
|
// When we are caught up, commit immediately to keep write checkpoint latency low.
|
|
734
823
|
// Once there is already a batch checkpoint pending, or the driver has buffered more
|
|
@@ -738,7 +827,7 @@ export class ChangeStream {
|
|
|
738
827
|
if (hasBufferedChanges && waitForCheckpointLsn == null) {
|
|
739
828
|
// Buffered changes - create a new batch checkpoint to rate limit commits
|
|
740
829
|
using _ = tracer.span('source_checkpoint');
|
|
741
|
-
waitForCheckpointLsn = await
|
|
830
|
+
waitForCheckpointLsn = await this.createBatchCheckpoint();
|
|
742
831
|
continue;
|
|
743
832
|
} else if (waitForCheckpointLsn != null) {
|
|
744
833
|
// Skip this checkpoint - wait for the batch checkpoint.
|
|
@@ -746,15 +835,15 @@ export class ChangeStream {
|
|
|
746
835
|
} else {
|
|
747
836
|
// No buffered changes, and no batch checkpoint pending - commit immediately.
|
|
748
837
|
}
|
|
749
|
-
} else if (!this.checkpointStreamId.equals(checkpointId)) {
|
|
750
|
-
continue;
|
|
751
838
|
}
|
|
752
|
-
|
|
753
|
-
timestamp: changeDocument.clusterTime!,
|
|
754
|
-
resume_token: changeDocument._id
|
|
755
|
-
});
|
|
839
|
+
// kind == 'own-barrier' falls through to commit.
|
|
756
840
|
|
|
757
|
-
|
|
841
|
+
const lsn = this.checkpointImplementation.event.lsn(changeDocument);
|
|
842
|
+
|
|
843
|
+
if (
|
|
844
|
+
waitForCheckpointLsn != null &&
|
|
845
|
+
this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)
|
|
846
|
+
) {
|
|
758
847
|
waitForCheckpointLsn = null;
|
|
759
848
|
}
|
|
760
849
|
const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
|
|
@@ -772,7 +861,7 @@ export class ChangeStream {
|
|
|
772
861
|
) {
|
|
773
862
|
if (waitForCheckpointLsn == null) {
|
|
774
863
|
using _ = tracer.span('source_checkpoint');
|
|
775
|
-
waitForCheckpointLsn = await
|
|
864
|
+
waitForCheckpointLsn = await this.createBatchCheckpoint();
|
|
776
865
|
}
|
|
777
866
|
|
|
778
867
|
const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
@@ -786,7 +875,11 @@ export class ChangeStream {
|
|
|
786
875
|
const tablesToReplicate = tables.filter((table) => table.syncAny);
|
|
787
876
|
if (tablesToReplicate.length > 0) {
|
|
788
877
|
this.replicationLag.trackUncommittedChange(
|
|
789
|
-
|
|
878
|
+
// Standard MongoDB uses clusterTime, unchanged. DocumentDB has no
|
|
879
|
+
// clusterTime, so fall back to wallTime there for the lag metric.
|
|
880
|
+
changeDocument.clusterTime != null
|
|
881
|
+
? timestampToDate(changeDocument.clusterTime)
|
|
882
|
+
: ((changeDocument as any).wallTime ?? null)
|
|
790
883
|
);
|
|
791
884
|
|
|
792
885
|
const transactionKeyValue = transactionKey(changeDocument);
|
|
@@ -841,10 +934,18 @@ export class ChangeStream {
|
|
|
841
934
|
// Batches are generally large (64MB or 6000 events, whichever comes first),
|
|
842
935
|
// so this is a good natural point to flush and mark progress.
|
|
843
936
|
// We avoid this when splitDocument is set, since we cannot resume in the middle of a split event.
|
|
844
|
-
const {
|
|
937
|
+
const { lsn, timestamp } = this.checkpointImplementation.lsnFromResumeToken(resumeToken);
|
|
845
938
|
await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
|
|
846
939
|
// TODO: We should consider making this standard behavior of flush().
|
|
847
940
|
await batch.setResumeLsn(lsn);
|
|
941
|
+
|
|
942
|
+
if (timestamp != null) {
|
|
943
|
+
// Note that this timestamp provided by MongoDB is not exact - it can be around 10s behind.
|
|
944
|
+
this.lastPersistedResumeTimestamp = timestamp.getTime();
|
|
945
|
+
} else {
|
|
946
|
+
// DocumentDB: No timestamp associated with the resumeToken. Just use the current time.
|
|
947
|
+
this.lastPersistedResumeTimestamp = Date.now();
|
|
948
|
+
}
|
|
848
949
|
}
|
|
849
950
|
|
|
850
951
|
batchSpan.end();
|
|
@@ -872,6 +973,34 @@ export class ChangeStream {
|
|
|
872
973
|
return this.replicationLag.getLagMillis();
|
|
873
974
|
}
|
|
874
975
|
|
|
976
|
+
async keepAlive() {
|
|
977
|
+
// This is called on an interval of keepaliveIntervalMs.
|
|
978
|
+
|
|
979
|
+
// This writes to _powersync_checkpoints.
|
|
980
|
+
// Main use case: When there are massive bulk writes to another database or collection, the change stream may
|
|
981
|
+
// start timing out due to reading through too much data in one batch. This breaks up those bulk writes,
|
|
982
|
+
// allowing the change stream to make progress.
|
|
983
|
+
|
|
984
|
+
// When there is low replication traffic, regular calls to setResumeLsn() with recent resume tokens make this unnecessary.
|
|
985
|
+
// We track that using lastPersistedResumeTimestamp.
|
|
986
|
+
// We add a bit of a margin here, since setResumeLsn is not called on an exact interval.
|
|
987
|
+
const staleResumeLsn = Date.now() - this.lastPersistedResumeTimestamp > this.keepaliveIntervalMs * 1.1;
|
|
988
|
+
|
|
989
|
+
// When there is replication lag, that resumeToken can get too outdated. In that case, we periodically create a
|
|
990
|
+
// new back checkpoint. This interval is controlled by the frequency of the keepAlive() call,
|
|
991
|
+
// while also skipping if there was another call to createBatchCheckpoint().
|
|
992
|
+
// We use a factor of 0.9 here, to make sure this is called on every keepAlive() interval, unless there
|
|
993
|
+
// was another call to createBatchCheckpoint().
|
|
994
|
+
const staleBatchCheckpoint = Date.now() - this.lastBatchCheckpoint > this.keepaliveIntervalMs * 0.9;
|
|
995
|
+
|
|
996
|
+
// We don't use oldestUncommittedChange here, since that may be unset in some edge cases where we do need
|
|
997
|
+
// to persist new checkpoints.
|
|
998
|
+
if (staleResumeLsn && staleBatchCheckpoint) {
|
|
999
|
+
await this.ensureDetected();
|
|
1000
|
+
await this.createBatchCheckpoint();
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
875
1004
|
private lastTouchedAt = performance.now();
|
|
876
1005
|
|
|
877
1006
|
private touch() {
|
|
@@ -24,7 +24,7 @@ export class ChangeStreamReplicationJob extends replication.AbstractReplicationJ
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
async keepAlive() {
|
|
27
|
-
|
|
27
|
+
await this.lastStream?.keepAlive();
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
async replicate() {
|
|
@@ -71,6 +71,7 @@ export class ChangeStreamReplicationJob extends replication.AbstractReplicationJ
|
|
|
71
71
|
storage: this.options.storage,
|
|
72
72
|
metrics: this.options.metrics,
|
|
73
73
|
connections: connectionManager,
|
|
74
|
+
keepaliveIntervalMs: connectionManager.options.heartbeat_interval_seconds * 1_000,
|
|
74
75
|
logger: this.logger
|
|
75
76
|
});
|
|
76
77
|
this.lastStream = stream;
|
|
@@ -14,7 +14,9 @@ import {
|
|
|
14
14
|
} from '@powersync/service-sync-rules';
|
|
15
15
|
|
|
16
16
|
import { ErrorCode, logger, ServiceAssertionError, ServiceError } from '@powersync/lib-services-framework';
|
|
17
|
+
import { ObjectId } from 'bson';
|
|
17
18
|
import { MongoLSN } from '../common/MongoLSN.js';
|
|
19
|
+
import { SentinelLSN } from '../common/SentinelLSN.js';
|
|
18
20
|
|
|
19
21
|
export function getMongoRelation(
|
|
20
22
|
source: mongo.ChangeStreamNameSpace,
|
|
@@ -173,15 +175,24 @@ function filterJsonData(data: any, context: CompatibilityContext, depth = 0): an
|
|
|
173
175
|
*/
|
|
174
176
|
export const STANDALONE_CHECKPOINT_ID = '_standalone_checkpoint';
|
|
175
177
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
178
|
+
/**
|
|
179
|
+
* Id for checkpoint records managed by the {@link SentinelCheckpointImplementation} implementation.
|
|
180
|
+
*/
|
|
181
|
+
export const SENTINEL_CHECKPOINT_ID = '_sentinel_checkpoint';
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Create a checkpoint by upserting a document in _powersync_checkpoints, and
|
|
185
|
+
* return a comparable LSN string derived from the write's operationTime.
|
|
186
|
+
*
|
|
187
|
+
* Standard MongoDB only: this requires session.operationTime, which DocumentDB
|
|
188
|
+
* does not provide (it throws PSYNC_S1004 when it is missing). The DocumentDB /
|
|
189
|
+
* sentinel path builds LSNs via {@link createSentinelCheckpointLsn} instead.
|
|
190
|
+
*/
|
|
191
|
+
export async function createCheckpoint(db: mongo.Db, id: mongo.ObjectId | string): Promise<string> {
|
|
181
192
|
const TRIES = 2;
|
|
182
193
|
for (let i = 0; i < TRIES; i++) {
|
|
183
194
|
try {
|
|
184
|
-
return await createCheckpointInner(
|
|
195
|
+
return await createCheckpointInner(db, id);
|
|
185
196
|
} catch (e) {
|
|
186
197
|
if (i < TRIES - 1) {
|
|
187
198
|
logger.warn(`Failed to create checkpoint on attempt ${i + 1}`, e);
|
|
@@ -193,11 +204,7 @@ export async function createCheckpoint(
|
|
|
193
204
|
throw new ServiceAssertionError(`Unreachable code`);
|
|
194
205
|
}
|
|
195
206
|
|
|
196
|
-
async function createCheckpointInner(
|
|
197
|
-
client: mongo.MongoClient,
|
|
198
|
-
db: mongo.Db,
|
|
199
|
-
id: mongo.ObjectId | string
|
|
200
|
-
): Promise<string> {
|
|
207
|
+
async function createCheckpointInner(db: mongo.Db, id: mongo.ObjectId | string): Promise<string> {
|
|
201
208
|
// We use an unique id per process, and clear documents on startup.
|
|
202
209
|
// This is so that we can filter events for our own process only, and ignore
|
|
203
210
|
// events from other processes.
|
|
@@ -208,6 +215,8 @@ async function createCheckpointInner(
|
|
|
208
215
|
// Instead, we do manual retries, which does not have the same write de-duplication logic.
|
|
209
216
|
// A sentinal-based approach would be better here, but that is a much bigger change.
|
|
210
217
|
|
|
218
|
+
const update: mongo.Document = { $inc: { i: 1 } };
|
|
219
|
+
|
|
211
220
|
const response = await db.command({
|
|
212
221
|
findAndModify: '_powersync_checkpoints',
|
|
213
222
|
query: {
|
|
@@ -215,9 +224,7 @@ async function createCheckpointInner(
|
|
|
215
224
|
},
|
|
216
225
|
new: true,
|
|
217
226
|
upsert: true,
|
|
218
|
-
update
|
|
219
|
-
$inc: { i: 1 }
|
|
220
|
-
}
|
|
227
|
+
update
|
|
221
228
|
});
|
|
222
229
|
|
|
223
230
|
const time = response.operationTime as mongo.Timestamp | undefined;
|
|
@@ -227,6 +234,109 @@ async function createCheckpointInner(
|
|
|
227
234
|
return new MongoLSN({ timestamp: time }).comparable;
|
|
228
235
|
}
|
|
229
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Create a DocumentDB comparable LSN by advancing the shared sentinel checkpoint
|
|
239
|
+
* document ({@link SENTINEL_CHECKPOINT_ID}). The returned LSN encodes the
|
|
240
|
+
* checkpoint counter in the same 16-hex shape as a MongoDB timestamp LSN (see
|
|
241
|
+
* {@link SentinelLSN}), so the two formats are directly comparable. The counter is
|
|
242
|
+
* seeded in the epoch-seconds range (see below), so a sentinel LSN always sorts
|
|
243
|
+
* above any real-timestamp LSN issued in the past.
|
|
244
|
+
*
|
|
245
|
+
* This counter is intentionally global to the source database. It is used for
|
|
246
|
+
* storage/client checkpoint comparisons and write checkpoint heads, so it must
|
|
247
|
+
* not reset when a new ChangeStream instance or new sync rules start.
|
|
248
|
+
*
|
|
249
|
+
* The document is a single shared record whose `stream_id` field alternates:
|
|
250
|
+
* batch/keepalive bumps stamp it with the calling stream's id (so a stream can
|
|
251
|
+
* recognise its own private barriers), while standalone bumps (write checkpoint
|
|
252
|
+
* heads, snapshot markers) clear it to null. `i` advances globally regardless.
|
|
253
|
+
*
|
|
254
|
+
* @param changeStreamId
|
|
255
|
+
* When provided, the bump is attributed to this stream (a private barrier).
|
|
256
|
+
* When omitted, it is a standalone bump and stream_id is cleared to null.
|
|
257
|
+
*/
|
|
258
|
+
export async function createSentinelCheckpointLsn(
|
|
259
|
+
client: mongo.MongoClient,
|
|
260
|
+
db: mongo.Db,
|
|
261
|
+
changeStreamId?: ObjectId
|
|
262
|
+
): Promise<string> {
|
|
263
|
+
const session = client.startSession();
|
|
264
|
+
try {
|
|
265
|
+
const collection = db.collection('_powersync_checkpoints');
|
|
266
|
+
|
|
267
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
268
|
+
// Common path: increment the existing counter.
|
|
269
|
+
const result = await collection.findOneAndUpdate(
|
|
270
|
+
{
|
|
271
|
+
_id: SENTINEL_CHECKPOINT_ID as any,
|
|
272
|
+
i: { $exists: true }
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
$inc: { i: 1 },
|
|
276
|
+
// Standalone bumps (no changeStreamId) must explicitly clear the
|
|
277
|
+
// stream_id left by a previous batch bump — this is a single shared
|
|
278
|
+
// document whose stream_id alternates. Write null rather than relying
|
|
279
|
+
// on `undefined` being serialized (which depends on the driver's
|
|
280
|
+
// ignoreUndefined option, and collapses to an empty $set if enabled).
|
|
281
|
+
$set: {
|
|
282
|
+
stream_id: changeStreamId ?? null
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
returnDocument: 'after',
|
|
287
|
+
session
|
|
288
|
+
}
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
if (result != null) {
|
|
292
|
+
// `i` is a bigint: the client is configured with useBigInt64, and the
|
|
293
|
+
// seed exceeds 2^53 so it could not be safely represented as a number.
|
|
294
|
+
return new SentinelLSN({ sentinel: result.i }).comparable;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// The counter document does not exist: first run, or a consumer deleted
|
|
298
|
+
// it in their source database. Seed the counter in the epoch-SECONDS range
|
|
299
|
+
// (seconds in the high 32 bits, mirroring a MongoDB timestamp) rather than
|
|
300
|
+
// starting at 1. Two properties follow:
|
|
301
|
+
//
|
|
302
|
+
// - A sentinel LSN sorts above any real-timestamp LSN issued in the past,
|
|
303
|
+
// because its high 32 bits are the current epoch seconds.
|
|
304
|
+
// - A re-created counter jumps forward instead of backward across deletion:
|
|
305
|
+
// the seed advances by 2^32 each wall-clock second, while checkpoints add
|
|
306
|
+
// 1 each, so any later re-seed exceeds the previously issued coordinate
|
|
307
|
+
// (keeping the LSN domain monotonic; otherwise new write checkpoint heads
|
|
308
|
+
// could resolve against old, higher committed LSNs).
|
|
309
|
+
//
|
|
310
|
+
// $setOnInsert cannot be combined with $inc on the same field, so this
|
|
311
|
+
// is a separate upsert; the loop then retries the increment. The
|
|
312
|
+
// $setOnInsert is a no-op if another process created the document
|
|
313
|
+
// concurrently.
|
|
314
|
+
await collection.updateOne(
|
|
315
|
+
{
|
|
316
|
+
_id: SENTINEL_CHECKPOINT_ID as any
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
$setOnInsert: {
|
|
320
|
+
i: BigInt(Math.floor(Date.now() / 1000)) << 32n,
|
|
321
|
+
stream_id: changeStreamId ?? null
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
upsert: true,
|
|
326
|
+
session
|
|
327
|
+
}
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
throw new ServiceError(
|
|
332
|
+
ErrorCode.PSYNC_S1301,
|
|
333
|
+
`Failed to increment the sentinel checkpoint counter - the checkpoint document may be getting deleted concurrently.`
|
|
334
|
+
);
|
|
335
|
+
} finally {
|
|
336
|
+
await session.endSession();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
230
340
|
const mongoTimeOptions: DateTimeSourceOptions = {
|
|
231
341
|
subSecondPrecision: TimeValuePrecision.milliseconds,
|
|
232
342
|
defaultSubSecondPrecision: TimeValuePrecision.milliseconds
|