@powersync/service-module-mongodb 0.18.1 → 0.19.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/api/MongoRouteAPIAdapter.d.ts +2 -0
  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 +15 -0
  9. package/dist/replication/ChangeStream.js +105 -44
  10. package/dist/replication/ChangeStream.js.map +1 -1
  11. package/dist/replication/MongoRelation.d.ts +36 -1
  12. package/dist/replication/MongoRelation.js +102 -6
  13. package/dist/replication/MongoRelation.js.map +1 -1
  14. package/dist/replication/MongoSnapshotter.d.ts +9 -0
  15. package/dist/replication/MongoSnapshotter.js +108 -42
  16. package/dist/replication/MongoSnapshotter.js.map +1 -1
  17. package/dist/replication/RawChangeStream.d.ts +12 -1
  18. package/dist/replication/RawChangeStream.js +30 -7
  19. package/dist/replication/RawChangeStream.js.map +1 -1
  20. package/dist/replication/checkpoints/CheckpointImplementation.d.ts +132 -0
  21. package/dist/replication/checkpoints/CheckpointImplementation.js +22 -0
  22. package/dist/replication/checkpoints/CheckpointImplementation.js.map +1 -0
  23. package/dist/replication/checkpoints/SentinelCheckpointImplementation.d.ts +55 -0
  24. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js +222 -0
  25. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js.map +1 -0
  26. package/dist/replication/checkpoints/TimestampCheckpointImplementation.d.ts +24 -0
  27. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js +113 -0
  28. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js.map +1 -0
  29. package/dist/replication/checkpoints/create-checkpoint-implementation.d.ts +6 -0
  30. package/dist/replication/checkpoints/create-checkpoint-implementation.js +10 -0
  31. package/dist/replication/checkpoints/create-checkpoint-implementation.js.map +1 -0
  32. package/dist/replication/replication-utils.d.ts +6 -0
  33. package/dist/replication/replication-utils.js +19 -2
  34. package/dist/replication/replication-utils.js.map +1 -1
  35. package/package.json +9 -9
  36. package/src/api/MongoRouteAPIAdapter.ts +26 -37
  37. package/src/common/SentinelLSN.ts +78 -0
  38. package/src/replication/ChangeStream.ts +118 -50
  39. package/src/replication/MongoRelation.ts +124 -14
  40. package/src/replication/MongoSnapshotter.ts +122 -47
  41. package/src/replication/RawChangeStream.ts +64 -28
  42. package/src/replication/checkpoints/CheckpointImplementation.ts +167 -0
  43. package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +267 -0
  44. package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +142 -0
  45. package/src/replication/checkpoints/create-checkpoint-implementation.ts +14 -0
  46. package/src/replication/replication-utils.ts +23 -2
  47. package/test/DOCUMENTDB_TESTING.md +115 -0
  48. package/test/src/DatabaseType.ts +25 -0
  49. package/test/src/change_stream.test.ts +103 -68
  50. package/test/src/change_stream_utils.ts +85 -9
  51. package/test/src/checkpoint_retry.test.ts +5 -2
  52. package/test/src/documentdb_helpers.test.ts +124 -0
  53. package/test/src/documentdb_mode.test.ts +1040 -0
  54. package/test/src/mongo_test.test.ts +15 -5
  55. package/test/src/raw_change_stream.test.ts +209 -125
  56. package/test/src/resume_token.test.ts +30 -0
  57. package/test/src/slow_tests.test.ts +4 -1
  58. package/test/src/test-timeouts.ts +23 -0
  59. package/test/src/util.ts +1 -2
  60. package/tsconfig.tsbuildinfo +1 -1
@@ -2,13 +2,30 @@ import { ErrorCode, ServiceError } from '@powersync/lib-services-framework';
2
2
  import { PostImagesOption } from '../types/types.js';
3
3
  export const CHECKPOINTS_COLLECTION = '_powersync_checkpoints';
4
4
  const REQUIRED_CHECKPOINT_PERMISSIONS = ['find', 'insert', 'update', 'remove', 'changeStream', 'createCollection'];
