@powersync/service-module-mongodb-storage 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +5 -1
  3. package/dist/storage/MongoBucketStorage.js +30 -6
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +23 -10
  6. package/dist/storage/implementation/MongoBucketBatch.js +132 -88
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoCompactor.d.ts +2 -0
  9. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  10. package/dist/storage/implementation/MongoIdSequence.d.ts +10 -1
  11. package/dist/storage/implementation/MongoIdSequence.js +14 -1
  12. package/dist/storage/implementation/MongoIdSequence.js.map +1 -1
  13. package/dist/storage/implementation/MongoOpIdAllocator.d.ts +27 -0
  14. package/dist/storage/implementation/MongoOpIdAllocator.js +97 -0
  15. package/dist/storage/implementation/MongoOpIdAllocator.js.map +1 -0
  16. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +5 -0
  17. package/dist/storage/implementation/MongoSyncBucketStorage.js +17 -0
  18. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  19. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +26 -2
  20. package/dist/storage/implementation/MongoSyncRulesLock.js +38 -1
  21. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  22. package/dist/storage/implementation/db.js +1 -0
  23. package/dist/storage/implementation/db.js.map +1 -1
  24. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +5 -0
  25. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +136 -133
  26. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  27. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +1 -1
  28. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  29. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +5 -1
  30. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +131 -148
  31. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  32. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +4 -3
  33. package/dist/storage/implementation/v3/MongoCompactorV3.js +14 -10
  34. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  35. package/dist/storage/implementation/v3/compact-utils.d.ts +3 -2
  36. package/dist/storage/implementation/v3/compact-utils.js +12 -5
  37. package/dist/storage/implementation/v3/compact-utils.js.map +1 -1
  38. package/dist/storage/implementation/v3/models.d.ts +1 -1
  39. package/dist/utils/test-utils.d.ts +2 -1
  40. package/dist/utils/test-utils.js +4 -3
  41. package/dist/utils/test-utils.js.map +1 -1
  42. package/package.json +7 -7
  43. package/src/storage/MongoBucketStorage.ts +35 -7
  44. package/src/storage/implementation/MongoBucketBatch.ts +201 -110
  45. package/src/storage/implementation/MongoCompactor.ts +2 -0
  46. package/src/storage/implementation/MongoIdSequence.ts +21 -1
  47. package/src/storage/implementation/MongoOpIdAllocator.ts +105 -0
  48. package/src/storage/implementation/MongoSyncBucketStorage.ts +19 -0
  49. package/src/storage/implementation/MongoSyncRulesLock.ts +56 -3
  50. package/src/storage/implementation/db.ts +1 -0
  51. package/src/storage/implementation/models.ts +1 -0
  52. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +150 -152
  53. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +1 -1
  54. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +167 -194
  55. package/src/storage/implementation/v3/MongoCompactorV3.ts +19 -22
  56. package/src/storage/implementation/v3/compact-utils.ts +16 -7
  57. package/src/storage/implementation/v3/models.ts +1 -1
  58. package/src/utils/test-utils.ts +5 -3
  59. package/test/src/__snapshots__/storage.test.ts.snap +102 -2
  60. package/test/src/__snapshots__/storage_compacting.test.ts.snap +2 -2
  61. package/test/src/__snapshots__/storage_sync.test.ts.snap +12 -12
  62. package/test/src/cleanup-stopped-sync-configs.test.ts +19 -13
  63. package/test/src/compact-utils.test.ts +33 -8
  64. package/test/src/concurrent_writers.test.ts +599 -0
  65. package/test/src/helpers/concurrentWriter.mjs +29 -0
  66. package/test/src/helpers/s3TestFactory.ts +12 -12
  67. package/test/src/object_storage_usage.test.ts +2 -1
  68. package/test/src/parameter_compacting_v1.test.ts +2 -2
  69. package/test/src/parameter_compacting_v3.test.ts +2 -2
  70. package/test/src/parameter_compaction_fence.test.ts +3 -3
  71. package/test/src/storage.test.ts +32 -33
  72. package/test/src/storage_compacting.test.ts +147 -21
  73. package/test/src/storage_object_storage_inline_threshold.test.ts +3 -3
  74. package/test/src/storage_s3_checksums.test.ts +4 -4
  75. package/test/src/storage_s3_compaction_lifecycle.test.ts +8 -8
  76. package/test/src/storage_s3_reading.test.ts +4 -4
  77. package/test/src/storage_s3_writing.test.ts +3 -3
  78. package/test/src/storage_sync.test.ts +52 -46
  79. package/test/src/storeCurrentData.test.ts +5 -5
  80. package/tsconfig.tsbuildinfo +1 -1
