@powersync/service-module-mongodb-storage 0.21.0 → 0.23.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 (130) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +10 -1
  3. package/dist/storage/MongoBucketStorage.js +37 -7
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +24 -10
  6. package/dist/storage/implementation/MongoBucketBatch.js +134 -89
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoCompactor.d.ts +2 -0
  9. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  10. package/dist/storage/implementation/MongoIdSequence.d.ts +10 -1
  11. package/dist/storage/implementation/MongoIdSequence.js +14 -1
  12. package/dist/storage/implementation/MongoIdSequence.js.map +1 -1
  13. package/dist/storage/implementation/MongoOpIdAllocator.d.ts +27 -0
  14. package/dist/storage/implementation/MongoOpIdAllocator.js +97 -0
  15. package/dist/storage/implementation/MongoOpIdAllocator.js.map +1 -0
  16. package/dist/storage/implementation/MongoStorageProvider.js +4 -2
  17. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  18. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +5 -0
  19. package/dist/storage/implementation/MongoSyncBucketStorage.js +17 -0
  20. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  21. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +26 -2
  22. package/dist/storage/implementation/MongoSyncRulesLock.js +38 -1
  23. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  24. package/dist/storage/implementation/MongoWriteBatch.d.ts +49 -0
  25. package/dist/storage/implementation/MongoWriteBatch.js +149 -0
  26. package/dist/storage/implementation/MongoWriteBatch.js.map +1 -0
  27. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +2 -2
  28. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  29. package/dist/storage/implementation/common/PersistedBatch.d.ts +5 -4
  30. package/dist/storage/implementation/common/PersistedBatch.js +6 -4
  31. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  32. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.d.ts +3 -0
  33. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +3 -0
  34. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  35. package/dist/storage/implementation/db.d.ts +5 -0
  36. package/dist/storage/implementation/db.js +14 -0
  37. package/dist/storage/implementation/db.js.map +1 -1
  38. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +5 -0
  39. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +149 -139
  40. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  41. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +2 -1
  42. package/dist/storage/implementation/v1/MongoCompactorV1.js +23 -17
  43. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  44. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +1 -1
  45. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  46. package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +7 -5
  47. package/dist/storage/implementation/v1/PersistedBatchV1.js +22 -28
  48. package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
  49. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +6 -1
  50. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +148 -162
  51. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  52. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +10 -4
  53. package/dist/storage/implementation/v3/MongoCompactorV3.js +175 -76
  54. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  55. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +12 -8
  56. package/dist/storage/implementation/v3/PersistedBatchV3.js +25 -32
  57. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  58. package/dist/storage/implementation/v3/compact-utils.d.ts +3 -2
  59. package/dist/storage/implementation/v3/compact-utils.js +12 -5
  60. package/dist/storage/implementation/v3/compact-utils.js.map +1 -1
  61. package/dist/storage/implementation/v3/models.d.ts +1 -1
  62. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +8 -1
  63. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  64. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +3 -3
  65. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +7 -9
  66. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  67. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +3 -2
  68. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +4 -7
  69. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -1
  70. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +21 -3
  71. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
  72. package/dist/types/types.d.ts +5 -0
  73. package/dist/types/types.js +12 -0
  74. package/dist/types/types.js.map +1 -1
  75. package/dist/utils/test-utils.d.ts +2 -1
  76. package/dist/utils/test-utils.js +5 -3
  77. package/dist/utils/test-utils.js.map +1 -1
  78. package/package.json +7 -7
  79. package/src/storage/MongoBucketStorage.ts +47 -8
  80. package/src/storage/implementation/MongoBucketBatch.ts +205 -111
  81. package/src/storage/implementation/MongoCompactor.ts +2 -0
  82. package/src/storage/implementation/MongoIdSequence.ts +21 -1
  83. package/src/storage/implementation/MongoOpIdAllocator.ts +105 -0
  84. package/src/storage/implementation/MongoStorageProvider.ts +12 -2
  85. package/src/storage/implementation/MongoSyncBucketStorage.ts +19 -0
  86. package/src/storage/implementation/MongoSyncRulesLock.ts +56 -3
  87. package/src/storage/implementation/MongoWriteBatch.ts +172 -0
  88. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +4 -2
  89. package/src/storage/implementation/common/PersistedBatch.ts +12 -8
  90. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +4 -0
  91. package/src/storage/implementation/db.ts +15 -0
  92. package/src/storage/implementation/models.ts +1 -0
  93. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +167 -160
  94. package/src/storage/implementation/v1/MongoCompactorV1.ts +29 -26
  95. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +1 -1
  96. package/src/storage/implementation/v1/PersistedBatchV1.ts +25 -30
  97. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +217 -242
  98. package/src/storage/implementation/v3/MongoCompactorV3.ts +284 -108
  99. package/src/storage/implementation/v3/PersistedBatchV3.ts +36 -36
  100. package/src/storage/implementation/v3/compact-utils.ts +16 -7
  101. package/src/storage/implementation/v3/models.ts +1 -1
  102. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +8 -1
  103. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +11 -10
  104. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +8 -9
  105. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +23 -3
  106. package/src/types/types.ts +18 -0
  107. package/src/utils/test-utils.ts +6 -3
  108. package/test/src/__snapshots__/storage.test.ts.snap +102 -2
  109. package/test/src/__snapshots__/storage_compacting.test.ts.snap +2 -2
  110. package/test/src/__snapshots__/storage_sync.test.ts.snap +12 -12
  111. package/test/src/chunk_compaction_config.test.ts +38 -0
  112. package/test/src/cleanup-stopped-sync-configs.test.ts +19 -13
  113. package/test/src/compact-utils.test.ts +33 -8
  114. package/test/src/concurrent_writers.test.ts +599 -0
  115. package/test/src/helpers/concurrentWriter.mjs +29 -0
  116. package/test/src/helpers/s3TestFactory.ts +12 -12
  117. package/test/src/object_storage_usage.test.ts +18 -5
  118. package/test/src/parameter_compacting_v1.test.ts +2 -2
  119. package/test/src/parameter_compacting_v3.test.ts +2 -2
  120. package/test/src/parameter_compaction_fence.test.ts +3 -3
  121. package/test/src/storage.test.ts +32 -33
  122. package/test/src/storage_compacting.test.ts +402 -24
  123. package/test/src/storage_object_storage_inline_threshold.test.ts +3 -3
  124. package/test/src/storage_s3_checksums.test.ts +4 -4
  125. package/test/src/storage_s3_compaction_lifecycle.test.ts +8 -8
  126. package/test/src/storage_s3_reading.test.ts +51 -5
  127. package/test/src/storage_s3_writing.test.ts +3 -3
  128. package/test/src/storage_sync.test.ts +52 -46
  129. package/test/src/storeCurrentData.test.ts +5 -5
  130. package/tsconfig.tsbuildinfo +1 -1
