@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
@@ -14,6 +14,7 @@ import {
14
14
  ErrorCode,
15
15
  errors,
16
16
  Logger,
17
+ ReplicationAbortedError,
17
18
  ReplicationAssertionError,
18
19
  ServiceError
19
20
  } from '@powersync/lib-services-framework';
@@ -33,21 +34,20 @@ import { mongoTableId } from '../../utils/util.js';
33
34
  import { PersistedBatch } from './common/PersistedBatch.js';
34
35
  import { LoadedSourceRecord, SourceRecordStore } from './common/SourceRecordStore.js';
35
36
  import type { VersionedPowerSyncMongo } from './db.js';
37
+ import { SyncRuleDocumentBase } from './models.js';
36
38
  import { MAX_ROW_SIZE } from './MongoBucketBatchShared.js';
37
- import { MongoIdSequence } from './MongoIdSequence.js';
39
+ import { MongoIdSequence, OpIdRangeExhausted } from './MongoIdSequence.js';
40
+ import { MongoOpIdAllocator } from './MongoOpIdAllocator.js';
38
41
  import { MongoParsedSyncConfigSet } from './MongoParsedSyncConfigSet.js';
42
+ import { MongoSyncRulesLock } from './MongoSyncRulesLock.js';
43
+ import { MongoWriteBatch } from './MongoWriteBatch.js';
39
44
  import { OperationBatch, RecordOperation } from './OperationBatch.js';
40
45
  import { ObjectStorage } from './v3/object-storage/ObjectStorage.js';
41
46
  import { createObjectStorageUsageWriterId } from './v3/object-storage/ObjectStorageUsage.js';
42
47
 
