@powersync/service-module-mongodb 0.17.0 → 0.18.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.
@@ -116,7 +116,7 @@ export class ChangeStream {
116
116
  constructor(options: ChangeStreamOptions) {
117
117
  this.storage = options.storage;
118
118
  this.metrics = options.metrics;
119
- this.group_id = options.storage.group_id;
119
+ this.group_id = options.storage.replicationStreamId;
120
120
  this.connections = options.connections;
121
121
  this.maxAwaitTimeMS = options.maxAwaitTimeMS ?? 10_000;
122
122
  this.snapshotChunkLength = options.snapshotChunkLength ?? 6_000;
@@ -132,11 +132,16 @@ export class ChangeStream {
132
132
  // so we use 90% of the socket timeout value.
133
133
  this.changeStreamTimeout = Math.ceil(this.client.options.socketTimeoutMS * 0.9);
134
134
 
135
- this.logger = options.logger ?? this.storage.logger;
135
+ const baseLogger = options.logger ?? this.storage.logger;
136
+ // Unfortunately the Winston APIs don't have a nice way to append to the prefix,
137
+ // so we replace it here.
138
+ this.logger = baseLogger.child({ prefix: `[${this.storage.replicationStreamName}] [stream] ` });
139
+ const snapshotLogger = baseLogger.child({ prefix: `[${this.storage.replicationStreamName}] [snapshot] ` });
140
+
136
141
  this.snapshotter = new MongoSnapshotter({
137
142
  ...options,
138
143
  abortSignal: this.abortSignal,
139
- logger: this.logger,
144
+ logger: snapshotLogger,
140
145
  checkpointStreamId: this.checkpointStreamId
141
146
  });
142
147
 
@@ -366,6 +371,7 @@ export class ChangeStream {
366
371
  async replicate() {
367
372
  let streamPromise: Promise<void> | null = null;
368
373
  let loopPromise: Promise<void> | null = null;
374
+ let cleanupPromise: Promise<void> | null = null;
369
375
  try {
370
376
  // If anything errors here, the entire replication process is halted, and
371
377
  // all connections automatically closed, including this one.
@@ -383,6 +389,11 @@ export class ChangeStream {
383
389
  if (!this.snapshotter.supportsConcurrentSnapshots) {
384
390
  await Promise.race([this.snapshotter.waitForInitialSnapshot(), loopPromise]);
385
391
  }
392
+ // Unlike the other two, this resolves on completion, not an indefinite loop.
393
+ cleanupPromise = this.cleanupStoppedSyncConfigs().catch((e) => {
394
+ this.abortController.abort(e);
395
+ throw e;
396
+ });
386
397
  streamPromise = this.streamChanges()
387
398
  .then(() => {
388
399
  throw new ReplicationAssertionError(`Replication stream exited unexpectedly`);
@@ -392,7 +403,7 @@ export class ChangeStream {
392
403
  throw e;
393
404
  });
394
405
 
395
- const results = await Promise.allSettled([loopPromise, streamPromise]);
406
+ const results = await Promise.allSettled([loopPromise, streamPromise, cleanupPromise]);
396
407
  throw replicationLoopError(results);
397
408
  } catch (e) {
398
409
  await this.storage.reportError(e);
@@ -422,6 +433,15 @@ export class ChangeStream {
422
433
  }
423
434
  }
424
435
 
436
+ private async cleanupStoppedSyncConfigs() {
437
+ await this.storage.cleanupStoppedSyncConfigs?.({
438
+ signal: this.abortSignal,
439
+ logger: this.logger,
440
+ defaultSchema: this.defaultDb.databaseName,
441
+ sourceConnectionTag: this.connections.connectionTag
442
+ });
443
+ }
444
+
425
445
  private async streamChanges() {
426
446
  try {
427
447
  await this.streamChangesInternal();
@@ -733,25 +753,15 @@ export class ChangeStream {
733
753
  timestamp: changeDocument.clusterTime!,
734
754
  resume_token: changeDocument._id
735
755
  });
736
- if (batch.lastCheckpointLsn != null && lsn < batch.lastCheckpointLsn) {
737
- // Checkpoint out of order - should never happen with MongoDB.
738
- // If it does happen, we throw an error to stop the replication - restarting should recover.
739
- // Since we use batch.lastCheckpointLsn for the next resumeAfter, this should not result in an infinite loop.
740
- // Originally a workaround for https://jira.mongodb.org/browse/NODE-7042.
741
- // This has been fixed in the driver in the meantime, but we still keep this as a safety-check.
742
- throw new ReplicationAssertionError(
743
- `Change resumeToken ${(changeDocument._id as any)._data} (${timestampToDate(changeDocument.clusterTime!).toISOString()}) is less than last checkpoint LSN ${batch.lastCheckpointLsn}. Restarting replication.`
744
- );
745
- }
746
756
 
747
757
  if (waitForCheckpointLsn != null && lsn >= waitForCheckpointLsn) {
748
758
  waitForCheckpointLsn = null;
749
759
  }
750
- const { checkpointBlocked } = await batch.commit(lsn, {
760
+ const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
751
761
  oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
752
762
  });
753
763
 
754
- if (!checkpointBlocked) {
764
+ if (!checkpointBlocked || checkpointCreated) {
755
765
  this.replicationLag.markCommitted();
756
766
  }
757
767
  } else if (
@@ -47,7 +47,7 @@ export class ChangeStreamReplicationJob extends replication.AbstractReplicationJ
47
47
 
48
48
  if (e instanceof ChangeStreamInvalidatedError) {
49
49
  // This stops replication and restarts with a new instance
50
- await this.options.storage.factory.restartReplication(this.storage.group_id);
50
+ await this.options.storage.factory.restartReplication(this.storage.replicationStreamId);
51
51
  }
52
52
 
53
53
  // No need to rethrow - the error is already logged, and retry behavior is the same on error
@@ -17,7 +17,7 @@ export class ChangeStreamReplicator extends replication.AbstractReplicator<Chang
17
17
 
18
18
  createJob(options: replication.CreateJobOptions): ChangeStreamReplicationJob {
19
19
  return new ChangeStreamReplicationJob({
20
- id: this.createJobId(options.storage.group_id),
20
+ id: this.createJobId(options.storage.replicationStreamId),
21
21
  storage: options.storage,
22
22
  metrics: this.metrics,
23
23
  connectionFactory: this.connectionFactory,
@@ -1,7 +1,6 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
2
  import { container, ErrorCode, Logger, ReplicationAbortedError, ServiceError } from '@powersync/lib-services-framework';
3
3
  import {
4
- InternalOpId,
5
4
  MetricsEngine,
6
5
  PerformanceTracer,
7
6
  SaveOperationTag,
@@ -71,7 +70,6 @@ export class MongoSnapshotter {
71
70
  private readonly queue = new Set<SnapshotQueueItem>();
72
71
  private initialSnapshotDone = Promise.withResolvers<void>();
73
72
  private nextItemQueued: PromiseWithResolvers<void> | null = null;
74
- private lastSnapshotOpId: InternalOpId | null = null;
75
73
  private lastTouchedAt = performance.now();
76
74
 
77
75
  constructor(options: MongoSnapshotterOptions) {
@@ -112,12 +110,12 @@ export class MongoSnapshotter {
112
110
 
113
111
  async checkSlot(): Promise<InitResult> {
114
112
  const status = await this.storage.getStatus();
115
- if (status.snapshot_done && status.checkpoint_lsn) {
113
+ if (status.snapshotDone) {
116
114
  this.logger.info(`Initial replication already done`);
117
115
  return { needsInitialSync: false, snapshotLsn: null };
118
116
  }
119
117
 
120
- return { needsInitialSync: true, snapshotLsn: status.snapshot_lsn };
118
+ return { needsInitialSync: true, snapshotLsn: status.resumeLsn };
121
119
  }
122
120
 
123
121
  async setupCheckpointsCollection() {
@@ -277,20 +275,16 @@ export class MongoSnapshotter {
277
275
  }
278
276
 
279
277
  const status = await this.storage.getStatus();
280
- if (status.snapshot_done) {
278
+ if (status.snapshotDone) {
281
279
  return;
282
280
  }
283
281
 
284
- const lastOp = this.lastSnapshotOpId ?? status.keepalive_op;
285
- if (lastOp != null) {
286
- // Populate the cache _after_ initial replication, but _before_ we switch to this replication stream.
287
- // Keeping snapshot_done false until this completes makes this resumable after interruption.
288
- await this.storage.populatePersistentChecksumCache({
289
- // No checkpoint yet, but we do have the opId.
290
- maxOpId: lastOp,
291
- signal: this.abortSignal
292
- });
293
- }
282
+ // Populate the cache _after_ initial replication, but _before_ we switch to this replication stream.
283
+ // Keeping snapshot_done false until this completes makes this resumable after interruption.
284
+ // No checkpoint exists yet - storage defaults to its highest persisted op id.
285
+ await this.storage.populatePersistentChecksumCache({
286
+ signal: this.abortSignal
287
+ });
294
288
 
295
289
  if (this.queue.size != 0) {
296
290
  return;
@@ -345,13 +339,10 @@ export class MongoSnapshotter {
345
339
  const noCheckpointBefore = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
346
340
  await writer.markTableSnapshotDone([table], noCheckpointBefore);
347
341
 
348
- // This commit ensures we set keepalive_op.
342
+ // This commit durably records the persisted ops, so a later checkpoint covers them.
349
343
  const resumeLsn = writer.resumeFromLsn ?? MongoLSN.ZERO.comparable;
350
344
  await writer.commit(resumeLsn);
351
345
 
352
- if (writer.last_flushed_op != null) {
353
- this.lastSnapshotOpId = writer.last_flushed_op;
354
- }
355
346
  this.logger.info(`Flushed snapshot at ${writer.last_flushed_op}`);
356
347
  }
357
348
 
@@ -445,10 +436,7 @@ export class MongoSnapshotter {
445
436
  }
446
437
 
447
438
  // Important: flush before marking progress
448
- const result = await batch.flush();
449
- if (result?.flushed_op != null) {
450
- this.lastSnapshotOpId = result.flushed_op;
451
- }
439
+ await batch.flush();
452
440
  at += docBatch.length;
453
441
  rowsReplicatedMetric.add(docBatch.length);
454
442
 
@@ -481,10 +469,12 @@ export class MongoSnapshotter {
481
469
  // Ignore the postImages check in this case.
482
470
  }
483
471
 
472
+ // Note: resolveTables uses the batch's own parsed sync config set. Passing
473
+ // this.syncRules here would pair sources from one parse with the batch's mapping
474
+ // from another parse.
484
475
  const result = await batch.resolveTables({
485
476
  connection_id: this.connectionId,
486
- source: descriptor,
487
- syncRules: this.syncRules
477
+ source: descriptor
488
478
  });
489
479
 
490
480
  // Drop conflicting collections.
@@ -820,9 +820,9 @@ bucket_definitions:
820
820
 
821
821
  // Simulate an error
822
822
  await context.storage!.reportError(new Error('simulated error'));
823
- const syncRules = await context.factory.getActiveSyncRulesContent();
823
+ const syncRules = (await context.factory.getActiveSyncConfig())?.content;
824
824
  expect(syncRules).toBeTruthy();
825
- expect(syncRules?.last_fatal_error).toEqual('simulated error');
825
+ expect((await syncRules?.getSyncConfigStatus())?.last_fatal_error).toEqual('simulated error');
826
826
 
827
827
  // The next checkpoint should clear the error.
828
828
  await context.getCheckpoint();
@@ -830,7 +830,8 @@ bucket_definitions:
830
830
  // Just wait, and check that the error is cleared automatically.
831
831
  await vi.waitUntil(
832
832
  async () => {
833
- const error = (await context.factory.getActiveSyncRulesContent())?.last_fatal_error;
833
+ const syncRules = (await context.factory.getActiveSyncConfig())?.content;
834
+ const error = (await syncRules?.getSyncConfigStatus())?.last_fatal_error;
834
835
  return error == null;
835
836
  },
836
837
  { timeout: 2_000 }
@@ -30,7 +30,7 @@ export class ChangeStreamTestContext {
30
30
  private _walStream?: ChangeStream;
31
31
  private abortController = new AbortController();
32
32
  private settledReplicationPromise?: Promise<PromiseSettledResult<void>>;
33
- private syncRulesContent?: storage.PersistedSyncRulesContent;
33
+ private syncRulesContent?: storage.PersistedSyncConfigContent;
34
34
  public storage?: SyncRulesBucketStorage;
35
35
 
36
36
  /**
@@ -101,26 +101,26 @@ export class ChangeStreamTestContext {
101
101
  }
102
102
 
103
103
  async updateSyncRules(content: string) {
104
- const syncRules = await this.factory.updateSyncRules(
104
+ const replicationStream = await this.factory.updateSyncRules(
105
105
  updateSyncRulesFromYaml(content, { validate: true, storageVersion: this.storageVersion })
106
106
  );
107
- this.syncRulesContent = syncRules;
108
- this.storage = this.factory.getInstance(syncRules);
107
+ this.syncRulesContent = replicationStream.syncConfigContent[0];
108
+ this.storage = this.factory.getInstance(replicationStream);
109
109
  return this.storage!;
110
110
  }
111
111
 
112
112
  async loadNextSyncRules() {
113
- const syncRules = await this.factory.getNextSyncRulesContent();
114
- if (syncRules == null) {
113
+ const syncConfig = await this.factory.getDeployingSyncConfig();
114
+ if (syncConfig == null) {
115
115
  throw new Error(`Next sync config not available`);
116
116
  }
117
117
 
118
- this.syncRulesContent = syncRules;
119
- this.storage = this.factory.getInstance(syncRules);
118
+ this.syncRulesContent = syncConfig.content;
119
+ this.storage = syncConfig.storage;
120
120
  return this.storage!;
121
121
  }
122
122
 
123
- private getSyncRulesContent(): storage.PersistedSyncRulesContent {
123
+ private getSyncConfigContent(): storage.PersistedSyncConfigContent {
124
124
  if (this.syncRulesContent == null) {
125
125
  throw new Error('Sync config not configured - call updateSyncRules() first');
126
126
  }
@@ -197,8 +197,8 @@ export class ChangeStreamTestContext {
197
197
 
198
198
  async getBucketsDataBatch(buckets: Record<string, InternalOpId>, options?: { timeout?: number }) {
199
199
  let checkpoint = await this.getCheckpoint(options);
200
- const syncRules = this.getSyncRulesContent();
201
- const map = Object.entries(buckets).map(([bucket, start]) => bucketRequest(syncRules, bucket, start));
200
+ const syncConfigContent = this.getSyncConfigContent();
201
+ const map = Object.entries(buckets).map(([bucket, start]) => bucketRequest(syncConfigContent, bucket, start));
202
202
  return test_utils.fromAsync(this.storage!.getBucketDataBatch(checkpoint, map));
203
203
  }
204
204
 
@@ -207,9 +207,9 @@ export class ChangeStreamTestContext {
207
207
  if (typeof start == 'string') {
208
208
  start = BigInt(start);
209
209
  }
210
- const syncRules = this.getSyncRulesContent();
210
+ const syncConfigContent = this.getSyncConfigContent();
211
211
  const checkpoint = await this.getCheckpoint(options);
212
- let map = [bucketRequest(syncRules, bucket, start)];
212
+ let map = [bucketRequest(syncConfigContent, bucket, start)];
213
213
  let data: OplogEntry[] = [];
214
214
  while (true) {
215
215
  const batch = this.storage!.getBucketDataBatch(checkpoint, map);
@@ -219,15 +219,15 @@ export class ChangeStreamTestContext {
219
219
  if (batches.length == 0 || !batches[0]!.chunkData.has_more) {
220
220
  break;
221
221
  }
222
- map = [bucketRequest(syncRules, bucket, BigInt(batches[0]!.chunkData.next_after))];
222
+ map = [bucketRequest(syncConfigContent, bucket, BigInt(batches[0]!.chunkData.next_after))];
223
223
  }
224
224
  return data;
225
225
  }
226
226
 
227
227
  async getChecksums(buckets: string[], options?: { timeout?: number }): Promise<utils.ChecksumMap> {
228
228
  let checkpoint = await this.getCheckpoint(options);
229
- const syncRules = this.getSyncRulesContent();
230
- const versionedBuckets = buckets.map((bucket) => bucketRequest(syncRules, bucket, 0n));
229
+ const syncConfigContent = this.getSyncConfigContent();
230
+ const versionedBuckets = buckets.map((bucket) => bucketRequest(syncConfigContent, bucket, 0n));
231
231
  const checksums = await this.storage!.getChecksums(checkpoint, versionedBuckets);
232
232
 
233
233
  const unversioned: utils.ChecksumMap = new Map();
@@ -258,7 +258,7 @@ export async function getClientCheckpoint(
258
258
  let lastCp: ReplicationCheckpoint | null = null;
259
259
 
260
260
  while (Date.now() - start < timeout) {
261
- const storage = await storageFactory.getActiveStorage();
261
+ const storage = (await storageFactory.getActiveSyncConfig())?.storage;
262
262
  const cp = await storage?.getCheckpoint();
263
263
  if (cp != null) {
264
264
  lastCp = cp;
@@ -20,7 +20,7 @@ describe('checkpoint retryable writes', () => {
20
20
  // It is quite difficult to simulate this, even with failCommand. We currently rely on triggering a socket timeout for the first command,
21
21
  // with another write happening right after that.
22
22
 
23
- const TIMEOUT = 100;
23
+ const TIMEOUT = 200;
24
24
  const INSERT_COUNT = 5;
25
25
 
26
26
  const { db, client } = await connectMongoData({
@@ -67,8 +67,8 @@ function defineResumeTest({ factory: factoryGenerator, storageVersion }: Storage
67
67
 
68
68
  // Create a new context without updating the sync config
69
69
  await using context2 = new ChangeStreamTestContext(factory, connectionManager, {}, storageVersion);
70
- const activeContent = await factory.getActiveSyncRulesContent();
71
- context2.storage = factory.getInstance(activeContent!);
70
+ const active = await factory.getActiveSyncConfig();
71
+ context2.storage = active!.storage;
72
72
 
73
73
  // If this test times out, it likely didn't throw the expected error here.
74
74
  const result = await context2.startStreaming();
package/test/src/util.ts CHANGED
@@ -21,7 +21,8 @@ export const TEST_CONNECTION_OPTIONS = types.normalizeConnectionConfig({
21
21
 
22
22
  export const INITIALIZED_MONGO_STORAGE_FACTORY = mongo_storage.test_utils.mongoTestStorageFactoryGenerator({
23
23
  url: env.MONGO_TEST_URL,
24
- isCI: env.CI
24
+ isCI: env.CI,
25
+ supportsMultipleSyncConfigs: true
25
26
  });
26
27
 
27
28
  export const INITIALIZED_POSTGRES_STORAGE_FACTORY = postgres_storage.test_utils.postgresTestSetup({
package/tsconfig.json CHANGED
@@ -2,10 +2,7 @@
2
2
  "extends": "../../tsconfig.base.json",
3
3
  "compilerOptions": {
4
4
  "rootDir": "src",
5
- "outDir": "dist",
6
- "esModuleInterop": true,
7
- "skipLibCheck": true,
8
- "sourceMap": true
5
+ "outDir": "dist"
9
6
  },
10
7
  "include": ["src"],
11
8
  "references": [
@@ -6,8 +6,7 @@
6
6
  "target": "esnext",
7
7
  "noEmit": true,
8
8
  "composite": false,
9
- "types": ["node"],
10
- "skipLibCheck": true
9
+ "types": ["node"]
11
10
  },
12
11
  "include": ["scripts/**/*.mts"]
13
12
  }