@@ -7,9 +7,11 @@ import { mongoTableId } from '../../../utils/util.js';
7
7
  import { canCheckpointState } from '../CheckpointState.js';
8
8
  import { MongoBucketBatch, MongoBucketBatchOptions } from '../MongoBucketBatch.js';
9
9
  import { MongoParsedSyncConfigSet } from '../MongoParsedSyncConfigSet.js';
10
+ import { MongoWriteBatch } from '../MongoWriteBatch.js';
10
11
  import { stopReplicationStreamPipeline } from '../SyncRuleStateUpdate.js';
11
12
  import { PersistedBatch } from '../common/PersistedBatch.js';
12
13
  import { SourceRecordStore } from '../common/SourceRecordStore.js';
14
+ import { SyncRuleDocumentBase } from '../models.js';
13
15
  import { PersistedBatchV3 } from './PersistedBatchV3.js';
14
16
  import { SourceRecordStoreV3 } from './SourceRecordStoreV3.js';
15
17
  import { VersionedPowerSyncMongoV3 } from './VersionedPowerSyncMongoV3.js';
@@ -60,14 +62,16 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
60
62
  return this.store;
61
63
  }
62
64
 
63
- protected override async onReplicationTransactionFlush(
64
- session: lib_mongo.mongo.ClientSession,
65
- lastOp: bigint
66
- ): Promise<void> {
65
+ protected override persistedOpHead(stream: SyncRuleDocumentBase): InternalOpId {
66
+ return (stream as ReplicationStreamDocumentV3).last_persisted_op ?? 0n;
67
+ }
68
+
69
+ protected override onReplicationTransactionFlush(writes: MongoWriteBatch, lastOp: InternalOpId): void {
67
70
  // Durably advance the stream-level head of persisted ops within the flush transaction.
68
71
  // This ensures a checkpoint created later (even by an empty commit, or by a freshly-appended
69
72
  // config that replicates nothing) covers all ops persisted before a potential crash.
70
- await this.db.sync_rules.updateOne(
73
+ writes.updateOne(
74
+ this.db.sync_rules,
71
75
  {
72
76
  _id: this.replicationStreamId
73
77
  },
@@ -75,8 +79,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
75
79
  $max: {
76
80
  last_persisted_op: lastOp
77
81
  }
78
- },
79
- { session }
82
+ }
80
83
  );
81
84
  }
