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

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 (105) hide show
  1. package/CHANGELOG.md +95 -7
  2. package/ci/test-connection.yaml +12 -0
  3. package/dist/api/MongoRouteAPIAdapter.d.ts +2 -2
  4. package/dist/api/MongoRouteAPIAdapter.js +73 -143
  5. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  6. package/dist/api/infer-collection-schema.d.ts +12 -0
  7. package/dist/api/infer-collection-schema.js +142 -0
  8. package/dist/api/infer-collection-schema.js.map +1 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.js +1 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/module/MongoModule.d.ts +1 -2
  13. package/dist/module/MongoModule.js +2 -2
  14. package/dist/module/MongoModule.js.map +1 -1
  15. package/dist/replication/ChangeStream.d.ts +18 -2
  16. package/dist/replication/ChangeStream.js +275 -307
  17. package/dist/replication/ChangeStream.js.map +1 -1
  18. package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
  19. package/dist/replication/ChangeStreamReplicationJob.js +6 -2
  20. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  21. package/dist/replication/ChangeStreamReplicator.d.ts +3 -0
  22. package/dist/replication/ChangeStreamReplicator.js +3 -0
  23. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  24. package/dist/replication/MongoRelation.js +5 -2
  25. package/dist/replication/MongoRelation.js.map +1 -1
  26. package/dist/replication/MongoReplicationQueryProvider.d.ts +87 -0
  27. package/dist/replication/MongoReplicationQueryProvider.js +23 -0
  28. package/dist/replication/MongoReplicationQueryProvider.js.map +1 -0
  29. package/dist/replication/MongoReplicationStream.d.ts +85 -0
  30. package/dist/replication/MongoReplicationStream.js +101 -0
  31. package/dist/replication/MongoReplicationStream.js.map +1 -0
  32. package/dist/replication/MongoSnapshotQuery.d.ts +2 -0
  33. package/dist/replication/MongoSnapshotQuery.js +12 -2
  34. package/dist/replication/MongoSnapshotQuery.js.map +1 -1
  35. package/dist/replication/MongoSnapshotter.d.ts +7 -2
  36. package/dist/replication/MongoSnapshotter.js +64 -91
  37. package/dist/replication/MongoSnapshotter.js.map +1 -1
  38. package/dist/replication/RawChangeStream.d.ts +4 -12
  39. package/dist/replication/RawChangeStream.js +1 -18
  40. package/dist/replication/RawChangeStream.js.map +1 -1
  41. package/dist/replication/SourceRowConverter.d.ts +1 -0
  42. package/dist/replication/SourceRowConverter.js +4 -2
  43. package/dist/replication/SourceRowConverter.js.map +1 -1
  44. package/dist/replication/bufferToSqlite.d.ts +3 -1
  45. package/dist/replication/bufferToSqlite.js +35 -23
  46. package/dist/replication/bufferToSqlite.js.map +1 -1
  47. package/dist/replication/checkpoints/CheckpointImplementation.d.ts +4 -1
  48. package/dist/replication/checkpoints/SentinelCheckpointImplementation.d.ts +4 -1
  49. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js +1 -1
  50. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js.map +1 -1
  51. package/dist/replication/checkpoints/TimestampCheckpointImplementation.d.ts +4 -1
  52. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js +2 -1
  53. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js.map +1 -1
  54. package/dist/replication/replication-index.d.ts +6 -0
  55. package/dist/replication/replication-index.js +6 -0
  56. package/dist/replication/replication-index.js.map +1 -1
  57. package/dist/test-utils/ChangeStreamTestContext.d.ts +131 -0
  58. package/dist/test-utils/ChangeStreamTestContext.js +391 -0
  59. package/dist/test-utils/ChangeStreamTestContext.js.map +1 -0
  60. package/dist/test-utils/test-utils-index.d.ts +1 -0
  61. package/dist/test-utils/test-utils-index.js +2 -0
  62. package/dist/test-utils/test-utils-index.js.map +1 -0
  63. package/dist/types/types.d.ts +5 -0
  64. package/dist/types/types.js +20 -2
  65. package/dist/types/types.js.map +1 -1
  66. package/package.json +9 -9
  67. package/src/api/MongoRouteAPIAdapter.ts +79 -127
  68. package/src/api/infer-collection-schema.ts +153 -0
  69. package/src/index.ts +1 -0
  70. package/src/module/MongoModule.ts +3 -4
  71. package/src/replication/ChangeStream.ts +303 -350
  72. package/src/replication/ChangeStreamReplicationJob.ts +9 -3
  73. package/src/replication/ChangeStreamReplicator.ts +5 -0
  74. package/src/replication/MongoRelation.ts +8 -1
  75. package/src/replication/MongoReplicationQueryProvider.ts +108 -0
  76. package/src/replication/MongoReplicationStream.ts +201 -0
  77. package/src/replication/MongoSnapshotQuery.ts +19 -3
  78. package/src/replication/MongoSnapshotter.ts +76 -98
  79. package/src/replication/RawChangeStream.ts +5 -32
  80. package/src/replication/SourceRowConverter.ts +4 -2
  81. package/src/replication/bufferToSqlite.ts +43 -23
  82. package/src/replication/checkpoints/CheckpointImplementation.ts +1 -1
  83. package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +2 -2
  84. package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +3 -2
  85. package/src/replication/replication-index.ts +6 -0
  86. package/src/test-utils/ChangeStreamTestContext.ts +520 -0
  87. package/src/test-utils/test-utils-index.ts +1 -0
  88. package/src/types/types.ts +27 -2
  89. package/test/src/buffer_to_sqlite.test.ts +9 -0
  90. package/test/src/change_stream.test.ts +76 -3
  91. package/test/src/change_stream_test_setup.ts +38 -0
  92. package/test/src/chunked_snapshot.test.ts +3 -3
  93. package/test/src/config.test.ts +34 -0
  94. package/test/src/documentdb_mode.test.ts +4 -5
  95. package/test/src/raw_change_stream.test.ts +0 -63
  96. package/test/src/replication_stream.test.ts +311 -0
  97. package/test/src/resume.test.ts +10 -4
  98. package/test/src/resuming_snapshots.test.ts +3 -3
  99. package/test/src/schema.test.ts +223 -0
  100. package/test/src/slow_tests.test.ts +3 -3
  101. package/test/src/snapshot_query_provider.test.ts +95 -0
  102. package/test/src/stream_progress.test.ts +312 -0
  103. package/test/src/util.ts +11 -29
  104. package/tsconfig.tsbuildinfo +1 -1
  105. package/test/src/change_stream_utils.ts +0 -375
