@powersync/service-module-mongodb 0.0.0-dev-20260601140105 → 0.0.0-dev-20260803144812

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.
Files changed (70) hide show
  1. package/CHANGELOG.md +97 -5
  2. package/dist/api/MongoRouteAPIAdapter.d.ts +4 -2
  3. package/dist/api/MongoRouteAPIAdapter.js +23 -32
  4. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  5. package/dist/common/SentinelLSN.d.ts +37 -0
  6. package/dist/common/SentinelLSN.js +59 -0
  7. package/dist/common/SentinelLSN.js.map +1 -0
  8. package/dist/replication/ChangeStream.d.ts +16 -0
  9. package/dist/replication/ChangeStream.js +143 -64
  10. package/dist/replication/ChangeStream.js.map +1 -1
  11. package/dist/replication/ChangeStreamReplicationJob.js +1 -1
  12. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  13. package/dist/replication/ChangeStreamReplicator.js +1 -1
  14. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  15. package/dist/replication/MongoRelation.d.ts +36 -1
  16. package/dist/replication/MongoRelation.js +133 -18
  17. package/dist/replication/MongoRelation.js.map +1 -1
  18. package/dist/replication/MongoSnapshotter.d.ts +9 -1
  19. package/dist/replication/MongoSnapshotter.js +128 -66
  20. package/dist/replication/MongoSnapshotter.js.map +1 -1
  21. package/dist/replication/RawChangeStream.d.ts +12 -1
  22. package/dist/replication/RawChangeStream.js +23 -10
  23. package/dist/replication/RawChangeStream.js.map +1 -1
  24. package/dist/replication/checkpoints/CheckpointImplementation.d.ts +132 -0
  25. package/dist/replication/checkpoints/CheckpointImplementation.js +22 -0
  26. package/dist/replication/checkpoints/CheckpointImplementation.js.map +1 -0
  27. package/dist/replication/checkpoints/SentinelCheckpointImplementation.d.ts +55 -0
  28. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js +224 -0
  29. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js.map +1 -0
  30. package/dist/replication/checkpoints/TimestampCheckpointImplementation.d.ts +24 -0
  31. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js +115 -0
  32. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js.map +1 -0
  33. package/dist/replication/checkpoints/create-checkpoint-implementation.d.ts +6 -0
  34. package/dist/replication/checkpoints/create-checkpoint-implementation.js +10 -0
  35. package/dist/replication/checkpoints/create-checkpoint-implementation.js.map +1 -0
  36. package/dist/replication/replication-utils.d.ts +6 -0
  37. package/dist/replication/replication-utils.js +19 -2
  38. package/dist/replication/replication-utils.js.map +1 -1
  39. package/dist/types/types.d.ts +2 -2
  40. package/package.json +11 -21
  41. package/src/api/MongoRouteAPIAdapter.ts +28 -39
  42. package/src/common/SentinelLSN.ts +78 -0
  43. package/src/replication/ChangeStream.ts +159 -73
  44. package/src/replication/ChangeStreamReplicationJob.ts +1 -1
  45. package/src/replication/ChangeStreamReplicator.ts +1 -1
  46. package/src/replication/MongoRelation.ts +153 -21
  47. package/src/replication/MongoSnapshotter.ts +147 -72
  48. package/src/replication/RawChangeStream.ts +54 -32
  49. package/src/replication/checkpoints/CheckpointImplementation.ts +167 -0
  50. package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +269 -0
  51. package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +144 -0
  52. package/src/replication/checkpoints/create-checkpoint-implementation.ts +14 -0
  53. package/src/replication/replication-utils.ts +23 -2
  54. package/test/DOCUMENTDB_TESTING.md +115 -0
  55. package/test/src/DatabaseType.ts +25 -0
  56. package/test/src/change_stream.test.ts +107 -71
  57. package/test/src/change_stream_utils.ts +117 -29
  58. package/test/src/checkpoint_retry.test.ts +134 -0
  59. package/test/src/documentdb_helpers.test.ts +124 -0
  60. package/test/src/documentdb_mode.test.ts +1040 -0
  61. package/test/src/mongo_test.test.ts +15 -5
  62. package/test/src/raw_change_stream.test.ts +209 -125
  63. package/test/src/resume.test.ts +2 -2
  64. package/test/src/resume_token.test.ts +30 -0
  65. package/test/src/slow_tests.test.ts +4 -1
  66. package/test/src/test-timeouts.ts +23 -0
  67. package/test/src/util.ts +3 -3
  68. package/tsconfig.json +1 -4
  69. package/tsconfig.scripts.json +1 -2
  70. package/tsconfig.tsbuildinfo +1 -1
