@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
|
@@ -11,6 +11,7 @@ import { MongoWriteBatch } from '../MongoWriteBatch.js';
|
|
|
11
11
|
import { stopReplicationStreamPipeline } from '../SyncRuleStateUpdate.js';
|
|
12
12
|
import { PersistedBatch } from '../common/PersistedBatch.js';
|
|
13
13
|
import { SourceRecordStore } from '../common/SourceRecordStore.js';
|
|
14
|
+
import { SyncRuleDocumentBase } from '../models.js';
|
|
14
15
|
import { PersistedBatchV3 } from './PersistedBatchV3.js';
|
|
15
16
|
import { SourceRecordStoreV3 } from './SourceRecordStoreV3.js';
|
|
16
17
|
import { VersionedPowerSyncMongoV3 } from './VersionedPowerSyncMongoV3.js';
|
|
@@ -61,7 +62,11 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
61
62
|
return this.store;
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
protected override
|
|
65
|
+
protected override persistedOpHead(stream: SyncRuleDocumentBase): InternalOpId {
|
|
66
|
+
return (stream as ReplicationStreamDocumentV3).last_persisted_op ?? 0n;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
protected override onReplicationTransactionFlush(writes: MongoWriteBatch, lastOp: InternalOpId): void {
|
|
65
70
|
// Durably advance the stream-level head of persisted ops within the flush transaction.
|
|
66
71
|
// This ensures a checkpoint created later (even by an empty commit, or by a freshly-appended
|
|
67
72
|
// config that replicates nothing) covers all ops persisted before a potential crash.
|
|
@@ -171,7 +176,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
171
176
|
const session = this.db.client.startSession();
|
|
172
177
|
await using _ = { [Symbol.asyncDispose]: () => session.endSession() };
|
|
173
178
|
|
|
174
|
-
await
|
|
179
|
+
await this.withTransaction(async () => {
|
|
175
180
|
const col = this.db.sourceTables(this.replicationStreamId);
|
|
176
181
|
|
|
177
182
|
// Find records that overlap by name or relation id.
|
|
@@ -241,7 +246,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
241
246
|
sourceTableFromDocument(doc, context.connectionTag, syncConfig, mapping, eventById)
|
|
242
247
|
)
|
|
243
248
|
};
|
|
244
|
-
});
|
|
249
|
+
}, session);
|
|
245
250
|
|
|
246
251
|
return result!;
|
|
247
252
|
}
|
|
@@ -266,156 +271,143 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
266
271
|
async commit(lsn: string, options?: storage.BucketBatchCommitOptions): Promise<storage.CheckpointResult> {
|
|
267
272
|
const { createEmptyCheckpoints } = { ...storage.DEFAULT_BUCKET_BATCH_COMMIT_OPTIONS, ...options };
|
|
268
273
|
|
|
269
|
-
await this.flush(options);
|
|
270
|
-
|
|
271
274
|
using _ = this.tracer.span('storage', 'commit');
|
|
272
275
|
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
{
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
$set: {
|
|
282
|
-
processed_at_lsn: lsn
|
|
283
|
-
}
|
|
284
|
-
},
|
|
285
|
-
{
|
|
286
|
-
session: this.session
|
|
287
|
-
}
|
|
288
|
-
);
|
|
289
|
-
|
|
290
|
-
const preUpdateDocument = (await this.db.sync_rules.findOne(
|
|
291
|
-
{
|
|
292
|
-
_id: this.replicationStreamId,
|
|
293
|
-
'sync_configs._id': { $in: this.syncConfigIds }
|
|
294
|
-
},
|
|
295
|
-
{
|
|
296
|
-
session: this.session,
|
|
297
|
-
projection: {
|
|
298
|
-
sync_configs: 1,
|
|
299
|
-
last_persisted_op: 1
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
)) as ReplicationStreamDocumentV3 | null;
|
|
303
|
-
|
|
304
|
-
const states =
|
|
305
|
-
preUpdateDocument?.sync_configs?.filter((config) => this.syncConfigIds.some((id) => id.equals(config._id))) ?? [];
|
|
306
|
-
if (states.length == 0) {
|
|
307
|
-
throw new ReplicationAssertionError(
|
|
308
|
-
`Failed to update checkpoint - no matching sync_config for _id: ${this.replicationStreamId}/${this.syncConfigIds
|
|
309
|
-
.map((id) => id.toHexString())
|
|
310
|
-
.join(',')}`
|
|
276
|
+
const checkpoint = async (stream: SyncRuleDocumentBase, lastOp?: InternalOpId) => {
|
|
277
|
+
const now = new Date();
|
|
278
|
+
const preUpdateDocument = stream as ReplicationStreamDocumentV3;
|
|
279
|
+
const writes = this.db.createWriteBatch(this.session, { ordered: false });
|
|
280
|
+
writes.updateMany(
|
|
281
|
+
this.db.write_checkpoints,
|
|
282
|
+
{ processed_at_lsn: null, 'lsns.1': { $lte: lsn } },
|
|
283
|
+
{ $set: { processed_at_lsn: lsn } }
|
|
311
284
|
);
|
|
312
|
-
}
|
|
313
|
-
// The replication job / MongoBucketBatch must be constructed with all replicating (PROCESSING / ACTIVE)
|
|
314
|
-
// sync configs in the stream, otherwise we'll get inconsistencies. Configs in other states (e.g. STOP)
|
|
315
|
-
// remain embedded in the document and are ignored here.
|
|
316
|
-
const missingSyncConfig = preUpdateDocument!.sync_configs.find(
|
|
317
|
-
(config) =>
|
|
318
|
-
[storage.SyncRuleState.PROCESSING, storage.SyncRuleState.ACTIVE].includes(config.state) &&
|
|
319
|
-
!this.syncConfigIds.some((id) => id.equals(config._id))
|
|
320
|
-
);
|
|
321
|
-
if (missingSyncConfig != null) {
|
|
322
|
-
throw new ReplicationAssertionError(`Replication job not configured for sync config ${missingSyncConfig._id}`);
|
|
323
|
-
}
|
|
324
285
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
preUpdateDocument?.last_persisted_op == null ? 0n : BigInt(preUpdateDocument.last_persisted_op);
|
|
330
|
-
|
|
331
|
-
let checkpointBlocked = false;
|
|
332
|
-
let checkpointCreated = false;
|
|
333
|
-
let checkpointLogState: unknown = null;
|
|
334
|
-
const unblockedConfigIds: bson.ObjectId[] = [];
|
|
335
|
-
|
|
336
|
-
for (const state of states) {
|
|
337
|
-
if (state.last_checkpoint != null && state.last_checkpoint > newCheckpoint) {
|
|
338
|
-
// last_persisted_op is $max-advanced durably in the same transaction as every flush, and
|
|
339
|
-
// checkpoints are only ever created at that head, so a checkpoint past the head means the
|
|
340
|
-
// op sequence or the stored state is corrupt.
|
|
286
|
+
const states =
|
|
287
|
+
preUpdateDocument?.sync_configs?.filter((config) => this.syncConfigIds.some((id) => id.equals(config._id))) ??
|
|
288
|
+
[];
|
|
289
|
+
if (states.length == 0) {
|
|
341
290
|
throw new ReplicationAssertionError(
|
|
342
|
-
`
|
|
291
|
+
`Failed to update checkpoint - no matching sync_config for _id: ${this.replicationStreamId}/${this.syncConfigIds
|
|
292
|
+
.map((id) => id.toHexString())
|
|
293
|
+
.join(',')}`
|
|
343
294
|
);
|
|
344
295
|
}
|
|
296
|
+
// The replication job / MongoBucketBatch must be constructed with all replicating (PROCESSING / ACTIVE)
|
|
297
|
+
// sync configs in the stream, otherwise we'll get inconsistencies. Configs in other states (e.g. STOP)
|
|
298
|
+
// remain embedded in the document and are ignored here.
|
|
299
|
+
const missingSyncConfig = preUpdateDocument!.sync_configs.find(
|
|
300
|
+
(config) =>
|
|
301
|
+
[storage.SyncRuleState.PROCESSING, storage.SyncRuleState.ACTIVE].includes(config.state) &&
|
|
302
|
+
!this.syncConfigIds.some((id) => id.equals(config._id))
|
|
303
|
+
);
|
|
304
|
+
if (missingSyncConfig != null) {
|
|
305
|
+
throw new ReplicationAssertionError(`Replication job not configured for sync config ${missingSyncConfig._id}`);
|
|
306
|
+
}
|
|
345
307
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
noCheckpointBefore: state.no_checkpoint_before
|
|
350
|
-
});
|
|
308
|
+
// Effective head of the stream's op sequence.
|
|
309
|
+
// A combined flush supplies its new head; an empty commit uses the fenced persisted head.
|
|
310
|
+
const newCheckpoint = lastOp ?? this.persistedOpHead(preUpdateDocument);
|
|
351
311
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
snapshot_done: state.snapshot_done,
|
|
357
|
-
last_checkpoint_lsn: state.last_checkpoint_lsn,
|
|
358
|
-
no_checkpoint_before: state.no_checkpoint_before
|
|
359
|
-
};
|
|
360
|
-
continue;
|
|
361
|
-
}
|
|
312
|
+
let checkpointBlocked = false;
|
|
313
|
+
let checkpointCreated = false;
|
|
314
|
+
let checkpointLogState: unknown = null;
|
|
315
|
+
const unblockedConfigIds: bson.ObjectId[] = [];
|
|
362
316
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
317
|
+
for (const state of states) {
|
|
318
|
+
if (state.last_checkpoint != null && state.last_checkpoint > newCheckpoint) {
|
|
319
|
+
// last_persisted_op is $max-advanced durably in the same transaction as every flush, and
|
|
320
|
+
// checkpoints are only ever created at that head, so a checkpoint past the head means the
|
|
321
|
+
// op sequence or the stored state is corrupt.
|
|
322
|
+
throw new ReplicationAssertionError(
|
|
323
|
+
`Invariant violation: sync config ${state._id} has last_checkpoint ${state.last_checkpoint} > stream head ${newCheckpoint}`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
366
326
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
updateSet['last_checkpoint_ts'] = now;
|
|
327
|
+
const canCheckpoint = canCheckpointState(lsn, {
|
|
328
|
+
snapshotDone: state.snapshot_done === true,
|
|
329
|
+
lastCheckpointLsn: state.last_checkpoint_lsn,
|
|
330
|
+
noCheckpointBefore: state.no_checkpoint_before
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
if (!canCheckpoint) {
|
|
334
|
+
checkpointBlocked = true;
|
|
335
|
+
// Log the first blocked config's state.
|
|
336
|
+
checkpointLogState ??= {
|
|
337
|
+
snapshot_done: state.snapshot_done,
|
|
338
|
+
last_checkpoint_lsn: state.last_checkpoint_lsn,
|
|
339
|
+
no_checkpoint_before: state.no_checkpoint_before
|
|
340
|
+
};
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
checkpointCreated ||= createEmptyCheckpoints || state.last_checkpoint !== newCheckpoint;
|
|
345
|
+
unblockedConfigIds.push(state._id);
|
|
387
346
|
}
|
|
388
347
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
{
|
|
396
|
-
|
|
397
|
-
|
|
348
|
+
// Every commit advances the stream's resume position: commit() flushes first, so all
|
|
349
|
+
// source changes up to this lsn have been persisted, even when checkpoints are blocked.
|
|
350
|
+
// In the future we could also advance this on flush, when the connector provides the
|
|
351
|
+
// current position (see setResumeLsn, which connectors may already call after flushing).
|
|
352
|
+
const resumeLsnUpdate = {
|
|
353
|
+
resume_lsn: lsn,
|
|
354
|
+
...(lastOp == null ? {} : { last_persisted_op: lastOp })
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
if (unblockedConfigIds.length > 0) {
|
|
358
|
+
// All unblocked configs get the SAME new value, so we apply it with a single updateOne
|
|
359
|
+
// (single-document atomicity).
|
|
360
|
+
const updateSet: Record<string, any> = {
|
|
361
|
+
last_fatal_error: null,
|
|
362
|
+
last_fatal_error_ts: null
|
|
363
|
+
};
|
|
364
|
+
// Only advance checkpoint fields when an actual (non-empty) checkpoint is created, matching
|
|
365
|
+
// the previous per-config / v1 behaviour.
|
|
366
|
+
if (checkpointCreated) {
|
|
367
|
+
updateSet['sync_configs.$[config].last_checkpoint'] = newCheckpoint;
|
|
368
|
+
updateSet['sync_configs.$[config].last_checkpoint_lsn'] = lsn;
|
|
369
|
+
updateSet['last_checkpoint_ts'] = now;
|
|
398
370
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
_id: this.replicationStreamId
|
|
406
|
-
},
|
|
407
|
-
{
|
|
408
|
-
$set: {
|
|
409
|
-
last_keepalive_ts: now,
|
|
410
|
-
last_fatal_error: null,
|
|
411
|
-
last_fatal_error_ts: null
|
|
371
|
+
|
|
372
|
+
writes.updateOne(
|
|
373
|
+
this.db.sync_rules,
|
|
374
|
+
{
|
|
375
|
+
_id: this.replicationStreamId,
|
|
376
|
+
'sync_configs._id': { $in: unblockedConfigIds }
|
|
412
377
|
},
|
|
413
|
-
$max: resumeLsnUpdate
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
378
|
+
{ $set: updateSet, $max: resumeLsnUpdate },
|
|
379
|
+
{
|
|
380
|
+
arrayFilters: checkpointCreated ? [{ 'config._id': { $in: unblockedConfigIds } }] : undefined
|
|
381
|
+
}
|
|
382
|
+
);
|
|
383
|
+
} else {
|
|
384
|
+
// All selected configs are blocked - only update keepalive/error tracking and the
|
|
385
|
+
// resume position.
|
|
386
|
+
writes.updateOne(
|
|
387
|
+
this.db.sync_rules,
|
|
388
|
+
{
|
|
389
|
+
_id: this.replicationStreamId
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
$set: {
|
|
393
|
+
last_fatal_error: null,
|
|
394
|
+
last_fatal_error_ts: null
|
|
395
|
+
},
|
|
396
|
+
$max: resumeLsnUpdate
|
|
397
|
+
}
|
|
398
|
+
);
|
|
399
|
+
}
|
|
418
400
|
|
|
401
|
+
await writes.execute();
|
|
402
|
+
return { checkpointBlocked, checkpointCreated, checkpointLogState, newCheckpoint };
|
|
403
|
+
};
|
|
404
|
+
const projection = { sync_configs: 1, last_persisted_op: 1 };
|
|
405
|
+
const { checkpointBlocked, checkpointCreated, checkpointLogState, newCheckpoint } = await this.flushAndCommit(
|
|
406
|
+
checkpoint,
|
|
407
|
+
() => this.withFencedTransaction(() => this.fence(this.session, projection), checkpoint),
|
|
408
|
+
options,
|
|
409
|
+
projection
|
|
410
|
+
);
|
|
419
411
|
if (checkpointBlocked) {
|
|
420
412
|
if (Date.now() - this.lastWaitingLogThrottledV3 > 5_000) {
|
|
421
413
|
this.logger.info(
|
|
@@ -446,23 +438,9 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
446
438
|
|
|
447
439
|
async setResumeLsn(lsn: string): Promise<void> {
|
|
448
440
|
using _ = this.tracer.span('storage', 'set_resume_lsn');
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
},
|
|
453
|
-
{
|
|
454
|
-
$set: {
|
|
455
|
-
resume_lsn: lsn
|
|
456
|
-
}
|
|
457
|
-
},
|
|
458
|
-
{
|
|
459
|
-
session: this.session,
|
|
460
|
-
// Losing occasional resume LSN is fine. That may mean reprocessing
|
|
461
|
-
// some source changes in some edge cases, which is not an issue since
|
|
462
|
-
// changes are processed in an idempotent way.
|
|
463
|
-
writeConcern: { w: 1 }
|
|
464
|
-
}
|
|
465
|
-
);
|
|
441
|
+
// Losing occasional resume LSN would only reprocess source changes.
|
|
442
|
+
// Keep the lease check atomic, but do not wait for majority replication of this resume hint.
|
|
443
|
+
await this.updateStreamMetadata({ resume_lsn: { $literal: lsn } }, {}, { w: 1 });
|
|
466
444
|
}
|
|
467
445
|
|
|
468
446
|
private async autoActivateV3(lsn: string): Promise<void> {
|
|
@@ -473,7 +451,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
473
451
|
const session = this.session;
|
|
474
452
|
let activated = false;
|
|
475
453
|
let needsFutureActivationCheck = true;
|
|
476
|
-
await
|
|
454
|
+
await this.withTransaction(async () => {
|
|
477
455
|
// Reset on transaction retries.
|
|
478
456
|
needsFutureActivationCheck = true;
|
|
479
457
|
activated = false;
|
|
@@ -561,7 +539,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
561
539
|
} else if (doc.state == storage.SyncRuleState.ACTIVE && processingStates.length == 0) {
|
|
562
540
|
needsFutureActivationCheck = false;
|
|
563
541
|
}
|
|
564
|
-
});
|
|
542
|
+
}, session);
|
|
565
543
|
if (activated) {
|
|
566
544
|
this.logger.info(`Activated new replication stream at ${lsn}`);
|
|
567
545
|
await this.db.notifyCheckpoint();
|
|
@@ -571,26 +549,35 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
571
549
|
}
|
|
572
550
|
}
|
|
573
551
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
{
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
552
|
+
private syncConfigMetadataUpdate(syncConfigIds: bson.ObjectId[], set: lib_mongo.mongo.Document) {
|
|
553
|
+
return {
|
|
554
|
+
sync_configs: {
|
|
555
|
+
$map: {
|
|
556
|
+
input: '$sync_configs',
|
|
557
|
+
as: 'config',
|
|
558
|
+
in: {
|
|
559
|
+
$cond: [
|
|
560
|
+
{ $in: ['$$config._id', { $literal: syncConfigIds }] },
|
|
561
|
+
{ $mergeObjects: ['$$config', set] },
|
|
562
|
+
'$$config'
|
|
563
|
+
]
|
|
564
|
+
}
|
|
587
565
|
}
|
|
588
|
-
},
|
|
589
|
-
{
|
|
590
|
-
session: this.session,
|
|
591
|
-
arrayFilters: [{ 'config._id': { $in: this.syncConfigIds } }]
|
|
592
566
|
}
|
|
593
|
-
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private snapshotDoneUpdate(no_checkpoint_before_lsn: string) {
|
|
571
|
+
return this.syncConfigMetadataUpdate(this.syncConfigIds, {
|
|
572
|
+
snapshot_done: true,
|
|
573
|
+
no_checkpoint_before: { $max: ['$$config.no_checkpoint_before', { $literal: no_checkpoint_before_lsn }] }
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async markAllSnapshotDone(no_checkpoint_before_lsn: string): Promise<void> {
|
|
578
|
+
await this.updateStreamMetadata(this.snapshotDoneUpdate(no_checkpoint_before_lsn), {
|
|
579
|
+
'sync_configs._id': { $in: this.syncConfigIds }
|
|
580
|
+
});
|
|
594
581
|
}
|
|
595
582
|
|
|
596
583
|
async markSnapshotDone(no_checkpoint_before_lsn: string, options?: { throwOnConflict?: boolean }): Promise<void> {
|
|
@@ -614,7 +601,9 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
614
601
|
}
|
|
615
602
|
}
|
|
616
603
|
|
|
617
|
-
await this.
|
|
604
|
+
await this.updateStreamMetadataInTransaction(this.snapshotDoneUpdate(no_checkpoint_before_lsn), {
|
|
605
|
+
'sync_configs._id': { $in: this.syncConfigIds }
|
|
606
|
+
});
|
|
618
607
|
});
|
|
619
608
|
}
|
|
620
609
|
|
|
@@ -623,22 +612,9 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
623
612
|
if (syncConfigIds.length == 0) {
|
|
624
613
|
return;
|
|
625
614
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
_id: this.replicationStreamId,
|
|
630
|
-
'sync_configs._id': { $in: syncConfigIds }
|
|
631
|
-
},
|
|
632
|
-
{
|
|
633
|
-
$set: {
|
|
634
|
-
'sync_configs.$[config].snapshot_done': false
|
|
635
|
-
}
|
|
636
|
-
},
|
|
637
|
-
{
|
|
638
|
-
session: this.session,
|
|
639
|
-
arrayFilters: [{ 'config._id': { $in: syncConfigIds } }]
|
|
640
|
-
}
|
|
641
|
-
);
|
|
615
|
+
await this.updateStreamMetadata(this.syncConfigMetadataUpdate(syncConfigIds, { snapshot_done: false }), {
|
|
616
|
+
'sync_configs._id': { $in: syncConfigIds }
|
|
617
|
+
});
|
|
642
618
|
}
|
|
643
619
|
|
|
644
620
|
async markTableSnapshotDone(
|
|
@@ -672,9 +648,6 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
672
648
|
'sync_configs._id': { $in: syncConfigIds }
|
|
673
649
|
},
|
|
674
650
|
{
|
|
675
|
-
$set: {
|
|
676
|
-
last_keepalive_ts: new Date()
|
|
677
|
-
},
|
|
678
651
|
$max: {
|
|
679
652
|
'sync_configs.$[config].no_checkpoint_before': no_checkpoint_before_lsn
|
|
680
653
|
}
|
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
import { mongo } from '@powersync/lib-service-mongodb';
|
|
2
2
|
import { ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
|
|
3
|
-
import {
|
|
4
|
-
acquireSemaphoreAbortable,
|
|
5
|
-
addChecksums,
|
|
6
|
-
formatBytes,
|
|
7
|
-
InternalOpId,
|
|
8
|
-
storage,
|
|
9
|
-
utils
|
|
10
|
-
} from '@powersync/service-core';
|
|
3
|
+
import { acquireSemaphoreAbortable, addChecksums, formatBytes, InternalOpId, utils } from '@powersync/service-core';
|
|
11
4
|
import { BucketDefinitionId } from '@powersync/service-sync-rules';
|
|
12
5
|
import { setImmediate } from 'node:timers/promises';
|
|
13
6
|
import { BucketDataDoc } from '../common/BucketDataDoc.js';
|
|
14
7
|
import { BucketDataKey } from '../models.js';
|
|
15
|
-
import { ConcurrentCompactionError, MongoCompactor } from '../MongoCompactor.js';
|
|
8
|
+
import { ConcurrentCompactionError, MongoCompactOptions, MongoCompactor } from '../MongoCompactor.js';
|
|
16
9
|
import { MongoWriteBatch } from '../MongoWriteBatch.js';
|
|
17
10
|
import { cacheKey } from '../OperationBatch.js';
|
|
18
11
|
import { loadBucketDataDocument, maxOpId, serializeBucketData } from './bucket-format.js';
|
|
@@ -23,6 +16,7 @@ import {
|
|
|
23
16
|
bucketStats,
|
|
24
17
|
BucketStatsWithChecksum,
|
|
25
18
|
chooseCompactionKind,
|
|
19
|
+
chooseRequestedCompactionKind,
|
|
26
20
|
combineAdjacentStats,
|
|
27
21
|
combineChunkStats,
|
|
28
22
|
CompactIntervalConfig,
|
|
@@ -33,7 +27,6 @@ import {
|
|
|
33
27
|
CompactTargetConfig,
|
|
34
28
|
emptyBucketStats,
|
|
35
29
|
firstUncompactedWrite,
|
|
36
|
-
forcedCompactionKind,
|
|
37
30
|
PendingCompactionGroup,
|
|
38
31
|
readCompactionBatch,
|
|
39
32
|
ScheduledCompactionOptions,
|
|
@@ -78,15 +71,17 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
|
|
|
78
71
|
readonly maxCompactFullIntervalMs: number;
|
|
79
72
|
readonly compactLeaseDurationMs: number;
|
|
80
73
|
readonly maxOpIdCap: InternalOpId | undefined;
|
|
74
|
+
readonly forceChunkCompaction: boolean;
|
|
81
75
|
private readonly objectStorageUsage: ObjectStorageUsage;
|
|
82
76
|
|
|
83
|
-
constructor(bucketStorage: MongoSyncBucketStorageV3, db: VersionedPowerSyncMongoV3, options:
|
|
77
|
+
constructor(bucketStorage: MongoSyncBucketStorageV3, db: VersionedPowerSyncMongoV3, options: MongoCompactOptions) {
|
|
84
78
|
super(bucketStorage, db, options);
|
|
85
79
|
this.minCompactChunkIntervalMs = options.minCompactChunkIntervalMs ?? DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS;
|
|
86
80
|
this.minCompactFullIntervalMs = options.minCompactFullIntervalMs ?? DEFAULT_MIN_COMPACT_FULL_INTERVAL_MS;
|
|
87
81
|
this.maxCompactFullIntervalMs = options.maxCompactFullIntervalMs ?? DEFAULT_MAX_COMPACT_FULL_INTERVAL_MS;
|
|
88
82
|
this.compactLeaseDurationMs = options.compactLeaseDurationMs ?? DEFAULT_COMPACT_LEASE_DURATION_MS;
|
|
89
83
|
this.maxOpIdCap = options.maxOpId;
|
|
84
|
+
this.forceChunkCompaction = options.forceChunkCompaction ?? false;
|
|
90
85
|
this.objectStorageUsage = new ObjectStorageUsage(this.db, this.group_id, createObjectStorageUsageWriterId());
|
|
91
86
|
}
|
|
92
87
|
|
|
@@ -113,7 +108,7 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
|
|
|
113
108
|
// processes the work that existed when it started.
|
|
114
109
|
await this.compactScheduledBuckets({
|
|
115
110
|
dueAheadMs: DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS,
|
|
116
|
-
|
|
111
|
+
requestedKind: CompactionKind.Chunks
|
|
117
112
|
});
|
|
118
113
|
} else {
|
|
119
114
|
await this.compactScheduledBuckets();
|
|
@@ -187,7 +182,7 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
|
|
|
187
182
|
// the exact initial-replication interval.
|
|
188
183
|
const jobStartedAt = await this.readCompactionTime();
|
|
189
184
|
const dueBefore = new Date(jobStartedAt.getTime() + (options.dueAheadMs ?? 0));
|
|
190
|
-
const
|
|
185
|
+
const requestedKind = options.requestedKind;
|
|
191
186
|
const rescheduleNotBefore = new Date(dueBefore.getTime() + 1);
|
|
192
187
|
// Keep accounting documents bounded by workers, not buckets or scan batches.
|
|
193
188
|
const workerUsage = Array.from(
|
|
@@ -207,31 +202,31 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
|
|
|
207
202
|
const scheduled: {
|
|
208
203
|
state: BucketStateDocumentV3;
|
|
209
204
|
decision: CompactionDecision;
|
|
210
|
-
|
|
205
|
+
selectedKind: CompactionKind | null;
|
|
211
206
|
}[] = [];
|
|
212
207
|
for (const state of states) {
|
|
213
208
|
try {
|
|
214
209
|
scheduled.push({
|
|
215
210
|
state,
|
|
216
211
|
decision: chooseCompactionKind(state, batchStartedAt, this),
|
|
217
|
-
|
|
212
|
+
selectedKind: chooseRequestedCompactionKind(state, requestedKind, this)
|
|
218
213
|
});
|
|
219
214
|
} catch (error) {
|
|
220
215
|
await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
|
|
221
216
|
}
|
|
222
217
|
}
|
|
223
218
|
const noOpStates = scheduled.filter(
|
|
224
|
-
({ state, decision,
|
|
225
|
-
state.compact_lease == null && (
|
|
219
|
+
({ state, decision, selectedKind }) =>
|
|
220
|
+
state.compact_lease == null && (requestedKind == null ? decision.kind : selectedKind) == null
|
|
226
221
|
);
|
|
227
222
|
await this.rescheduleUnclaimedBuckets(noOpStates, rescheduleNotBefore);
|
|
228
223
|
|
|
229
224
|
const processBucket = async (
|
|
230
|
-
{ state, decision,
|
|
225
|
+
{ state, decision, selectedKind }: (typeof scheduled)[number],
|
|
231
226
|
objectStorageUsage: ObjectStorageUsage,
|
|
232
227
|
chunksOnly = false
|
|
233
228
|
) => {
|
|
234
|
-
const kind =
|
|
229
|
+
const kind = requestedKind == null ? decision.kind : selectedKind;
|
|
235
230
|
if (state.compact_lease == null && kind == null) {
|
|
236
231
|
return;
|
|
237
232
|
}
|
|
@@ -243,7 +238,9 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
|
|
|
243
238
|
}
|
|
244
239
|
const claimedDecision = chooseCompactionKind(lease.state, lease.startedAt, this);
|
|
245
240
|
const claimedKind =
|
|
246
|
-
|
|
241
|
+
requestedKind == null
|
|
242
|
+
? claimedDecision.kind
|
|
243
|
+
: chooseRequestedCompactionKind(lease.state, requestedKind, this);
|
|
247
244
|
if (chunksOnly && claimedKind === CompactionKind.Full) {
|
|
248
245
|
// The decision changed after scanning. Release the lease without
|
|
249
246
|
// rescheduling; the next batch will classify it with a fresh timestamp.
|
|
@@ -275,10 +272,10 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
|
|
|
275
272
|
};
|
|
276
273
|
|
|
277
274
|
const chunkBuckets = scheduled.filter(
|
|
278
|
-
({ decision,
|
|
275
|
+
({ decision, selectedKind }) => (requestedKind == null ? decision.kind : selectedKind) === CompactionKind.Chunks
|
|
279
276
|
);
|
|
280
277
|
const sequentialBuckets = scheduled.filter(
|
|
281
|
-
({ decision,
|
|
278
|
+
({ decision, selectedKind }) => (requestedKind == null ? decision.kind : selectedKind) !== CompactionKind.Chunks
|
|
282
279
|
);
|
|
283
280
|
await this.runChunkCompactionWorkers(chunkBuckets, workerUsage, (entry, usage) =>
|
|
284
281
|
processBucket(entry, usage, true)
|
|
@@ -35,6 +35,7 @@ export interface CompactIntervalConfig {
|
|
|
35
35
|
}
|
|
36
36
|
export interface CompactTargetConfig {
|
|
37
37
|
readonly maxOpIdCap: InternalOpId | undefined;
|
|
38
|
+
readonly forceChunkCompaction?: boolean;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
export enum CompactionKind {
|
|
@@ -51,7 +52,7 @@ export interface ScheduledCompactionOptions {
|
|
|
51
52
|
/** Process checks scheduled this far after the captured job start. */
|
|
52
53
|
dueAheadMs?: number;
|
|
53
54
|
/** Used by initial replication, which must not run a full compact. */
|
|
54
|
-
|
|
55
|
+
requestedKind?: CompactionKind;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
export class CompactionContext {
|
|
@@ -243,8 +244,7 @@ export function chooseCompactionKind(
|
|
|
243
244
|
// not wake before the exact full-compaction condition is true.
|
|
244
245
|
const fullCheckWithMargin = new Date(fullCheckAt.getTime() + FULL_COMPACT_RESCHEDULE_MARGIN_MS);
|
|
245
246
|
const compacted = state.compacted_state;
|
|
246
|
-
const
|
|
247
|
-
const shouldCompactChunks = chunksSinceCompact >= MERGE_CHUNKS_THRESHOLD;
|
|
247
|
+
const shouldCompactChunks = hasEnoughChunksToMerge(state);
|
|
248
248
|
const canCheckChunks =
|
|
249
249
|
compacted == null || now.getTime() - compacted.at.getTime() >= config.minCompactChunkIntervalMs;
|
|
250
250
|
// Too few new chunks cannot make chunk compaction eligible. Do not poll this
|
|
@@ -269,16 +269,25 @@ export function chooseCompactionKind(
|
|
|
269
269
|
};
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
-
export function
|
|
272
|
+
export function chooseRequestedCompactionKind(
|
|
273
273
|
state: BucketStateDocumentV3,
|
|
274
|
-
|
|
274
|
+
requestedKind: CompactionKind | undefined,
|
|
275
275
|
config: CompactTargetConfig
|
|
276
276
|
): CompactionKind | null {
|
|
277
|
-
if (
|
|
277
|
+
if (requestedKind == null) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
// Initial replication bypasses the interval and never runs full compaction,
|
|
281
|
+
// but small buckets should still use the normal bulk-rescheduling path.
|
|
282
|
+
if (requestedKind == CompactionKind.Chunks && !config.forceChunkCompaction && !hasEnoughChunksToMerge(state)) {
|
|
278
283
|
return null;
|
|
279
284
|
}
|
|
280
285
|
const maxOpId = config.maxOpIdCap == null || state.last_op < config.maxOpIdCap ? state.last_op : config.maxOpIdCap;
|
|
281
|
-
return state.compacted_state == null || state.compacted_state.op_id < maxOpId ?
|
|
286
|
+
return state.compacted_state == null || state.compacted_state.op_id < maxOpId ? requestedKind : null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function hasEnoughChunksToMerge(state: BucketStateDocumentV3): boolean {
|
|
290
|
+
return state.bucket_stats.chunks - (state.compacted_state?.chunks ?? 0) >= MERGE_CHUNKS_THRESHOLD;
|
|
282
291
|
}
|
|
283
292
|
|
|
284
293
|
/**
|
|
@@ -95,7 +95,7 @@ export interface SyncConfigDefinition {
|
|
|
95
95
|
created_at: Date;
|
|
96
96
|
storage_version: number;
|
|
97
97
|
/**
|
|
98
|
-
* The related
|
|
98
|
+
* The related ReplicationStreamDocumentV3.
|
|
99
99
|
*
|
|
100
100
|
* Note that a specific sync config definition never moves between replication streams. Instead, we can create a new copy for the new replication stream.
|
|
101
101
|
*
|