82
85
 
@@ -173,7 +176,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
173
176
  const session = this.db.client.startSession();
174
177
  await using _ = { [Symbol.asyncDispose]: () => session.endSession() };
175
178
 
176
- await session.withTransaction(async () => {
179
+ await this.withTransaction(async () => {
177
180
  const col = this.db.sourceTables(this.replicationStreamId);
178
181
 
179
182
  // Find records that overlap by name or relation id.
@@ -243,7 +246,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
243
246
  sourceTableFromDocument(doc, context.connectionTag, syncConfig, mapping, eventById)
244
247
  )
245
248
  };
246
- });
249
+ }, session);
247
250
 
248
251
  return result!;
249
252
  }
@@ -268,156 +271,143 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
268
271
  async commit(lsn: string, options?: storage.BucketBatchCommitOptions): Promise<storage.CheckpointResult> {
269
272
  const { createEmptyCheckpoints } = { ...storage.DEFAULT_BUCKET_BATCH_COMMIT_OPTIONS, ...options };
270
273
 
271
- await this.flush(options);
272
-
273
274
  using _ = this.tracer.span('storage', 'commit');
274
275
 
275
- const now = new Date();
276
-
277
- await this.db.write_checkpoints.updateMany(
278
- {
279
- processed_at_lsn: null,
280
- 'lsns.1': { $lte: lsn }
281
- },
282
- {
283
- $set: {
284
- processed_at_lsn: lsn
285
- }
286
- },
287
- {
288
- session: this.session
289
- }
290
- );
291
-
292
- const preUpdateDocument = (await this.db.sync_rules.findOne(
293
- {
294
- _id: this.replicationStreamId,
295
- 'sync_configs._id': { $in: this.syncConfigIds }
296
- },
297
- {
298
- session: this.session,
299
- projection: {
300
- sync_configs: 1,
301
- last_persisted_op: 1
302
- }
303
- }
304
- )) as ReplicationStreamDocumentV3 | null;
305
-
306
- const states =
307
- preUpdateDocument?.sync_configs?.filter((config) => this.syncConfigIds.some((id) => id.equals(config._id))) ?? [];
308
- if (states.length == 0) {
309
- throw new ReplicationAssertionError(
310
- `Failed to update checkpoint - no matching sync_config for _id: ${this.replicationStreamId}/${this.syncConfigIds
311
- .map((id) => id.toHexString())
312
- .join(',')}`
276
+ const checkpoint = async (stream: SyncRuleDocumentBase, lastOp?: InternalOpId) => {
277
+ const now = new Date();
278
+ const preUpdateDocument = stream as ReplicationStreamDocumentV3;
279
+ const writes = this.db.createWriteBatch(this.session, { ordered: false });
280
+ writes.updateMany(
281
+ this.db.write_checkpoints,
282
+ { processed_at_lsn: null, 'lsns.1': { $lte: lsn } },
283
+ { $set: { processed_at_lsn: lsn } }
313
284
  );
314
- }
315
- // The replication job / MongoBucketBatch must be constructed with all replicating (PROCESSING / ACTIVE)
316
- // sync configs in the stream, otherwise we'll get inconsistencies. Configs in other states (e.g. STOP)
317
- // remain embedded in the document and are ignored here.
318
- const missingSyncConfig = preUpdateDocument!.sync_configs.find(
319
- (config) =>
320
- [storage.SyncRuleState.PROCESSING, storage.SyncRuleState.ACTIVE].includes(config.state) &&
321
- !this.syncConfigIds.some((id) => id.equals(config._id))
322
- );
323
- if (missingSyncConfig != null) {
324
- throw new ReplicationAssertionError(`Replication job not configured for sync config ${missingSyncConfig._id}`);
325
- }
326
285
 
327
- // Effective head of the stream's op sequence.
328
- // last_persisted_op is $max-advanced durably in the same transaction as every flush, and this
329
- // read uses the same session, so it covers all ops persisted by this batch.
330
- const newCheckpoint =
331
- preUpdateDocument?.last_persisted_op == null ? 0n : BigInt(preUpdateDocument.last_persisted_op);
332
-
333
- let checkpointBlocked = false;
334
- let checkpointCreated = false;
335
- let checkpointLogState: unknown = null;
336
- const unblockedConfigIds: bson.ObjectId[] = [];
337
-
338
- for (const state of states) {
339
- if (state.last_checkpoint != null && state.last_checkpoint > newCheckpoint) {
340
- // last_persisted_op is $max-advanced durably in the same transaction as every flush, and
341
- // checkpoints are only ever created at that head, so a checkpoint past the head means the
342
- // op sequence or the stored state is corrupt.
286
+ const states =
287
+ preUpdateDocument?.sync_configs?.filter((config) => this.syncConfigIds.some((id) => id.equals(config._id))) ??
288
+ [];
289
+ if (states.length == 0) {
343
290
  throw new ReplicationAssertionError(
344
- `Invariant violation: sync config ${state._id} has last_checkpoint ${state.last_checkpoint} > stream head ${newCheckpoint}`
291
+ `Failed to update checkpoint - no matching sync_config for _id: ${this.replicationStreamId}/${this.syncConfigIds
292
+ .map((id) => id.toHexString())
293
+ .join(',')}`
345
294
  );
346
295
  }
296
+ // The replication job / MongoBucketBatch must be constructed with all replicating (PROCESSING / ACTIVE)
297
+ // sync configs in the stream, otherwise we'll get inconsistencies. Configs in other states (e.g. STOP)
298
+ // remain embedded in the document and are ignored here.
299
+ const missingSyncConfig = preUpdateDocument!.sync_configs.find(
300
+ (config) =>
301
+ [storage.SyncRuleState.PROCESSING, storage.SyncRuleState.ACTIVE].includes(config.state) &&
302
+ !this.syncConfigIds.some((id) => id.equals(config._id))
303
+ );
304
+ if (missingSyncConfig != null) {
305
+ throw new ReplicationAssertionError(`Replication job not configured for sync config ${missingSyncConfig._id}`);
306
+ }
347
307
 
348
- const canCheckpoint = canCheckpointState(lsn, {
349
- snapshotDone: state.snapshot_done === true,
350
- lastCheckpointLsn: state.last_checkpoint_lsn,
351
- noCheckpointBefore: state.no_checkpoint_before
352
- });
308
+ // Effective head of the stream's op sequence.
309
+ // A combined flush supplies its new head; an empty commit uses the fenced persisted head.
310
+ const newCheckpoint = lastOp ?? this.persistedOpHead(preUpdateDocument);
353
311
 
354
- if (!canCheckpoint) {
355
- checkpointBlocked = true;
356
- // Log the first blocked config's state.
357
- checkpointLogState ??= {
358
- snapshot_done: state.snapshot_done,
359
- last_checkpoint_lsn: state.last_checkpoint_lsn,
360
- no_checkpoint_before: state.no_checkpoint_before
361
- };
362
- continue;
363
- }
312
+ let checkpointBlocked = false;
313
+ let checkpointCreated = false;
314
+ let checkpointLogState: unknown = null;
315
+ const unblockedConfigIds: bson.ObjectId[] = [];
364
316
 
365
- checkpointCreated ||= createEmptyCheckpoints || state.last_checkpoint !== newCheckpoint;
366
- unblockedConfigIds.push(state._id);
367
- }
317
+ for (const state of states) {
318
+ if (state.last_checkpoint != null && state.last_checkpoint > newCheckpoint) {
319
+ // last_persisted_op is $max-advanced durably in the same transaction as every flush, and
320
+ // checkpoints are only ever created at that head, so a checkpoint past the head means the
321
+ // op sequence or the stored state is corrupt.
322
+ throw new ReplicationAssertionError(
323
+ `Invariant violation: sync config ${state._id} has last_checkpoint ${state.last_checkpoint} > stream head ${newCheckpoint}`
324
+ );
325
+ }
368
326
 
369
- // Every commit advances the stream's resume position: commit() flushes first, so all
370
- // source changes up to this lsn have been persisted, even when checkpoints are blocked.
371
- // In the future we could also advance this on flush, when the connector provides the
372
- // current position (see setResumeLsn, which connectors may already call after flushing).
373
- const resumeLsnUpdate = { resume_lsn: lsn };
374
-
375
- if (unblockedConfigIds.length > 0) {
376
- // All unblocked configs get the SAME new value, so we apply it with a single updateOne
377
- // (single-document atomicity).
378
- const updateSet: Record<string, any> = {
379
- last_keepalive_ts: now,
380
- last_fatal_error: null,
381
- last_fatal_error_ts: null
382
- };
383
- // Only advance checkpoint fields when an actual (non-empty) checkpoint is created, matching
384
- // the previous per-config / v1 behaviour.
385
- if (checkpointCreated) {
386
- updateSet['sync_configs.$[config].last_checkpoint'] = newCheckpoint;
387
- updateSet['sync_configs.$[config].last_checkpoint_lsn'] = lsn;
388
- updateSet['last_checkpoint_ts'] = now;
327
+ const canCheckpoint = canCheckpointState(lsn, {
328
+ snapshotDone: state.snapshot_done === true,
329
+ lastCheckpointLsn: state.last_checkpoint_lsn,
330
+ noCheckpointBefore: state.no_checkpoint_before
331
+ });
332
+
333
+ if (!canCheckpoint) {
334
+ checkpointBlocked = true;
335
+ // Log the first blocked config's state.
336
+ checkpointLogState ??= {
337
+ snapshot_done: state.snapshot_done,
338
+ last_checkpoint_lsn: state.last_checkpoint_lsn,
339
+ no_checkpoint_before: state.no_checkpoint_before
340
+ };
341
+ continue;
342
+ }
343
+
344
+ checkpointCreated ||= createEmptyCheckpoints || state.last_checkpoint !== newCheckpoint;
345
+ unblockedConfigIds.push(state._id);
389
346
  }
390
347
 
391
- await this.db.sync_rules.updateOne(
392
- {
393
- _id: this.replicationStreamId,
394
- 'sync_configs._id': { $in: unblockedConfigIds }
395
- },
396
- { $set: updateSet, $max: resumeLsnUpdate },
397
- {
398
- session: this.session,
399
- arrayFilters: checkpointCreated ? [{ 'config._id': { $in: unblockedConfigIds } }] : undefined
348
+ // Every commit advances the stream's resume position: commit() flushes first, so all
349
+ // source changes up to this lsn have been persisted, even when checkpoints are blocked.
350
+ // In the future we could also advance this on flush, when the connector provides the
351
+ // current position (see setResumeLsn, which connectors may already call after flushing).
352
+ const resumeLsnUpdate = {
353
+ resume_lsn: lsn,
354
+ ...(lastOp == null ? {} : { last_persisted_op: lastOp })
355
+ };
356
+
357
+ if (unblockedConfigIds.length > 0) {
358
+ // All unblocked configs get the SAME new value, so we apply it with a single updateOne
359
+ // (single-document atomicity).
360
+ const updateSet: Record<string, any> = {
361
+ last_fatal_error: null,
362
+ last_fatal_error_ts: null
363
+ };
364
+ // Only advance checkpoint fields when an actual (non-empty) checkpoint is created, matching
365
+ // the previous per-config / v1 behaviour.
366
+ if (checkpointCreated) {
367
+ updateSet['sync_configs.$[config].last_checkpoint'] = newCheckpoint;
368
+ updateSet['sync_configs.$[config].last_checkpoint_lsn'] = lsn;
369
+ updateSet['last_checkpoint_ts'] = now;
400
370
  }
401
- );
402
- } else {
403
- // All selected configs are blocked - only update keepalive/error tracking and the
404
- // resume position.
405
- await this.db.sync_rules.updateOne(
406
- {
407
- _id: this.replicationStreamId
408
- },
409
- {
410
- $set: {
411
- last_keepalive_ts: now,
412
- last_fatal_error: null,
413
- last_fatal_error_ts: null
371
+
372
+ writes.updateOne(
373
+ this.db.sync_rules,
374
+ {
375
+ _id: this.replicationStreamId,
376
+ 'sync_configs._id': { $in: unblockedConfigIds }
414
377
  },
415
- $max: resumeLsnUpdate
416
- },
417
- { session: this.session }
418
- );
419
- }
378
+ { $set: updateSet, $max: resumeLsnUpdate },
379
+ {
380
+ arrayFilters: checkpointCreated ? [{ 'config._id': { $in: unblockedConfigIds } }] : undefined
381
+ }
382
+ );
383
+ } else {
384
+ // All selected configs are blocked - only update keepalive/error tracking and the
385
+ // resume position.
386
+ writes.updateOne(
387
+ this.db.sync_rules,
388
+ {
389
+ _id: this.replicationStreamId
390
+ },
391
+ {
392
+ $set: {
393
+ last_fatal_error: null,
394
+ last_fatal_error_ts: null
395
+ },
396
+ $max: resumeLsnUpdate
397
+ }
398
+ );
399
+ }
420
400
 
401
+ await writes.execute();
402
+ return { checkpointBlocked, checkpointCreated, checkpointLogState, newCheckpoint };
403
+ };
404
+ const projection = { sync_configs: 1, last_persisted_op: 1 };
405
+ const { checkpointBlocked, checkpointCreated, checkpointLogState, newCheckpoint } = await this.flushAndCommit(
406
+ checkpoint,
407
+ () => this.withFencedTransaction(() => this.fence(this.session, projection), checkpoint),
408
+ options,
409
+ projection
410
+ );
421
411
  if (checkpointBlocked) {
422
412
  if (Date.now() - this.lastWaitingLogThrottledV3 > 5_000) {
423
413
  this.logger.info(
@@ -448,23 +438,9 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
448
438
 
449
439
  async setResumeLsn(lsn: string): Promise<void> {
450
440
  using _ = this.tracer.span('storage', 'set_resume_lsn');
451
- await this.db.sync_rules.updateOne(
452
- {
453
- _id: this.replicationStreamId
454
- },
455
- {
456
- $set: {
457
- resume_lsn: lsn
458
- }
459
- },
460
- {
461
- session: this.session,
462
- // Losing occasional resume LSN is fine. That may mean reprocessing
463
- // some source changes in some edge cases, which is not an issue since
464
- // changes are processed in an idempotent way.
465
- writeConcern: { w: 1 }
466
- }
467
- );
441
+ // Losing occasional resume LSN would only reprocess source changes.
442
+ // Keep the lease check atomic, but do not wait for majority replication of this resume hint.
443
+ await this.updateStreamMetadata({ resume_lsn: { $literal: lsn } }, {}, { w: 1 });
468
444
  }
469
445
 
470
446
  private async autoActivateV3(lsn: string): Promise<void> {
@@ -475,7 +451,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
475
451
  const session = this.session;
476
452
  let activated = false;
477
453
  let needsFutureActivationCheck = true;
478
- await session.withTransaction(async () => {
454
+ await this.withTransaction(async () => {
479
455
  // Reset on transaction retries.
480
456
  needsFutureActivationCheck = true;
481
457
  activated = false;
@@ -563,7 +539,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
563
539
  } else if (doc.state == storage.SyncRuleState.ACTIVE && processingStates.length == 0) {
564
540
  needsFutureActivationCheck = false;
565
541
  }
566
- });
542
+ }, session);
567
543
  if (activated) {
568
544
  this.logger.info(`Activated new replication stream at ${lsn}`);
569
545
  await this.db.notifyCheckpoint();
@@ -573,26 +549,35 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
573
549
  }
574
550
  }
575
551
 
576
- async markAllSnapshotDone(no_checkpoint_before_lsn: string): Promise<void> {
577
- await this.db.sync_rules.updateOne(
578
- {
579
- _id: this.replicationStreamId,
580
- 'sync_configs._id': { $in: this.syncConfigIds }
581
- },
582
- {
583
- $set: {
584
- 'sync_configs.$[config].snapshot_done': true,
585
- last_keepalive_ts: new Date()
586
- },
587
- $max: {
588
- 'sync_configs.$[config].no_checkpoint_before': no_checkpoint_before_lsn
552
+ private syncConfigMetadataUpdate(syncConfigIds: bson.ObjectId[], set: lib_mongo.mongo.Document) {
553
+ return {
554
+ sync_configs: {
555
+ $map: {
556
+ input: '$sync_configs',
557
+ as: 'config',
558
+ in: {
559
+ $cond: [
560
+ { $in: ['$$config._id', { $literal: syncConfigIds }] },
561
+ { $mergeObjects: ['$$config', set] },
562
+ '$$config'
563
+ ]
564
+ }
589
565
  }
590
- },
591
- {
592
- session: this.session,
593
- arrayFilters: [{ 'config._id': { $in: this.syncConfigIds } }]
594
566
  }
595
- );
567
+ };
568
+ }
569
+
570
+ private snapshotDoneUpdate(no_checkpoint_before_lsn: string) {
571
+ return this.syncConfigMetadataUpdate(this.syncConfigIds, {
572
+ snapshot_done: true,
573
+ no_checkpoint_before: { $max: ['$$config.no_checkpoint_before', { $literal: no_checkpoint_before_lsn }] }
574
+ });
575
+ }
576
+
577
+ async markAllSnapshotDone(no_checkpoint_before_lsn: string): Promise<void> {
578
+ await this.updateStreamMetadata(this.snapshotDoneUpdate(no_checkpoint_before_lsn), {
579
+ 'sync_configs._id': { $in: this.syncConfigIds }
580
+ });
596
581
  }
597
582
 
598
583
  async markSnapshotDone(no_checkpoint_before_lsn: string, options?: { throwOnConflict?: boolean }): Promise<void> {
@@ -616,7 +601,9 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
616
601
  }
617
602
  }
618
603
 
619
- await this.markAllSnapshotDone(no_checkpoint_before_lsn);
604
+ await this.updateStreamMetadataInTransaction(this.snapshotDoneUpdate(no_checkpoint_before_lsn), {
605
+ 'sync_configs._id': { $in: this.syncConfigIds }
606
+ });
620
607
  });
621
608
  }
622
609
 
@@ -625,22 +612,9 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
625
612
  if (syncConfigIds.length == 0) {
626
613
  return;
627
614
  }
628
-
629
- await this.db.sync_rules.updateOne(
630
- {
631
- _id: this.replicationStreamId,
632
- 'sync_configs._id': { $in: syncConfigIds }
633
- },
634
- {
635
- $set: {
636
- 'sync_configs.$[config].snapshot_done': false
637
- }
638
- },
639
- {
640
- session: this.session,
641
- arrayFilters: [{ 'config._id': { $in: syncConfigIds } }]
642
- }
643
- );
615
+ await this.updateStreamMetadata(this.syncConfigMetadataUpdate(syncConfigIds, { snapshot_done: false }), {
616
+ 'sync_configs._id': { $in: syncConfigIds }
617
+ });
644
618
  }
645
619
 
646
620
  async markTableSnapshotDone(
@@ -652,7 +626,9 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
652
626
  const syncConfigIds = this.relevantSyncConfigIdsForTables(tables);
653
627
 
654
628
  await this.withTransaction(async () => {
655
- await this.db.sourceTables(this.replicationStreamId).updateMany(
629
+ const writes = this.db.createWriteBatch(session, { ordered: false });
630
+ writes.updateMany(
631
+ this.db.sourceTables(this.replicationStreamId),
656
632
  { _id: { $in: ids } },
657
633
  {
658
634
  $set: {
@@ -661,31 +637,28 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
661
637
  $unset: {
662
638
  snapshot_status: 1
663
639
  }
664
- },
665
- { session }
640
+ }
666
641
  );
667
642
 
668
643
  if (no_checkpoint_before_lsn != null && syncConfigIds.length > 0) {
669
- await this.db.sync_rules.updateOne(
644
+ writes.updateOne(
645
+ this.db.sync_rules,
670
646
  {
671
647
  _id: this.replicationStreamId,
672
648
  'sync_configs._id': { $in: syncConfigIds }
673
649
  },
674
650
  {
675
- $set: {
676
- last_keepalive_ts: new Date()
677
- },
678
651
  $max: {
679
652
  'sync_configs.$[config].no_checkpoint_before': no_checkpoint_before_lsn
680
653
  }
681
654
  },
682
655
  {
683
- session: this.session,
684
656
  // Only set for sync configs that use this table
685
657
  arrayFilters: [{ 'config._id': { $in: syncConfigIds } }]
686
658
  }
687
659
  );
688
660
  }
661
+ await writes.execute();
689
662
  });
690
663
  return tables.map((table) => {
691
664
  const copy = table.clone();
@@ -706,9 +679,11 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
706
679
  this.validateCustomCheckpointEventId(checkpoint.event_id)
707
680
  );
708
681
 
682
+ const writes = this.db.createWriteBatch(session, { ordered: false });
709
683
  for (const [eventId, checkpoints] of checkpointsByEvent) {
710
- await this.batchCreateEventCustomWriteCheckpoints(session, opId, eventId, checkpoints);
684
+ this.batchCreateEventCustomWriteCheckpoints(writes, opId, eventId, checkpoints);
711
685
  }
686
+ await writes.execute();
712
687
  }
713
688
 
714
689
  protected override async prepareCustomWriteCheckpoints(): Promise<void> {
@@ -741,48 +716,48 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
741
716
  return eventId;
742
717
  }
743
718
 
744
- private async batchCreateEventCustomWriteCheckpoints(
745
- session: lib_mongo.mongo.ClientSession,
719
+ private batchCreateEventCustomWriteCheckpoints(
720
+ writes: MongoWriteBatch,
746
721
  opId: InternalOpId,
747
722
  eventId: EventDefinitionId,
748
723
  checkpoints: storage.CustomWriteCheckpointOptions[]
749
- ): Promise<void> {
750
- await this.db
751
- .customCheckpointRequests({
724
+ ): void {
725
+ // A repeated user within an event replaces the complete checkpoint state.
726
+ const uniqueCheckpoints = new Map(checkpoints.map((checkpoint) => [checkpoint.user_id, checkpoint]));
727
+ writes.bulkWriteUnordered(
728
+ this.db.customCheckpointRequests({
752
729
  eventId,
753
730
  replicationStreamId: this.replicationStreamId
754
- })
755
- .bulkWrite(
756
- checkpoints.map((checkpointOptions) => {
757
- const set: Partial<CustomCheckpointRequestDocumentV3> = {
758
- user_id: checkpointOptions.user_id,
759
- checkpoint: checkpointOptions.checkpoint,
760
- op_id: opId
761
- };
762
- if (checkpointOptions.checkpoint_requested_at != null) {
763
- set.checkpoint_requested_at = checkpointOptions.checkpoint_requested_at;
764
- }
731
+ }),
732
+ [...uniqueCheckpoints.values()].map((checkpointOptions) => {
733
+ const set: Partial<CustomCheckpointRequestDocumentV3> = {
734
+ user_id: checkpointOptions.user_id,
735
+ checkpoint: checkpointOptions.checkpoint,
736
+ op_id: opId
737
+ };
738
+ if (checkpointOptions.checkpoint_requested_at != null) {
739
+ set.checkpoint_requested_at = checkpointOptions.checkpoint_requested_at;
740
+ }
765
741
 
766
- return {
767
- updateOne: {
768
- filter: {
769
- user_id: checkpointOptions.user_id
770
- },
771
- update: {
772
- $set: set,
773
- ...(checkpointOptions.checkpoint_requested_at == null
774
- ? {
775
- $unset: {
776
- checkpoint_requested_at: 1
777
- }
742
+ return {
743
+ updateOne: {
744
+ filter: {
745
+ user_id: checkpointOptions.user_id
746
+ },
747
+ update: {
748
+ $set: set,
749
+ ...(checkpointOptions.checkpoint_requested_at == null
750
+ ? {
751
+ $unset: {
752
+ checkpoint_requested_at: 1
778
753
  }
779
- : {})
780
- },
781
- upsert: true
782
- }
783
- };
784
- }),
785
- { session }
786
- );
754
+ }
755
+ : {})
756
+ },
757
+ upsert: true
758
+ }
759
+ };
760
+ })
761
+ );
787
762
  }
788
763
  }