5
+ /**
6
+ * Whether a `hello` response indicates Azure DocumentDB (formerly Azure Cosmos
7
+ * DB for MongoDB vCore), which reports `documentdb_versions` in the `internal`
8
+ * section.
9
+ */
10
+ function isDocumentDbHello(hello) {
11
+ return hello.internal?.documentdb_versions != null;
12
+ }
13
+ /**
14
+ * Detect whether the connected server is DocumentDB. DocumentDB lacks usable
15
+ * clusterTime/operationTime and uses the sentinel checkpoint implementation.
16
+ */
17
+ export async function detectDocumentDb(db) {
18
+ const hello = await db.command({ hello: 1 });
19
+ return isDocumentDbHello(hello);
20
+ }
5
21
  export async function checkSourceConfiguration(connectionManager) {
6
22
  const db = connectionManager.db;
7
23
  const hello = await db.command({ hello: 1 });
8
- if (hello.msg == 'isdbgrid') {
24
+ const isDocumentDb = isDocumentDbHello(hello);
25
+ if (hello.msg == 'isdbgrid' && !isDocumentDb) {
9
26
  throw new ServiceError(ErrorCode.PSYNC_S1341, 'Sharded MongoDB Clusters are not supported yet (including MongoDB Serverless instances).');
10
27
  }
11
- else if (hello.setName == null) {
28
+ else if (hello.setName == null && !isDocumentDb) {
12
29
  throw new ServiceError(ErrorCode.PSYNC_S1342, 'Standalone MongoDB instances are not supported - use a replicaset.');
13
30
  }
14
31
  // https://www.mongodb.com/docs/manual/reference/command/connectionStatus/
@@ -1 +1 @@
1
- {"version":3,"file":"replication-utils.js","sourceRoot":"","sources":["../../src/replication/replication-utils.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"replication-utils.js","sourceRoot":"","sources":["../../src/replication/replication-utils.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@powersync/service-module-mongodb",
3
3
  "repository": "https://github.com/powersync-ja/powersync-service",
4
- "version": "0.18.1",
4
+ "version": "0.19.0",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",
7
7
  "publishConfig": {
@@ -15,17 +15,17 @@
15
15
  "bson": "^6.10.4",
16
16
  "ts-codec": "^1.3.0",
17
17
  "uuid": "^14.0.0",
18
- "@powersync/lib-service-mongodb": "0.6.27",
19
- "@powersync/lib-services-framework": "0.9.6",
20
- "@powersync/service-core": "1.23.1",
18
+ "@powersync/lib-services-framework": "0.9.8",
19
+ "@powersync/lib-service-mongodb": "0.6.29",
20
+ "@powersync/service-core": "1.23.3",
21
21
  "@powersync/service-jsonbig": "0.17.13",
22
- "@powersync/service-sync-rules": "0.38.0",
23
- "@powersync/service-types": "0.16.0"
22
+ "@powersync/service-sync-rules": "0.39.0",
23
+ "@powersync/service-types": "0.16.1"
24
24
  },
25
25
  "devDependencies": {
26
- "@powersync/service-core-tests": "0.17.0",
27
- "@powersync/service-module-mongodb-storage": "0.18.1",
28
- "@powersync/service-module-postgres-storage": "0.16.1"
26
+ "@powersync/service-core-tests": "0.17.2",
27
+ "@powersync/service-module-mongodb-storage": "0.18.3",
28
+ "@powersync/service-module-postgres-storage": "0.16.3"
29
29
  },
30
30
  "scripts": {
31
31
  "build": "tsc -b",
@@ -4,11 +4,12 @@ import { api, ParseSyncConfigOptions, ReplicationHeadCallback } from '@powersync
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;
@@ -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,6 +119,11 @@ 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;
@@ -120,6 +131,7 @@ export class ChangeStream {
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;
@@ -169,6 +181,37 @@ export class ChangeStream {
169
181
  return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
170
182
  }
171
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
+
172
215
  private getSourceNamespaceFilters(): { $match: any; multipleDatabases: boolean } {
173
216
  const sourceTables = this.sync_rules.getSourceTables();
174
217
 
@@ -209,7 +252,15 @@ export class ChangeStream {
209
252
  // For details, see:
210
253
  // https://github.com/powersync-ja/powersync-service/pull/417
211
254
  // https://jira.mongodb.org/browse/SERVER-114532
212
- 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
213
264
  ? // cluster-level: filter on the entire namespace
214
265
  { ns: { $in: $inFilters } }
215
266
  : // collection-level: filter on coll only
@@ -465,15 +516,18 @@ export class ChangeStream {
465
516
  signal?: AbortSignal;
466
517
  tracer?: PerformanceTracer<'changestream'>;
467
518
  }): AsyncIterableIterator<ChangeStreamBatch> {
468
- const lastLsn = options.lsn ? MongoLSN.fromSerialized(options.lsn) : null;
469
- const startAfter = lastLsn?.timestamp;
470
- 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;
471
522
 
472
523
  const filters = options.filters;
473
524
 
474
525
  let fullDocument: 'required' | 'updateLookup';
475
526
 
476
- 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) {
477
531
  // 'read_only' or 'auto_configure'
478
532
  // Configuration happens during snapshot, or when we see new
479
533
  // collections.
@@ -482,42 +536,56 @@ export class ChangeStream {
482
536
  fullDocument = 'updateLookup';
483
537
  }
484
538
  const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
485
- showExpandedEvents: true,
486
539
  fullDocument: fullDocument
487
540
  };
541
+ if (!this.isDocumentDb) {
542
+ // DocumentDB does not support showExpandedEvents.
543
+ streamOptions.showExpandedEvents = true;
544
+ }
488
545
  const pipeline: mongo.Document[] = [
489
546
  {
490
547
  $changeStream: streamOptions
491
548
  },
492
549
  {
493
550
  $match: filters.$match
494
- },
495
- { $changeStreamSplitLargeEvent: {} }
551
+ }
496
552
  ];
553
+ if (!this.isDocumentDb) {
554
+ // DocumentDB does not support $changeStreamSplitLargeEvent.
555
+ pipeline.push({ $changeStreamSplitLargeEvent: {} });
556
+ }
497
557
 
498
558
  /**
499
559
  * Only one of these options can be supplied at a time.
500
560
  */
501
561
  if (resumeAfter) {
502
562
  streamOptions.resumeAfter = resumeAfter;
503
- } else {
563
+ } else if (startAfter != null) {
504
564
  // Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
505
565
  // case if we have an old one.
506
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.
507
569
  streamOptions.startAtOperationTime = startAfter;
508
570
  }
509
571
 
510
572
  let watchDb: mongo.Db;
511
- if (filters.multipleDatabases) {
573
+ if (this.isDocumentDb || filters.multipleDatabases) {
574
+ // DocumentDB only supports cluster-level change streams.
512
575
  watchDb = this.client.db('admin');
513
576
  streamOptions.allChangesForCluster = true;
514
577
  } else {
515
578
  watchDb = this.defaultDb;
516
579
  }
517
580
 
581
+ const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
582
+
518
583
  return rawChangeStream(watchDb, pipeline, {
519
584
  batchSize: options.batchSize ?? this.snapshotChunkLength,
520
- 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,
521
589
  maxTimeMS: this.changeStreamTimeout,
522
590
 
523
591
  signal: options.signal,
@@ -531,6 +599,7 @@ export class ChangeStream {
531
599
  }
532
600
 
533
601
  async streamChangesInternal() {
602
+ await this.ensureDetected();
534
603
  const transactionsReplicatedMetric = this.metrics.getCounter(ReplicationMetric.TRANSACTIONS_REPLICATED);
535
604
  const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
536
605
  const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
@@ -541,7 +610,7 @@ export class ChangeStream {
541
610
  await this.storage.startBatch(
542
611
  {
543
612
  logger: this.logger,
544
- zeroLSN: MongoLSN.ZERO.comparable,
613
+ zeroLSN: this.checkpointImplementation.zeroLsn,
545
614
  defaultSchema: this.defaultDb.databaseName,
546
615
  // We get a complete postimage for every change, so we don't need to store the current data.
547
616
  storeCurrentData: false,
@@ -553,15 +622,14 @@ export class ChangeStream {
553
622
  if (resumeFromLsn == null) {
554
623
  throw new ReplicationAssertionError(`No LSN found to resume from`);
555
624
  }
556
- const lastLsn = MongoLSN.fromSerialized(resumeFromLsn);
557
- 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);
558
630
  let outerSpan = tracer.span('batch');
559
631
 
560
- // It is normal for this to be a minute or two old when there is a low volume
561
- // of ChangeStream events.
562
- const tokenAgeSeconds = Math.round((Date.now() - timestampToDate(startAfter).getTime()) / 1000);
563
-
564
- this.logger.info(`Resume streaming at ${startAfter?.inspect()} / ${lastLsn} | Token age: ${tokenAgeSeconds}s`);
632
+ this.checkpointImplementation.logResume(resumeFromLsn);
565
633
 
566
634
  const filters = this.getSourceNamespaceFilters();
567
635
  // This is closed when the for loop below returns/breaks/throws
@@ -575,11 +643,7 @@ export class ChangeStream {
575
643
  // Always start with a checkpoint.
576
644
  // This helps us to clear errors when restarting, even if there is
577
645
  // no data to replicate.
578
- let waitForCheckpointLsn: string | null = await createCheckpoint(
579
- this.client,
580
- this.defaultDb,
581
- this.checkpointStreamId
582
- );
646
+ let waitForCheckpointLsn: string | null = await this.checkpointImplementation.createBatchCheckpoint();
583
647
 
584
648
  let splitDocument: ProjectedChangeStreamDocument | null = null;
585
649
 
@@ -603,22 +667,19 @@ export class ChangeStream {
603
667
  // We do this by persisting a keepalive checkpoint.
604
668
  // If we don't update it on empty events, we do keep consistency, but resuming the stream
605
669
  // with old tokens may cause connection timeouts.
606
- if (waitForCheckpointLsn == null && performance.now() - lastEmptyResume > 60_000) {
607
- const { comparable: lsn, timestamp } = MongoLSN.fromResumeToken(resumeToken);
608
- 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);
609
675
  this.touch();
610
676
  lastEmptyResume = performance.now();
611
- // Log the token update. This helps as a general "replication is still active" message in the logs.
612
- // This token would typically be around 10s behind.
613
- this.logger.info(
614
- `Idle change stream. Persisted resumeToken for ${timestampToDate(timestamp).toISOString()}`
615
- );
616
677
  this.replicationLag.markStarted();
617
678
  }
618
679
 
619
680
  // If we have no changes, we can just persist the keepalive.
620
- // This is throttled to once per minute.
621
- if (performance.now() - lastEmptyResume < 60_000) {
681
+ // This is throttled to once per interval.
682
+ if (performance.now() - lastEmptyResume < this.keepaliveIntervalMs) {
622
683
  continue;
623
684
  }
624
685
  }
@@ -726,9 +787,12 @@ export class ChangeStream {
726
787
  // It may be useful to also throttle commits due to standalone checkpoints in the future.
727
788
  // However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
728
789
 
729
- const checkpointId = changeDocument.documentKey._id as string | mongo.ObjectId;
790
+ const kind = this.checkpointImplementation.event.observe(changeDocument);
730
791
 
731
- if (checkpointId == STANDALONE_CHECKPOINT_ID) {
792
+ if (kind == 'foreign') {
793
+ // Another stream's barrier - ignore.
794
+ continue;
795
+ } else if (kind == 'standalone') {
732
796
  // Standalone / write checkpoint received.
733
797
  // When we are caught up, commit immediately to keep write checkpoint latency low.
734
798
  // Once there is already a batch checkpoint pending, or the driver has buffered more
@@ -738,7 +802,7 @@ export class ChangeStream {
738
802
  if (hasBufferedChanges && waitForCheckpointLsn == null) {
739
803
  // Buffered changes - create a new batch checkpoint to rate limit commits
740
804
  using _ = tracer.span('source_checkpoint');
741
- waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
805
+ waitForCheckpointLsn = await this.checkpointImplementation.createBatchCheckpoint();
742
806
  continue;
743
807
  } else if (waitForCheckpointLsn != null) {
744
808
  // Skip this checkpoint - wait for the batch checkpoint.
@@ -746,15 +810,15 @@ export class ChangeStream {
746
810
  } else {
747
811
  // No buffered changes, and no batch checkpoint pending - commit immediately.
748
812
  }
749
- } else if (!this.checkpointStreamId.equals(checkpointId)) {
750
- continue;
751
813
  }
752
- const { comparable: lsn } = new MongoLSN({
753
- timestamp: changeDocument.clusterTime!,
754
- resume_token: changeDocument._id
755
- });
814
+ // kind == 'own-barrier' falls through to commit.
756
815
 
757
- if (waitForCheckpointLsn != null && lsn >= waitForCheckpointLsn) {
816
+ const lsn = this.checkpointImplementation.event.lsn(changeDocument);
817
+
818
+ if (
819
+ waitForCheckpointLsn != null &&
820
+ this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)
821
+ ) {
758
822
  waitForCheckpointLsn = null;
759
823
  }
760
824
  const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
@@ -772,7 +836,7 @@ export class ChangeStream {
772
836
  ) {
773
837
  if (waitForCheckpointLsn == null) {
774
838
  using _ = tracer.span('source_checkpoint');
775
- waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
839
+ waitForCheckpointLsn = await this.checkpointImplementation.createBatchCheckpoint();
776
840
  }
777
841
 
778
842
  const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
@@ -786,7 +850,11 @@ export class ChangeStream {
786
850
  const tablesToReplicate = tables.filter((table) => table.syncAny);
787
851
  if (tablesToReplicate.length > 0) {
788
852
  this.replicationLag.trackUncommittedChange(
789
- 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)
790
858
  );
791
859
 
792
860
  const transactionKeyValue = transactionKey(changeDocument);
@@ -841,7 +909,7 @@ export class ChangeStream {
841
909
  // Batches are generally large (64MB or 6000 events, whichever comes first),
842
910
  // so this is a good natural point to flush and mark progress.
843
911
  // We avoid this when splitDocument is set, since we cannot resume in the middle of a split event.
844
- const { comparable: lsn } = MongoLSN.fromResumeToken(resumeToken);
912
+ const lsn = this.checkpointImplementation.lsnFromResumeToken(resumeToken);
845
913
  await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
846
914
  // TODO: We should consider making this standard behavior of flush().
847
915
  await batch.setResumeLsn(lsn);