@@ -7,8 +7,9 @@ import { PostImagesOption } from '../types/types.js';
7
7
  import { escapeRegExp } from '../utils.js';
8
8
  import { createCheckpointImplementation } from './checkpoints/create-checkpoint-implementation.js';
9
9
  import { getCacheIdentifier, getMongoRelation } from './MongoRelation.js';
10
+ import { DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER } from './MongoReplicationQueryProvider.js';
11
+ import { openMongoReplicationStream } from './MongoReplicationStream.js';
10
12
  import { MongoSnapshotter } from './MongoSnapshotter.js';
11
- import { parseChangeDocument, rawChangeStream } from './RawChangeStream.js';
12
13
  import { CHECKPOINTS_COLLECTION, detectDocumentDb, timestampToDate } from './replication-utils.js';
13
14
  import { DirectSourceRowConverter } from './SourceRowConverter.js';
14
15
  /**
@@ -49,23 +50,43 @@ export class ChangeStream {
49
50
  changeStreamTimeout;
50
51
  storageHooks;
51
52
  sourceRowConverter;
53
+ queryProvider;
52
54
  keepaliveIntervalMs;
53
55
  isDocumentDb = false;
54
56
  _checkpointImplementation = null;
57
+ /**
58
+ * Last persisted resume timestamp as Date.now(). This represents the equivalent timestamp from the
59
+ * source database, rather than the time when we persisted it.
60
+ */
61
+ lastPersistedResumeTimestamp = 0;
62
+ lastBatchCheckpoint = 0;
55
63
  constructor(options) {
56
64
  this.storage = options.storage;
57
65
  this.metrics = options.metrics;
58
66
  this.group_id = options.storage.replicationStreamId;
59
67
  this.connections = options.connections;
60
- this.maxAwaitTimeMS = options.maxAwaitTimeMS ?? 10_000;
61
68
  this.snapshotChunkLength = options.snapshotChunkLength ?? 6_000;
62
69
  this.keepaliveIntervalMs = options.keepaliveIntervalMs ?? 60_000;
70
+ if (options.maxAwaitTimeMS) {
71
+ this.maxAwaitTimeMS = options.maxAwaitTimeMS;
72
+ }
73
+ else {
74
+ // Default to 10s, unless keepaliveIntervalMs is shorter. In that case, use keepaliveIntervalMs,
75
+ // but add a bit of a margin, so that our keepalive logic is triggered on each empty change stream batch.
76
+ this.maxAwaitTimeMS = Math.min(10_000, this.keepaliveIntervalMs + 100);
77
+ }
63
78
  this.storageHooks = options.storageHooks;
64
79
  this.client = this.connections.client;
65
80
  this.defaultDb = this.connections.db;
66
81
  this.sync_rules = options.storage.getParsedSyncRules({
67
82
  defaultSchema: this.defaultDb.databaseName
68
83
  });
84
+ this.queryProvider =
85
+ options.createReplicationQueryProvider?.({
86
+ syncConfig: this.sync_rules,
87
+ connectionTag: this.connections.connectionTag,
88
+ defaultSchema: this.defaultDb.databaseName
89
+ }) ?? DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER;
69
90
  this.sourceRowConverter = new DirectSourceRowConverter(this.sync_rules.compatibility);
70
91
  // The change stream aggregation command should timeout before the socket times out,
71
92
  // so we use 90% of the socket timeout value.
@@ -79,7 +100,8 @@ export class ChangeStream {
79
100
  ...options,
80
101
  abortSignal: this.abortSignal,
81
102
  logger: snapshotLogger,
82
- checkpointStreamId: this.checkpointStreamId
103
+ checkpointStreamId: this.checkpointStreamId,
104
+ queryProvider: this.queryProvider
83
105
  });
