@powersync/service-module-mongodb-storage 0.22.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 (80) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +5 -1
  3. package/dist/storage/MongoBucketStorage.js +30 -6
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +23 -10
  6. package/dist/storage/implementation/MongoBucketBatch.js +132 -88
  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/MongoSyncBucketStorage.d.ts +5 -0
  17. package/dist/storage/implementation/MongoSyncBucketStorage.js +17 -0
  18. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  19. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +26 -2
  20. package/dist/storage/implementation/MongoSyncRulesLock.js +38 -1
  21. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  22. package/dist/storage/implementation/db.js +1 -0
  23. package/dist/storage/implementation/db.js.map +1 -1
  24. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +5 -0
  25. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +136 -133
  26. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  27. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +1 -1
  28. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  29. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +5 -1
  30. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +131 -148
  31. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  32. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +4 -3
  33. package/dist/storage/implementation/v3/MongoCompactorV3.js +14 -10
  34. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  35. package/dist/storage/implementation/v3/compact-utils.d.ts +3 -2
  36. package/dist/storage/implementation/v3/compact-utils.js +12 -5
  37. package/dist/storage/implementation/v3/compact-utils.js.map +1 -1
  38. package/dist/storage/implementation/v3/models.d.ts +1 -1
  39. package/dist/utils/test-utils.d.ts +2 -1
  40. package/dist/utils/test-utils.js +4 -3
  41. package/dist/utils/test-utils.js.map +1 -1
  42. package/package.json +7 -7
  43. package/src/storage/MongoBucketStorage.ts +35 -7
  44. package/src/storage/implementation/MongoBucketBatch.ts +201 -110
  45. package/src/storage/implementation/MongoCompactor.ts +2 -0
  46. package/src/storage/implementation/MongoIdSequence.ts +21 -1
  47. package/src/storage/implementation/MongoOpIdAllocator.ts +105 -0
  48. package/src/storage/implementation/MongoSyncBucketStorage.ts +19 -0
  49. package/src/storage/implementation/MongoSyncRulesLock.ts +56 -3
  50. package/src/storage/implementation/db.ts +1 -0
  51. package/src/storage/implementation/models.ts +1 -0
  52. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +150 -152
  53. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +1 -1
  54. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +167 -194
  55. package/src/storage/implementation/v3/MongoCompactorV3.ts +19 -22
  56. package/src/storage/implementation/v3/compact-utils.ts +16 -7
  57. package/src/storage/implementation/v3/models.ts +1 -1
  58. package/src/utils/test-utils.ts +5 -3
  59. package/test/src/__snapshots__/storage.test.ts.snap +102 -2
  60. package/test/src/__snapshots__/storage_compacting.test.ts.snap +2 -2
  61. package/test/src/__snapshots__/storage_sync.test.ts.snap +12 -12
  62. package/test/src/cleanup-stopped-sync-configs.test.ts +19 -13
  63. package/test/src/compact-utils.test.ts +33 -8
  64. package/test/src/concurrent_writers.test.ts +599 -0
  65. package/test/src/helpers/concurrentWriter.mjs +29 -0
  66. package/test/src/helpers/s3TestFactory.ts +12 -12
  67. package/test/src/object_storage_usage.test.ts +2 -1
  68. package/test/src/parameter_compacting_v1.test.ts +2 -2
  69. package/test/src/parameter_compacting_v3.test.ts +2 -2
  70. package/test/src/parameter_compaction_fence.test.ts +3 -3
  71. package/test/src/storage.test.ts +32 -33
  72. package/test/src/storage_compacting.test.ts +147 -21
  73. package/test/src/storage_object_storage_inline_threshold.test.ts +3 -3
  74. package/test/src/storage_s3_checksums.test.ts +4 -4
  75. package/test/src/storage_s3_compaction_lifecycle.test.ts +8 -8
  76. package/test/src/storage_s3_reading.test.ts +4 -4
  77. package/test/src/storage_s3_writing.test.ts +3 -3
  78. package/test/src/storage_sync.test.ts +52 -46
  79. package/test/src/storeCurrentData.test.ts +5 -5
  80. 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,22 +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';