@@ -1,14 +1,15 @@
1
1
  import * as lib_mongo from '@powersync/lib-service-mongodb';
2
2
  import { mongo } from '@powersync/lib-service-mongodb';
3
- import { api, ParseSyncRulesOptions, ReplicationHeadCallback } from '@powersync/service-core';
3
+ import { api, ParseSyncConfigOptions, ReplicationHeadCallback } from '@powersync/service-core';
4
4
  import * as sync_rules from '@powersync/service-sync-rules';
5
5
  import * as service_types from '@powersync/service-types';
6
6
 
7
- import { ServiceAssertionError } from '@powersync/lib-services-framework';
8
- import { MongoLSN } from '../common/MongoLSN.js';
7
+ import { logger } from '@powersync/lib-services-framework';
8
+ import { CheckpointImplementation } from '../replication/checkpoints/CheckpointImplementation.js';
9
+ import { createCheckpointImplementation } from '../replication/checkpoints/create-checkpoint-implementation.js';
9
10
  import { MongoManager } from '../replication/MongoManager.js';
10
- import { constructAfterRecord, STANDALONE_CHECKPOINT_ID } from '../replication/MongoRelation.js';
11
- import { CHECKPOINTS_COLLECTION } from '../replication/replication-utils.js';
11
+ import { constructAfterRecord } from '../replication/MongoRelation.js';
12
+ import { CHECKPOINTS_COLLECTION, detectDocumentDb } from '../replication/replication-utils.js';
12
13
  import * as types from '../types/types.js';
13
14
  import { escapeRegExp } from '../utils.js';
14
15
 