@@ -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 onReplicationTransactionFlush(writes: MongoWriteBatch, lastOp: bigint): void {
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 session.withTransaction(async () => {
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 now = new Date();
274
-
275
- await this.db.write_checkpoints.updateMany(
276
- {
277
- processed_at_lsn: null,
278
- 'lsns.1': { $lte: lsn }
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
- // Effective head of the stream's op sequence.
326
- // last_persisted_op is $max-advanced durably in the same transaction as every flush, and this
327
- // read uses the same session, so it covers all ops persisted by this batch.
328
- const newCheckpoint =
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
- `Invariant violation: sync config ${state._id} has last_checkpoint ${state.last_checkpoint} > stream head ${newCheckpoint}`
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
- const canCheckpoint = canCheckpointState(lsn, {
347
- snapshotDone: state.snapshot_done === true,
348
- lastCheckpointLsn: state.last_checkpoint_lsn,
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
- if (!canCheckpoint) {
353
- checkpointBlocked = true;
354
- // Log the first blocked config's state.
355
- checkpointLogState ??= {
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
- checkpointCreated ||= createEmptyCheckpoints || state.last_checkpoint !== newCheckpoint;
364
- unblockedConfigIds.push(state._id);
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
- // Every commit advances the stream's resume position: commit() flushes first, so all
368
- // source changes up to this lsn have been persisted, even when checkpoints are blocked.
369
- // In the future we could also advance this on flush, when the connector provides the
370
- // current position (see setResumeLsn, which connectors may already call after flushing).
371
- const resumeLsnUpdate = { resume_lsn: lsn };
372
-
373
- if (unblockedConfigIds.length > 0) {
374
- // All unblocked configs get the SAME new value, so we apply it with a single updateOne
375
- // (single-document atomicity).
376
- const updateSet: Record<string, any> = {
377
- last_keepalive_ts: now,
378
- last_fatal_error: null,
379
- last_fatal_error_ts: null
380
- };
381
- // Only advance checkpoint fields when an actual (non-empty) checkpoint is created, matching
382
- // the previous per-config / v1 behaviour.
383
- if (checkpointCreated) {
384
- updateSet['sync_configs.$[config].last_checkpoint'] = newCheckpoint;
385
- updateSet['sync_configs.$[config].last_checkpoint_lsn'] = lsn;
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
- await this.db.sync_rules.updateOne(
390
- {
391
- _id: this.replicationStreamId,
392
- 'sync_configs._id': { $in: unblockedConfigIds }
393
- },
394
- { $set: updateSet, $max: resumeLsnUpdate },
395
- {
396
- session: this.session,
397
- arrayFilters: checkpointCreated ? [{ 'config._id': { $in: unblockedConfigIds } }] : undefined
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
- } else {
401
- // All selected configs are blocked - only update keepalive/error tracking and the
402
- // resume position.
403
- await this.db.sync_rules.updateOne(
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
- { session: this.session }
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
- await this.db.sync_rules.updateOne(
450
- {
451
- _id: this.replicationStreamId
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 session.withTransaction(async () => {
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
- async markAllSnapshotDone(no_checkpoint_before_lsn: string): Promise<void> {
575
- await this.db.sync_rules.updateOne(
576
- {
577
- _id: this.replicationStreamId,
578
- 'sync_configs._id': { $in: this.syncConfigIds }
579
- },
580
- {
581
- $set: {
582
- 'sync_configs.$[config].snapshot_done': true,
583
- last_keepalive_ts: new Date()
584
- },
585
- $max: {
586
- 'sync_configs.$[config].no_checkpoint_before': no_checkpoint_before_lsn
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.markAllSnapshotDone(no_checkpoint_before_lsn);
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
- await this.db.sync_rules.updateOne(
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: storage.CompactOptions) {
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
- forceKind: CompactionKind.Chunks
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 forceKind = options.forceKind;
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
- forcedKind: CompactionKind | null;
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
- forcedKind: forcedCompactionKind(state, forceKind, this)
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, forcedKind }) =>
225
- state.compact_lease == null && (forceKind == null ? decision.kind : forcedKind) == 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, forcedKind }: (typeof scheduled)[number],
225
+ { state, decision, selectedKind }: (typeof scheduled)[number],
231
226
  objectStorageUsage: ObjectStorageUsage,
232
227
  chunksOnly = false
233
228
  ) => {
234
- const kind = forceKind == null ? decision.kind : forcedKind;
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
- forceKind == null ? claimedDecision.kind : forcedCompactionKind(lease.state, forceKind, this);
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, forcedKind }) => (forceKind == null ? decision.kind : forcedKind) === CompactionKind.Chunks
275
+ ({ decision, selectedKind }) => (requestedKind == null ? decision.kind : selectedKind) === CompactionKind.Chunks
279
276
  );
280
277
  const sequentialBuckets = scheduled.filter(
281
- ({ decision, forcedKind }) => (forceKind == null ? decision.kind : forcedKind) !== CompactionKind.Chunks
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
- forceKind?: CompactionKind;
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 chunksSinceCompact = Math.max(0, state.bucket_stats.chunks - (compacted?.chunks ?? 0));
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 forcedCompactionKind(
272
+ export function chooseRequestedCompactionKind(
273
273
  state: BucketStateDocumentV3,
274
- forceKind: CompactionKind | undefined,
274
+ requestedKind: CompactionKind | undefined,
275
275
  config: CompactTargetConfig
276
276
  ): CompactionKind | null {
277
- if (forceKind == null) {
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 ? forceKind : null;
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 SyncRuleDocumentV3.
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
  *