84
106
  options.abort_signal.addEventListener('abort', () => {
85
107
  this.abortController.abort(options.abort_signal.reason);
@@ -97,7 +119,9 @@ export class ChangeStream {
97
119
  get configurePostImages() {
98
120
  return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
99
121
  }
100
- /** The active checkpoint strategy. Only valid after ensureDetected(). */
122
+ /**
123
+ * The active checkpoint strategy. Only valid after ensureDetected().
124
+ */
101
125
  get checkpointImplementation() {
102
126
  if (this._checkpointImplementation == null) {
103
127
  throw new ReplicationAssertionError('Checkpoint implementation not initialized - call ensureDetected() first');
@@ -115,8 +139,9 @@ export class ChangeStream {
115
139
  }
116
140
  this.isDocumentDb = await detectDocumentDb(this.defaultDb);
117
141
  if (this.isDocumentDb) {
118
- this.logger.warn('Azure DocumentDB support is experimental. APIs and behavior may change, and long-term stability is not yet guaranteed.');
142
+ this.logger.warn('Azure DocumentDB support is in alpha. APIs and behavior may change, and long-term stability is not yet guaranteed.');
119
143
  }
144
+ await this.queryProvider.validateSource?.({ connectionManager: this.connections, isDocumentDb: this.isDocumentDb });
120
145
  this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
121
146
  client: this.client,
122
147
  db: this.defaultDb,
@@ -377,89 +402,40 @@ export class ChangeStream {
377
402
  catch (e) {
378
403
  if (e instanceof mongo.MongoServerError &&
379
404
  e.codeName == 'NoMatchingDocument' &&
380
- e.errmsg?.includes('post-image was not found')) {
405
+ (e.errmsg?.includes('post-image was not found') || e.errmsg?.includes('pre-image was not found'))) {
381
406
  throw new ChangeStreamInvalidatedError(e.errmsg, e);
382
407
  }
383
408
  throw e;
384
409
  }
385
410
  }
386
- rawChangeStreamBatches(options) {
387
- const position = options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null;
388
- const startAfter = position?.startAfter ?? undefined;
389
- const resumeAfter = position?.resumeAfter ?? undefined;
390
- const filters = options.filters;
391
- let fullDocument;
392
- if (this.isDocumentDb) {
393
- // DocumentDB does not support changeStreamPreAndPostImages, so 'required' won't work.
394
- fullDocument = 'updateLookup';
395
- }
396
- else if (this.usePostImages) {
397
- // 'read_only' or 'auto_configure'
398
- // Configuration happens during snapshot, or when we see new
399
- // collections.
400
- fullDocument = 'required';
401
- }
402
- else {
403
- fullDocument = 'updateLookup';
404
- }
405
- const streamOptions = {
406
- fullDocument: fullDocument
407
- };
408
- if (!this.isDocumentDb) {
409
- // DocumentDB does not support showExpandedEvents.
410
- streamOptions.showExpandedEvents = true;
411
- }
412
- const pipeline = [
413
- {
414
- $changeStream: streamOptions
415
- },
416
- {
417
- $match: filters.$match
411
+ openChangeStream(options) {
412
+ return openMongoReplicationStream({
413
+ db: this.defaultDb,
414
+ queryProvider: this.queryProvider,
415
+ namespaceFilter: options.filters,
416
+ isDocumentDb: this.isDocumentDb,
417
+ usePostImages: this.usePostImages,
418
+ position: options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null,
419
+ skipInitialTimestamp: true,
420
+ onBatch: options.onBatch,
421
+ options: {
422
+ batchSize: options.batchSize ?? this.snapshotChunkLength,
423
+ maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
424
+ maxTimeMS: this.changeStreamTimeout,
425
+ signal: options.signal,
426
+ logger: this.logger,
427
+ tracer: options.tracer
418
428
  }
419
- ];
420
- if (!this.isDocumentDb) {
421
- // DocumentDB does not support $changeStreamSplitLargeEvent.
422
- pipeline.push({ $changeStreamSplitLargeEvent: {} });
423
- }
424
- /**
425
- * Only one of these options can be supplied at a time.
426
- */
427
- if (resumeAfter) {
428
- streamOptions.resumeAfter = resumeAfter;
429
- }
430
- else if (startAfter != null) {
431
- // Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
432
- // case if we have an old one.
433
- // This is also relevant for getSnapshotLSN().
434
- // The sentinel implementation never produces a startAfter, and a fresh DocumentDB stream
435
- // opens from "now" with neither option set.
436
- streamOptions.startAtOperationTime = startAfter;
437
- }
438
- let watchDb;
439
- if (this.isDocumentDb || filters.multipleDatabases) {
440
- // DocumentDB only supports cluster-level change streams.
441
- watchDb = this.client.db('admin');
442
- streamOptions.allChangesForCluster = true;
443
- }
444
- else {
445
- watchDb = this.defaultDb;
446
- }
447
- const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
448
- return rawChangeStream(watchDb, pipeline, {
449
- batchSize: options.batchSize ?? this.snapshotChunkLength,
450
- maxAwaitTimeMS,
451
- // maxAwaitTimeMS can be 0 for probe-style streams that do not want an idle wait.
452
- // In that case there is no client-side wait to emulate for DocumentDB.
453
- clientSideMaxAwaitTimeMS: this.isDocumentDb && maxAwaitTimeMS > 0,
454
- maxTimeMS: this.changeStreamTimeout,
455
- signal: options.signal,
456
- logger: this.logger,
457
- tracer: options.tracer
458
429
  });
459
430
  }
460
431
  rawToSqliteRow(row) {
461
432
  return this.sourceRowConverter.rawToSqliteRow(row);
462
433
  }
434
+ async createBatchCheckpoint() {
435
+ const checkpoint = await this.checkpointImplementation.createBatchCheckpoint();
436
+ this.lastBatchCheckpoint = Date.now();
437
+ return checkpoint;
438
+ }
463
439
  async streamChangesInternal() {
464
440
  await this.ensureDetected();
465
441
  const transactionsReplicatedMetric = this.metrics.getCounter(ReplicationMetric.TRANSACTIONS_REPLICATED);
@@ -473,276 +449,245 @@ export class ChangeStream {
473
449
  // We get a complete postimage for every change, so we don't need to store the current data.
474
450
  storeCurrentData: false,
475
451
  hooks: this.storageHooks,
452
+ signal: this.abortSignal,
476
453
  tracer
477
454
  }, async (batch) => {
478
455
  const { resumeFromLsn } = batch;
479
456
  if (resumeFromLsn == null) {
480
457
  throw new ReplicationAssertionError(`No LSN found to resume from`);
481
458
  }
482
- // Seed the implementation's coordinate state from the stored LSN, and parse
483
- // the legacy startAfter timestamp (timestamp implementation only) for the
484
- // resume-boundary dedupe guard below.
459
+ // Seed the checkpoint strategy's coordinate from the durable source position.
485
460
  this.checkpointImplementation.seedPosition(resumeFromLsn);
486
- const { startAfter } = this.checkpointImplementation.parseResumePosition(resumeFromLsn);
487
461
  let outerSpan = tracer.span('batch');
488
462
  this.checkpointImplementation.logResume(resumeFromLsn);
489
463
  const filters = this.getSourceNamespaceFilters();
490
464
  // This is closed when the for loop below returns/breaks/throws
491
- const batchStream = this.rawChangeStreamBatches({
465
+ let processingSpan;
466
+ let receivedBytes = 0;
467
+ let changesSinceProgress = 0;
468
+ const stream = this.openChangeStream({
492
469
  lsn: resumeFromLsn,
493
470
  filters,
494
471
  signal: this.abortSignal,
495
- tracer
472
+ tracer,
473
+ onBatch: (batch) => {
474
+ // Count actual transport, even if the adapter drops every envelope or reassembles fragments.
475
+ bytesReplicatedMetric.add(batch.byteSize);
476
+ chunksReplicatedMetric.add(1);
477
+ receivedBytes += batch.byteSize;
478
+ processingSpan = tracer.span('processing');
479
+ return processingSpan;
480
+ }
496
481
  });
497
482
  // Always start with a checkpoint.
498
483
  // This helps us to clear errors when restarting, even if there is
499
484
  // no data to replicate.
500
- let waitForCheckpointLsn = await this.checkpointImplementation.createBatchCheckpoint();
501
- let splitDocument = null;
502
- let flexDbNameWorkaroundLogged = false;
503
- let lastEmptyResume = performance.now();
485
+ let waitForCheckpointLsn = await this.createBatchCheckpoint();
486
+ let lastKeepalive = performance.now();
504
487
  let lastTxnKey = null;
505
- for await (let eventBatch of batchStream) {
506
- const { events, resumeToken } = eventBatch;
507
- using batchSpan = tracer.span('processing');
508
- bytesReplicatedMetric.add(eventBatch.byteSize);
509
- chunksReplicatedMetric.add(1);
510
- if (this.abortSignal.aborted) {
488
+ for await (const item of stream) {
489
+ if (this.abortSignal.aborted)
511
490
  break;
512
- }
513
491
  this.touch();
514
- if (events.length == 0) {
515
- // No changes in this batch, but we still want to keep the connection alive.
516
- // We do this by persisting a keepalive checkpoint.
517
- // If we don't update it on empty events, we do keep consistency, but resuming the stream
518
- // with old tokens may cause connection timeouts.
519
- if (waitForCheckpointLsn == null && performance.now() - lastEmptyResume > this.keepaliveIntervalMs) {
520
- // The implementation persists a keepalive (timestamp) or bumps the
521
- // sentinel so a later event commits (sentinel). Logging is handled
522
- // inside the implementation.
523
- await this.checkpointImplementation.keepalive(batch, resumeToken);
524
- this.touch();
525
- lastEmptyResume = performance.now();
526
- this.replicationLag.markStarted();
527
- }
528
- // If we have no changes, we can just persist the keepalive.
529
- // This is throttled to once per interval.
530
- if (performance.now() - lastEmptyResume < this.keepaliveIntervalMs) {
531
- continue;
492
+ if (item.type == 'progress') {
493
+ const { resumeToken, filteredCount } = item;
494
+ if (changesSinceProgress == 0) {
495
+ // No retained changes since the previous progress item: this is either idle or filtered-only traffic.
496
+ // Case 1: No pending barrier and the keepalive interval has elapsed. Advance checkpoints as below,
497
+ // then flush and save the resume token.
498
+ // Case 2: Idle traffic with a recent keepalive. Skip this resume update, whether or not a barrier
499
+ // is pending, to preserve the idle throttle.
500
+ // Case 3: All other combinations. Flush and save the resume token without requesting a checkpoint.
501
+ // A pending barrier will provide the checkpoint boundary once replication reaches it. Filtered
502
+ // progress still saves its token even during the keepalive interval, so excluded work is resumable.
503
+ const hadRecentKeepalive = performance.now() - lastKeepalive < this.keepaliveIntervalMs;
504
+ if (waitForCheckpointLsn == null && !hadRecentKeepalive) {
505
+ if (filteredCount > 0) {
506
+ // Case 1a: A transaction can span batches: this batch may contain only filtered changes,
507
+ // while retained changes from the same transaction are still unread. They share a timestamp,
508
+ // so publishing a checkpoint here could acknowledge the transaction before its data is saved.
509
+ // Request a source barrier; reaching it ensures those retained changes have been processed.
510
+ // The progress token can still be saved below for recovery without publishing a checkpoint.
511
+ using _ = tracer.span('source_checkpoint');
512
+ waitForCheckpointLsn = await this.createBatchCheckpoint();
513
+ }
514
+ else {
515
+ // Case 1b: Idle traffic. The timestamp implementation persists a keepalive directly;
516
+ // the sentinel implementation writes a source marker whose event will commit later.
517
+ await this.checkpointImplementation.keepalive(batch, resumeToken);
518
+ this.replicationLag.markStarted();
519
+ }
520
+ this.touch();
521
+ lastKeepalive = performance.now();
522
+ }
523
+ else if (filteredCount == 0 && hadRecentKeepalive) {
524
+ // Case 2: Only idle progress skips resume persistence. Cases 1 and 3 fall through below.
525
+ continue;
526
+ }
532
527
  }
528
+ const { lsn, timestamp } = this.checkpointImplementation.lsnFromResumeToken(resumeToken);
529
+ // Row writes must be durable before their source token. This advances recovery without
530
+ // publishing a checkpoint ahead of an outstanding barrier, transaction or snapshot.
531
+ await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
532
+ await batch.setResumeLsn(lsn);
533
+ // MongoDB's token timestamp may lag by about 10 seconds. DocumentDB tokens have no timestamp.
534
+ this.lastPersistedResumeTimestamp = timestamp?.getTime() ?? Date.now();
535
+ processingSpan?.end();
536
+ const durationsMicroseconds = outerSpan.end();
537
+ this.logger.info(`Processed ${changesSinceProgress} changes and ${filteredCount} filtered events`, {
538
+ count: changesSinceProgress,
539
+ filteredCount,
540
+ bytes: receivedBytes,
541
+ duration: processingSpan?.durationMillis,
542
+ t: durationsMicroseconds
543
+ });
544
+ changesSinceProgress = 0;
545
+ receivedBytes = 0;
546
+ outerSpan = tracer.span('batch');
547
+ continue;
533
548
  }
534
- this.touch();
535
- for (let eventIndex = 0; eventIndex < events.length; eventIndex++) {
536
- const rawChangeDocument = events[eventIndex];
537
- const originalChangeDocument = parseChangeDocument(rawChangeDocument);
538
- if (this.abortSignal.aborted) {
539
- break;
549
+ // The shared reader has already reassembled complete events and normalized their namespaces.
550
+ const changeDocument = item.event;
551
+ changesSinceProgress++;
552
+ const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
553
+ if (ns?.coll == CHECKPOINTS_COLLECTION) {
554
+ /**
555
+ * Dropping the database does not provide an `invalidate` event.
556
+ * We typically would receive `drop` events for the collection which we
557
+ * would process below.
558
+ *
559
+ * However we don't commit the LSN after collections are dropped.
560
+ * This prevents the `startAfter` or `resumeToken` from advancing past the drop events.
561
+ * The stream also closes after the drop events.
562
+ * This causes an infinite loop of processing the collection drop events.
563
+ *
564
+ * This check here invalidates the change stream if our `_powersync_checkpoints` collection
565
+ * is dropped. This allows for detecting when the DB is dropped.
566
+ */
567
+ if (changeDocument.operationType == 'drop') {
568
+ throw new ChangeStreamInvalidatedError('Internal collections have been dropped', new Error('_powersync_checkpoints collection was dropped'));
540
569
  }
541
- if (startAfter != null && originalChangeDocument.clusterTime?.lte(startAfter)) {
570
+ if (!(changeDocument.operationType == 'insert' ||
571
+ changeDocument.operationType == 'update' ||
572
+ changeDocument.operationType == 'replace')) {
542
573
  continue;
543
574
  }
544
- let changeDocument = originalChangeDocument;
545
- if (originalChangeDocument?.splitEvent != null) {
546
- // Handle split events from $changeStreamSplitLargeEvent.
547
- // This is only relevant for very large update operations.
548
- const splitEvent = originalChangeDocument?.splitEvent;
549
- if (splitDocument == null) {
550
- splitDocument = originalChangeDocument;
551
- }
552
- else {
553
- splitDocument = Object.assign(splitDocument, originalChangeDocument);
575
+ // We handle two types of checkpoint events:
576
+ // 1. "Standalone" checkpoints, typically write checkpoints. We want to process these
577
+ // immediately, regardless of where they were created.
578
+ // 2. "Batch" checkpoints for the current stream. This is used as a form of dynamic rate
579
+ // limiting of commits, so we specifically want to exclude checkpoints from other streams.
580
+ //
581
+ // It may be useful to also throttle commits due to standalone checkpoints in the future.
582
+ // However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
583
+ const kind = this.checkpointImplementation.event.observe(changeDocument);
584
+ if (kind == 'foreign') {
585
+ // Another stream's barrier - ignore.
586
+ continue;
587
+ }
588
+ else if (kind == 'standalone') {
589
+ // Standalone / write checkpoint received.
590
+ // When we are caught up, commit immediately to keep write checkpoint latency low.
591
+ // Once there is already a batch checkpoint pending, or the driver has buffered more
592
+ // change stream events, collapse standalone checkpoints into the normal batch
593
+ // checkpoint flow to avoid commit churn under sustained load.
594
+ const hasBufferedChanges = item.hasBufferedChanges;
595
+ if (hasBufferedChanges && waitForCheckpointLsn == null) {
596
+ // Buffered changes - create a new batch checkpoint to rate limit commits
597
+ using _ = tracer.span('source_checkpoint');
598
+ waitForCheckpointLsn = await this.createBatchCheckpoint();
599
+ continue;
554
600
  }
555
- if (splitEvent.fragment == splitEvent.of) {
556
- // Got all fragments
557
- changeDocument = splitDocument;
558
- splitDocument = null;
601
+ else if (waitForCheckpointLsn != null) {
602
+ // Skip this checkpoint - wait for the batch checkpoint.
603
+ continue;
559
604
  }
560
605
  else {
561
- // Wait for more fragments
562
- continue;
606
+ // No buffered changes, and no batch checkpoint pending - commit immediately.
563
607
  }
564
608
  }
565
- else if (splitDocument != null) {
566
- // We were waiting for fragments, but got a different event
567
- throw new ReplicationAssertionError(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
609
+ // kind == 'own-barrier' falls through to commit.
610
+ const lsn = this.checkpointImplementation.event.lsn(changeDocument);
611
+ if (waitForCheckpointLsn != null &&
612
+ this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)) {
613
+ waitForCheckpointLsn = null;
568
614
  }
569
- if (!filters.multipleDatabases &&
570
- 'ns' in changeDocument &&
571
- changeDocument.ns.db != this.defaultDb.databaseName &&
572
- changeDocument.ns.db.endsWith(`_${this.defaultDb.databaseName}`)) {
573
- // When all of the following conditions are met:
574
- // 1. We're replicating from an Atlas Flex instance.
575
- // 2. There were changestream events recorded while the PowerSync service is paused.
576
- // 3. We're only replicating from a single database.
577
- // Then we've observed an ns with for example {db: '67b83e86cd20730f1e766dde_ps'},
578
- // instead of the expected {db: 'ps'}.
579
- // We correct this.
580
- changeDocument.ns.db = this.defaultDb.databaseName;
581
- if (!flexDbNameWorkaroundLogged) {
582
- flexDbNameWorkaroundLogged = true;
583
- this.logger.warn(`Incorrect DB name in change stream: ${changeDocument.ns.db}. Changed to ${this.defaultDb.databaseName}.`);
584
- }
615
+ const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
616
+ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
617
+ });
618
+ if (!checkpointBlocked || checkpointCreated) {
619
+ this.replicationLag.markCommitted();
585
620
  }
586
- const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
587
- if (ns?.coll == CHECKPOINTS_COLLECTION) {
588
- /**
589
- * Dropping the database does not provide an `invalidate` event.
590
- * We typically would receive `drop` events for the collection which we
591
- * would process below.
592
- *
593
- * However we don't commit the LSN after collections are dropped.
594
- * This prevents the `startAfter` or `resumeToken` from advancing past the drop events.
595
- * The stream also closes after the drop events.
596
- * This causes an infinite loop of processing the collection drop events.
597
- *
598
- * This check here invalidates the change stream if our `_powersync_checkpoints` collection
599
- * is dropped. This allows for detecting when the DB is dropped.
600
- */
601
- if (changeDocument.operationType == 'drop') {
602
- throw new ChangeStreamInvalidatedError('Internal collections have been dropped', new Error('_powersync_checkpoints collection was dropped'));
603
- }
604
- if (!(changeDocument.operationType == 'insert' ||
605
- changeDocument.operationType == 'update' ||
606
- changeDocument.operationType == 'replace')) {
607
- continue;
608
- }
609
- // We handle two types of checkpoint events:
610
- // 1. "Standalone" checkpoints, typically write checkpoints. We want to process these
611
- // immediately, regardless of where they were created.
612
- // 2. "Batch" checkpoints for the current stream. This is used as a form of dynamic rate
613
- // limiting of commits, so we specifically want to exclude checkpoints from other streams.
614
- //
615
- // It may be useful to also throttle commits due to standalone checkpoints in the future.
616
- // However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
617
- const kind = this.checkpointImplementation.event.observe(changeDocument);
618
- if (kind == 'foreign') {
619
- // Another stream's barrier - ignore.
620
- continue;
621
- }
622
- else if (kind == 'standalone') {
623
- // Standalone / write checkpoint received.
624
- // When we are caught up, commit immediately to keep write checkpoint latency low.
625
- // Once there is already a batch checkpoint pending, or the driver has buffered more
626
- // change stream events, collapse standalone checkpoints into the normal batch
627
- // checkpoint flow to avoid commit churn under sustained load.
628
- const hasBufferedChanges = eventIndex < events.length - 1;
629
- if (hasBufferedChanges && waitForCheckpointLsn == null) {
630
- // Buffered changes - create a new batch checkpoint to rate limit commits
631
- using _ = tracer.span('source_checkpoint');
632
- waitForCheckpointLsn = await this.checkpointImplementation.createBatchCheckpoint();
633
- continue;
634
- }
635
- else if (waitForCheckpointLsn != null) {
636
- // Skip this checkpoint - wait for the batch checkpoint.
637
- continue;
638
- }
639
- else {
640
- // No buffered changes, and no batch checkpoint pending - commit immediately.
641
- }
642
- }
643
- // kind == 'own-barrier' falls through to commit.
644
- const lsn = this.checkpointImplementation.event.lsn(changeDocument);
645
- if (waitForCheckpointLsn != null &&
646
- this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)) {
647
- waitForCheckpointLsn = null;
648
- }
649
- const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
650
- oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
651
- });
652
- if (!checkpointBlocked || checkpointCreated) {
653
- this.replicationLag.markCommitted();
654
- }
621
+ }
622
+ else if (changeDocument.operationType == 'insert' ||
623
+ changeDocument.operationType == 'update' ||
624
+ changeDocument.operationType == 'replace' ||
625
+ changeDocument.operationType == 'delete') {
626
+ if (waitForCheckpointLsn == null) {
627
+ using _ = tracer.span('source_checkpoint');
628
+ waitForCheckpointLsn = await this.createBatchCheckpoint();
655
629
  }
656
- else if (changeDocument.operationType == 'insert' ||
657
- changeDocument.operationType == 'update' ||
658
- changeDocument.operationType == 'replace' ||
659
- changeDocument.operationType == 'delete') {
660
- if (waitForCheckpointLsn == null) {
661
- using _ = tracer.span('source_checkpoint');
662
- waitForCheckpointLsn = await this.checkpointImplementation.createBatchCheckpoint();
630
+ const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
631
+ const tables = await this.getRelations(batch, rel, {
632
+ // In most cases, we should not need to snapshot this. But if this is the first time we see the collection
633
+ // for whatever reason, then we do need to snapshot it.
634
+ // This may result in some duplicate operations when a collection is created for the first time after
635
+ // sync config was deployed.
636
+ snapshot: true
637
+ });
638
+ const tablesToReplicate = tables.filter((table) => table.syncAny);
639
+ if (tablesToReplicate.length > 0) {
640
+ this.replicationLag.trackUncommittedChange(
641
+ // Standard MongoDB uses clusterTime, unchanged. DocumentDB has no
642
+ // clusterTime, so fall back to wallTime there for the lag metric.
643
+ changeDocument.clusterTime != null
644
+ ? timestampToDate(changeDocument.clusterTime)
645
+ : (changeDocument.wallTime ?? null));
646
+ const transactionKeyValue = transactionKey(changeDocument);
647
+ if (transactionKeyValue == null || lastTxnKey != transactionKeyValue) {
648
+ // Very crude metric for counting transactions replicated.
649
+ // We ignore operations other than basic CRUD, and ignore changes to _powersync_checkpoints.
650
+ // Individual writes may not have a txnNumber, in which case we count them as separate transactions.
651
+ lastTxnKey = transactionKeyValue;
652
+ transactionsReplicatedMetric.add(1);
663
653
  }
664
- const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
665
- const tables = await this.getRelations(batch, rel, {
666
- // In most cases, we should not need to snapshot this. But if this is the first time we see the collection
667
- // for whatever reason, then we do need to snapshot it.
668
- // This may result in some duplicate operations when a collection is created for the first time after
669
- // sync config was deployed.
670
- snapshot: true
671
- });
672
- const tablesToReplicate = tables.filter((table) => table.syncAny);
673
- if (tablesToReplicate.length > 0) {
674
- this.replicationLag.trackUncommittedChange(
675
- // Standard MongoDB uses clusterTime, unchanged. DocumentDB has no
676
- // clusterTime, so fall back to wallTime there for the lag metric.
677
- changeDocument.clusterTime != null
678
- ? timestampToDate(changeDocument.clusterTime)
679
- : (changeDocument.wallTime ?? null));
680
- const transactionKeyValue = transactionKey(changeDocument);
681
- if (transactionKeyValue == null || lastTxnKey != transactionKeyValue) {
682
- // Very crude metric for counting transactions replicated.
683
- // We ignore operations other than basic CRUD, and ignore changes to _powersync_checkpoints.
684
- // Individual writes may not have a txnNumber, in which case we count them as separate transactions.
685
- lastTxnKey = transactionKeyValue;
686
- transactionsReplicatedMetric.add(1);
687
- }
688
- for (const table of tablesToReplicate) {
689
- await this.writeChange(batch, table, changeDocument);
690
- }
691
- }
692
- }
693
- else if (changeDocument.operationType == 'drop') {
694
- const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
695
- const tables = await this.getRelations(batch, rel, {
696
- // We're "dropping" this collection, so never snapshot it.
697
- snapshot: false
698
- });
699
- const tablesToDrop = tables.filter((table) => table.syncAny);
700
- if (tablesToDrop.length > 0) {
701
- await batch.drop(tablesToDrop);
654
+ for (const table of tablesToReplicate) {
655
+ await this.writeChange(batch, table, changeDocument);
702
656
  }
703
- this.relationCache.delete(rel);
704
657
  }
705
- else if (changeDocument.operationType == 'rename') {
706
- const relFrom = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
707
- const relTo = getMongoRelation(changeDocument.to, this.connections.connectionTag);
708
- const tablesFrom = await this.getRelations(batch, relFrom, {
709
- // We're "dropping" this collection, so never snapshot it.
710
- snapshot: false
711
- });
712
- const tablesToDrop = tablesFrom.filter((table) => table.syncAny);
713
- if (tablesToDrop.length > 0) {
714
- await batch.drop(tablesToDrop);
715
- }
716
- this.relationCache.delete(relFrom);
717
- // Here we do need to snapshot the new table
718
- const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
719
- await this.handleRelation(batch, relTo, {
720
- // This is a new (renamed) collection, so always snapshot it.
721
- snapshot: true,
722
- collectionInfo: collection
723
- });
658
+ }
659
+ else if (changeDocument.operationType == 'drop') {
660
+ const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
661
+ const tables = await this.getRelations(batch, rel, {
662
+ // We're "dropping" this collection, so never snapshot it.
663
+ snapshot: false
664
+ });
665
+ const tablesToDrop = tables.filter((table) => table.syncAny);
666
+ if (tablesToDrop.length > 0) {
667
+ await batch.drop(tablesToDrop);
724
668
  }
669
+ this.relationCache.delete(rel);
725
670
  }
726
- if (splitDocument == null) {
727
- // We flush and mark progress on every batch of data we receive.
728
- // Batches are generally large (64MB or 6000 events, whichever comes first),
729
- // so this is a good natural point to flush and mark progress.
730
- // We avoid this when splitDocument is set, since we cannot resume in the middle of a split event.
731
- const lsn = this.checkpointImplementation.lsnFromResumeToken(resumeToken);
732
- await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
733
- // TODO: We should consider making this standard behavior of flush().
734
- await batch.setResumeLsn(lsn);
671
+ else if (changeDocument.operationType == 'rename') {
672
+ const relFrom = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
673
+ const relTo = getMongoRelation(changeDocument.to, this.connections.connectionTag);
674
+ const tablesFrom = await this.getRelations(batch, relFrom, {
675
+ // We're "dropping" this collection, so never snapshot it.
676
+ snapshot: false
677
+ });
678
+ const tablesToDrop = tablesFrom.filter((table) => table.syncAny);
679
+ if (tablesToDrop.length > 0) {
680
+ await batch.drop(tablesToDrop);
681
+ }
682
+ this.relationCache.delete(relFrom);
683
+ // Here we do need to snapshot the new table
684
+ const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
685
+ await this.handleRelation(batch, relTo, {
686
+ // This is a new (renamed) collection, so always snapshot it.
687
+ snapshot: true,
688
+ collectionInfo: collection
689
+ });
735
690
  }
736
- batchSpan.end();
737
- const durationsMicroseconds = outerSpan.end();
738
- const duration = batchSpan.durationMillis;
739
- this.logger.info(`Processed batch of ${events.length} changes / ${eventBatch.byteSize} bytes in ${duration}ms`, {
740
- count: events.length,
741
- bytes: eventBatch.byteSize,
742
- duration,
743
- t: durationsMicroseconds
744
- });
745
- outerSpan = tracer.span('batch');
746
691
  }
747
692
  });
748
693
  throw new ReplicationAbortedError(`Replication stream aborted`, this.abortSignal.reason);
@@ -750,6 +695,29 @@ export class ChangeStream {
750
695
  getReplicationLagMillis() {
751
696
  return this.replicationLag.getLagMillis();
752
697
  }
698
+ async keepAlive() {
699
+ // This is called on an interval of keepaliveIntervalMs.
700
+ // This writes to _powersync_checkpoints.
701
+ // Main use case: When there are massive bulk writes to another database or collection, the change stream may
702
+ // start timing out due to reading through too much data in one batch. This breaks up those bulk writes,
703
+ // allowing the change stream to make progress.
704
+ // When there is low replication traffic, regular calls to setResumeLsn() with recent resume tokens make this unnecessary.
705
+ // We track that using lastPersistedResumeTimestamp.
706
+ // We add a bit of a margin here, since setResumeLsn is not called on an exact interval.
707
+ const staleResumeLsn = Date.now() - this.lastPersistedResumeTimestamp > this.keepaliveIntervalMs * 1.1;
708
+ // When there is replication lag, that resumeToken can get too outdated. In that case, we periodically create a
709
+ // new back checkpoint. This interval is controlled by the frequency of the keepAlive() call,
710
+ // while also skipping if there was another call to createBatchCheckpoint().
711
+ // We use a factor of 0.9 here, to make sure this is called on every keepAlive() interval, unless there
712
+ // was another call to createBatchCheckpoint().
713
+ const staleBatchCheckpoint = Date.now() - this.lastBatchCheckpoint > this.keepaliveIntervalMs * 0.9;
714
+ // We don't use oldestUncommittedChange here, since that may be unset in some edge cases where we do need
715
+ // to persist new checkpoints.
716
+ if (staleResumeLsn && staleBatchCheckpoint) {
717
+ await this.ensureDetected();
718
+ await this.createBatchCheckpoint();
719
+ }
720
+ }
753
721
  lastTouchedAt = performance.now();
754
722
  touch() {
755
723
  if (performance.now() - this.lastTouchedAt > 1_000) {