@powersync/service-module-mongodb 0.21.2 → 0.22.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 (90) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/benchmarks/env.ts +11 -0
  3. package/benchmarks/replication_branch_throughput.benchmark.ts +240 -0
  4. package/dist/api/MongoRouteAPIAdapter.d.ts +6 -0
  5. package/dist/api/MongoRouteAPIAdapter.js +21 -2
  6. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/module/MongoModule.d.ts +1 -1
  11. package/dist/module/MongoModule.js +1 -0
  12. package/dist/module/MongoModule.js.map +1 -1
  13. package/dist/replication/ChangeStream.d.ts +13 -3
  14. package/dist/replication/ChangeStream.js +130 -196
  15. package/dist/replication/ChangeStream.js.map +1 -1
  16. package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
  17. package/dist/replication/ChangeStreamReplicationJob.js +19 -8
  18. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  19. package/dist/replication/ChangeStreamReplicator.d.ts +4 -0
  20. package/dist/replication/ChangeStreamReplicator.js +6 -0
  21. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  22. package/dist/replication/MongoReplicationQueryProvider.d.ts +85 -0
  23. package/dist/replication/MongoReplicationQueryProvider.js +23 -0
  24. package/dist/replication/MongoReplicationQueryProvider.js.map +1 -0
  25. package/dist/replication/MongoReplicationStream.d.ts +74 -0
  26. package/dist/replication/MongoReplicationStream.js +228 -0
  27. package/dist/replication/MongoReplicationStream.js.map +1 -0
  28. package/dist/replication/MongoReplicationStreamTypes.d.ts +157 -0
  29. package/dist/replication/MongoReplicationStreamTypes.js +19 -0
  30. package/dist/replication/MongoReplicationStreamTypes.js.map +1 -0
  31. package/dist/replication/MongoSnapshotQuery.d.ts +2 -0
  32. package/dist/replication/MongoSnapshotQuery.js +12 -2
  33. package/dist/replication/MongoSnapshotQuery.js.map +1 -1
  34. package/dist/replication/MongoSnapshotter.d.ts +7 -2
  35. package/dist/replication/MongoSnapshotter.js +90 -88
  36. package/dist/replication/MongoSnapshotter.js.map +1 -1
  37. package/dist/replication/RawChangeStream.d.ts +1 -1
  38. package/dist/replication/RawChangeStream.js +1 -1
  39. package/dist/replication/RawChangeStream.js.map +1 -1
  40. package/dist/replication/estimateFilteredSnapshotCount.d.ts +12 -0
  41. package/dist/replication/estimateFilteredSnapshotCount.js +79 -0
  42. package/dist/replication/estimateFilteredSnapshotCount.js.map +1 -0
  43. package/dist/replication/replication-index.d.ts +7 -0
  44. package/dist/replication/replication-index.js +7 -0
  45. package/dist/replication/replication-index.js.map +1 -1
  46. package/dist/test-utils/ChangeStreamTestContext.d.ts +131 -0
  47. package/dist/test-utils/ChangeStreamTestContext.js +390 -0
  48. package/dist/test-utils/ChangeStreamTestContext.js.map +1 -0
  49. package/dist/test-utils/test-utils-index.d.ts +1 -0
  50. package/dist/test-utils/test-utils-index.js +2 -0
  51. package/dist/test-utils/test-utils-index.js.map +1 -0
  52. package/package.json +11 -10
  53. package/src/api/MongoRouteAPIAdapter.ts +26 -2
  54. package/src/index.ts +1 -0
  55. package/src/module/MongoModule.ts +2 -1
  56. package/src/replication/ChangeStream.ts +143 -226
  57. package/src/replication/ChangeStreamReplicationJob.ts +21 -9
  58. package/src/replication/ChangeStreamReplicator.ts +9 -0
  59. package/src/replication/MongoReplicationQueryProvider.ts +106 -0
  60. package/src/replication/MongoReplicationStream.ts +314 -0
  61. package/src/replication/MongoReplicationStreamTypes.ts +169 -0
  62. package/src/replication/MongoSnapshotQuery.ts +19 -3
  63. package/src/replication/MongoSnapshotter.ts +101 -92
  64. package/src/replication/RawChangeStream.ts +2 -2
  65. package/src/replication/estimateFilteredSnapshotCount.ts +93 -0
  66. package/src/replication/replication-index.ts +7 -0
  67. package/src/test-utils/ChangeStreamTestContext.ts +520 -0
  68. package/src/test-utils/test-utils-index.ts +1 -0
  69. package/test/src/change_event_memory.test.ts +33 -0
  70. package/test/src/change_stream.test.ts +3 -3
  71. package/test/src/change_stream_abort.test.ts +110 -0
  72. package/test/src/change_stream_test_setup.ts +38 -0
  73. package/test/src/chunked_snapshot.test.ts +3 -3
  74. package/test/src/documentdb_mode.test.ts +3 -3
  75. package/test/src/filtering_example.test.ts +65 -0
  76. package/test/src/filtering_lifecycle.test.ts +101 -0
  77. package/test/src/query_provider_availability.test.ts +77 -0
  78. package/test/src/query_provider_diagnostics.test.ts +55 -0
  79. package/test/src/replication_stream.test.ts +688 -0
  80. package/test/src/resume.test.ts +10 -4
  81. package/test/src/resuming_snapshots.test.ts +3 -3
  82. package/test/src/slow_tests.test.ts +3 -3
  83. package/test/src/snapshot_count.test.ts +146 -0
  84. package/test/src/snapshot_count_integration.test.ts +70 -0
  85. package/test/src/snapshot_query_provider.test.ts +122 -0
  86. package/test/src/stream_progress.test.ts +334 -0
  87. package/test/tsconfig.json +2 -2
  88. package/tsconfig.tsbuildinfo +1 -1
  89. package/vitest.benchmark.config.ts +15 -0
  90. package/test/src/change_stream_utils.ts +0 -375