43
- // Currently, we can only have a single flush() at a time, since it locks the op_id sequence.
44
- // While the MongoDB transaction retry mechanism handles this okay, using an in-process Mutex
45
- // makes it more fair and has less overhead.
46
- //
47
- // In the future, we can investigate allowing multiple replication streams operating independently.
48
- const replicationMutex = new utils.Mutex();
49
-
50
48
  export interface MongoBucketBatchOptions {
49
+ replicationLock: MongoSyncRulesLock;
50
+ opIdAllocator: MongoOpIdAllocator;
51
51
  db: VersionedPowerSyncMongo;
52
52
  /**
53
53
  * The parsed sync config set for this batch.
@@ -144,7 +144,13 @@ export abstract class MongoBucketBatch
144
144
  constructor(options: MongoBucketBatchOptions) {
145
145
  super();
146
146
  this.logger = options.logger;
147
- this.options = options;
147
+ this.options = {
148
+ ...options,
149
+ signal:
150
+ options.signal == null
151
+ ? options.replicationLock.signal
152
+ : AbortSignal.any([options.signal, options.replicationLock.signal])
153
+ };
148
154
  this.client = options.db.client;
149
155
  this.db = options.db;
150
156
  this.replicationStreamId = options.replicationStreamId;
@@ -214,7 +220,34 @@ export abstract class MongoBucketBatch
214
220
  return result;
215
221
  }
216
222
 
217
- private async flushInner(options?: storage.BatchBucketFlushOptions): Promise<storage.FlushedResult | null> {
223
+ /** Publish the checkpoint in the final flush transaction, or use the empty-commit path. */
224
+ protected async flushAndCommit<T>(
225
+ checkpoint: (stream: SyncRuleDocumentBase, lastOp: InternalOpId) => Promise<T>,
226
+ commitWithoutFlush: () => Promise<T>,
227
+ options?: storage.BatchBucketFlushOptions,
228
+ projection?: mongo.Document
229
+ ): Promise<T> {
230
+ if (this.batch == null && this.write_checkpoint_batch.length === 0) {
231
+ return commitWithoutFlush();
232
+ }
233
+ let result!: T;
234
+ while (this.batch != null || this.write_checkpoint_batch.length > 0) {
235
+ await this.flushInner(
236
+ options,
237
+ async (stream, lastOp) => {
238
+ result = await checkpoint(stream, lastOp);
239
+ },
240
+ projection
241
+ );
242
+ }
243
+ return result;
244
+ }
245
+
246
+ private async flushInner(
247
+ options?: storage.BatchBucketFlushOptions,
248
+ checkpoint?: (stream: SyncRuleDocumentBase, lastOp: InternalOpId) => Promise<void>,
249
+ projection?: mongo.Document
250
+ ): Promise<storage.FlushedResult | null> {
218
251
  const batch = this.batch;
219
252
  let last_op: InternalOpId | null = null;
220
253
  let resumeBatch: OperationBatch | null = null;
@@ -228,22 +261,36 @@ export abstract class MongoBucketBatch
228
261
  await this.prepareCustomWriteCheckpoints();
229
262
  }
230
263
 
231
- await this.withReplicationTransaction(`Flushing ${batch?.length ?? 0} ops`, async (session, opSeq) => {
232
- clearedError = false;
233
- if (batch != null) {
234
- const result = await this.replicateBatch(session, batch, opSeq, options);
235
- resumeBatch = result.resumeBatch;
236
- clearedError ||= result.clearedError;
237
- }
264
+ await this.withReplicationTransaction(
265
+ `Flushing ${batch?.length ?? 0} ops`,
266
+ async (session, opSeq) => {
267
+ clearedError = false;
268
+ if (batch != null) {
269
+ const result = await this.replicateBatch(session, batch, opSeq, options);
270
+ resumeBatch = result.resumeBatch;
271
+ clearedError ||= result.clearedError;
272
+ }
238
273
 
239
- if (this.write_checkpoint_batch.length > 0) {
240
- this.logger.info(`Writing ${this.write_checkpoint_batch.length} custom write checkpoints`);
241
- await this.batchCreateCustomWriteCheckpoints(session, opSeq.next());
242
- this.write_checkpoint_batch = [];
243
- }
274
+ if (this.write_checkpoint_batch.length > 0) {
275
+ this.logger.info(`Writing ${this.write_checkpoint_batch.length} custom write checkpoints`);
276
+ await this.batchCreateCustomWriteCheckpoints(session, opSeq.next());
277
+ }
244
278
 
245
- last_op = opSeq.last();
246
- });
279
+ last_op = opSeq.last();
280
+ },
281
+ async (stream, lastOp) => {
282
+ if (checkpoint != null && resumeBatch == null) {
283
+ // The checkpoint must also persist the head, including when visibility is blocked.
284
+ await checkpoint(stream, lastOp);
285
+ return true;
286
+ }
287
+ return false;
288
+ },
289
+ projection
290
+ );
291
+
292
+ // Keep checkpoints available if the transaction retries after writing them.
293
+ this.write_checkpoint_batch = [];
247
294
 
248
295
  if (clearedError) {
249
296
  this.clearedError = true;
@@ -640,17 +687,46 @@ export abstract class MongoBucketBatch
640
687
  return result;
641
688
  }
642
689
 
643
- protected async withTransaction(cb: () => Promise<void>) {
644
- using lockSpan = this.tracer.span('storage', 'internal_lock');
645
- await replicationMutex.exclusiveLock(async () => {
646
- lockSpan.end();
647
- await this.session.withTransaction(
690
+ protected async fence(session = this.session, projection?: mongo.Document) {
691
+ // Graceful cancellation belongs to the source connector's page/batch boundary.
692
+ // It must still be able to persist progress for rows already flushed. Lease
693
+ // loss is different: the fence rejects every subsequent writer transaction.
694
+ return MongoSyncRulesLock.fence(
695
+ this.db,
696
+ this.replicationStreamId,
697
+ this.options.replicationLock,
698
+ session,
699
+ projection
700
+ );
701
+ }
702
+
703
+ protected async withTransaction<T>(
704
+ cb: (stream: SyncRuleDocumentBase) => Promise<T>,
705
+ session = this.session
706
+ ): Promise<T> {
707
+ return this.withFencedTransaction(() => this.fence(session), cb, session);
708
+ }
709
+
710
+ /** The first operation must modify the stream document conditional on lease ownership. */
711
+ protected async withFencedTransaction<S, T>(
712
+ acquireFence: () => Promise<S>,
713
+ cb: (stream: S) => Promise<T>,
714
+ session = this.session
715
+ ): Promise<T> {
716
+ return this.withWriterLock(() =>
717
+ session.withTransaction(
648
718
  async () => {
719
+ const stream = await acquireFence();
649
720
  try {
650
- await cb();
721
+ const result = await cb(stream);
722
+ this.options.replicationLock.throwIfAborted();
723
+ return result;
651
724
  } catch (e: unknown) {
725
+ if (e instanceof OpIdRangeExhausted) {
726
+ throw e;
727
+ }
652
728
  if (e instanceof mongo.MongoError && e.hasErrorLabel('TransientTransactionError')) {
653
- // Likely write conflict caused by concurrent write stream replicating
729
+ // Likely write conflict caused by concurrent writes to this replication stream.
654
730
  } else {
655
731
  this.logger.warn('Transaction error', e as Error);
656
732
  }
@@ -660,102 +736,118 @@ export abstract class MongoBucketBatch
660
736
  throw e;
661
737
  }
662
738
  },
663
- { maxCommitTimeMS: 10000 }
664
- );
739
+ { maxCommitTimeMS: 10000, writeConcern: { w: 'majority' } }
740
+ )
741
+ );
742
+ }
743
+
744
+ private async withWriterLock<T>(callback: () => Promise<T>): Promise<T> {
745
+ using lockSpan = this.tracer.span('storage', 'internal_lock');
746
+ return this.options.replicationLock.writerMutex.exclusiveLock(async () => {
747
+ lockSpan.end();
748
+ this.options.replicationLock.throwIfAborted();
749
+ return callback();
665
750
  });
666
751
  }
667
752
 
668
- private async withReplicationTransaction(
669
- description: string,
670
- callback: (session: mongo.ClientSession, opSeq: MongoIdSequence) => Promise<void>
753
+ /** Single-document updates enforce ownership atomically, without a separate transaction. */
754
+ protected async updateStreamMetadata(
755
+ set: mongo.Document,
756
+ filter: mongo.Document = {},
757
+ writeConcern: mongo.WriteConcernSettings = { w: 'majority' }
671
758
  ): Promise<void> {
672
- let flushTry = 0;
673
-
674
- const start = Date.now();
675
- const lastTry = start + 90000;
676
-
677
- const session = this.session;
678
-
679
- await this.withTransaction(async () => {
680
- flushTry += 1;
681
- if (flushTry % 10 == 0) {
682
- this.logger.info(`${description} - try ${flushTry}`);
683
- }
684
- if (flushTry > 20 && Date.now() > lastTry) {
685
- throw new ServiceError(ErrorCode.PSYNC_S1402, 'Max transaction tries exceeded');
686
- }
759
+ await this.withWriterLock(() => this.writeStreamMetadata(set, filter, writeConcern));
760
+ }
687
761
 
688
- const next_op_id_doc = await this.db.op_id_sequence.findOneAndUpdate(
689
- {
690
- _id: 'main'
691
- },
692
- {
693
- $setOnInsert: { op_id: 0n },
694
- $set: {
695
- // Force update to ensure we get a mongo lock
696
- ts: Date.now()
697
- }
698
- },
699
- {
700
- upsert: true,
701
- returnDocument: 'after',
702
- session
703
- }
704
- );
705
- const opSeq = new MongoIdSequence(next_op_id_doc?.op_id ?? 0n);
762
+ /** Caller must already be inside a fenced transaction holding the writer mutex. */
763
+ protected async updateStreamMetadataInTransaction(set: mongo.Document, filter: mongo.Document = {}): Promise<void> {
764
+ await this.writeStreamMetadata(set, filter);
765
+ }
706
766
 
707
- await callback(session, opSeq);
767
+ private async writeStreamMetadata(
768
+ set: mongo.Document,
769
+ filter: mongo.Document,
770
+ writeConcern?: mongo.WriteConcernSettings
771
+ ): Promise<void> {
772
+ const result = await this.db.sync_rules.updateOne(
773
+ { ...filter, ...MongoSyncRulesLock.ownerFilter(this.replicationStreamId, this.options.replicationLock) },
774
+ [{ $set: { ...set, ...MongoSyncRulesLock.heartbeatUpdate() } }],
775
+ { session: this.session, ...(writeConcern == null ? {} : { writeConcern }) }
776
+ );
777
+ if (result.matchedCount === 0) {
778
+ throw new ReplicationAbortedError('Replication writer no longer owns the stream');
779
+ }
780
+ }
708
781
 
709
- await this.db.op_id_sequence.updateOne(
710
- {
711
- _id: 'main'
712
- },
713
- {
714
- $set: {
715
- op_id: opSeq.last()
782
+ private async withReplicationTransaction(
783
+ description: string,
784
+ callback: (session: mongo.ClientSession, opSeq: MongoIdSequence) => Promise<void>,
785
+ finish?: (stream: SyncRuleDocumentBase, lastOp: InternalOpId) => Promise<boolean>,
786
+ projection?: mongo.Document
787
+ ): Promise<void> {
788
+ let flushTry = 0;
789
+ const lastTry = Date.now() + 90000;
790
+ const allocator = this.options.opIdAllocator;
791
+ let lastOp = 0n;
792
+ // Refill outside the publication transaction, before evaluating any rows.
793
+ // Exhaustion remains a fallback for large batches or a newer stream head.
794
+ this.options.replicationLock.throwIfAborted();
795
+ await allocator.ensureCapacity();
796
+ for (;;) {
797
+ try {
798
+ await this.withFencedTransaction(
799
+ () => this.fence(this.session, projection),
800
+ async (stream) => {
801
+ flushTry += 1;
802
+ if (flushTry % 10 == 0) {
803
+ this.logger.info(`${description} - try ${flushTry}`);
804
+ }
805
+ if (flushTry > 20 && Date.now() > lastTry) {
806
+ throw new ServiceError(ErrorCode.PSYNC_S1402, 'Max transaction tries exceeded');
807
+ }
808
+ // The fence already holds this stream document for the transaction.
809
+ // A different writer may have used higher IDs since our last flush.
810
+ const opSeq = allocator.sequence(this.persistedOpHead(stream));
811
+ await callback(this.session, opSeq);
812
+ lastOp = opSeq.last();
813
+
814
+ if (!(await finish?.(stream, lastOp))) {
815
+ const writes = this.db.createWriteBatch(this.session, { ordered: false });
816
+ // Allow subclasses to persist additional flush-time state in the same transaction.
817
+ this.onReplicationTransactionFlush(writes, lastOp);
818
+ await writes.execute();
819
+ }
820
+ // Notifications and cleanup must wait until the transaction commits.
716
821
  }
717
- },
718
- {
719
- session
822
+ );
823
+ allocator.committed(lastOp);
824
+ return;
825
+ } catch (error) {
826
+ if (!(error instanceof OpIdRangeExhausted)) {
827
+ throw error;
720
828
  }
721
- );
722
-
723
- await this.db.sync_rules.updateOne(
724
- {
725
- _id: this.replicationStreamId
726
- },
727
- {
728
- $set: {
729
- last_keepalive_ts: new Date()
730
- }
731
- },
732
- { session }
733
- );
734
-
735
- // Allow subclasses to persist additional flush-time state in the same transaction
736
- // (e.g. v3 advances the stream-level last_persisted_op).
737
- await this.onReplicationTransactionFlush(session, opSeq.last());
738
- // We don't notify checkpoint here - we don't make any checkpoint updates directly
739
- });
829
+ // withTransaction has aborted every write. Reserve outside that transaction,
830
+ // then replay evaluation from the same committed stream head. Existing
831
+ // ranges can be reused on retry because no operation from this attempt committed.
832
+ this.options.replicationLock.throwIfAborted();
833
+ await allocator.reserve();
834
+ }
835
+ }
740
836
  }
741
837
 
742
- /**
743
- * Hook called inside the replication flush transaction, after ops have been persisted.
744
- *
745
- * The base implementation does nothing; v3 storage overrides this to `$max` the stream-level
746
- * `last_persisted_op` durably within the same transaction.
747
- */
748
- protected async onReplicationTransactionFlush(_session: mongo.ClientSession, _lastOp: InternalOpId): Promise<void> {
749
- // No-op by default (v1 behaviour unchanged).
750
- }
838
+ /** Highest durably persisted operation, including operations not yet checkpointed. */
839
+ protected abstract persistedOpHead(stream: SyncRuleDocumentBase): InternalOpId;
840
+
841
+ /** Advance the persisted head in the same transaction as the operations. */
842
+ protected abstract onReplicationTransactionFlush(writes: MongoWriteBatch, lastOp: InternalOpId): void;
751
843
 
752
844
  /**
753
845
  * Called after a replication transaction has successfully committed, with the last persisted op id.
754
846
  *
755
847
  * v1 storage tracks this in memory to fold into the next checkpoint. v3 storage does not need it:
756
848
  * the stream-level `last_persisted_op` is already advanced durably by
757
- * {@link onReplicationTransactionFlush} within the same transaction, and checkpoints read it
758
- * from the document.
849
+ * {@link onReplicationTransactionFlush} or the combined checkpoint update within the same
850
+ * transaction, and empty commits read it from the document.
759
851
  *
760
852
  * Keep calls to this adjacent to {@link withReplicationTransaction} usage - both must observe
761
853
  * every path that persists ops.
@@ -834,7 +926,9 @@ export abstract class MongoBucketBatch
834
926
 
835
927
  await this.withTransaction(async () => {
836
928
  for (let table of sourceTables) {
837
- await this.db.commonSourceTables(this.replicationStreamId).deleteOne({ _id: mongoTableId(table.id) });
929
+ await this.db
930
+ .commonSourceTables(this.replicationStreamId)
931
+ .deleteOne({ _id: mongoTableId(table.id) }, { session: this.session });
838
932
  }
839
933
  });
840
934
 
@@ -16,6 +16,8 @@ export interface MongoCompactOptions extends storage.CompactOptions {
16
16
  * the lightweight pass.
17
17
  */
18
18
  compactChunksOnly?: boolean;
19
+ /** Internal/testing use: bypass the new-chunk threshold during chunk-only compaction. */
20
+ forceChunkCompaction?: boolean;
19
21
  }
20
22
 
21
23
  const DEFAULT_CLEAR_BATCH_LIMIT = 5000;
@@ -9,7 +9,10 @@ import { ReplicationAssertionError } from '@powersync/lib-services-framework';
9
9
  export class MongoIdSequence {
10
10
  private _last: bigint;
11
11
 
12
- constructor(last: bigint) {
12
+ constructor(
13
+ last: bigint,
14
+ private readonly ranges?: readonly OpIdRange[]
15
+ ) {
13
16
  if (typeof last != 'bigint') {
14
17
  throw new ReplicationAssertionError(`BigInt required, got ${last} ${typeof last}`);
15
18
  }
@@ -17,6 +20,14 @@ export class MongoIdSequence {
17
20
  }
18
21
 
19
22
  next() {
23
+ if (this.ranges != null) {
24
+ const next = this.ranges.find((range) => range.end > this._last);
25
+ if (next == null) {
26
+ throw new OpIdRangeExhausted();
27
+ }
28
+ this._last = this._last + 1n < next.start ? next.start : this._last + 1n;
29
+ return this._last;
30
+ }
20
31
  return ++this._last;
21
32
  }
22
33
 
@@ -24,3 +35,12 @@ export class MongoIdSequence {
24
35
  return this._last;
25
36
  }
26
37
  }
38
+
39
+ /** Inclusive, durably reserved operation IDs. Gaps between ranges are valid. */
40
+ export interface OpIdRange {
41
+ start: bigint;
42
+ end: bigint;
43
+ }
44
+
45
+ /** Abort the transaction before reserving more IDs and retrying its evaluation. */
46
+ export class OpIdRangeExhausted extends Error {}
@@ -0,0 +1,105 @@
1
+ import { mongo } from '@powersync/lib-service-mongodb';
2
+ import { ReplicationAssertionError } from '@powersync/lib-services-framework';
3
+ import { VersionedPowerSyncMongo } from './db.js';
4
+ import { MongoIdSequence, OpIdRange } from './MongoIdSequence.js';
5
+
6
+ const RESERVATION_SIZE = 65_536n;
7
+ const LOW_WATER_MARK = 16_384n;
8
+ const MAX_OP_ID = (1n << 63n) - 1n;
9
+
10
+ /**
11
+ * Shared by writers of one stream and lease owner in this process. Reservations
12
+ * use short majority writes, never the publication session. A crashed owner
13
+ * abandons its unused ranges; the global sequence is an allocation watermark.
14
+ */
15
+ export class MongoOpIdAllocator {
16
+ private ranges: OpIdRange[] = [];
17
+ private reserving?: Promise<void>;
18
+
19
+ constructor(private readonly db: VersionedPowerSyncMongo) {}
20
+
21
+ discard() {
22
+ this.ranges = [];
23
+ }
24
+
25
+ sequence(persistedOp: bigint): MongoIdSequence {
26
+ // The stream is fenced before reading this head. Another writer may have
27
+ // committed since this allocator was last used, including in another process.
28
+ // Never reuse reserved IDs below that durable head.
29
+ return new MongoIdSequence(persistedOp, this.ranges.slice());
30
+ }
31
+
32
+ committed(lastOp: bigint) {
33
+ this.ranges = this.ranges
34
+ .filter((range) => range.end > lastOp)
35
+ .map((range) => ({ start: range.start > lastOp ? range.start : lastOp + 1n, end: range.end }));
36
+ }
37
+
38
+ /** Top up between transactions, retaining every unused ID in existing ranges. */
39
+ async ensureCapacity(): Promise<void> {
40
+ const remaining = this.ranges.reduce((total, range) => total + range.end - range.start + 1n, 0n);
41
+ if (remaining < LOW_WATER_MARK) {
42
+ await this.reserve();
43
+ }
44
+ }
45
+
46
+ async reserve(): Promise<void> {
47
+ if (this.reserving != null) {
48
+ return this.reserving;
49
+ }
50
+ this.reserving = this.reserveRange();
51
+ try {
52
+ await this.reserving;
53
+ } finally {
54
+ this.reserving = undefined;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Atomically reserve a range of operation IDs in the global sequence.
60
+ *
61
+ * We continue using the range across multiple batches, until the range is exhausted,
62
+ * so we don't "waste" large segements in long-running processes with small batches.
63
+ * However, we can't return unused portions back to the database if we don't use them.
64
+ */
65
+ private async reserveRange(): Promise<void> {
66
+ try {
67
+ // Return the previous watermark so an unchanged value at the limit can be
68
+ // distinguished from a successful reservation ending near the limit.
69
+ const previous = await this.db.op_id_sequence.findOneAndUpdate(
70
+ { _id: 'main' },
71
+ [
72
+ {
73
+ $set: {
74
+ op_id: {
75
+ $let: {
76
+ vars: { current: { $ifNull: ['$op_id', 0n] } },
77
+ in: {
78
+ $cond: [
79
+ { $lte: ['$$current', MAX_OP_ID - RESERVATION_SIZE] },
80
+ { $add: ['$$current', RESERVATION_SIZE] },
81
+ '$$current'
82
+ ]
83
+ }
84
+ }
85
+ }
86
+ }
87
+ }
88
+ ],
89
+ { upsert: true, returnDocument: 'before', writeConcern: { w: 'majority' } }
90
+ );
91
+ const last = previous?.op_id ?? 0n;
92
+ if (last > MAX_OP_ID - RESERVATION_SIZE) {
93
+ // Not expected to ever happen, unless the sequence was manually modified
94
+ throw new ReplicationAssertionError('Operation ID sequence exhausted');
95
+ }
96
+ this.ranges.push({ start: last + 1n, end: last + RESERVATION_SIZE });
97
+ } catch (error) {
98
+ // Concurrent first reservations may race to insert the sequence document - retry in that case.
99
+ if (error instanceof mongo.MongoServerError && error.code === 11000) {
100
+ return this.reserveRange();
101
+ }
102
+ throw error;
103
+ }
104
+ }
105
+ }
@@ -1,7 +1,11 @@
1
1
  import * as lib_mongo from '@powersync/lib-service-mongodb';
2
2
  import { ErrorCode, logger, ServiceAssertionError, ServiceError } from '@powersync/lib-services-framework';
3
3
  import { POWERSYNC_VERSION, storage } from '@powersync/service-core';
4
- import { MongoStorageConfig, normalizeClearBatchThrottleRate } from '../../types/types.js';
4
+ import {
5
+ MongoStorageConfig,
6
+ normalizeChunkCompactionConcurrency,
7
+ normalizeClearBatchThrottleRate
8
+ } from '../../types/types.js';
5
9
  import { MongoBucketStorage } from '../MongoBucketStorage.js';
6
10
  import { MongoReportStorage } from '../MongoReportStorage.js';
7
11
  import { PowerSyncMongo } from './db.js';
@@ -24,6 +28,10 @@ export class MongoStorageProvider implements storage.StorageProvider {
24
28
  }
25
29
 
26
30
  const decodedConfig = MongoStorageConfig.decode(storage as any);
31
+ const chunkCompactionConcurrency = normalizeChunkCompactionConcurrency(
32
+ decodedConfig.chunk_compaction_concurrency,
33
+ decodedConfig.object_storage != null
34
+ );
27
35
 
28
36
  let objectStorage: ObjectStorage | undefined;
29
37
  if (decodedConfig.object_storage?.type === 's3') {
@@ -47,6 +55,8 @@ export class MongoStorageProvider implements storage.StorageProvider {
47
55
  maxPoolSize: resolvedConfig.storage.max_pool_size ?? 8
48
56
  });
49
57
 
58
+ const database = new PowerSyncMongo(client, { database: resolvedConfig.storage.database });
59
+
50
60
  let shuttingDown = false;
51
61
 
52
62
  // Explicitly connect on startup.
@@ -56,7 +66,6 @@ export class MongoStorageProvider implements storage.StorageProvider {
56
66
  // Errors here will cause the process to exit.
57
67
  await client.connect();
58
68
 
59
- const database = new PowerSyncMongo(client, { database: resolvedConfig.storage.database });
60
69
  const readPreference =
61
70
  decodedConfig.bulk_read_preference == null
62
71
  ? undefined
@@ -66,6 +75,7 @@ export class MongoStorageProvider implements storage.StorageProvider {
66
75
  maxStalenessSeconds: decodedConfig.bulk_read_preference == 'primary' ? undefined : 90
67
76
  });
68
77
  const syncStorageFactory = new MongoBucketStorage(database, {
78
+ chunkCompactionConcurrency,
69
79
  replicationStreamNamePrefix: resolvedConfig.slot_name_prefix,
70
80
  readPreference,
71
81
  clearBatchThrottleRate: normalizeClearBatchThrottleRate(decodedConfig.clear_batch_throttle_rate),
@@ -43,6 +43,7 @@ import { MongoCompactOptions, MongoCompactor } from './MongoCompactor.js';
43
43
  import { MongoParameterCompactor } from './MongoParameterCompactor.js';
44
44
  import { MongoParsedSyncConfigSet } from './MongoParsedSyncConfigSet.js';
45
45
  import { MongoPersistedReplicationStream } from './MongoPersistedReplicationStream.js';
46
+ import { MongoSyncRulesLock } from './MongoSyncRulesLock.js';
46
47
  import { MongoCheckpointAPIOptions, MongoWriteCheckpointAPI } from './MongoWriteCheckpointAPI.js';
47
48
  import { ObjectStorage } from './v3/object-storage/ObjectStorage.js';
48
49
 
@@ -109,6 +110,10 @@ export abstract class MongoSyncBucketStorage
109
110
  public readonly readPreference: mongo.ReadPreference | undefined;
110
111
  public readonly clearBatchThrottleRate: number;
111
112
  #storageInitialized = false;
113
+ /**
114
+ * Required for writing, optional for reading.
115
+ */
116
+ private readonly replicationLock: MongoSyncRulesLock | null;
112
117
 
113
118
  constructor(
114
119
  public readonly factory: MongoBucketStorage,
@@ -119,6 +124,7 @@ export abstract class MongoSyncBucketStorage
119
124
  options: MongoSyncBucketStorageOptions
120
125
  ) {
121
126
  super();
127
+ this.replicationLock = replicationStream.current_lock;
122
128
  this.storageConfig = options.storageConfig;
123
129
  this.objectStorage = options.objectStorage;
124
130
  // Keep small chunks inline in MongoDB rather than offloading them to S3.
@@ -269,12 +275,15 @@ export abstract class MongoSyncBucketStorage
269
275
  * The version-independent part of the batch options.
270
276
  */
271
277
  protected writerBatchOptions(options: storage.CreateWriterOptions): Omit<MongoBucketBatchOptions, 'resumeFromLsn'> {
278
+ const replicationLock = this.requireReplicationLock();
272
279
  return {
273
280
  logger: options.logger ?? this.logger,
274
281
  db: this.db,
275
282
  parsedSyncConfig: this.getParsedSyncConfigSet(options),
276
283
  replicationStreamId: this.replicationStreamId,
277
284
  replicationStreamName: this.replicationStreamName,
285
+ replicationLock,
286
+ opIdAllocator: this.factory.getOpIdAllocator(this.replicationStream, replicationLock),
278
287
  storeCurrentData: options.storeCurrentData,
279
288
  skipExistingRows: options.skipExistingRows ?? false,
280
289
  markRecordUnavailable: options.markRecordUnavailable,
@@ -286,7 +295,16 @@ export abstract class MongoSyncBucketStorage
286
295
  };
287
296
  }
288
297
 
298
+ private requireReplicationLock(): MongoSyncRulesLock {
299
+ if (this.replicationLock == null) {
300
+ throw new ServiceAssertionError('A replication lease is required to create a writer');
301
+ }
302
+ this.replicationLock.throwIfAborted();
303
+ return this.replicationLock;
304
+ }
305
+
289
306
  async createWriter(options: storage.CreateWriterOptions): Promise<storage.BucketStorageBatch> {
307
+ this.requireReplicationLock();
290
308
  await this.initializeStorage();
291
309
 
292
310
  const writer = await this.createWriterImpl(options);
@@ -385,6 +403,7 @@ export abstract class MongoSyncBucketStorage
385
403
  throw new ReplicationAbortedError('Aborted clearing data', signal.reason);
386
404
  }
387
405
 
406
+ this.factory.discardOpIdAllocator(this.replicationStreamId);
388
407
  await this.clearSyncRuleState();
389
408
 
390
409
  await this.clearBucketData(signal);