@@ -19,6 +20,8 @@ export class MongoRouteAPIAdapter implements api.RouteAPI {
19
20
  connectionTag: string;
20
21
  defaultSchema: string;
21
22
 
23
+ private checkpointImplementation: CheckpointImplementation | null = null;
24
+
22
25
  constructor(protected config: types.ResolvedConnectionConfig) {
23
26
  const manager = new MongoManager(config);
24
27
  this.client = manager.client;
@@ -27,7 +30,7 @@ export class MongoRouteAPIAdapter implements api.RouteAPI {
27
30
  this.connectionTag = config.tag ?? sync_rules.DEFAULT_TAG;
28
31
  }
29
32
 
30
- getParseSyncRulesOptions(): ParseSyncRulesOptions {
33
+ getParseSyncRulesOptions(): ParseSyncConfigOptions {
31
34
  return {
32
35
  defaultSchema: this.defaultSchema
33
36
  };
@@ -199,41 +202,27 @@ export class MongoRouteAPIAdapter implements api.RouteAPI {
199
202
  }
200
203
 
201
204
  async createReplicationHead<T>(callback: ReplicationHeadCallback<T>): Promise<T> {
202
- const session = this.client.startSession();
203
- try {
204
- await this.db.command({ hello: 1 }, { session });
205
- const head = session.clusterTime?.clusterTime;
206
- if (head == null) {
207
- throw new ServiceAssertionError(`clusterTime not available for write checkpoint`);
208
- }
209
-
210
- const r = await callback(new MongoLSN({ timestamp: head }).comparable);
211
-
212
- // Trigger a change on the changestream.
213
- await this.db.collection(CHECKPOINTS_COLLECTION).findOneAndUpdate(
214
- {
215
- _id: STANDALONE_CHECKPOINT_ID as any
216
- },
217
- {
218
- $inc: { i: 1 }
219
- },
220
- {
221
- upsert: true,
222
- returnDocument: 'after',
223
- session
224
- }
225
- );
226
- const time = session.operationTime!;
227
- if (time == null) {
228
- throw new ServiceAssertionError(`operationTime not available for write checkpoint`);
229
- } else if (time.lt(head)) {
230
- throw new ServiceAssertionError(`operationTime must be > clusterTime`);
231
- }
205
+ const checkpointImplementation = await this.getCheckpointImplementation();
206
+ return checkpointImplementation.createReplicationHead(callback);
207
+ }
232
208
 
233
- return r;
234
- } finally {
235
- await session.endSession();
209
+ private async getCheckpointImplementation(): Promise<CheckpointImplementation> {
210
+ if (this.checkpointImplementation == null) {
211
+ const isDocumentDb = await detectDocumentDb(this.db);
212
+ this.checkpointImplementation = createCheckpointImplementation(isDocumentDb, {
213
+ client: this.client,
214
+ db: this.db,
215
+ // The adapter never streams, so it has no real barrier document. This
216
+ // random id is only safe because the adapter only ever calls
217
+ // createReplicationHead, which produces a standalone (stream_id = null)
218
+ // head that every real ChangeStream observes. It must NOT be used for a
219
+ // batch barrier (createBatchCheckpoint stamps this id), or the head would
220
+ // become an own-barrier of a phantom stream that nothing resolves.
221
+ checkpointStreamId: new mongo.ObjectId(),
222
+ logger
223
+ });
236
224
  }
225
+ return this.checkpointImplementation;
237
226
  }
238
227
 
239
228
  async getConnectionSchema(): Promise<service_types.DatabaseSchema[]> {
@@ -0,0 +1,78 @@
1
+ import { mongo } from '@powersync/lib-service-mongodb';
2
+ import { storage } from '@powersync/service-core';
3
+
4
+ export type SentinelLSNSpecification = {
5
+ sentinel: bigint;
6
+ /**
7
+ * Resume tokens are opaque on sentinel-based sources (e.g. DocumentDB). The
8
+ * sentinel component is the comparable position; this token is only used to
9
+ * resume the change stream.
10
+ */
11
+ resume_token?: mongo.ResumeToken | null;
12
+ };
13
+
14
+ const DELIMINATOR = '|';
15
+
16
+ /**
17
+ * Width of the sentinel coordinate, matching a MongoDB cluster timestamp
18
+ * (64-bit → 16 hex chars). The sentinel counter is a 64-bit `Long`, so this is
19
+ * sufficient and never truncates.
20
+ */
21
+ const SENTINEL_HEX_LENGTH = 16;
22
+
23
+ /**
24
+ * LSN for sentinel-based checkpointing (sources without a usable clusterTime,
25
+ * e.g. DocumentDB). The ordered coordinate is a monotonic sentinel counter; the
26
+ * opaque resume token is carried alongside it only for `resumeAfter`.
27
+ *
28
+ * The coordinate is serialized as a 16-hex-char value with the **same shape as
29
+ * a MongoDB timestamp LSN** ({@link MongoLSN}): the high 32 bits resemble epoch
30
+ * seconds and the low 32 bits an increment. This is deliberate — it makes
31
+ * sentinel LSNs directly string-comparable with timestamp LSNs, so a sentinel
32
+ * coordinate (seeded at the current epoch seconds; see createSentinelCheckpointLsn)
33
+ * always sorts **above** any real-timestamp LSN issued in the past.
34
+ *
35
+ * It only *resembles* a timestamp. The value is a synthetic monotonic counter,
36
+ * not a real cluster time, and must never be used as one (e.g. it is never fed
37
+ * to `startAtOperationTime`; the sentinel implementation resumes purely via the
38
+ * resume token).
39
+ */
40
+ export class SentinelLSN {
41
+ static ZERO = new SentinelLSN({ sentinel: 0n });
42
+
43
+ static fromSerialized(comparable: string): SentinelLSN {
44
+ const [sentinelString, resumeString] = comparable.split(DELIMINATOR);
45
+
46
+ return new SentinelLSN({
47
+ sentinel: BigInt(`0x${sentinelString}`),
48
+ resume_token: resumeString ? storage.deserializeBson(Buffer.from(resumeString, 'base64')).resumeToken : null
49
+ });
50
+ }
51
+
52
+ constructor(protected options: SentinelLSNSpecification) {}
53
+
54
+ get sentinel() {
55
+ return this.options.sentinel;
56
+ }
57
+
58
+ get resumeToken() {
59
+ return this.options.resume_token;
60
+ }
61
+
62
+ get comparable() {
63
+ // padStart(16) of the combined 64-bit value yields the identical string to
64
+ // MongoLSN's high(8)+low(8) formatting, keeping the two formats comparable.
65
+ const sentinel = this.sentinel.toString(16).padStart(SENTINEL_HEX_LENGTH, '0');
66
+ const segments = [sentinel];
67
+
68
+ if (this.resumeToken) {
69
+ segments.push(storage.serializeBson({ resumeToken: this.resumeToken }).toString('base64'));
70
+ }
71
+
72
+ return segments.join(DELIMINATOR);
73
+ }
74
+
75
+ toString() {
76
+ return this.comparable;
77
+ }
78
+ }
@@ -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 { createCheckpoint, getCacheIdentifier, getMongoRelation, STANDALONE_CHECKPOINT_ID } from './MongoRelation.js';
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,19 @@ 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
+
116
127
  constructor(options: ChangeStreamOptions) {
117
128
  this.storage = options.storage;
118
129
  this.metrics = options.metrics;
119
- this.group_id = options.storage.group_id;
130
+ this.group_id = options.storage.replicationStreamId;
120
131
  this.connections = options.connections;
121
132
  this.maxAwaitTimeMS = options.maxAwaitTimeMS ?? 10_000;
122
133
  this.snapshotChunkLength = options.snapshotChunkLength ?? 6_000;
134
+ this.keepaliveIntervalMs = options.keepaliveIntervalMs ?? 60_000;
123
135
  this.storageHooks = options.storageHooks;
124
136
  this.client = this.connections.client;
125
137
  this.defaultDb = this.connections.db;
@@ -132,11 +144,16 @@ export class ChangeStream {
132
144
  // so we use 90% of the socket timeout value.
133
145
  this.changeStreamTimeout = Math.ceil(this.client.options.socketTimeoutMS * 0.9);
134
146
 
135
- this.logger = options.logger ?? this.storage.logger;
147
+ const baseLogger = options.logger ?? this.storage.logger;
148
+ // Unfortunately the Winston APIs don't have a nice way to append to the prefix,
149
+ // so we replace it here.
150
+ this.logger = baseLogger.child({ prefix: `[${this.storage.replicationStreamName}] [stream] ` });
151
+ const snapshotLogger = baseLogger.child({ prefix: `[${this.storage.replicationStreamName}] [snapshot] ` });
152
+
136
153
  this.snapshotter = new MongoSnapshotter({
137
154
  ...options,
138
155
  abortSignal: this.abortSignal,
139
- logger: this.logger,
156
+ logger: snapshotLogger,
140
157
  checkpointStreamId: this.checkpointStreamId
141
158
  });
142
159
 
@@ -164,6 +181,37 @@ export class ChangeStream {
164
181
  return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
165
182
  }
166
183
 
184
+ /** The active checkpoint strategy. Only valid after ensureDetected(). */
185
+ private get checkpointImplementation(): CheckpointImplementation {
186
+ if (this._checkpointImplementation == null) {
187
+ throw new ReplicationAssertionError('Checkpoint implementation not initialized - call ensureDetected() first');
188
+ }
189
+ return this._checkpointImplementation;
190
+ }
191
+
192
+ /**
193
+ * Detect DocumentDB and select the checkpoint implementation for the streaming
194
+ * loop. Idempotent. The snapshotter detects independently; the two coordinate
195
+ * through stored LSNs, not shared in-memory state.
196
+ */
197
+ private async ensureDetected(): Promise<void> {
198
+ if (this._checkpointImplementation != null) {
199
+ return;
200
+ }
201
+ this.isDocumentDb = await detectDocumentDb(this.defaultDb);
202
+ if (this.isDocumentDb) {
203
+ this.logger.warn(
204
+ 'Azure DocumentDB support is experimental. APIs and behavior may change, and long-term stability is not yet guaranteed.'
205
+ );
206
+ }
207
+ this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
208
+ client: this.client,
209
+ db: this.defaultDb,
210
+ checkpointStreamId: this.checkpointStreamId,
211
+ logger: this.logger
212
+ });
213
+ }
214
+
167
215
  private getSourceNamespaceFilters(): { $match: any; multipleDatabases: boolean } {
168
216
  const sourceTables = this.sync_rules.getSourceTables();
169
217
 
@@ -204,7 +252,15 @@ export class ChangeStream {
204
252
  // For details, see:
205
253
  // https://github.com/powersync-ja/powersync-service/pull/417
206
254
  // https://jira.mongodb.org/browse/SERVER-114532
207
- const nsFilter = multipleDatabases
255
+ //
256
+ // DocumentDB always opens a cluster-level change stream (admin +
257
+ // allChangesForCluster), even in single-database mode. A coll-only filter
258
+ // would then match same-named collections (including _powersync_checkpoints)
259
+ // in other databases of the cluster, letting a foreign standalone-checkpoint
260
+ // event advance/resolve checkpoints against the wrong source database. So we
261
+ // must filter on the full namespace whenever the stream is cluster-scoped.
262
+ const useFullNamespaceFilter = this.isDocumentDb || multipleDatabases;
263
+ const nsFilter = useFullNamespaceFilter
208
264
  ? // cluster-level: filter on the entire namespace
209
265
  { ns: { $in: $inFilters } }
210
266
  : // collection-level: filter on coll only
@@ -366,6 +422,7 @@ export class ChangeStream {
366
422
  async replicate() {
367
423
  let streamPromise: Promise<void> | null = null;
368
424
  let loopPromise: Promise<void> | null = null;
425
+ let cleanupPromise: Promise<void> | null = null;
369
426
  try {
370
427
  // If anything errors here, the entire replication process is halted, and
371
428
  // all connections automatically closed, including this one.
@@ -383,6 +440,11 @@ export class ChangeStream {
383
440
  if (!this.snapshotter.supportsConcurrentSnapshots) {
384
441
  await Promise.race([this.snapshotter.waitForInitialSnapshot(), loopPromise]);
385
442
  }
443
+ // Unlike the other two, this resolves on completion, not an indefinite loop.
444
+ cleanupPromise = this.cleanupStoppedSyncConfigs().catch((e) => {
445
+ this.abortController.abort(e);
446
+ throw e;
447
+ });
386
448
  streamPromise = this.streamChanges()
387
449
  .then(() => {
388
450
  throw new ReplicationAssertionError(`Replication stream exited unexpectedly`);
@@ -392,7 +454,7 @@ export class ChangeStream {
392
454
  throw e;
393
455
  });
394
456
 
395
- const results = await Promise.allSettled([loopPromise, streamPromise]);
457
+ const results = await Promise.allSettled([loopPromise, streamPromise, cleanupPromise]);
396
458
  throw replicationLoopError(results);
397
459
  } catch (e) {
398
460
  await this.storage.reportError(e);
@@ -422,6 +484,15 @@ export class ChangeStream {
422
484
  }
423
485
  }
424
486
 
487
+ private async cleanupStoppedSyncConfigs() {
488
+ await this.storage.cleanupStoppedSyncConfigs?.({
489
+ signal: this.abortSignal,
490
+ logger: this.logger,
491
+ defaultSchema: this.defaultDb.databaseName,
492
+ sourceConnectionTag: this.connections.connectionTag
493
+ });
494
+ }
495
+
425
496
  private async streamChanges() {
426
497
  try {
427
498
  await this.streamChangesInternal();
@@ -445,15 +516,18 @@ export class ChangeStream {
445
516
  signal?: AbortSignal;
446
517
  tracer?: PerformanceTracer<'changestream'>;
447
518
  }): AsyncIterableIterator<ChangeStreamBatch> {
448
- const lastLsn = options.lsn ? MongoLSN.fromSerialized(options.lsn) : null;
449
- const startAfter = lastLsn?.timestamp;
450
- const resumeAfter = lastLsn?.resumeToken;
519
+ const position = options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null;
520
+ const startAfter = position?.startAfter ?? undefined;
521
+ const resumeAfter = position?.resumeAfter ?? undefined;
451
522
 
452
523
  const filters = options.filters;
453
524
 
454
525
  let fullDocument: 'required' | 'updateLookup';
455
526
 
456
- if (this.usePostImages) {
527
+ if (this.isDocumentDb) {
528
+ // DocumentDB does not support changeStreamPreAndPostImages, so 'required' won't work.
529
+ fullDocument = 'updateLookup';
530
+ } else if (this.usePostImages) {
457
531
  // 'read_only' or 'auto_configure'
458
532
  // Configuration happens during snapshot, or when we see new
459
533
  // collections.
@@ -462,42 +536,56 @@ export class ChangeStream {
462
536
  fullDocument = 'updateLookup';
463
537
  }
464
538
  const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
465
- showExpandedEvents: true,
466
539
  fullDocument: fullDocument
467
540
  };
541
+ if (!this.isDocumentDb) {
542
+ // DocumentDB does not support showExpandedEvents.
543
+ streamOptions.showExpandedEvents = true;
544
+ }
468
545
  const pipeline: mongo.Document[] = [
469
546
  {
470
547
  $changeStream: streamOptions
471
548
  },
472
549
  {
473
550
  $match: filters.$match
474
- },
475
- { $changeStreamSplitLargeEvent: {} }
551
+ }
476
552
  ];
553
+ if (!this.isDocumentDb) {
554
+ // DocumentDB does not support $changeStreamSplitLargeEvent.
555
+ pipeline.push({ $changeStreamSplitLargeEvent: {} });
556
+ }
477
557
 
478
558
  /**
479
559
  * Only one of these options can be supplied at a time.
480
560
  */
481
561
  if (resumeAfter) {
482
562
  streamOptions.resumeAfter = resumeAfter;
483
- } else {
563
+ } else if (startAfter != null) {
484
564
  // Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
485
565
  // case if we have an old one.
486
566
  // This is also relevant for getSnapshotLSN().
567
+ // The sentinel implementation never produces a startAfter, and a fresh DocumentDB stream
568
+ // opens from "now" with neither option set.
487
569
  streamOptions.startAtOperationTime = startAfter;
488
570
  }
489
571
 
490
572
  let watchDb: mongo.Db;
491
- if (filters.multipleDatabases) {
573
+ if (this.isDocumentDb || filters.multipleDatabases) {
574
+ // DocumentDB only supports cluster-level change streams.
492
575
  watchDb = this.client.db('admin');
493
576
  streamOptions.allChangesForCluster = true;
494
577
  } else {
495
578
  watchDb = this.defaultDb;
496
579
  }
497
580
 
581
+ const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
582
+
498
583
  return rawChangeStream(watchDb, pipeline, {
499
584
  batchSize: options.batchSize ?? this.snapshotChunkLength,
500
- maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
585
+ maxAwaitTimeMS,
586
+ // maxAwaitTimeMS can be 0 for probe-style streams that do not want an idle wait.
587
+ // In that case there is no client-side wait to emulate for DocumentDB.
588
+ clientSideMaxAwaitTimeMS: this.isDocumentDb && maxAwaitTimeMS > 0,
501
589
  maxTimeMS: this.changeStreamTimeout,
502
590
 
503
591
  signal: options.signal,
@@ -511,15 +599,18 @@ export class ChangeStream {
511
599
  }
512
600
 
513
601
  async streamChangesInternal() {
602
+ await this.ensureDetected();
514
603
  const transactionsReplicatedMetric = this.metrics.getCounter(ReplicationMetric.TRANSACTIONS_REPLICATED);
515
604
  const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
516
605
  const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
517
606
 
518
- const tracer = new PerformanceTracer('MongoDB streaming replication');
607
+ const tracer = new PerformanceTracer<
608
+ 'storage' | 'evaluate' | 'batch' | 'source_checkpoint' | 'changestream' | 'processing'
609
+ >('MongoDB streaming replication');
519
610
  await this.storage.startBatch(
520
611
  {
521
612
  logger: this.logger,
522
- zeroLSN: MongoLSN.ZERO.comparable,
613
+ zeroLSN: this.checkpointImplementation.zeroLsn,
523
614
  defaultSchema: this.defaultDb.databaseName,
524
615
  // We get a complete postimage for every change, so we don't need to store the current data.
525
616
  storeCurrentData: false,
@@ -531,15 +622,14 @@ export class ChangeStream {
531
622
  if (resumeFromLsn == null) {
532
623
  throw new ReplicationAssertionError(`No LSN found to resume from`);
533
624
  }
534
- const lastLsn = MongoLSN.fromSerialized(resumeFromLsn);
535
- const startAfter = lastLsn?.timestamp;
625
+ // Seed the implementation's coordinate state from the stored LSN, and parse
626
+ // the legacy startAfter timestamp (timestamp implementation only) for the
627
+ // resume-boundary dedupe guard below.
628
+ this.checkpointImplementation.seedPosition(resumeFromLsn);
629
+ const { startAfter } = this.checkpointImplementation.parseResumePosition(resumeFromLsn);
536
630
  let outerSpan = tracer.span('batch');
537
631
 
538
- // It is normal for this to be a minute or two old when there is a low volume
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`);
632
+ this.checkpointImplementation.logResume(resumeFromLsn);
543
633
 
544
634
  const filters = this.getSourceNamespaceFilters();
545
635
  // This is closed when the for loop below returns/breaks/throws
@@ -553,11 +643,7 @@ export class ChangeStream {
553
643
  // Always start with a checkpoint.
554
644
  // This helps us to clear errors when restarting, even if there is
555
645
  // no data to replicate.
556
- let waitForCheckpointLsn: string | null = await createCheckpoint(
557
- this.client,
558
- this.defaultDb,
559
- this.checkpointStreamId
560
- );
646
+ let waitForCheckpointLsn: string | null = await this.checkpointImplementation.createBatchCheckpoint();
561
647
 
562
648
  let splitDocument: ProjectedChangeStreamDocument | null = null;
563
649
 
@@ -581,22 +667,19 @@ export class ChangeStream {
581
667
  // We do this by persisting a keepalive checkpoint.
582
668
  // If we don't update it on empty events, we do keep consistency, but resuming the stream
583
669
  // with old tokens may cause connection timeouts.
584
- if (waitForCheckpointLsn == null && performance.now() - lastEmptyResume > 60_000) {
585
- const { comparable: lsn, timestamp } = MongoLSN.fromResumeToken(resumeToken);
586
- await batch.keepalive(lsn);
670
+ if (waitForCheckpointLsn == null && performance.now() - lastEmptyResume > this.keepaliveIntervalMs) {
671
+ // The implementation persists a keepalive (timestamp) or bumps the
672
+ // sentinel so a later event commits (sentinel). Logging is handled
673
+ // inside the implementation.
674
+ await this.checkpointImplementation.keepalive(batch, resumeToken);
587
675
  this.touch();
588
676
  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
677
  this.replicationLag.markStarted();
595
678
  }
596
679
 
597
680
  // If we have no changes, we can just persist the keepalive.
598
- // This is throttled to once per minute.
599
- if (performance.now() - lastEmptyResume < 60_000) {
681
+ // This is throttled to once per interval.
682
+ if (performance.now() - lastEmptyResume < this.keepaliveIntervalMs) {
600
683
  continue;
601
684
  }
602
685
  }
@@ -704,47 +787,45 @@ export class ChangeStream {
704
787
  // It may be useful to also throttle commits due to standalone checkpoints in the future.
705
788
  // However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
706
789
 
707
- const checkpointId = changeDocument.documentKey._id as string | mongo.ObjectId;
790
+ const kind = this.checkpointImplementation.event.observe(changeDocument);
708
791
 
709
- if (checkpointId == STANDALONE_CHECKPOINT_ID) {
792
+ if (kind == 'foreign') {
793
+ // Another stream's barrier - ignore.
794
+ continue;
795
+ } else if (kind == 'standalone') {
710
796
  // Standalone / write checkpoint received.
711
797
  // When we are caught up, commit immediately to keep write checkpoint latency low.
712
798
  // Once there is already a batch checkpoint pending, or the driver has buffered more
713
799
  // change stream events, collapse standalone checkpoints into the normal batch
714
800
  // checkpoint flow to avoid commit churn under sustained load.
715
801
  const hasBufferedChanges = eventIndex < events.length - 1;
716
- if (waitForCheckpointLsn != null || hasBufferedChanges) {
717
- if (waitForCheckpointLsn == null) {
718
- waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
719
- }
802
+ if (hasBufferedChanges && waitForCheckpointLsn == null) {
803
+ // Buffered changes - create a new batch checkpoint to rate limit commits
804
+ using _ = tracer.span('source_checkpoint');
805
+ waitForCheckpointLsn = await this.checkpointImplementation.createBatchCheckpoint();
806
+ continue;
807
+ } else if (waitForCheckpointLsn != null) {
808
+ // Skip this checkpoint - wait for the batch checkpoint.
720
809
  continue;
810
+ } else {
811
+ // No buffered changes, and no batch checkpoint pending - commit immediately.
721
812
  }
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
813
  }
814
+ // kind == 'own-barrier' falls through to commit.
815
+
816
+ const lsn = this.checkpointImplementation.event.lsn(changeDocument);
739
817
 
740
- if (waitForCheckpointLsn != null && lsn >= waitForCheckpointLsn) {
818
+ if (
819
+ waitForCheckpointLsn != null &&
820
+ this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)
821
+ ) {
741
822
  waitForCheckpointLsn = null;
742
823
  }
743
- const { checkpointBlocked } = await batch.commit(lsn, {
824
+ const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
744
825
  oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
745
826
  });
746
827
 
747
- if (!checkpointBlocked) {
828
+ if (!checkpointBlocked || checkpointCreated) {
748
829
  this.replicationLag.markCommitted();
749
830
  }
750
831
  } else if (
@@ -754,7 +835,8 @@ export class ChangeStream {
754
835
  changeDocument.operationType == 'delete'
755
836
  ) {
756
837
  if (waitForCheckpointLsn == null) {
757
- waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
838
+ using _ = tracer.span('source_checkpoint');
839
+ waitForCheckpointLsn = await this.checkpointImplementation.createBatchCheckpoint();
758
840
  }
759
841
 
760
842
  const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
@@ -768,7 +850,11 @@ export class ChangeStream {
768
850
  const tablesToReplicate = tables.filter((table) => table.syncAny);
769
851
  if (tablesToReplicate.length > 0) {
770
852
  this.replicationLag.trackUncommittedChange(
771
- changeDocument.clusterTime == null ? null : timestampToDate(changeDocument.clusterTime)
853
+ // Standard MongoDB uses clusterTime, unchanged. DocumentDB has no
854
+ // clusterTime, so fall back to wallTime there for the lag metric.
855
+ changeDocument.clusterTime != null
856
+ ? timestampToDate(changeDocument.clusterTime)
857
+ : ((changeDocument as any).wallTime ?? null)
772
858
  );
773
859
 
774
860
  const transactionKeyValue = transactionKey(changeDocument);
@@ -823,15 +909,15 @@ export class ChangeStream {
823
909
  // Batches are generally large (64MB or 6000 events, whichever comes first),
824
910
  // so this is a good natural point to flush and mark progress.
825
911
  // We avoid this when splitDocument is set, since we cannot resume in the middle of a split event.
826
- const { comparable: lsn } = MongoLSN.fromResumeToken(resumeToken);
912
+ const lsn = this.checkpointImplementation.lsnFromResumeToken(resumeToken);
827
913
  await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
828
914
  // TODO: We should consider making this standard behavior of flush().
829
915
  await batch.setResumeLsn(lsn);
830
916
  }
831
917
 
832
918
  batchSpan.end();
833
- const durations = outerSpan.end();
834
- const duration = batchSpan.endAt - batchSpan.startAt;
919
+ const durationsMicroseconds = outerSpan.end();
920
+ const duration = batchSpan.durationMillis;
835
921
 
836
922
  this.logger.info(
837
923
  `Processed batch of ${events.length} changes / ${eventBatch.byteSize} bytes in ${duration}ms`,
@@ -839,7 +925,7 @@ export class ChangeStream {
839
925
  count: events.length,
840
926
  bytes: eventBatch.byteSize,
841
927
  duration,
842
- t: durations
928
+ t: durationsMicroseconds
843
929
  }
844
930
  );
845
931
  outerSpan = tracer.span('batch');
@@ -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.group_id);
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
@@ -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.group_id),
20
+ id: this.createJobId(options.storage.replicationStreamId),
21
21
  storage: options.storage,
22
22
  metrics: this.metrics,
23
23
  connectionFactory: this.connectionFactory,