@@ -0,0 +1 @@
1
+ export * from './ChangeStreamTestContext.js';
@@ -0,0 +1,2 @@
1
+ export * from './ChangeStreamTestContext.js';
2
+ //# sourceMappingURL=test-utils-index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test-utils-index.js","sourceRoot":"","sources":["../../src/test-utils/test-utils-index.ts"],"names":[],"mappings":"AAAA,cAAc,8BAA8B,CAAC"}
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.21.2",
4
+ "version": "0.22.0",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",
7
7
  "publishConfig": {
@@ -15,22 +15,23 @@
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.33",
19
- "@powersync/lib-services-framework": "0.10.3",
20
- "@powersync/service-core": "1.27.0",
18
+ "@powersync/lib-service-mongodb": "0.6.34",
19
+ "@powersync/lib-services-framework": "0.10.4",
20
+ "@powersync/service-core": "1.27.1",
21
21
  "@powersync/service-jsonbig": "0.17.13",
22
- "@powersync/service-sync-rules": "0.43.0",
23
- "@powersync/service-types": "0.19.0"
22
+ "@powersync/service-sync-rules": "0.44.0",
23
+ "@powersync/service-types": "0.20.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@powersync/service-core-tests": "0.18.3",
27
- "@powersync/service-module-mongodb-storage": "0.23.0",
28
- "@powersync/service-module-postgres-storage": "0.19.2"
26
+ "@powersync/service-core-tests": "0.18.4",
27
+ "@powersync/service-module-mongodb-storage": "0.23.1",
28
+ "@powersync/service-module-postgres-storage": "0.19.3"
29
29
  },
30
30
  "scripts": {
31
31
  "build": "tsc -b",
32
32
  "build:tests": "tsc -b test/tsconfig.json",
33
33
  "clean": "rm -rf ./dist && tsc -b --clean",
34
- "test": "vitest"
34
+ "test": "vitest",
35
+ "benchmark:replication": "vitest run --config vitest.benchmark.config.ts"
35
36
  }
36
37
  }
@@ -4,7 +4,7 @@ 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 { logger } from '@powersync/lib-services-framework';
7
+ import { ErrorCode, logger } from '@powersync/lib-services-framework';
8
8
  import { CheckpointImplementation } from '../replication/checkpoints/CheckpointImplementation.js';
9
9
  import { createCheckpointImplementation } from '../replication/checkpoints/create-checkpoint-implementation.js';
10
10
  import { MongoManager } from '../replication/MongoManager.js';