39
43
  import { MongoWriteBatch } from './MongoWriteBatch.js';
40
44
  import { OperationBatch, RecordOperation } from './OperationBatch.js';
41
45
  import { ObjectStorage } from './v3/object-storage/ObjectStorage.js';
42
46
  import { createObjectStorageUsageWriterId } from './v3/object-storage/ObjectStorageUsage.js';
43
47
 
44
- // Currently, we can only have a single flush() at a time, since it locks the op_id sequence.
45
- // While the MongoDB transaction retry mechanism handles this okay, using an in-process Mutex
46
- // makes it more fair and has less overhead.
47
- //
48
- // In the future, we can investigate allowing multiple replication streams operating independently.
49
- const replicationMutex = new utils.Mutex();
50
-
51
48
  export interface MongoBucketBatchOptions {
49
+ replicationLock: MongoSyncRulesLock;
50
+ opIdAllocator: MongoOpIdAllocator;
52
51
  db: VersionedPowerSyncMongo;
53
52
  /**
54
53
  * The parsed sync config set for this batch.
@@ -145,7 +144,13 @@ export abstract class MongoBucketBatch
145
144
  constructor(options: MongoBucketBatchOptions) {
146
145
  super();
147
146
  this.logger = options.logger;
148
- 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
+ };
149
154
  this.client = options.db.client;
150
155
  this.db = options.db;
151
156
  this.replicationStreamId = options.replicationStreamId;
@@ -215,7 +220,34 @@ export abstract class MongoBucketBatch
215
220
  return result;
216
221
  }
217
222
 
218
- 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> {
219
251
  const batch = this.batch;
220
252
  let last_op: InternalOpId | null = null;
221
253
  let resumeBatch: OperationBatch | null = null;
@@ -229,21 +261,33 @@ export abstract class MongoBucketBatch
229
261
  await this.prepareCustomWriteCheckpoints();
230
262
  }
231
263
 
232
- await this.withReplicationTransaction(`Flushing ${batch?.length ?? 0} ops`, async (session, opSeq) => {
233
- clearedError = false;
234
- if (batch != null) {
235
- const result = await this.replicateBatch(session, batch, opSeq, options);
236
- resumeBatch = result.resumeBatch;
237
- clearedError ||= result.clearedError;
238
- }
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
+ }
239
273
 
240
- if (this.write_checkpoint_batch.length > 0) {
241
- this.logger.info(`Writing ${this.write_checkpoint_batch.length} custom write checkpoints`);
242
- await this.batchCreateCustomWriteCheckpoints(session, opSeq.next());
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
+ );
247
291
 
248
292
  // Keep checkpoints available if the transaction retries after writing them.
249
293
  this.write_checkpoint_batch = [];
@@ -643,17 +687,46 @@ export abstract class MongoBucketBatch
643
687
  return result;
644
688
  }
645
689
 
646
- protected async withTransaction(cb: () => Promise<void>) {
647
- using lockSpan = this.tracer.span('storage', 'internal_lock');
648
- await replicationMutex.exclusiveLock(async () => {
649
- lockSpan.end();
650
- 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(
651
718
  async () => {
719
+ const stream = await acquireFence();
652
720
  try {
653
- await cb();
721
+ const result = await cb(stream);
722
+ this.options.replicationLock.throwIfAborted();
723
+ return result;
654
724
  } catch (e: unknown) {
725
+ if (e instanceof OpIdRangeExhausted) {
726
+ throw e;
727
+ }
655
728
  if (e instanceof mongo.MongoError && e.hasErrorLabel('TransientTransactionError')) {
656
- // Likely write conflict caused by concurrent write stream replicating
729
+ // Likely write conflict caused by concurrent writes to this replication stream.
657
730
  } else {
658
731
  this.logger.warn('Transaction error', e as Error);
659
732
  }
@@ -663,102 +736,118 @@ export abstract class MongoBucketBatch
663
736
  throw e;
664
737
  }
665
738
  },
666
- { maxCommitTimeMS: 10000 }
667
- );
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();
668
750
  });
669
751
  }
670
752
 
671
- private async withReplicationTransaction(
672
- description: string,
673
- 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' }
674
758
  ): Promise<void> {
675
- let flushTry = 0;
676
-
677
- const start = Date.now();
678
- const lastTry = start + 90000;
679
-
680
- const session = this.session;
681
-
682
- await this.withTransaction(async () => {
683
- flushTry += 1;
684
- if (flushTry % 10 == 0) {
685
- this.logger.info(`${description} - try ${flushTry}`);
686
- }
687
- if (flushTry > 20 && Date.now() > lastTry) {
688
- throw new ServiceError(ErrorCode.PSYNC_S1402, 'Max transaction tries exceeded');
689
- }
690
-
691
- const next_op_id_doc = await this.db.op_id_sequence.findOneAndUpdate(
692
- {
693
- _id: 'main'
694
- },
695
- {
696
- $setOnInsert: { op_id: 0n },
697
- $set: {
698
- // Force update to ensure we get a mongo lock
699
- ts: Date.now()
700
- }
701
- },
702
- {
703
- upsert: true,
704
- returnDocument: 'after',
705
- session
706
- }
707
- );
708
- const opSeq = new MongoIdSequence(next_op_id_doc?.op_id ?? 0n);
759
+ await this.withWriterLock(() => this.writeStreamMetadata(set, filter, writeConcern));
760
+ }
709
761
 
710
- await callback(session, opSeq);
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
+ }
711
766
 
712
- const writes = this.db.createWriteBatch(session, { ordered: false });
713
- writes.updateOne(
714
- this.db.op_id_sequence,
715
- {
716
- _id: 'main'
717
- },
718
- {
719
- $set: {
720
- op_id: opSeq.last()
721
- }
722
- }
723
- );
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
+ }
724
781
 
725
- writes.updateOne(
726
- this.db.sync_rules,
727
- {
728
- _id: this.replicationStreamId
729
- },
730
- {
731
- $set: {
732
- last_keepalive_ts: new Date()
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.
733
821
  }
822
+ );
823
+ allocator.committed(lastOp);
824
+ return;
825
+ } catch (error) {
826
+ if (!(error instanceof OpIdRangeExhausted)) {
827
+ throw error;
734
828
  }
735
- );
736
-
737
- // Allow subclasses to persist additional flush-time state in the same transaction
738
- // (e.g. v3 advances the stream-level last_persisted_op).
739
- this.onReplicationTransactionFlush(writes, opSeq.last());
740
- await writes.execute();
741
- // We don't notify checkpoint here - we don't make any checkpoint updates directly
742
- });
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
+ }
743
836
  }
744
837
 
745
- /**
746
- * Hook called inside the replication flush transaction, after ops have been persisted.
747
- *
748
- * The base implementation does nothing; v3 storage overrides this to `$max` the stream-level
749
- * `last_persisted_op` durably within the same transaction.
750
- */
751
- protected onReplicationTransactionFlush(_writes: MongoWriteBatch, _lastOp: InternalOpId): void {
752
- // No-op by default (v1 behaviour unchanged).
753
- }
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;
754
843
 
755
844
  /**
756
845
  * Called after a replication transaction has successfully committed, with the last persisted op id.
757
846
  *
758
847
  * v1 storage tracks this in memory to fold into the next checkpoint. v3 storage does not need it:
759
848
  * the stream-level `last_persisted_op` is already advanced durably by
760
- * {@link onReplicationTransactionFlush} within the same transaction, and checkpoints read it
761
- * from the document.
849
+ * {@link onReplicationTransactionFlush} or the combined checkpoint update within the same
850
+ * transaction, and empty commits read it from the document.
762
851
  *
763
852
  * Keep calls to this adjacent to {@link withReplicationTransaction} usage - both must observe
764
853
  * every path that persists ops.
@@ -837,7 +926,9 @@ export abstract class MongoBucketBatch
837
926
 
838
927
  await this.withTransaction(async () => {
839
928
  for (let table of sourceTables) {
840
- 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 });
841
932
  }
842
933
  });
843
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
+ }
@@ -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);