@powersync/service-module-mongodb 0.0.0-dev-20260602073133 → 0.0.0-dev-20260909133214
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 +153 -5
- package/dist/api/MongoRouteAPIAdapter.d.ts +4 -2
- 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 +24 -0
- package/dist/replication/ChangeStream.js +203 -67
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.js +3 -2
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/ChangeStreamReplicator.js +1 -1
- package/dist/replication/ChangeStreamReplicator.js.map +1 -1
- package/dist/replication/MongoRelation.d.ts +36 -1
- package/dist/replication/MongoRelation.js +133 -18
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/MongoSnapshotter.d.ts +9 -1
- package/dist/replication/MongoSnapshotter.js +129 -67
- package/dist/replication/MongoSnapshotter.js.map +1 -1
- package/dist/replication/RawChangeStream.d.ts +4 -1
- package/dist/replication/RawChangeStream.js +6 -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 +7 -2
- package/dist/types/types.js +20 -2
- package/dist/types/types.js.map +1 -1
- package/package.json +11 -21
- package/src/api/MongoRouteAPIAdapter.ts +28 -39
- package/src/common/SentinelLSN.ts +78 -0
- package/src/module/MongoModule.ts +2 -1
- package/src/replication/ChangeStream.ts +223 -78
- package/src/replication/ChangeStreamReplicationJob.ts +3 -2
- package/src/replication/ChangeStreamReplicator.ts +1 -1
- package/src/replication/MongoRelation.ts +153 -21
- package/src/replication/MongoSnapshotter.ts +148 -73
- package/src/replication/RawChangeStream.ts +27 -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 +180 -71
- package/test/src/change_stream_utils.ts +117 -29
- package/test/src/checkpoint_retry.test.ts +134 -0
- package/test/src/config.test.ts +34 -0
- package/test/src/documentdb_helpers.test.ts +124 -0
- package/test/src/documentdb_mode.test.ts +1039 -0
- package/test/src/mongo_test.test.ts +15 -5
- package/test/src/raw_change_stream.test.ts +147 -126
- package/test/src/resume.test.ts +2 -2
- 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 +14 -32
- package/tsconfig.json +1 -4
- package/tsconfig.scripts.json +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
|
-
this.group_id = options.storage.
|
|
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;
|
|
@@ -132,11 +156,16 @@ export class ChangeStream {
|
|
|
132
156
|
// so we use 90% of the socket timeout value.
|
|
133
157
|
this.changeStreamTimeout = Math.ceil(this.client.options.socketTimeoutMS * 0.9);
|
|
134
158
|
|
|
135
|
-
|
|
159
|
+
const baseLogger = options.logger ?? this.storage.logger;
|
|
160
|
+
// Unfortunately the Winston APIs don't have a nice way to append to the prefix,
|
|
161
|
+
// so we replace it here.
|
|
162
|
+
this.logger = baseLogger.child({ prefix: `[${this.storage.replicationStreamName}] [stream] ` });
|
|
163
|
+
const snapshotLogger = baseLogger.child({ prefix: `[${this.storage.replicationStreamName}] [snapshot] ` });
|
|
164
|
+
|
|
136
165
|
this.snapshotter = new MongoSnapshotter({
|
|
137
166
|
...options,
|
|
138
167
|
abortSignal: this.abortSignal,
|
|
139
|
-
logger:
|
|
168
|
+
logger: snapshotLogger,
|
|
140
169
|
checkpointStreamId: this.checkpointStreamId
|
|
141
170
|
});
|
|
142
171
|
|
|
@@ -164,6 +193,37 @@ export class ChangeStream {
|
|
|
164
193
|
return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
|
|
165
194
|
}
|
|
166
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 in alpha. 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
|
+
|
|
167
227
|
private getSourceNamespaceFilters(): { $match: any; multipleDatabases: boolean } {
|
|
168
228
|
const sourceTables = this.sync_rules.getSourceTables();
|
|
169
229
|
|
|
@@ -204,7 +264,15 @@ export class ChangeStream {
|
|
|
204
264
|
// For details, see:
|
|
205
265
|
// https://github.com/powersync-ja/powersync-service/pull/417
|
|
206
266
|
// https://jira.mongodb.org/browse/SERVER-114532
|
|
207
|
-
|
|
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
|
|
208
276
|
? // cluster-level: filter on the entire namespace
|
|
209
277
|
{ ns: { $in: $inFilters } }
|
|
210
278
|
: // collection-level: filter on coll only
|
|
@@ -366,6 +434,7 @@ export class ChangeStream {
|
|
|
366
434
|
async replicate() {
|
|
367
435
|
let streamPromise: Promise<void> | null = null;
|
|
368
436
|
let loopPromise: Promise<void> | null = null;
|
|
437
|
+
let cleanupPromise: Promise<void> | null = null;
|
|
369
438
|
try {
|
|
370
439
|
// If anything errors here, the entire replication process is halted, and
|
|
371
440
|
// all connections automatically closed, including this one.
|
|
@@ -383,6 +452,11 @@ export class ChangeStream {
|
|
|
383
452
|
if (!this.snapshotter.supportsConcurrentSnapshots) {
|
|
384
453
|
await Promise.race([this.snapshotter.waitForInitialSnapshot(), loopPromise]);
|
|
385
454
|
}
|
|
455
|
+
// Unlike the other two, this resolves on completion, not an indefinite loop.
|
|
456
|
+
cleanupPromise = this.cleanupStoppedSyncConfigs().catch((e) => {
|
|
457
|
+
this.abortController.abort(e);
|
|
458
|
+
throw e;
|
|
459
|
+
});
|
|
386
460
|
streamPromise = this.streamChanges()
|
|
387
461
|
.then(() => {
|
|
388
462
|
throw new ReplicationAssertionError(`Replication stream exited unexpectedly`);
|
|
@@ -392,7 +466,7 @@ export class ChangeStream {
|
|
|
392
466
|
throw e;
|
|
393
467
|
});
|
|
394
468
|
|
|
395
|
-
const results = await Promise.allSettled([loopPromise, streamPromise]);
|
|
469
|
+
const results = await Promise.allSettled([loopPromise, streamPromise, cleanupPromise]);
|
|
396
470
|
throw replicationLoopError(results);
|
|
397
471
|
} catch (e) {
|
|
398
472
|
await this.storage.reportError(e);
|
|
@@ -422,6 +496,15 @@ export class ChangeStream {
|
|
|
422
496
|
}
|
|
423
497
|
}
|
|
424
498
|
|
|
499
|
+
private async cleanupStoppedSyncConfigs() {
|
|
500
|
+
await this.storage.cleanupStoppedSyncConfigs?.({
|
|
501
|
+
signal: this.abortSignal,
|
|
502
|
+
logger: this.logger,
|
|
503
|
+
defaultSchema: this.defaultDb.databaseName,
|
|
504
|
+
sourceConnectionTag: this.connections.connectionTag
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
425
508
|
private async streamChanges() {
|
|
426
509
|
try {
|
|
427
510
|
await this.streamChangesInternal();
|
|
@@ -445,15 +528,18 @@ export class ChangeStream {
|
|
|
445
528
|
signal?: AbortSignal;
|
|
446
529
|
tracer?: PerformanceTracer<'changestream'>;
|
|
447
530
|
}): AsyncIterableIterator<ChangeStreamBatch> {
|
|
448
|
-
const
|
|
449
|
-
const startAfter =
|
|
450
|
-
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;
|
|
451
534
|
|
|
452
535
|
const filters = options.filters;
|
|
453
536
|
|
|
454
537
|
let fullDocument: 'required' | 'updateLookup';
|
|
455
538
|
|
|
456
|
-
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) {
|
|
457
543
|
// 'read_only' or 'auto_configure'
|
|
458
544
|
// Configuration happens during snapshot, or when we see new
|
|
459
545
|
// collections.
|
|
@@ -462,42 +548,53 @@ export class ChangeStream {
|
|
|
462
548
|
fullDocument = 'updateLookup';
|
|
463
549
|
}
|
|
464
550
|
const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
|
|
465
|
-
showExpandedEvents: true,
|
|
466
551
|
fullDocument: fullDocument
|
|
467
552
|
};
|
|
553
|
+
if (!this.isDocumentDb) {
|
|
554
|
+
// DocumentDB does not support showExpandedEvents.
|
|
555
|
+
streamOptions.showExpandedEvents = true;
|
|
556
|
+
}
|
|
468
557
|
const pipeline: mongo.Document[] = [
|
|
469
558
|
{
|
|
470
559
|
$changeStream: streamOptions
|
|
471
560
|
},
|
|
472
561
|
{
|
|
473
562
|
$match: filters.$match
|
|
474
|
-
}
|
|
475
|
-
{ $changeStreamSplitLargeEvent: {} }
|
|
563
|
+
}
|
|
476
564
|
];
|
|
565
|
+
if (!this.isDocumentDb) {
|
|
566
|
+
// DocumentDB does not support $changeStreamSplitLargeEvent.
|
|
567
|
+
pipeline.push({ $changeStreamSplitLargeEvent: {} });
|
|
568
|
+
}
|
|
477
569
|
|
|
478
570
|
/**
|
|
479
571
|
* Only one of these options can be supplied at a time.
|
|
480
572
|
*/
|
|
481
573
|
if (resumeAfter) {
|
|
482
574
|
streamOptions.resumeAfter = resumeAfter;
|
|
483
|
-
} else {
|
|
575
|
+
} else if (startAfter != null) {
|
|
484
576
|
// Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
|
|
485
577
|
// case if we have an old one.
|
|
486
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.
|
|
487
581
|
streamOptions.startAtOperationTime = startAfter;
|
|
488
582
|
}
|
|
489
583
|
|
|
490
584
|
let watchDb: mongo.Db;
|
|
491
|
-
if (filters.multipleDatabases) {
|
|
585
|
+
if (this.isDocumentDb || filters.multipleDatabases) {
|
|
586
|
+
// DocumentDB only supports cluster-level change streams.
|
|
492
587
|
watchDb = this.client.db('admin');
|
|
493
588
|
streamOptions.allChangesForCluster = true;
|
|
494
589
|
} else {
|
|
495
590
|
watchDb = this.defaultDb;
|
|
496
591
|
}
|
|
497
592
|
|
|
593
|
+
const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
|
|
594
|
+
|
|
498
595
|
return rawChangeStream(watchDb, pipeline, {
|
|
499
596
|
batchSize: options.batchSize ?? this.snapshotChunkLength,
|
|
500
|
-
maxAwaitTimeMS
|
|
597
|
+
maxAwaitTimeMS,
|
|
501
598
|
maxTimeMS: this.changeStreamTimeout,
|
|
502
599
|
|
|
503
600
|
signal: options.signal,
|
|
@@ -510,20 +607,30 @@ export class ChangeStream {
|
|
|
510
607
|
return this.sourceRowConverter.rawToSqliteRow(row);
|
|
511
608
|
}
|
|
512
609
|
|
|
610
|
+
private async createBatchCheckpoint() {
|
|
611
|
+
const checkpoint = await this.checkpointImplementation.createBatchCheckpoint();
|
|
612
|
+
this.lastBatchCheckpoint = Date.now();
|
|
613
|
+
return checkpoint;
|
|
614
|
+
}
|
|
615
|
+
|
|
513
616
|
async streamChangesInternal() {
|
|
617
|
+
await this.ensureDetected();
|
|
514
618
|
const transactionsReplicatedMetric = this.metrics.getCounter(ReplicationMetric.TRANSACTIONS_REPLICATED);
|
|
515
619
|
const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
|
|
516
620
|
const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
|
|
517
621
|
|
|
518
|
-
const tracer = new PerformanceTracer
|
|
622
|
+
const tracer = new PerformanceTracer<
|
|
623
|
+
'storage' | 'evaluate' | 'batch' | 'source_checkpoint' | 'changestream' | 'processing'
|
|
624
|
+
>('MongoDB streaming replication');
|
|
519
625
|
await this.storage.startBatch(
|
|
520
626
|
{
|
|
521
627
|
logger: this.logger,
|
|
522
|
-
zeroLSN:
|
|
628
|
+
zeroLSN: this.checkpointImplementation.zeroLsn,
|
|
523
629
|
defaultSchema: this.defaultDb.databaseName,
|
|
524
630
|
// We get a complete postimage for every change, so we don't need to store the current data.
|
|
525
631
|
storeCurrentData: false,
|
|
526
632
|
hooks: this.storageHooks,
|
|
633
|
+
signal: this.abortSignal,
|
|
527
634
|
tracer
|
|
528
635
|
},
|
|
529
636
|
async (batch) => {
|
|
@@ -531,15 +638,14 @@ export class ChangeStream {
|
|
|
531
638
|
if (resumeFromLsn == null) {
|
|
532
639
|
throw new ReplicationAssertionError(`No LSN found to resume from`);
|
|
533
640
|
}
|
|
534
|
-
|
|
535
|
-
|
|
641
|
+
// Seed the implementation's coordinate state from the stored LSN, and parse
|
|
642
|
+
// the legacy startAfter timestamp (timestamp implementation only) for the
|
|
643
|
+
// resume-boundary dedupe guard below.
|
|
644
|
+
this.checkpointImplementation.seedPosition(resumeFromLsn);
|
|
645
|
+
const { startAfter } = this.checkpointImplementation.parseResumePosition(resumeFromLsn);
|
|
536
646
|
let outerSpan = tracer.span('batch');
|
|
537
647
|
|
|
538
|
-
|
|
539
|
-
// of ChangeStream events.
|
|
540
|
-
const tokenAgeSeconds = Math.round((Date.now() - timestampToDate(startAfter).getTime()) / 1000);
|
|
541
|
-
|
|
542
|
-
this.logger.info(`Resume streaming at ${startAfter?.inspect()} / ${lastLsn} | Token age: ${tokenAgeSeconds}s`);
|
|
648
|
+
this.checkpointImplementation.logResume(resumeFromLsn);
|
|
543
649
|
|
|
544
650
|
const filters = this.getSourceNamespaceFilters();
|
|
545
651
|
// This is closed when the for loop below returns/breaks/throws
|
|
@@ -553,11 +659,7 @@ export class ChangeStream {
|
|
|
553
659
|
// Always start with a checkpoint.
|
|
554
660
|
// This helps us to clear errors when restarting, even if there is
|
|
555
661
|
// no data to replicate.
|
|
556
|
-
let waitForCheckpointLsn: string | null = await
|
|
557
|
-
this.client,
|
|
558
|
-
this.defaultDb,
|
|
559
|
-
this.checkpointStreamId
|
|
560
|
-
);
|
|
662
|
+
let waitForCheckpointLsn: string | null = await this.createBatchCheckpoint();
|
|
561
663
|
|
|
562
664
|
let splitDocument: ProjectedChangeStreamDocument | null = null;
|
|
563
665
|
|
|
@@ -577,27 +679,31 @@ export class ChangeStream {
|
|
|
577
679
|
}
|
|
578
680
|
this.touch();
|
|
579
681
|
if (events.length == 0) {
|
|
580
|
-
// No changes in this batch, but we still want to
|
|
682
|
+
// No changes in this batch, but we still want to persist progress.
|
|
581
683
|
// We do this by persisting a keepalive checkpoint.
|
|
582
684
|
// If we don't update it on empty events, we do keep consistency, but resuming the stream
|
|
583
685
|
// with old tokens may cause connection timeouts.
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
686
|
+
const hadRecentKeepalive = performance.now() - lastEmptyResume < this.keepaliveIntervalMs;
|
|
687
|
+
if (waitForCheckpointLsn == null && !hadRecentKeepalive) {
|
|
688
|
+
// Case 1: We have no changes, and we are not waiting for a checkpoint to be created,
|
|
689
|
+
// and we have not recently persisted a keepalive. Persist one now, and call setResumeLsn() below.
|
|
690
|
+
// This is the normal case for an idle stream.
|
|
691
|
+
// The implementation persists a keepalive (timestamp) or bumps the
|
|
692
|
+
// sentinel so a later event commits (sentinel). Logging is handled
|
|
693
|
+
// inside the implementation.
|
|
694
|
+
await this.checkpointImplementation.keepalive(batch, resumeToken);
|
|
587
695
|
this.touch();
|
|
588
696
|
lastEmptyResume = performance.now();
|
|
589
|
-
// Log the token update. This helps as a general "replication is still active" message in the logs.
|
|
590
|
-
// This token would typically be around 10s behind.
|
|
591
|
-
this.logger.info(
|
|
592
|
-
`Idle change stream. Persisted resumeToken for ${timestampToDate(timestamp).toISOString()}`
|
|
593
|
-
);
|
|
594
697
|
this.replicationLag.markStarted();
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
if (performance.now() - lastEmptyResume < 60_000) {
|
|
698
|
+
} else if (hadRecentKeepalive) {
|
|
699
|
+
// Case 2: We have no changes, and may or may not be waiting for a checkpoint to be created.
|
|
700
|
+
// We have recently persisted a keepalive.
|
|
701
|
+
// Continue waiting.
|
|
600
702
|
continue;
|
|
703
|
+
} else {
|
|
704
|
+
// Case 3: Waiting for a checkpoint; have not had a recent keepalive.
|
|
705
|
+
// We cannot call checkpointImplementation.keepalive() here, but we do call
|
|
706
|
+
// setResumeLsn() below.
|
|
601
707
|
}
|
|
602
708
|
}
|
|
603
709
|
|
|
@@ -704,47 +810,45 @@ export class ChangeStream {
|
|
|
704
810
|
// It may be useful to also throttle commits due to standalone checkpoints in the future.
|
|
705
811
|
// However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
|
|
706
812
|
|
|
707
|
-
const
|
|
813
|
+
const kind = this.checkpointImplementation.event.observe(changeDocument);
|
|
708
814
|
|
|
709
|
-
if (
|
|
815
|
+
if (kind == 'foreign') {
|
|
816
|
+
// Another stream's barrier - ignore.
|
|
817
|
+
continue;
|
|
818
|
+
} else if (kind == 'standalone') {
|
|
710
819
|
// Standalone / write checkpoint received.
|
|
711
820
|
// When we are caught up, commit immediately to keep write checkpoint latency low.
|
|
712
821
|
// Once there is already a batch checkpoint pending, or the driver has buffered more
|
|
713
822
|
// change stream events, collapse standalone checkpoints into the normal batch
|
|
714
823
|
// checkpoint flow to avoid commit churn under sustained load.
|
|
715
824
|
const hasBufferedChanges = eventIndex < events.length - 1;
|
|
716
|
-
if (waitForCheckpointLsn
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
825
|
+
if (hasBufferedChanges && waitForCheckpointLsn == null) {
|
|
826
|
+
// Buffered changes - create a new batch checkpoint to rate limit commits
|
|
827
|
+
using _ = tracer.span('source_checkpoint');
|
|
828
|
+
waitForCheckpointLsn = await this.createBatchCheckpoint();
|
|
720
829
|
continue;
|
|
830
|
+
} else if (waitForCheckpointLsn != null) {
|
|
831
|
+
// Skip this checkpoint - wait for the batch checkpoint.
|
|
832
|
+
continue;
|
|
833
|
+
} else {
|
|
834
|
+
// No buffered changes, and no batch checkpoint pending - commit immediately.
|
|
721
835
|
}
|
|
722
|
-
} else if (!this.checkpointStreamId.equals(checkpointId)) {
|
|
723
|
-
continue;
|
|
724
|
-
}
|
|
725
|
-
const { comparable: lsn } = new MongoLSN({
|
|
726
|
-
timestamp: changeDocument.clusterTime!,
|
|
727
|
-
resume_token: changeDocument._id
|
|
728
|
-
});
|
|
729
|
-
if (batch.lastCheckpointLsn != null && lsn < batch.lastCheckpointLsn) {
|
|
730
|
-
// Checkpoint out of order - should never happen with MongoDB.
|
|
731
|
-
// If it does happen, we throw an error to stop the replication - restarting should recover.
|
|
732
|
-
// Since we use batch.lastCheckpointLsn for the next resumeAfter, this should not result in an infinite loop.
|
|
733
|
-
// Originally a workaround for https://jira.mongodb.org/browse/NODE-7042.
|
|
734
|
-
// This has been fixed in the driver in the meantime, but we still keep this as a safety-check.
|
|
735
|
-
throw new ReplicationAssertionError(
|
|
736
|
-
`Change resumeToken ${(changeDocument._id as any)._data} (${timestampToDate(changeDocument.clusterTime!).toISOString()}) is less than last checkpoint LSN ${batch.lastCheckpointLsn}. Restarting replication.`
|
|
737
|
-
);
|
|
738
836
|
}
|
|
837
|
+
// kind == 'own-barrier' falls through to commit.
|
|
838
|
+
|
|
839
|
+
const lsn = this.checkpointImplementation.event.lsn(changeDocument);
|
|
739
840
|
|
|
740
|
-
if (
|
|
841
|
+
if (
|
|
842
|
+
waitForCheckpointLsn != null &&
|
|
843
|
+
this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)
|
|
844
|
+
) {
|
|
741
845
|
waitForCheckpointLsn = null;
|
|
742
846
|
}
|
|
743
|
-
const { checkpointBlocked } = await batch.commit(lsn, {
|
|
847
|
+
const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
|
|
744
848
|
oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
|
|
745
849
|
});
|
|
746
850
|
|
|
747
|
-
if (!checkpointBlocked) {
|
|
851
|
+
if (!checkpointBlocked || checkpointCreated) {
|
|
748
852
|
this.replicationLag.markCommitted();
|
|
749
853
|
}
|
|
750
854
|
} else if (
|
|
@@ -754,7 +858,8 @@ export class ChangeStream {
|
|
|
754
858
|
changeDocument.operationType == 'delete'
|
|
755
859
|
) {
|
|
756
860
|
if (waitForCheckpointLsn == null) {
|
|
757
|
-
|
|
861
|
+
using _ = tracer.span('source_checkpoint');
|
|
862
|
+
waitForCheckpointLsn = await this.createBatchCheckpoint();
|
|
758
863
|
}
|
|
759
864
|
|
|
760
865
|
const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
@@ -768,7 +873,11 @@ export class ChangeStream {
|
|
|
768
873
|
const tablesToReplicate = tables.filter((table) => table.syncAny);
|
|
769
874
|
if (tablesToReplicate.length > 0) {
|
|
770
875
|
this.replicationLag.trackUncommittedChange(
|
|
771
|
-
|
|
876
|
+
// Standard MongoDB uses clusterTime, unchanged. DocumentDB has no
|
|
877
|
+
// clusterTime, so fall back to wallTime there for the lag metric.
|
|
878
|
+
changeDocument.clusterTime != null
|
|
879
|
+
? timestampToDate(changeDocument.clusterTime)
|
|
880
|
+
: ((changeDocument as any).wallTime ?? null)
|
|
772
881
|
);
|
|
773
882
|
|
|
774
883
|
const transactionKeyValue = transactionKey(changeDocument);
|
|
@@ -823,15 +932,23 @@ export class ChangeStream {
|
|
|
823
932
|
// Batches are generally large (64MB or 6000 events, whichever comes first),
|
|
824
933
|
// so this is a good natural point to flush and mark progress.
|
|
825
934
|
// We avoid this when splitDocument is set, since we cannot resume in the middle of a split event.
|
|
826
|
-
const {
|
|
935
|
+
const { lsn, timestamp } = this.checkpointImplementation.lsnFromResumeToken(resumeToken);
|
|
827
936
|
await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
|
|
828
937
|
// TODO: We should consider making this standard behavior of flush().
|
|
829
938
|
await batch.setResumeLsn(lsn);
|
|
939
|
+
|
|
940
|
+
if (timestamp != null) {
|
|
941
|
+
// Note that this timestamp provided by MongoDB is not exact - it can be around 10s behind.
|
|
942
|
+
this.lastPersistedResumeTimestamp = timestamp.getTime();
|
|
943
|
+
} else {
|
|
944
|
+
// DocumentDB: No timestamp associated with the resumeToken. Just use the current time.
|
|
945
|
+
this.lastPersistedResumeTimestamp = Date.now();
|
|
946
|
+
}
|
|
830
947
|
}
|
|
831
948
|
|
|
832
949
|
batchSpan.end();
|
|
833
|
-
const
|
|
834
|
-
const duration = batchSpan.
|
|
950
|
+
const durationsMicroseconds = outerSpan.end();
|
|
951
|
+
const duration = batchSpan.durationMillis;
|
|
835
952
|
|
|
836
953
|
this.logger.info(
|
|
837
954
|
`Processed batch of ${events.length} changes / ${eventBatch.byteSize} bytes in ${duration}ms`,
|
|
@@ -839,7 +956,7 @@ export class ChangeStream {
|
|
|
839
956
|
count: events.length,
|
|
840
957
|
bytes: eventBatch.byteSize,
|
|
841
958
|
duration,
|
|
842
|
-
t:
|
|
959
|
+
t: durationsMicroseconds
|
|
843
960
|
}
|
|
844
961
|
);
|
|
845
962
|
outerSpan = tracer.span('batch');
|
|
@@ -854,6 +971,34 @@ export class ChangeStream {
|
|
|
854
971
|
return this.replicationLag.getLagMillis();
|
|
855
972
|
}
|
|
856
973
|
|
|
974
|
+
async keepAlive() {
|
|
975
|
+
// This is called on an interval of keepaliveIntervalMs.
|
|
976
|
+
|
|
977
|
+
// This writes to _powersync_checkpoints.
|
|
978
|
+
// Main use case: When there are massive bulk writes to another database or collection, the change stream may
|
|
979
|
+
// start timing out due to reading through too much data in one batch. This breaks up those bulk writes,
|
|
980
|
+
// allowing the change stream to make progress.
|
|
981
|
+
|
|
982
|
+
// When there is low replication traffic, regular calls to setResumeLsn() with recent resume tokens make this unnecessary.
|
|
983
|
+
// We track that using lastPersistedResumeTimestamp.
|
|
984
|
+
// We add a bit of a margin here, since setResumeLsn is not called on an exact interval.
|
|
985
|
+
const staleResumeLsn = Date.now() - this.lastPersistedResumeTimestamp > this.keepaliveIntervalMs * 1.1;
|
|
986
|
+
|
|
987
|
+
// When there is replication lag, that resumeToken can get too outdated. In that case, we periodically create a
|
|
988
|
+
// new back checkpoint. This interval is controlled by the frequency of the keepAlive() call,
|
|
989
|
+
// while also skipping if there was another call to createBatchCheckpoint().
|
|
990
|
+
// We use a factor of 0.9 here, to make sure this is called on every keepAlive() interval, unless there
|
|
991
|
+
// was another call to createBatchCheckpoint().
|
|
992
|
+
const staleBatchCheckpoint = Date.now() - this.lastBatchCheckpoint > this.keepaliveIntervalMs * 0.9;
|
|
993
|
+
|
|
994
|
+
// We don't use oldestUncommittedChange here, since that may be unset in some edge cases where we do need
|
|
995
|
+
// to persist new checkpoints.
|
|
996
|
+
if (staleResumeLsn && staleBatchCheckpoint) {
|
|
997
|
+
await this.ensureDetected();
|
|
998
|
+
await this.createBatchCheckpoint();
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
857
1002
|
private lastTouchedAt = performance.now();
|
|
858
1003
|
|
|
859
1004
|
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() {
|
|
@@ -47,7 +47,7 @@ export class ChangeStreamReplicationJob extends replication.AbstractReplicationJ
|
|
|
47
47
|
|
|
48
48
|
if (e instanceof ChangeStreamInvalidatedError) {
|
|
49
49
|
// This stops replication and restarts with a new instance
|
|
50
|
-
await this.options.storage.factory.restartReplication(this.storage.
|
|
50
|
+
await this.options.storage.factory.restartReplication(this.storage.replicationStreamId);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
// No need to rethrow - the error is already logged, and retry behavior is the same on error
|
|
@@ -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;
|
|
@@ -17,7 +17,7 @@ export class ChangeStreamReplicator extends replication.AbstractReplicator<Chang
|
|
|
17
17
|
|
|
18
18
|
createJob(options: replication.CreateJobOptions): ChangeStreamReplicationJob {
|
|
19
19
|
return new ChangeStreamReplicationJob({
|
|
20
|
-
id: this.createJobId(options.storage.
|
|
20
|
+
id: this.createJobId(options.storage.replicationStreamId),
|
|
21
21
|
storage: options.storage,
|
|
22
22
|
metrics: this.metrics,
|
|
23
23
|
connectionFactory: this.connectionFactory,
|