@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.
- package/CHANGELOG.md +26 -0
- package/dist/storage/MongoBucketStorage.d.ts +5 -1
- package/dist/storage/MongoBucketStorage.js +30 -6
- package/dist/storage/MongoBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoBucketBatch.d.ts +23 -10
- package/dist/storage/implementation/MongoBucketBatch.js +132 -88
- package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
- package/dist/storage/implementation/MongoCompactor.d.ts +2 -0
- package/dist/storage/implementation/MongoCompactor.js.map +1 -1
- package/dist/storage/implementation/MongoIdSequence.d.ts +10 -1
- package/dist/storage/implementation/MongoIdSequence.js +14 -1
- package/dist/storage/implementation/MongoIdSequence.js.map +1 -1
- package/dist/storage/implementation/MongoOpIdAllocator.d.ts +27 -0
- package/dist/storage/implementation/MongoOpIdAllocator.js +97 -0
- package/dist/storage/implementation/MongoOpIdAllocator.js.map +1 -0
- package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +5 -0
- package/dist/storage/implementation/MongoSyncBucketStorage.js +17 -0
- package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoSyncRulesLock.d.ts +26 -2
- package/dist/storage/implementation/MongoSyncRulesLock.js +38 -1
- package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
- package/dist/storage/implementation/db.js +1 -0
- package/dist/storage/implementation/db.js.map +1 -1
- package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +5 -0
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js +136 -133
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +1 -1
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
- package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +5 -1
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js +131 -148
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +4 -3
- package/dist/storage/implementation/v3/MongoCompactorV3.js +14 -10
- package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
- package/dist/storage/implementation/v3/compact-utils.d.ts +3 -2
- package/dist/storage/implementation/v3/compact-utils.js +12 -5
- package/dist/storage/implementation/v3/compact-utils.js.map +1 -1
- package/dist/storage/implementation/v3/models.d.ts +1 -1
- package/dist/utils/test-utils.d.ts +2 -1
- package/dist/utils/test-utils.js +4 -3
- package/dist/utils/test-utils.js.map +1 -1
- package/package.json +7 -7
- package/src/storage/MongoBucketStorage.ts +35 -7
- package/src/storage/implementation/MongoBucketBatch.ts +201 -110
- package/src/storage/implementation/MongoCompactor.ts +2 -0
- package/src/storage/implementation/MongoIdSequence.ts +21 -1
- package/src/storage/implementation/MongoOpIdAllocator.ts +105 -0
- package/src/storage/implementation/MongoSyncBucketStorage.ts +19 -0
- package/src/storage/implementation/MongoSyncRulesLock.ts +56 -3
- package/src/storage/implementation/db.ts +1 -0
- package/src/storage/implementation/models.ts +1 -0
- package/src/storage/implementation/v1/MongoBucketBatchV1.ts +150 -152
- package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +1 -1
- package/src/storage/implementation/v3/MongoBucketBatchV3.ts +167 -194
- package/src/storage/implementation/v3/MongoCompactorV3.ts +19 -22
- package/src/storage/implementation/v3/compact-utils.ts +16 -7
- package/src/storage/implementation/v3/models.ts +1 -1
- package/src/utils/test-utils.ts +5 -3
- package/test/src/__snapshots__/storage.test.ts.snap +102 -2
- package/test/src/__snapshots__/storage_compacting.test.ts.snap +2 -2
- package/test/src/__snapshots__/storage_sync.test.ts.snap +12 -12
- package/test/src/cleanup-stopped-sync-configs.test.ts +19 -13
- package/test/src/compact-utils.test.ts +33 -8
- package/test/src/concurrent_writers.test.ts +599 -0
- package/test/src/helpers/concurrentWriter.mjs +29 -0
- package/test/src/helpers/s3TestFactory.ts +12 -12
- package/test/src/object_storage_usage.test.ts +2 -1
- package/test/src/parameter_compacting_v1.test.ts +2 -2
- package/test/src/parameter_compacting_v3.test.ts +2 -2
- package/test/src/parameter_compaction_fence.test.ts +3 -3
- package/test/src/storage.test.ts +32 -33
- package/test/src/storage_compacting.test.ts +147 -21
- package/test/src/storage_object_storage_inline_threshold.test.ts +3 -3
- package/test/src/storage_s3_checksums.test.ts +4 -4
- package/test/src/storage_s3_compaction_lifecycle.test.ts +8 -8
- package/test/src/storage_s3_reading.test.ts +4 -4
- package/test/src/storage_s3_writing.test.ts +3 -3
- package/test/src/storage_sync.test.ts +52 -46
- package/test/src/storeCurrentData.test.ts +5 -5
- 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 =
|
|
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
|
-
|
|
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(
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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}
|
|
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
|
|
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(
|
|
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);
|