@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.
- package/CHANGELOG.md +43 -0
- package/dist/storage/MongoBucketStorage.d.ts +10 -1
- package/dist/storage/MongoBucketStorage.js +37 -7
- package/dist/storage/MongoBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoBucketBatch.d.ts +24 -10
- package/dist/storage/implementation/MongoBucketBatch.js +134 -89
- 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/MongoStorageProvider.js +4 -2
- package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
- 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/MongoWriteBatch.d.ts +49 -0
- package/dist/storage/implementation/MongoWriteBatch.js +149 -0
- package/dist/storage/implementation/MongoWriteBatch.js.map +1 -0
- package/dist/storage/implementation/MongoWriteCheckpointAPI.js +2 -2
- package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
- package/dist/storage/implementation/common/PersistedBatch.d.ts +5 -4
- package/dist/storage/implementation/common/PersistedBatch.js +6 -4
- package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
- package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.d.ts +3 -0
- package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +3 -0
- package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
- package/dist/storage/implementation/db.d.ts +5 -0
- package/dist/storage/implementation/db.js +14 -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 +149 -139
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +2 -1
- package/dist/storage/implementation/v1/MongoCompactorV1.js +23 -17
- package/dist/storage/implementation/v1/MongoCompactorV1.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/v1/PersistedBatchV1.d.ts +7 -5
- package/dist/storage/implementation/v1/PersistedBatchV1.js +22 -28
- package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
- package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +6 -1
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js +148 -162
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +10 -4
- package/dist/storage/implementation/v3/MongoCompactorV3.js +175 -76
- package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
- package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +12 -8
- package/dist/storage/implementation/v3/PersistedBatchV3.js +25 -32
- package/dist/storage/implementation/v3/PersistedBatchV3.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/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +8 -1
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +3 -3
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +7 -9
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +3 -2
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +4 -7
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +21 -3
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
- package/dist/types/types.d.ts +5 -0
- package/dist/types/types.js +12 -0
- package/dist/types/types.js.map +1 -1
- package/dist/utils/test-utils.d.ts +2 -1
- package/dist/utils/test-utils.js +5 -3
- package/dist/utils/test-utils.js.map +1 -1
- package/package.json +7 -7
- package/src/storage/MongoBucketStorage.ts +47 -8
- package/src/storage/implementation/MongoBucketBatch.ts +205 -111
- 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/MongoStorageProvider.ts +12 -2
- package/src/storage/implementation/MongoSyncBucketStorage.ts +19 -0
- package/src/storage/implementation/MongoSyncRulesLock.ts +56 -3
- package/src/storage/implementation/MongoWriteBatch.ts +172 -0
- package/src/storage/implementation/MongoWriteCheckpointAPI.ts +4 -2
- package/src/storage/implementation/common/PersistedBatch.ts +12 -8
- package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +4 -0
- package/src/storage/implementation/db.ts +15 -0
- package/src/storage/implementation/models.ts +1 -0
- package/src/storage/implementation/v1/MongoBucketBatchV1.ts +167 -160
- package/src/storage/implementation/v1/MongoCompactorV1.ts +29 -26
- package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +1 -1
- package/src/storage/implementation/v1/PersistedBatchV1.ts +25 -30
- package/src/storage/implementation/v3/MongoBucketBatchV3.ts +217 -242
- package/src/storage/implementation/v3/MongoCompactorV3.ts +284 -108
- package/src/storage/implementation/v3/PersistedBatchV3.ts +36 -36
- package/src/storage/implementation/v3/compact-utils.ts +16 -7
- package/src/storage/implementation/v3/models.ts +1 -1
- package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +8 -1
- package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +11 -10
- package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +8 -9
- package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +23 -3
- package/src/types/types.ts +18 -0
- package/src/utils/test-utils.ts +6 -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/chunk_compaction_config.test.ts +38 -0
- 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 +18 -5
- 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 +402 -24
- 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 +51 -5
- 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,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 =
|
|
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
|
-
|
|
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(
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
+
);
|
|
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
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
822
|
+
);
|
|
823
|
+
allocator.committed(lastOp);
|
|
824
|
+
return;
|
|
825
|
+
} catch (error) {
|
|
826
|
+
if (!(error instanceof OpIdRangeExhausted)) {
|
|
827
|
+
throw error;
|
|
720
828
|
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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}
|
|
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
|
|
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(
|
|
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 {
|
|
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);
|