@@ -35,10 +35,34 @@ export class MongoRouteAPIAdapter implements api.RouteAPI {
35
35
 
36
36
  getParseSyncRulesOptions(): ParseSyncConfigOptions {
37
37
  return {
38
- defaultSchema: this.defaultSchema
38
+ defaultSchema: this.defaultSchema,
39
+ connections: { [this.connectionTag]: { type: this.config.type } }
39
40
  };
40
41
  }
41
42
 
43
+ async validateSourceCapabilities(config: sync_rules.SyncConfig): Promise<service_types.ValidationDiagnostic[]> {
44
+ return this.validateMongoFilters(config);
45
+ }
46
+
47
+ /**
48
+ * Validate MongoDB pre-filtering support. External modules can override this method to account for
49
+ * their plan parameters and filter capabilities while retaining other source-capability checks.
50
+ */
51
+ protected async validateMongoFilters(config: sync_rules.SyncConfig): Promise<service_types.ValidationDiagnostic[]> {
52
+ const filtered = sync_rules.hasMongoFilterExpressions(
53
+ config instanceof sync_rules.PrecompiledSyncConfig ? (config.plan.sourceTableConfig ?? {}) : {},
54
+ this.connectionTag
55
+ );
56
+ return filtered
57
+ ? [
58
+ {
59
+ level: 'fatal',
60
+ message: `[${ErrorCode.PSYNC_S1348}] MongoDB replication pre-filtering is not supported in this edition of the PowerSync service.`
61
+ }
62
+ ]
63
+ : [];
64
+ }
65
+
42
66
  async shutdown(): Promise<void> {
43
67
  await this.client.close();
44
68
  }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './api/MongoRouteAPIAdapter.js';
2
2
  export * from './module/MongoModule.js';
3
3
  export * from './replication/replication-index.js';
4
+ export * as test_utils from './test-utils/test-utils-index.js';
4
5
  export * from './types/types.js';
@@ -37,6 +37,7 @@ export class MongoModule extends replication.ReplicationModule<types.MongoConnec
37
37
  id: this.getDefaultId(normalisedConfig.database ?? ''),
38
38
  syncRuleProvider: syncRuleProvider,
39
39
  storageEngine: context.storageEngine,
40
+ configuration: context.configuration,
40
41
  metricsEngine: context.metricsEngine,
41
42
  connectionFactory: connectionFactory,
42
43
  rateLimiter: new MongoErrorRateLimiter(),
@@ -47,7 +48,7 @@ export class MongoModule extends replication.ReplicationModule<types.MongoConnec
47
48
  /**
48
49
  * Combines base config with normalized connection settings
49
50
  */
50
- private resolveConfig(config: types.MongoConnectionConfig): types.ResolvedConnectionConfig {
51
+ protected resolveConfig(config: types.MongoConnectionConfig): types.ResolvedConnectionConfig {
51
52
  return {
52
53
  ...config,
53
54
  ...types.normalizeConnectionConfig(config)
@@ -18,7 +18,7 @@ import {
18
18
  SourceTable,
19
19
  storage
20
20
  } from '@powersync/service-core';
21
- import { HydratedSyncConfig } from '@powersync/service-sync-rules';
21
+ import { hasMongoFilterExpressions, HydratedSyncConfig } from '@powersync/service-sync-rules';
22
22
  import { ReplicationMetric } from '@powersync/service-types';
23
23
  import { performance } from 'node:perf_hooks';
24
24
  import { PostImagesOption } from '../types/types.js';
@@ -27,18 +27,20 @@ import { CheckpointImplementation } from './checkpoints/CheckpointImplementation
27
27
  import { createCheckpointImplementation } from './checkpoints/create-checkpoint-implementation.js';
28
28
  import { MongoManager } from './MongoManager.js';
29
29
  import { getCacheIdentifier, getMongoRelation } from './MongoRelation.js';
30
- import { MongoSnapshotter, MongoSnapshotterHooks } from './MongoSnapshotter.js';
31
30
  import {
32
- ChangeStreamBatch,
33
- parseChangeDocument,
34
- ProjectedChangeStreamDocument,
35
- rawChangeStream
36
- } from './RawChangeStream.js';
31
+ DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER,
32
+ MongoReplicationQueryProvider,
33
+ MongoReplicationQueryProviderFactory
34
+ } from './MongoReplicationQueryProvider.js';
35
+ import { openMongoReplicationStream } from './MongoReplicationStream.js';
36
+ import type { MongoReplicationStreamBatch } from './MongoReplicationStreamTypes.js';
37
+ import { MongoSnapshotter, MongoSnapshotterHooks } from './MongoSnapshotter.js';
38
+ import { ChangeStreamBatch, ProjectedChangeStreamDocument } from './RawChangeStream.js';
37
39
  import { CHECKPOINTS_COLLECTION, detectDocumentDb, timestampToDate } from './replication-utils.js';
38
40
  import { DirectSourceRowConverter, SourceRowConverter } from './SourceRowConverter.js';
39
41
  export interface ChangeStreamOptions {
40
- connections: MongoManager;
41
42
  storage: storage.SyncRulesBucketStorage;
43
+ connections: MongoManager;
42
44
  metrics: MetricsEngine;
43
45
  abort_signal: AbortSignal;
44
46
  /**
@@ -61,6 +63,10 @@ export interface ChangeStreamOptions {
61
63
 
62
64
  storageHooks?: storage.StorageHooks;
63
65
  snapshotHooks?: MongoSnapshotterHooks;
66
+ /**
67
+ * Create one adapter per replication attempt, shared with its snapshotter and source probes.
68
+ */
69
+ createReplicationQueryProvider?: MongoReplicationQueryProviderFactory;
64
70
 
65
71
  logger?: Logger;
66
72
  }
@@ -118,6 +124,8 @@ export class ChangeStream {
118
124
  private storageHooks: storage.StorageHooks | undefined;
119
125
 
120
126
  private readonly sourceRowConverter: SourceRowConverter;
127
+ private readonly queryProvider: MongoReplicationQueryProvider;
128
+ private readonly hasQueryProviderFactory: boolean;
121
129
 
122
130
  private keepaliveIntervalMs: number;
123
131
 
@@ -130,7 +138,7 @@ export class ChangeStream {
130
138
  private lastPersistedResumeTimestamp = 0;
131
139
  private lastBatchCheckpoint = 0;
132
140
 
133
- constructor(options: ChangeStreamOptions) {
141
+ constructor(private readonly options: ChangeStreamOptions) {
134
142
  this.storage = options.storage;
135
143
  this.metrics = options.metrics;
136
144
  this.group_id = options.storage.replicationStreamId;
@@ -150,6 +158,13 @@ export class ChangeStream {
150
158
  this.sync_rules = options.storage.getParsedSyncRules({
151
159
  defaultSchema: this.defaultDb.databaseName
152
160
  });
161
+ this.hasQueryProviderFactory = options.createReplicationQueryProvider != null;
162
+ this.queryProvider =
163
+ options.createReplicationQueryProvider?.({
164
+ syncConfig: this.sync_rules,
165
+ connectionTag: this.connections.connectionTag,
166
+ defaultSchema: this.defaultDb.databaseName
167
+ }) ?? DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER;
153
168
  this.sourceRowConverter = new DirectSourceRowConverter(this.sync_rules.compatibility);
154
169
 
155
170
  // The change stream aggregation command should timeout before the socket times out,
@@ -166,7 +181,8 @@ export class ChangeStream {
166
181
  ...options,
167
182
  abortSignal: this.abortSignal,
168
183
  logger: snapshotLogger,
169
- checkpointStreamId: this.checkpointStreamId
184
+ checkpointStreamId: this.checkpointStreamId,
185
+ queryProvider: this.queryProvider
170
186
  });
171
187
 
172
188
  options.abort_signal.addEventListener(
@@ -193,7 +209,9 @@ export class ChangeStream {
193
209
  return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
194
210
  }
195
211
 
196
- /** The active checkpoint strategy. Only valid after ensureDetected(). */
212
+ /**
213
+ * The active checkpoint strategy. Only valid after ensureDetected().
214
+ */
197
215
  private get checkpointImplementation(): CheckpointImplementation {
198
216
  if (this._checkpointImplementation == null) {
199
217
  throw new ReplicationAssertionError('Checkpoint implementation not initialized - call ensureDetected() first');
@@ -216,6 +234,7 @@ export class ChangeStream {
216
234
  'Azure DocumentDB support is in alpha. APIs and behavior may change, and long-term stability is not yet guaranteed.'
217
235
  );
218
236
  }
237
+ await this.queryProvider.validateSource?.({ connectionManager: this.connections, isDocumentDb: this.isDocumentDb });
219
238
  this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
220
239
  client: this.client,
221
240
  db: this.defaultDb,
@@ -485,6 +504,17 @@ export class ChangeStream {
485
504
  }
486
505
 
487
506
  private async initReplication() {
507
+ // A previously deployed config may outlive the module that provided pre-filtering.
508
+ // Throw inside replicate() so the failure is persisted in replication diagnostics.
509
+ if (
510
+ !this.hasQueryProviderFactory &&
511
+ hasMongoFilterExpressions(this.sync_rules.sourceTableConfig, this.connections.connectionTag)
512
+ ) {
513
+ throw new ServiceError(
514
+ ErrorCode.PSYNC_S1348,
515
+ 'This sync config requires MongoDB pre-filtering, but no query provider factory is registered. Restore the required module, or remove the filters and deploy a new sync config to restart replication.'
516
+ );
517
+ }
488
518
  const result = await this.snapshotter.checkSlot();
489
519
  await this.snapshotter.setupCheckpointsCollection();
490
520
  if (result.needsInitialSync) {
@@ -512,7 +542,7 @@ export class ChangeStream {
512
542
  if (
513
543
  e instanceof mongo.MongoServerError &&
514
544
  e.codeName == 'NoMatchingDocument' &&
515
- e.errmsg?.includes('post-image was not found')
545
+ (e.errmsg?.includes('post-image was not found') || e.errmsg?.includes('pre-image was not found'))
516
546
  ) {
517
547
  throw new ChangeStreamInvalidatedError(e.errmsg, e);
518
548
  }
@@ -520,86 +550,32 @@ export class ChangeStream {
520
550
  }
521
551
  }
522
552
 
523
- private rawChangeStreamBatches(options: {
553
+ private openChangeStream(options: {
524
554
  lsn: string | null;
525
555
  maxAwaitTimeMS?: number;
526
556
  batchSize?: number;
527
557
  filters: { $match: any; multipleDatabases: boolean };
528
558
  signal?: AbortSignal;
529
559
  tracer?: PerformanceTracer<'changestream'>;
530
- }): AsyncIterableIterator<ChangeStreamBatch> {
531
- const position = options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null;
532
- const startAfter = position?.startAfter ?? undefined;
533
- const resumeAfter = position?.resumeAfter ?? undefined;
534
-
535
- const filters = options.filters;
536
-
537
- let fullDocument: 'required' | 'updateLookup';
538
-
539
- if (this.isDocumentDb) {
540
- // DocumentDB does not support changeStreamPreAndPostImages, so 'required' won't work.
541
- fullDocument = 'updateLookup';
542
- } else if (this.usePostImages) {
543
- // 'read_only' or 'auto_configure'
544
- // Configuration happens during snapshot, or when we see new
545
- // collections.
546
- fullDocument = 'required';
547
- } else {
548
- fullDocument = 'updateLookup';
549
- }
550
- const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
551
- fullDocument: fullDocument
552
- };
553
- if (!this.isDocumentDb) {
554
- // DocumentDB does not support showExpandedEvents.
555
- streamOptions.showExpandedEvents = true;
556
- }
557
- const pipeline: mongo.Document[] = [
558
- {
559
- $changeStream: streamOptions
560
- },
561
- {
562
- $match: filters.$match
560
+ onBatch?: (batch: ChangeStreamBatch) => Disposable | void;
561
+ }): AsyncIterableIterator<MongoReplicationStreamBatch> {
562
+ return openMongoReplicationStream({
563
+ db: this.defaultDb,
564
+ queryProvider: this.queryProvider,
565
+ namespaceFilter: options.filters,
566
+ isDocumentDb: this.isDocumentDb,
567
+ usePostImages: this.usePostImages,
568
+ position: options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null,
569
+ skipInitialTimestamp: true,
570
+ onBatch: options.onBatch,
571
+ options: {
572
+ batchSize: options.batchSize ?? this.snapshotChunkLength,
573
+ maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
574
+ maxTimeMS: this.changeStreamTimeout,
575
+ signal: options.signal,
576
+ logger: this.logger,
577
+ tracer: options.tracer
563
578
  }
564
- ];
565
- if (!this.isDocumentDb) {
566
- // DocumentDB does not support $changeStreamSplitLargeEvent.
567
- pipeline.push({ $changeStreamSplitLargeEvent: {} });
568
- }
569
-
570
- /**
571
- * Only one of these options can be supplied at a time.
572
- */
573
- if (resumeAfter) {
574
- streamOptions.resumeAfter = resumeAfter;
575
- } else if (startAfter != null) {
576
- // Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
577
- // case if we have an old one.
578
- // This is also relevant for getSnapshotLSN().
579
- // The sentinel implementation never produces a startAfter, and a fresh DocumentDB stream
580
- // opens from "now" with neither option set.
581
- streamOptions.startAtOperationTime = startAfter;
582
- }
583
-
584
- let watchDb: mongo.Db;
585
- if (this.isDocumentDb || filters.multipleDatabases) {
586
- // DocumentDB only supports cluster-level change streams.
587
- watchDb = this.client.db('admin');
588
- streamOptions.allChangesForCluster = true;
589
- } else {
590
- watchDb = this.defaultDb;
591
- }
592
-
593
- const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
594
-
595
- return rawChangeStream(watchDb, pipeline, {
596
- batchSize: options.batchSize ?? this.snapshotChunkLength,
597
- maxAwaitTimeMS,
598
- maxTimeMS: this.changeStreamTimeout,
599
-
600
- signal: options.signal,
601
- logger: this.logger,
602
- tracer: options.tracer
603
579
  });
604
580
  }
605
581
 
@@ -638,22 +614,31 @@ export class ChangeStream {
638
614
  if (resumeFromLsn == null) {
639
615
  throw new ReplicationAssertionError(`No LSN found to resume from`);
640
616
  }
641
- // Seed the implementation's coordinate state from the stored LSN, and parse
642
- // the legacy startAfter timestamp (timestamp implementation only) for the
643
- // resume-boundary dedupe guard below.
617
+ // Seed the checkpoint strategy's coordinate from the durable source position.
644
618
  this.checkpointImplementation.seedPosition(resumeFromLsn);
645
- const { startAfter } = this.checkpointImplementation.parseResumePosition(resumeFromLsn);
646
619
  let outerSpan = tracer.span('batch');
647
620
 
648
621
  this.checkpointImplementation.logResume(resumeFromLsn);
649
622
 
650
623
  const filters = this.getSourceNamespaceFilters();
651
624
  // This is closed when the for loop below returns/breaks/throws
652
- const batchStream = this.rawChangeStreamBatches({
625
+ let processingSpan: ReturnType<typeof tracer.span> | undefined;
626
+ let receivedBytes = 0;
627
+ let changesSinceProgress = 0;
628
+ let filteredSinceProgress: number | null = null;
629
+ const stream = this.openChangeStream({
653
630
  lsn: resumeFromLsn,
654
631
  filters,
655
632
  signal: this.abortSignal,
656
- tracer
633
+ tracer,
634
+ onBatch: (batch) => {
635
+ // Count actual transport, even if the adapter drops every envelope or reassembles fragments.
636
+ bytesReplicatedMetric.add(batch.byteSize);
637
+ chunksReplicatedMetric.add(1);
638
+ receivedBytes += batch.byteSize;
639
+ processingSpan = tracer.span('processing');
640
+ return processingSpan;
641
+ }
657
642
  });
658
643
 
659
644
  // Always start with a checkpoint.
@@ -661,113 +646,15 @@ export class ChangeStream {
661
646
  // no data to replicate.
662
647
  let waitForCheckpointLsn: string | null = await this.createBatchCheckpoint();
663
648
 
664
- let splitDocument: ProjectedChangeStreamDocument | null = null;
665
-
666
- let flexDbNameWorkaroundLogged = false;
667
-
668
- let lastEmptyResume = performance.now();
649
+ let lastKeepalive = performance.now();
669
650
  let lastTxnKey: string | null = null;
670
651
 
671
- for await (let eventBatch of batchStream) {
672
- const { events, resumeToken } = eventBatch;
673
- using batchSpan = tracer.span('processing');
674
-
675
- bytesReplicatedMetric.add(eventBatch.byteSize);
676
- chunksReplicatedMetric.add(1);
677
- if (this.abortSignal.aborted) {
678
- break;
679
- }
680
- this.touch();
681
- if (events.length == 0) {
682
- // No changes in this batch, but we still want to persist progress.
683
- // We do this by persisting a keepalive checkpoint.
684
- // If we don't update it on empty events, we do keep consistency, but resuming the stream
685
- // with old tokens may cause connection timeouts.
686
- const hadRecentKeepalive = performance.now() - lastEmptyResume < this.keepaliveIntervalMs;
687
- if (waitForCheckpointLsn == null && !hadRecentKeepalive) {
688
- // Case 1: We have no changes, and we are not waiting for a checkpoint to be created,
689
- // and we have not recently persisted a keepalive. Persist one now, and call setResumeLsn() below.
690
- // This is the normal case for an idle stream.
691
- // The implementation persists a keepalive (timestamp) or bumps the
692
- // sentinel so a later event commits (sentinel). Logging is handled
693
- // inside the implementation.
694
- await this.checkpointImplementation.keepalive(batch, resumeToken);
695
- this.touch();
696
- lastEmptyResume = performance.now();
697
- this.replicationLag.markStarted();
698
- } else if (hadRecentKeepalive) {
699
- // Case 2: We have no changes, and may or may not be waiting for a checkpoint to be created.
700
- // We have recently persisted a keepalive.
701
- // Continue waiting.
702
- continue;
703
- } else {
704
- // Case 3: Waiting for a checkpoint; have not had a recent keepalive.
705
- // We cannot call checkpointImplementation.keepalive() here, but we do call
706
- // setResumeLsn() below.
707
- }
708
- }
709
-
710
- this.touch();
711
-
712
- for (let eventIndex = 0; eventIndex < events.length; eventIndex++) {
713
- const rawChangeDocument = events[eventIndex];
714
- const originalChangeDocument = parseChangeDocument(rawChangeDocument);
715
- if (this.abortSignal.aborted) {
716
- break;
717
- }
718
-
719
- if (startAfter != null && originalChangeDocument.clusterTime?.lte(startAfter)) {
720
- continue;
721
- }
722
-
723
- let changeDocument = originalChangeDocument;
724
- if (originalChangeDocument?.splitEvent != null) {
725
- // Handle split events from $changeStreamSplitLargeEvent.
726
- // This is only relevant for very large update operations.
727
- const splitEvent = originalChangeDocument?.splitEvent;
728
-
729
- if (splitDocument == null) {
730
- splitDocument = originalChangeDocument;
731
- } else {
732
- splitDocument = Object.assign(splitDocument, originalChangeDocument);
733
- }
734
-
735
- if (splitEvent.fragment == splitEvent.of) {
736
- // Got all fragments
737
- changeDocument = splitDocument;
738
- splitDocument = null;
739
- } else {
740
- // Wait for more fragments
741
- continue;
742
- }
743
- } else if (splitDocument != null) {
744
- // We were waiting for fragments, but got a different event
745
- throw new ReplicationAssertionError(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
746
- }
747
-
748
- if (
749
- !filters.multipleDatabases &&
750
- 'ns' in changeDocument &&
751
- changeDocument.ns.db != this.defaultDb.databaseName &&
752
- changeDocument.ns.db.endsWith(`_${this.defaultDb.databaseName}`)
753
- ) {
754
- // When all of the following conditions are met:
755
- // 1. We're replicating from an Atlas Flex instance.
756
- // 2. There were changestream events recorded while the PowerSync service is paused.
757
- // 3. We're only replicating from a single database.
758
- // Then we've observed an ns with for example {db: '67b83e86cd20730f1e766dde_ps'},
759
- // instead of the expected {db: 'ps'}.
760
- // We correct this.
761
- changeDocument.ns.db = this.defaultDb.databaseName;
762
-
763
- if (!flexDbNameWorkaroundLogged) {
764
- flexDbNameWorkaroundLogged = true;
765
- this.logger.warn(
766
- `Incorrect DB name in change stream: ${changeDocument.ns.db}. Changed to ${this.defaultDb.databaseName}.`
767
- );
768
- }
769
- }
770
-
652
+ streamLoop: for await (const sourceBatch of stream) {
653
+ if (this.abortSignal.aborted) break;
654
+ for (const { event: changeDocument, hasBufferedChanges } of sourceBatch.events) {
655
+ if (this.abortSignal.aborted) break streamLoop;
656
+ this.touch();
657
+ changesSinceProgress++;
771
658
  const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
772
659
 
773
660
  if (ns?.coll == CHECKPOINTS_COLLECTION) {
@@ -821,7 +708,6 @@ export class ChangeStream {
821
708
  // Once there is already a batch checkpoint pending, or the driver has buffered more
822
709
  // change stream events, collapse standalone checkpoints into the normal batch
823
710
  // checkpoint flow to avoid commit churn under sustained load.
824
- const hasBufferedChanges = eventIndex < events.length - 1;
825
711
  if (hasBufferedChanges && waitForCheckpointLsn == null) {
826
712
  // Buffered changes - create a new batch checkpoint to rate limit commits
827
713
  using _ = tracer.span('source_checkpoint');
@@ -926,40 +812,71 @@ export class ChangeStream {
926
812
  });
927
813
  }
928
814
  }
815
+ if (this.abortSignal.aborted) break;
816
+ this.touch();
817
+ const batchFilteredCount = sourceBatch.metadata.filteredCount;
818
+ if (batchFilteredCount != null) {
819
+ filteredSinceProgress = (filteredSinceProgress ?? 0) + batchFilteredCount;
820
+ }
821
+ const resumeToken = sourceBatch.getResumeToken();
822
+ if (resumeToken != null) {
823
+ const filteredCount = filteredSinceProgress;
824
+ if (changesSinceProgress == 0) {
825
+ // No retained changes since the previous progress boundary: this is either idle or filtered-only traffic.
826
+ // Case 1: No pending barrier and the keepalive interval has elapsed. Advance checkpoints as below,
827
+ // then flush and save the resume token.
828
+ // Case 2: Idle traffic with a recent keepalive. Skip this resume update, whether or not a barrier
829
+ // is pending, to preserve the idle throttle.
830
+ // Case 3: All other combinations. Flush and save the resume token without requesting a checkpoint.
831
+ // A pending barrier will provide the checkpoint boundary once replication reaches it. Filtered
832
+ // progress still saves its token even during the keepalive interval, so excluded work is resumable.
833
+ const hadRecentKeepalive = performance.now() - lastKeepalive < this.keepaliveIntervalMs;
834
+ if (waitForCheckpointLsn == null && !hadRecentKeepalive) {
835
+ if ((filteredCount ?? 0) > 0) {
836
+ // Case 1a: A transaction can span batches: this batch may contain only filtered changes,
837
+ // while retained changes from the same transaction are still unread. They share a timestamp,
838
+ // so publishing a checkpoint here could acknowledge the transaction before its data is saved.
839
+ // Request a source barrier; reaching it ensures those retained changes have been processed.
840
+ // The progress token can still be saved below for recovery without publishing a checkpoint.
841
+ using _ = tracer.span('source_checkpoint');
842
+ waitForCheckpointLsn = await this.createBatchCheckpoint();
843
+ } else {
844
+ // Case 1b: Idle traffic. The timestamp implementation persists a keepalive directly;
845
+ // the sentinel implementation writes a source marker whose event will commit later.
846
+ await this.checkpointImplementation.keepalive(batch, resumeToken);
847
+ this.replicationLag.markStarted();
848
+ }
849
+ this.touch();
850
+ lastKeepalive = performance.now();
851
+ } else if ((filteredCount ?? 0) == 0 && hadRecentKeepalive) {
852
+ // Case 2: Only idle progress skips resume persistence. Cases 1 and 3 fall through below.
853
+ continue;
854
+ }
855
+ }
929
856
 
930
- if (splitDocument == null) {
931
- // We flush and mark progress on every batch of data we receive.
932
- // Batches are generally large (64MB or 6000 events, whichever comes first),
933
- // so this is a good natural point to flush and mark progress.
934
- // We avoid this when splitDocument is set, since we cannot resume in the middle of a split event.
935
857
  const { lsn, timestamp } = this.checkpointImplementation.lsnFromResumeToken(resumeToken);
858
+ // Row writes must be durable before their source token. This advances recovery without
859
+ // publishing a checkpoint ahead of an outstanding barrier, transaction or snapshot.
936
860
  await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
937
- // TODO: We should consider making this standard behavior of flush().
938
861
  await batch.setResumeLsn(lsn);
939
-
940
- if (timestamp != null) {
941
- // Note that this timestamp provided by MongoDB is not exact - it can be around 10s behind.
942
- this.lastPersistedResumeTimestamp = timestamp.getTime();
943
- } else {
944
- // DocumentDB: No timestamp associated with the resumeToken. Just use the current time.
945
- this.lastPersistedResumeTimestamp = Date.now();
946
- }
947
- }
948
-
949
- batchSpan.end();
950
- const durationsMicroseconds = outerSpan.end();
951
- const duration = batchSpan.durationMillis;
952
-
953
- this.logger.info(
954
- `Processed batch of ${events.length} changes / ${eventBatch.byteSize} bytes in ${duration}ms`,
955
- {
956
- count: events.length,
957
- bytes: eventBatch.byteSize,
958
- duration,
862
+ // MongoDB's token timestamp may lag by about 10 seconds. DocumentDB tokens have no timestamp.
863
+ this.lastPersistedResumeTimestamp = timestamp?.getTime() ?? Date.now();
864
+ processingSpan?.end();
865
+ const durationsMicroseconds = outerSpan.end();
866
+ const filteringDescription = filteredCount == null ? '' : ` and ${filteredCount} filtered events`;
867
+ this.logger.info(`Processed ${changesSinceProgress} changes${filteringDescription}`, {
868
+ count: changesSinceProgress,
869
+ ...(filteredCount == null ? {} : { filteredCount }),
870
+ bytes: receivedBytes,
871
+ duration: processingSpan?.durationMillis,
959
872
  t: durationsMicroseconds
960
- }
961
- );
962
- outerSpan = tracer.span('batch');
873
+ });
874
+ changesSinceProgress = 0;
875
+ filteredSinceProgress = null;
876
+ receivedBytes = 0;
877
+ outerSpan = tracer.span('batch');
878
+ continue;
879
+ }
963
880
  }
964
881
  }
965
882
  );