@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
|
@@ -14,11 +14,13 @@ import { performance } from 'node:perf_hooks';
|
|
|
14
14
|
import { MongoLSN } from '../common/MongoLSN.js';
|
|
15
15
|
import { PostImagesOption } from '../types/types.js';
|
|
16
16
|
import { escapeRegExp } from '../utils.js';
|
|
17
|
+
import { CheckpointImplementation } from './checkpoints/CheckpointImplementation.js';
|
|
18
|
+
import { createCheckpointImplementation } from './checkpoints/create-checkpoint-implementation.js';
|
|
17
19
|
import { MongoManager } from './MongoManager.js';
|
|
18
|
-
import {
|
|
20
|
+
import { getMongoRelation } from './MongoRelation.js';
|
|
19
21
|
import { ChunkedSnapshotQuery } from './MongoSnapshotQuery.js';
|
|
20
22
|
import { ChangeStreamBatch, parseChangeDocument, rawChangeStream } from './RawChangeStream.js';
|
|
21
|
-
import { CHECKPOINTS_COLLECTION } from './replication-utils.js';
|
|
23
|
+
import { CHECKPOINTS_COLLECTION, detectDocumentDb } from './replication-utils.js';
|
|
22
24
|
import { DirectSourceRowConverter, SourceRowConverter } from './SourceRowConverter.js';
|
|
23
25
|
|
|
24
26
|
export interface MongoSnapshotterOptions {
|
|
@@ -72,6 +74,9 @@ export class MongoSnapshotter {
|
|
|
72
74
|
private nextItemQueued: PromiseWithResolvers<void> | null = null;
|
|
73
75
|
private lastTouchedAt = performance.now();
|
|
74
76
|
|
|
77
|
+
private isDocumentDb = false;
|
|
78
|
+
private _checkpointImplementation: CheckpointImplementation | null = null;
|
|
79
|
+
|
|
75
80
|
constructor(options: MongoSnapshotterOptions) {
|
|
76
81
|
this.storage = options.storage;
|
|
77
82
|
this.metrics = options.metrics;
|
|
@@ -94,6 +99,9 @@ export class MongoSnapshotter {
|
|
|
94
99
|
this.abortSignal.addEventListener('abort', () => {
|
|
95
100
|
this.nextItemQueued?.resolve();
|
|
96
101
|
});
|
|
102
|
+
|
|
103
|
+
// Errors here should not result in uncaught rejection - calling waitForInitialSnapshot is optional.
|
|
104
|
+
void this.initialSnapshotDone.promise.catch(() => {});
|
|
97
105
|
}
|
|
98
106
|
|
|
99
107
|
private get usePostImages() {
|
|
@@ -108,6 +116,48 @@ export class MongoSnapshotter {
|
|
|
108
116
|
return this.storage.storageConfig.softDeleteCurrentData;
|
|
109
117
|
}
|
|
110
118
|
|
|
119
|
+
/** The active checkpoint strategy. Only valid after ensureDetected(). */
|
|
120
|
+
private get checkpointImplementation(): CheckpointImplementation {
|
|
121
|
+
if (this._checkpointImplementation == null) {
|
|
122
|
+
throw new ServiceError(
|
|
123
|
+
ErrorCode.PSYNC_S1301,
|
|
124
|
+
'Checkpoint implementation not initialized - call ensureDetected() first'
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return this._checkpointImplementation;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Detect DocumentDB and select the checkpoint implementation. Idempotent.
|
|
132
|
+
* Also validates server topology (sharded/standalone) for standard MongoDB.
|
|
133
|
+
*/
|
|
134
|
+
private async ensureDetected(): Promise<void> {
|
|
135
|
+
if (this._checkpointImplementation != null) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
this.isDocumentDb = await detectDocumentDb(this.defaultDb);
|
|
139
|
+
if (!this.isDocumentDb) {
|
|
140
|
+
const hello = await this.defaultDb.command({ hello: 1 });
|
|
141
|
+
if (hello.msg == 'isdbgrid') {
|
|
142
|
+
throw new ServiceError(
|
|
143
|
+
ErrorCode.PSYNC_S1341,
|
|
144
|
+
'Sharded MongoDB Clusters are not supported yet (including MongoDB Serverless instances).'
|
|
145
|
+
);
|
|
146
|
+
} else if (hello.setName == null) {
|
|
147
|
+
throw new ServiceError(
|
|
148
|
+
ErrorCode.PSYNC_S1342,
|
|
149
|
+
'Standalone MongoDB instances are not supported - use a replicaset.'
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
|
|
154
|
+
client: this.client,
|
|
155
|
+
db: this.defaultDb,
|
|
156
|
+
checkpointStreamId: this.checkpointStreamId,
|
|
157
|
+
logger: this.logger
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
111
161
|
async checkSlot(): Promise<InitResult> {
|
|
112
162
|
const status = await this.storage.getStatus();
|
|
113
163
|
if (status.snapshotDone) {
|
|
@@ -119,12 +169,18 @@ export class MongoSnapshotter {
|
|
|
119
169
|
}
|
|
120
170
|
|
|
121
171
|
async setupCheckpointsCollection() {
|
|
172
|
+
await this.ensureDetected();
|
|
122
173
|
const collection = await this.getCollectionInfo(this.defaultDb.databaseName, CHECKPOINTS_COLLECTION);
|
|
123
174
|
if (collection == null) {
|
|
124
175
|
await this.defaultDb.createCollection(CHECKPOINTS_COLLECTION, {
|
|
125
|
-
|
|
176
|
+
// DocumentDB does not support changeStreamPreAndPostImages.
|
|
177
|
+
...(this.isDocumentDb ? {} : { changeStreamPreAndPostImages: { enabled: true } })
|
|
126
178
|
});
|
|
127
|
-
} else if (
|
|
179
|
+
} else if (
|
|
180
|
+
!this.isDocumentDb &&
|
|
181
|
+
this.usePostImages &&
|
|
182
|
+
collection.options?.changeStreamPreAndPostImages?.enabled != true
|
|
183
|
+
) {
|
|
128
184
|
// Drop + create requires less permissions than collMod,
|
|
129
185
|
// and we don't care about the data in this collection.
|
|
130
186
|
await this.defaultDb.dropCollection(CHECKPOINTS_COLLECTION);
|
|
@@ -135,12 +191,22 @@ export class MongoSnapshotter {
|
|
|
135
191
|
// Clear the collection on startup, to keep it clean
|
|
136
192
|
// We never query this collection directly, and don't want to keep the data around.
|
|
137
193
|
// We only use this to get data into the oplog/changestream.
|
|
138
|
-
|
|
194
|
+
//
|
|
195
|
+
// The implementation supplies the filter: the sentinel implementation must
|
|
196
|
+
// preserve the sentinel checkpoint document, since its counter is the
|
|
197
|
+
// globally-ordered component of every committed DocumentDB LSN.
|
|
198
|
+
await this.defaultDb
|
|
199
|
+
.collection(CHECKPOINTS_COLLECTION)
|
|
200
|
+
.deleteMany(this.checkpointImplementation.checkpointClearFilter);
|
|
139
201
|
}
|
|
140
202
|
}
|
|
141
203
|
|
|
142
204
|
async queueSnapshotTables(snapshotLsn: string | null) {
|
|
143
205
|
await this.client.connect();
|
|
206
|
+
// Ensure isDocumentDb is set before any getSourceNamespaceFilters() call below
|
|
207
|
+
// (notably the validateSnapshotLsn resume path, which does not go through
|
|
208
|
+
// getSnapshotLsn). Idempotent.
|
|
209
|
+
await this.ensureDetected();
|
|
144
210
|
await using writer = await this.storage.createWriter({
|
|
145
211
|
zeroLSN: MongoLSN.ZERO.comparable,
|
|
146
212
|
defaultSchema: this.defaultDb.databaseName,
|
|
@@ -256,7 +322,7 @@ export class MongoSnapshotter {
|
|
|
256
322
|
for (const table of tables) {
|
|
257
323
|
await this.snapshotTable(batch, table);
|
|
258
324
|
}
|
|
259
|
-
const noCheckpointBefore = await
|
|
325
|
+
const noCheckpointBefore = await this.checkpointImplementation.createStandaloneCheckpoint();
|
|
260
326
|
|
|
261
327
|
await batch.markTableSnapshotDone(tables, noCheckpointBefore);
|
|
262
328
|
}
|
|
@@ -300,7 +366,7 @@ export class MongoSnapshotter {
|
|
|
300
366
|
|
|
301
367
|
// The checkpoint here is a marker - we need to replicate up to at least this
|
|
302
368
|
// point before the data can be considered consistent.
|
|
303
|
-
const checkpoint = await
|
|
369
|
+
const checkpoint = await this.checkpointImplementation.createStandaloneCheckpoint();
|
|
304
370
|
if (this.queue.size != 0) {
|
|
305
371
|
return;
|
|
306
372
|
}
|
|
@@ -312,7 +378,7 @@ export class MongoSnapshotter {
|
|
|
312
378
|
// KLUDGE: We need to create an extra checkpoint _after_ marking the snapshot done, to fix
|
|
313
379
|
// issues with order of processing commits(). This is picked up by tests on postgres storage,
|
|
314
380
|
// the issue may be specific to that storage engine.
|
|
315
|
-
await
|
|
381
|
+
await this.checkpointImplementation.createStandaloneCheckpoint();
|
|
316
382
|
}
|
|
317
383
|
|
|
318
384
|
private async replicateTable(tableRequest: SourceTable) {
|
|
@@ -336,7 +402,7 @@ export class MongoSnapshotter {
|
|
|
336
402
|
}
|
|
337
403
|
|
|
338
404
|
await this.snapshotTable(writer, table);
|
|
339
|
-
const noCheckpointBefore = await
|
|
405
|
+
const noCheckpointBefore = await this.checkpointImplementation.createStandaloneCheckpoint();
|
|
340
406
|
await writer.markTableSnapshotDone([table], noCheckpointBefore);
|
|
341
407
|
|
|
342
408
|
// This commit durably records the persisted ops, so a later checkpoint covers them.
|
|
@@ -355,6 +421,13 @@ export class MongoSnapshotter {
|
|
|
355
421
|
return [];
|
|
356
422
|
}
|
|
357
423
|
|
|
424
|
+
if (tablePattern.isSchemaWildcard) {
|
|
425
|
+
throw new ServiceError(
|
|
426
|
+
ErrorCode.PSYNC_R2201,
|
|
427
|
+
'Schema wildcards ("%") in table patterns are not supported for MongoDB connections.'
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
358
431
|
const nameFilter = tablePattern.isWildcard
|
|
359
432
|
? new RegExp('^' + escapeRegExp(tablePattern.tablePrefix))
|
|
360
433
|
: tablePattern.name;
|
|
@@ -516,19 +589,7 @@ export class MongoSnapshotter {
|
|
|
516
589
|
}
|
|
517
590
|
|
|
518
591
|
private async getSnapshotLsn(): Promise<string> {
|
|
519
|
-
|
|
520
|
-
// Basic sanity check
|
|
521
|
-
if (hello.msg == 'isdbgrid') {
|
|
522
|
-
throw new ServiceError(
|
|
523
|
-
ErrorCode.PSYNC_S1341,
|
|
524
|
-
'Sharded MongoDB Clusters are not supported yet (including MongoDB Serverless instances).'
|
|
525
|
-
);
|
|
526
|
-
} else if (hello.setName == null) {
|
|
527
|
-
throw new ServiceError(
|
|
528
|
-
ErrorCode.PSYNC_S1342,
|
|
529
|
-
'Standalone MongoDB instances are not supported - use a replicaset.'
|
|
530
|
-
);
|
|
531
|
-
}
|
|
592
|
+
await this.ensureDetected();
|
|
532
593
|
|
|
533
594
|
// Open a change stream just to get a resume token for later use.
|
|
534
595
|
// We could use clusterTime from the hello command, but that won't tell us if the
|
|
@@ -541,14 +602,19 @@ export class MongoSnapshotter {
|
|
|
541
602
|
// Instead, we create a new checkpoint document, and wait until we get that document back in the stream.
|
|
542
603
|
// To avoid potential race conditions with the checkpoint creation, we create a new checkpoint document
|
|
543
604
|
// periodically until the timeout is reached.
|
|
605
|
+
//
|
|
606
|
+
// For the sentinel implementation (DocumentDB) there is no
|
|
607
|
+
// startAtOperationTime: the stream opens from "now" (lsn null) and the
|
|
608
|
+
// retry loop below re-creates checkpoints until one is observed.
|
|
544
609
|
|
|
545
610
|
const LSN_TIMEOUT_SECONDS = 60;
|
|
546
611
|
const LSN_CREATE_INTERVAL_SECONDS = 1;
|
|
547
612
|
|
|
548
|
-
|
|
613
|
+
this.checkpointImplementation.seedPosition(null);
|
|
614
|
+
const startStreamFromLsn = await this.checkpointImplementation.createFirstBarrier();
|
|
549
615
|
const filters = this.getSourceNamespaceFilters();
|
|
550
616
|
const iter = this.rawChangeStreamBatches({
|
|
551
|
-
lsn:
|
|
617
|
+
lsn: startStreamFromLsn,
|
|
552
618
|
maxAwaitTimeMS: 0,
|
|
553
619
|
signal: this.abortSignal,
|
|
554
620
|
filters
|
|
@@ -563,7 +629,7 @@ export class MongoSnapshotter {
|
|
|
563
629
|
break;
|
|
564
630
|
}
|
|
565
631
|
if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
|
|
566
|
-
await
|
|
632
|
+
await this.checkpointImplementation.createBatchCheckpoint();
|
|
567
633
|
lastCheckpointCreated = performance.now();
|
|
568
634
|
}
|
|
569
635
|
batchesSeen += 1;
|
|
@@ -573,14 +639,15 @@ export class MongoSnapshotter {
|
|
|
573
639
|
const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
|
|
574
640
|
|
|
575
641
|
if (ns?.coll == CHECKPOINTS_COLLECTION && 'documentKey' in changeDocument) {
|
|
576
|
-
const
|
|
577
|
-
if (
|
|
642
|
+
const kind = this.checkpointImplementation.event.observe(changeDocument);
|
|
643
|
+
if (kind != 'own-barrier') {
|
|
644
|
+
// Standalone events still feed the implementation's coordinate via
|
|
645
|
+
// event.observe above; we only resolve on our own barrier.
|
|
578
646
|
continue;
|
|
579
647
|
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
}).comparable;
|
|
648
|
+
// Observing our own barrier has set the coordinate (the barrier event
|
|
649
|
+
// carries the sentinel counter directly), so the LSN is ready.
|
|
650
|
+
return this.checkpointImplementation.event.lsn(changeDocument);
|
|
584
651
|
}
|
|
585
652
|
|
|
586
653
|
eventsSeen += 1;
|
|
@@ -638,7 +705,12 @@ export class MongoSnapshotter {
|
|
|
638
705
|
}
|
|
639
706
|
}
|
|
640
707
|
|
|
641
|
-
|
|
708
|
+
// DocumentDB always opens a cluster-level change stream, even in single-database
|
|
709
|
+
// mode, so a coll-only filter would match same-named collections in other
|
|
710
|
+
// databases of the cluster. Filter on the full namespace whenever the stream
|
|
711
|
+
// is cluster-scoped. See ChangeStream.getSourceNamespaceFilters for details.
|
|
712
|
+
const useFullNamespaceFilter = this.isDocumentDb || multipleDatabases;
|
|
713
|
+
const nsFilter = useFullNamespaceFilter
|
|
642
714
|
? { ns: { $in: inFilters } }
|
|
643
715
|
: { 'ns.coll': { $in: inFilters.map((ns) => ns.coll) } };
|
|
644
716
|
if (regexFilters.length > 0) {
|
|
@@ -655,12 +727,15 @@ export class MongoSnapshotter {
|
|
|
655
727
|
signal?: AbortSignal;
|
|
656
728
|
tracer?: PerformanceTracer<'changestream'>;
|
|
657
729
|
}): AsyncIterableIterator<ChangeStreamBatch> {
|
|
658
|
-
const
|
|
659
|
-
const startAfter =
|
|
660
|
-
const resumeAfter =
|
|
730
|
+
const position = options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null;
|
|
731
|
+
const startAfter = position?.startAfter ?? undefined;
|
|
732
|
+
const resumeAfter = position?.resumeAfter ?? undefined;
|
|
661
733
|
|
|
662
734
|
let fullDocument: 'required' | 'updateLookup';
|
|
663
|
-
if (this.
|
|
735
|
+
if (this.isDocumentDb) {
|
|
736
|
+
// DocumentDB does not support changeStreamPreAndPostImages, so 'required' won't work.
|
|
737
|
+
fullDocument = 'updateLookup';
|
|
738
|
+
} else if (this.usePostImages) {
|
|
664
739
|
// 'read_only' or 'auto_configure'
|
|
665
740
|
// Configuration happens during snapshot, or when we see new
|
|
666
741
|
// collections.
|
|
@@ -669,27 +744,32 @@ export class MongoSnapshotter {
|
|
|
669
744
|
fullDocument = 'updateLookup';
|
|
670
745
|
}
|
|
671
746
|
const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
|
|
672
|
-
showExpandedEvents: true,
|
|
673
747
|
fullDocument
|
|
674
748
|
};
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
];
|
|
749
|
+
if (!this.isDocumentDb) {
|
|
750
|
+
// DocumentDB does not support showExpandedEvents.
|
|
751
|
+
streamOptions.showExpandedEvents = true;
|
|
752
|
+
}
|
|
753
|
+
const pipeline: mongo.Document[] = [{ $changeStream: streamOptions }, { $match: options.filters.$match }];
|
|
754
|
+
if (!this.isDocumentDb) {
|
|
755
|
+
// DocumentDB does not support $changeStreamSplitLargeEvent.
|
|
756
|
+
pipeline.push({ $changeStreamSplitLargeEvent: {} });
|
|
757
|
+
}
|
|
680
758
|
|
|
681
759
|
// Only one of these options can be supplied at a time.
|
|
682
760
|
if (resumeAfter) {
|
|
683
761
|
streamOptions.resumeAfter = resumeAfter;
|
|
684
|
-
} else {
|
|
762
|
+
} else if (startAfter != null) {
|
|
685
763
|
// Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
|
|
686
|
-
// case if we have an old one.
|
|
764
|
+
// case if we have an old one. The sentinel implementation never produces a startAfter,
|
|
765
|
+
// and a fresh DocumentDB stream opens from "now" with neither option set.
|
|
687
766
|
streamOptions.startAtOperationTime = startAfter;
|
|
688
767
|
}
|
|
689
768
|
|
|
690
769
|
let watchDb: mongo.Db;
|
|
691
|
-
if (options.filters.multipleDatabases) {
|
|
692
|
-
//
|
|
770
|
+
if (this.isDocumentDb || options.filters.multipleDatabases) {
|
|
771
|
+
// DocumentDB only supports cluster-level change streams.
|
|
772
|
+
// Requires readAnyDatabase@admin on Atlas.
|
|
693
773
|
watchDb = this.client.db('admin');
|
|
694
774
|
streamOptions.allChangesForCluster = true;
|
|
695
775
|
} else {
|
|
@@ -697,9 +777,14 @@ export class MongoSnapshotter {
|
|
|
697
777
|
watchDb = this.defaultDb;
|
|
698
778
|
}
|
|
699
779
|
|
|
780
|
+
const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
|
|
781
|
+
|
|
700
782
|
return rawChangeStream(watchDb, pipeline, {
|
|
701
783
|
batchSize: options.batchSize ?? this.snapshotChunkLength,
|
|
702
|
-
maxAwaitTimeMS
|
|
784
|
+
maxAwaitTimeMS,
|
|
785
|
+
// maxAwaitTimeMS can be 0 for probe-style streams that do not want an idle wait.
|
|
786
|
+
// In that case there is no client-side wait to emulate for DocumentDB.
|
|
787
|
+
clientSideMaxAwaitTimeMS: this.isDocumentDb && maxAwaitTimeMS > 0,
|
|
703
788
|
maxTimeMS: this.changeStreamTimeout,
|
|
704
789
|
signal: options.signal,
|
|
705
790
|
logger: this.logger,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isMaxTimeMSExpiredError, isMongoServerError, isTimeoutError, mongo } from '@powersync/lib-service-mongodb';
|
|
2
2
|
import {
|
|
3
3
|
DatabaseConnectionError,
|
|
4
4
|
ErrorCode,
|
|
@@ -6,18 +6,36 @@ import {
|
|
|
6
6
|
ReplicationAssertionError
|
|
7
7
|
} from '@powersync/lib-services-framework';
|
|
8
8
|
import { PerformanceTracer } from '@powersync/service-core';
|
|
9
|
+
import { performance } from 'node:perf_hooks';
|
|
10
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
9
11
|
import { ChangeStreamInvalidatedError } from './ChangeStream.js';
|
|
10
12
|
|
|
13
|
+
// Keep the DocumentDB idle-poll workaround from adding the full maxAwaitTimeMS
|
|
14
|
+
// as local latency when an update arrives just after an empty batch.
|
|
15
|
+
const CLIENT_SIDE_MAX_AWAIT_TIME_MS_DELAY_CAP_MS = 1_000;
|
|
16
|
+
|
|
11
17
|
export interface RawChangeStreamOptions {
|
|
12
18
|
signal?: AbortSignal;
|
|
13
19
|
|
|
14
20
|
/**
|
|
15
21
|
* How long to wait for new data per batch (max time for long-polling).
|
|
22
|
+
* This is sent as maxTimeMS for the getMore command.
|
|
16
23
|
*
|
|
17
|
-
*
|
|
24
|
+
* A value of 0 is allowed for probe-style streams that do not want an idle
|
|
25
|
+
* wait; in that case PowerSync also skips the local empty-batch delay.
|
|
18
26
|
*/
|
|
19
27
|
maxAwaitTimeMS: number;
|
|
20
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Also enforce maxAwaitTimeMS on the client for empty getMore batches.
|
|
31
|
+
*
|
|
32
|
+
* Azure DocumentDB currently returns idle getMore calls before maxTimeMS. When
|
|
33
|
+
* this is enabled, empty batches are delayed locally (capped at 1s) to avoid
|
|
34
|
+
* tight polling. We still send maxTimeMS so this remains compatible with
|
|
35
|
+
* servers that handle maxAwaitTimeMS correctly.
|
|
36
|
+
*/
|
|
37
|
+
clientSideMaxAwaitTimeMS?: boolean;
|
|
38
|
+
|
|
21
39
|
/**
|
|
22
40
|
* Timeout for the initial aggregate command.
|
|
23
41
|
*/
|
|
@@ -212,31 +230,32 @@ async function* rawChangeStreamInner(
|
|
|
212
230
|
options.signal?.throwIfAborted();
|
|
213
231
|
|
|
214
232
|
using commandSpan = options.tracer?.span('changestream', 'getmore');
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
233
|
+
const getMoreStartedAt = performance.now();
|
|
234
|
+
const getMoreCommand: mongo.Document = {
|
|
235
|
+
getMore: cursorId,
|
|
236
|
+
collection: nsCollection,
|
|
237
|
+
batchSize: batchSizer.next(),
|
|
238
|
+
maxTimeMS: options.maxAwaitTimeMS
|
|
239
|
+
};
|
|
240
|
+
// Azure DocumentDB currently returns empty getMore batches before
|
|
241
|
+
// maxTimeMS expires. Keep maxTimeMS for forward compatibility with the
|
|
242
|
+
// server-side behavior, and when client-side mode is enabled, enforce the
|
|
243
|
+
// capped idle wait locally for empty batches below.
|
|
244
|
+
const getMoreResult: mongo.Document = await db.command(getMoreCommand, { session, raw: true }).catch((e) => {
|
|
245
|
+
if (isMongoServerError(e) && e.codeName == 'CursorKilled') {
|
|
246
|
+
// This may be due to the killCursors command issued when aborting.
|
|
247
|
+
// In that case, use the abort error instead.
|
|
248
|
+
options.signal?.throwIfAborted();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (isResumableChangeStreamError(e)) {
|
|
252
|
+
if (isTimeoutError(e)) {
|
|
253
|
+
batchSizer.reduceAfterError();
|
|
230
254
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
throw new ResumableChangeStreamError(e.message, { cause: e });
|
|
237
|
-
}
|
|
238
|
-
throw mapChangeStreamError(e);
|
|
239
|
-
});
|
|
255
|
+
throw new ResumableChangeStreamError(e.message, { cause: e });
|
|
256
|
+
}
|
|
257
|
+
throw mapChangeStreamError(e);
|
|
258
|
+
});
|
|
240
259
|
|
|
241
260
|
commandSpan?.end();
|
|
242
261
|
|
|
@@ -249,6 +268,13 @@ async function* rawChangeStreamInner(
|
|
|
249
268
|
// postBatchResumeToken is returned in MongoDB 4.0.7 and later, and we support 6.0+
|
|
250
269
|
throw new ReplicationAssertionError(`postBatchResumeToken from aggregate response`);
|
|
251
270
|
}
|
|
271
|
+
if (options.clientSideMaxAwaitTimeMS && nextBatch.length == 0) {
|
|
272
|
+
const remainingMaxAwaitTimeMS = Math.ceil(options.maxAwaitTimeMS - (performance.now() - getMoreStartedAt));
|
|
273
|
+
if (remainingMaxAwaitTimeMS > 0) {
|
|
274
|
+
const clientSideDelayMs = Math.min(remainingMaxAwaitTimeMS, CLIENT_SIDE_MAX_AWAIT_TIME_MS_DELAY_CAP_MS);
|
|
275
|
+
await delay(clientSideDelayMs, undefined, { signal: options.signal });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
252
278
|
yield {
|
|
253
279
|
events: nextBatch,
|
|
254
280
|
resumeToken: cursor.postBatchResumeToken,
|
|
@@ -400,10 +426,6 @@ export function parseChangeDocument(buffer: Buffer): ProjectedChangeStreamDocume
|
|
|
400
426
|
return doc as any;
|
|
401
427
|
}
|
|
402
428
|
|
|
403
|
-
function isTimeoutError(e: unknown) {
|
|
404
|
-
return isMongoNetworkTimeoutError(e) || (isMongoServerError(e) && e.codeName == 'MaxTimeMSExpired');
|
|
405
|
-
}
|
|
406
|
-
|
|
407
429
|
function isResumableChangeStreamError(e: unknown) {
|
|
408
430
|
// See: https://github.com/mongodb/specifications/blob/master/source/change-streams/change-streams.md#resumable-error
|
|
409
431
|
if (!isMongoServerError(e)) {
|
|
@@ -415,8 +437,8 @@ function isResumableChangeStreamError(e: unknown) {
|
|
|
415
437
|
} else if (e.hasErrorLabel('ResumableChangeStreamError')) {
|
|
416
438
|
// For servers with wire version 9 or higher (server version 4.4 or higher), any server error with the ResumableChangeStreamError error label.
|
|
417
439
|
return true;
|
|
418
|
-
} else if (e
|
|
419
|
-
// Our own exception for
|
|
440
|
+
} else if (isMaxTimeMSExpiredError(e)) {
|
|
441
|
+
// Our own exception for maxTimeMS timeouts.
|
|
420
442
|
// This can help us retry faster, with a smaller batch size (if initialBatchSize is set to 1), which should hopefully avoid the timeout.
|
|
421
443
|
return true;
|
|
422
444
|
} else {
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
2
|
+
import { Logger } from '@powersync/lib-services-framework';
|
|
3
|
+
import { ReplicationHeadCallback, storage } from '@powersync/service-core';
|
|
4
|
+
|
|
5
|
+
import { ProjectedChangeStreamDocument } from '../RawChangeStream.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Classification of an event on the `_powersync_checkpoints` collection.
|
|
9
|
+
*
|
|
10
|
+
* - 'standalone': source-wide checkpoint (write checkpoints, keepalive bumps,
|
|
11
|
+
* snapshot markers). Processed regardless of which process created it.
|
|
12
|
+
* - 'own-barrier': this stream's private batch barrier document.
|
|
13
|
+
* - 'foreign': another stream's barrier document — ignore.
|
|
14
|
+
*/
|
|
15
|
+
export type CheckpointEventKind = 'standalone' | 'own-barrier' | 'foreign';
|
|
16
|
+
|
|
17
|
+
export interface StreamResumePosition {
|
|
18
|
+
resumeAfter: mongo.ResumeToken | null;
|
|
19
|
+
/**
|
|
20
|
+
* Legacy startAtOperationTime fallback. Only produced by the timestamp implementation,
|
|
21
|
+
* and only for old LSNs persisted without a resume token.
|
|
22
|
+
*/
|
|
23
|
+
startAfter: mongo.Timestamp | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CheckpointImplementationContext {
|
|
27
|
+
client: mongo.MongoClient;
|
|
28
|
+
db: mongo.Db;
|
|
29
|
+
checkpointStreamId: mongo.ObjectId;
|
|
30
|
+
logger: Logger;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Event-interpretation surface of a {@link CheckpointImplementation}. Every method
|
|
35
|
+
* operates on a single raw change event.
|
|
36
|
+
*
|
|
37
|
+
* Ordering contract: {@link observe} must be called before {@link lsn} for a given
|
|
38
|
+
* event, so the sentinel implementation's coordinate is current.
|
|
39
|
+
*/
|
|
40
|
+
export interface CheckpointEventApi {
|
|
41
|
+
/**
|
|
42
|
+
* Classify a checkpoint-collection event and absorb any coordinate it
|
|
43
|
+
* carries (sentinel implementation). Must be called for every checkpoint event, in
|
|
44
|
+
* stream order.
|
|
45
|
+
*/
|
|
46
|
+
observe(doc: ProjectedChangeStreamDocument): CheckpointEventKind;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Comparable LSN for committing/resuming at this event.
|
|
50
|
+
*
|
|
51
|
+
* Note this is not purely a function of `doc`. The timestamp implementation
|
|
52
|
+
* derives the whole LSN from the event (its coordinate is the event's
|
|
53
|
+
* clusterTime). The sentinel implementation pairs the *tracked* coordinate
|
|
54
|
+
* (updated by {@link observe}) with the event's resume token, because data
|
|
55
|
+
* events carry no coordinate of their own — hence the observe-before-lsn
|
|
56
|
+
* contract.
|
|
57
|
+
*/
|
|
58
|
+
lsn(doc: ProjectedChangeStreamDocument): string;
|
|
59
|
+
|
|
60
|
+
/** Whether a barrier marker from {@link CheckpointImplementation.createBatchCheckpoint} is resolved by this event. */
|
|
61
|
+
resolvesBarrier(marker: string, doc: ProjectedChangeStreamDocument): boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Strategy for producing and interpreting replication checkpoints.
|
|
66
|
+
*
|
|
67
|
+
* Two implementations exist:
|
|
68
|
+
*
|
|
69
|
+
* - {@link TimestampCheckpointImplementation}: standard MongoDB. The ordered LSN
|
|
70
|
+
* coordinate is the oplog clusterTime, which is unique per operation and
|
|
71
|
+
* parseable from resume tokens.
|
|
72
|
+
* - {@link SentinelCheckpointImplementation}: for sources without a usable clusterTime
|
|
73
|
+
* (DocumentDB, and technically usable on any MongoDB). The ordered coordinate
|
|
74
|
+
* is a shared monotonic counter document, observed through the change
|
|
75
|
+
* stream itself.
|
|
76
|
+
*
|
|
77
|
+
* An implementation instance belongs to a single ChangeStream (or API adapter) and may
|
|
78
|
+
* hold per-stream coordinate state; call {@link seedPosition} at the start of
|
|
79
|
+
* each streaming loop.
|
|
80
|
+
*/
|
|
81
|
+
export interface CheckpointImplementation {
|
|
82
|
+
/** LSN representing "before any data". */
|
|
83
|
+
readonly zeroLsn: string;
|
|
84
|
+
|
|
85
|
+
/** Parse a stored LSN into change stream resume options. Pure. */
|
|
86
|
+
parseResumePosition(lsn: string): StreamResumePosition;
|
|
87
|
+
|
|
88
|
+
/** Reset/seed the implementation's coordinate state for a new stream loop. */
|
|
89
|
+
seedPosition(lsn: string | null): void;
|
|
90
|
+
|
|
91
|
+
/** Log the resume position at the start of a streaming loop. */
|
|
92
|
+
logResume(lsn: string): void;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create a source-wide consistency checkpoint (snapshot boundaries,
|
|
96
|
+
* `no_checkpoint_before` markers). Returns a comparable LSN.
|
|
97
|
+
*/
|
|
98
|
+
createStandaloneCheckpoint(): Promise<string>;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Create a batch barrier for this stream. Returns a marker that is resolved
|
|
102
|
+
* by a later change stream event via {@link CheckpointEventApi.resolvesBarrier}.
|
|
103
|
+
*/
|
|
104
|
+
createBatchCheckpoint(): Promise<string>;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Create the first batch barrier for snapshot-LSN acquisition and return the
|
|
108
|
+
* LSN to open the change stream from, or null to open from "now".
|
|
109
|
+
*
|
|
110
|
+
* The timestamp implementation's barrier marker is itself a comparable LSN, so
|
|
111
|
+
* the stream resumes from it. The sentinel implementation's marker is opaque
|
|
112
|
+
* (content-matched) and carries no resume position, so it returns null and the
|
|
113
|
+
* stream opens from the current point.
|
|
114
|
+
*/
|
|
115
|
+
createFirstBarrier(): Promise<string | null>;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Idle keepalive for an empty change stream batch. May persist a checkpoint
|
|
119
|
+
* directly (timestamp implementation) or nudge the source so that a later event
|
|
120
|
+
* commits (sentinel implementation).
|
|
121
|
+
*/
|
|
122
|
+
keepalive(batch: storage.BucketStorageBatch, resumeToken: mongo.ResumeToken): Promise<void>;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build a comparable LSN from a bare batch-level resume token (no change
|
|
126
|
+
* event). Used for the per-batch `setResumeLsn` progress marker.
|
|
127
|
+
*
|
|
128
|
+
* The timestamp implementation parses the timestamp embedded in the token.
|
|
129
|
+
* The sentinel implementation pairs the token with the current coordinate.
|
|
130
|
+
*/
|
|
131
|
+
lsnFromResumeToken(resumeToken: mongo.ResumeToken): { lsn: string; timestamp: Date | null };
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Source-side replication head for write checkpoints. The LSN passed to the
|
|
135
|
+
* callback must compare at or below any LSN committed after the caller's
|
|
136
|
+
* preceding writes.
|
|
137
|
+
*/
|
|
138
|
+
createReplicationHead<T>(callback: ReplicationHeadCallback<T>): Promise<T>;
|
|
139
|
+
|
|
140
|
+
/** Event-interpretation methods, all operating on a single raw change event. */
|
|
141
|
+
readonly event: CheckpointEventApi;
|
|
142
|
+
|
|
143
|
+
/** Filter for clearing the checkpoints collection on startup. */
|
|
144
|
+
readonly checkpointClearFilter: mongo.Filter<mongo.Document>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Extract the event timestamp: clusterTime when present, otherwise wallTime
|
|
149
|
+
* truncated to second precision.
|
|
150
|
+
*/
|
|
151
|
+
export function getEventTimestamp(changeDocument: ProjectedChangeStreamDocument): mongo.Timestamp {
|
|
152
|
+
if (changeDocument.clusterTime) {
|
|
153
|
+
return changeDocument.clusterTime;
|
|
154
|
+
}
|
|
155
|
+
const wallTime = (changeDocument as any).wallTime as Date | undefined;
|
|
156
|
+
if (wallTime != null) {
|
|
157
|
+
return mongo.Timestamp.fromBits(0, Math.floor(wallTime.getTime() / 1000));
|
|
158
|
+
}
|
|
159
|
+
throw new Error('Change event has neither clusterTime nor wallTime');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function getCheckpointId(doc: ProjectedChangeStreamDocument): string | mongo.ObjectId | null {
|
|
163
|
+
if (!('documentKey' in doc)) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
return doc.documentKey._id as string | mongo.ObjectId;
|
|
167
|
+
}
|