@powersync/service-module-mongodb-storage 0.20.0 → 0.22.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 (192) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +36 -3
  3. package/dist/storage/MongoBucketStorage.js +372 -207
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +17 -5
  6. package/dist/storage/implementation/MongoBucketBatch.js +44 -17
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoCompactor.d.ts +19 -79
  9. package/dist/storage/implementation/MongoCompactor.js +27 -238
  10. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  11. package/dist/storage/implementation/MongoParameterCompactor.d.ts +83 -10
  12. package/dist/storage/implementation/MongoParameterCompactor.js +309 -96
  13. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  14. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +9 -1
  15. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +29 -0
  16. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
  17. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +4 -2
  18. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -1
  19. package/dist/storage/implementation/MongoStorageProvider.js +7 -3
  20. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  21. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +27 -11
  22. package/dist/storage/implementation/MongoSyncBucketStorage.js +78 -31
  23. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  24. package/dist/storage/implementation/MongoSyncRulesLock.js +21 -9
  25. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  26. package/dist/storage/implementation/MongoWriteBatch.d.ts +49 -0
  27. package/dist/storage/implementation/MongoWriteBatch.js +149 -0
  28. package/dist/storage/implementation/MongoWriteBatch.js.map +1 -0
  29. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +9 -7
  30. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +9 -30
  31. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  32. package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +11 -5
  33. package/dist/storage/implementation/SyncRuleStateUpdate.js +14 -6
  34. package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -1
  35. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +22 -2
  36. package/dist/storage/implementation/common/PersistedBatch.d.ts +32 -4
  37. package/dist/storage/implementation/common/PersistedBatch.js +49 -6
  38. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  39. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.d.ts +3 -0
  40. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +3 -0
  41. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  42. package/dist/storage/implementation/db.d.ts +9 -1
  43. package/dist/storage/implementation/db.js +27 -1
  44. package/dist/storage/implementation/db.js.map +1 -1
  45. package/dist/storage/implementation/models.d.ts +31 -0
  46. package/dist/storage/implementation/models.js.map +1 -1
  47. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +2 -0
  48. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +33 -4
  49. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  50. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +38 -6
  51. package/dist/storage/implementation/v1/MongoCompactorV1.js +247 -20
  52. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  53. package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
  54. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
  55. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
  56. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +18 -11
  57. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +65 -16
  58. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  59. package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +7 -5
  60. package/dist/storage/implementation/v1/PersistedBatchV1.js +22 -28
  61. package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
  62. package/dist/storage/implementation/v1/models.js +1 -0
  63. package/dist/storage/implementation/v1/models.js.map +1 -1
  64. package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
  65. package/dist/storage/implementation/v3/CompactionLease.js +131 -0
  66. package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
  67. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +10 -2
  68. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +100 -26
  69. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  70. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +82 -25
  71. package/dist/storage/implementation/v3/MongoCompactorV3.js +698 -159
  72. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  73. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +18 -0
  74. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +23 -0
  75. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -0
  76. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +2 -15
  77. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +60 -92
  78. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  79. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +19 -9
  80. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +91 -31
  81. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  82. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
  83. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
  84. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
  85. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +14 -8
  86. package/dist/storage/implementation/v3/PersistedBatchV3.js +76 -42
  87. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  88. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +12 -2
  89. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +42 -3
  90. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  91. package/dist/storage/implementation/v3/bucket-format.js +2 -0
  92. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  93. package/dist/storage/implementation/v3/compact-utils.d.ts +107 -0
  94. package/dist/storage/implementation/v3/compact-utils.js +216 -0
  95. package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
  96. package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
  97. package/dist/storage/implementation/v3/compaction-constants.js +6 -0
  98. package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
  99. package/dist/storage/implementation/v3/models.d.ts +84 -4
  100. package/dist/storage/implementation/v3/models.js.map +1 -1
  101. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +7 -8
  102. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +16 -5
  103. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  104. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +14 -11
  105. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +6 -8
  106. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +17 -11
  107. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  108. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +38 -0
  109. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +164 -0
  110. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
  111. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +37 -12
  112. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +197 -25
  113. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
  114. package/dist/storage/implementation/v3/source-table-utils.d.ts +6 -6
  115. package/dist/storage/implementation/v3/source-table-utils.js +30 -37
  116. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
  117. package/dist/storage/storage-index.d.ts +2 -1
  118. package/dist/storage/storage-index.js +1 -0
  119. package/dist/storage/storage-index.js.map +1 -1
  120. package/dist/types/types.d.ts +28 -1
  121. package/dist/types/types.js +28 -7
  122. package/dist/types/types.js.map +1 -1
  123. package/dist/utils/test-utils.js +1 -0
  124. package/dist/utils/test-utils.js.map +1 -1
  125. package/dist/utils/util.js +3 -1
  126. package/dist/utils/util.js.map +1 -1
  127. package/package.json +10 -9
  128. package/src/storage/MongoBucketStorage.ts +507 -269
  129. package/src/storage/implementation/MongoBucketBatch.ts +67 -24
  130. package/src/storage/implementation/MongoCompactor.ts +35 -346
  131. package/src/storage/implementation/MongoParameterCompactor.ts +388 -102
  132. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +34 -0
  133. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +4 -2
  134. package/src/storage/implementation/MongoStorageProvider.ts +15 -3
  135. package/src/storage/implementation/MongoSyncBucketStorage.ts +114 -40
  136. package/src/storage/implementation/MongoSyncRulesLock.ts +22 -9
  137. package/src/storage/implementation/MongoWriteBatch.ts +172 -0
  138. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +16 -46
  139. package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
  140. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +24 -2
  141. package/src/storage/implementation/common/PersistedBatch.ts +66 -10
  142. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +4 -0
  143. package/src/storage/implementation/db.ts +35 -2
  144. package/src/storage/implementation/models.ts +34 -0
  145. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +46 -6
  146. package/src/storage/implementation/v1/MongoCompactorV1.ts +327 -40
  147. package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
  148. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +87 -27
  149. package/src/storage/implementation/v1/PersistedBatchV1.ts +25 -30
  150. package/src/storage/implementation/v1/models.ts +1 -0
  151. package/src/storage/implementation/v3/CompactionLease.ts +152 -0
  152. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +138 -34
  153. package/src/storage/implementation/v3/MongoCompactorV3.ts +958 -225
  154. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +34 -0
  155. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +100 -109
  156. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +111 -41
  157. package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
  158. package/src/storage/implementation/v3/PersistedBatchV3.ts +93 -48
  159. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +55 -4
  160. package/src/storage/implementation/v3/bucket-format.ts +2 -0
  161. package/src/storage/implementation/v3/compact-utils.ts +320 -0
  162. package/src/storage/implementation/v3/compaction-constants.ts +5 -0
  163. package/src/storage/implementation/v3/models.ts +91 -3
  164. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +24 -9
  165. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +20 -5
  166. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +26 -14
  167. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +229 -0
  168. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +279 -29
  169. package/src/storage/implementation/v3/source-table-utils.ts +39 -41
  170. package/src/storage/storage-index.ts +2 -0
  171. package/src/types/types.ts +42 -7
  172. package/src/utils/test-utils.ts +1 -0
  173. package/src/utils/util.ts +3 -1
  174. package/test/src/__snapshots__/storage.test.ts.snap +25 -0
  175. package/test/src/__snapshots__/storage_sync.test.ts.snap +605 -16
  176. package/test/src/chunk_compaction_config.test.ts +38 -0
  177. package/test/src/cleanup-stopped-sync-configs.test.ts +40 -13
  178. package/test/src/compact-utils.test.ts +226 -0
  179. package/test/src/helpers/MemoryObjectStorage.ts +14 -6
  180. package/test/src/object_storage_usage.test.ts +267 -0
  181. package/test/src/parameter_compacting_v1.test.ts +120 -0
  182. package/test/src/parameter_compacting_v3.test.ts +192 -0
  183. package/test/src/parameter_compaction_fence.test.ts +276 -0
  184. package/test/src/storage.test.ts +273 -22
  185. package/test/src/storage_compacting.test.ts +968 -246
  186. package/test/src/storage_s3_checksums.test.ts +3 -4
  187. package/test/src/storage_s3_compaction_lifecycle.test.ts +138 -12
  188. package/test/src/storage_s3_reading.test.ts +290 -2
  189. package/test/src/storage_s3_writing.test.ts +1 -0
  190. package/test/src/storage_sync.test.ts +336 -41
  191. package/test/src/util.ts +1 -1
  192. package/tsconfig.tsbuildinfo +1 -1
@@ -1,5 +1,11 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
- import { HydratedSyncConfig, SqlEventDescriptor, SqliteRow, SqliteValue } from '@powersync/service-sync-rules';
2
+ import {
3
+ EventDefinitionId,
4
+ HydratedEventDescriptor,
5
+ HydratedSyncConfig,
6
+ SqliteRow,
7
+ SqliteValue
8
+ } from '@powersync/service-sync-rules';
3
9
  import * as bson from 'bson';
4
10
 
5
11
  import {
@@ -30,9 +36,10 @@ import type { VersionedPowerSyncMongo } from './db.js';
30
36
  import { MAX_ROW_SIZE } from './MongoBucketBatchShared.js';
31
37
  import { MongoIdSequence } from './MongoIdSequence.js';
32
38
  import { MongoParsedSyncConfigSet } from './MongoParsedSyncConfigSet.js';
33
- import { batchCreateCustomWriteCheckpoints } from './MongoWriteCheckpointAPI.js';
39
+ import { MongoWriteBatch } from './MongoWriteBatch.js';
34
40
  import { OperationBatch, RecordOperation } from './OperationBatch.js';
35
41
  import { ObjectStorage } from './v3/object-storage/ObjectStorage.js';
42
+ import { createObjectStorageUsageWriterId } from './v3/object-storage/ObjectStorageUsage.js';
36
43
 
37
44
  // Currently, we can only have a single flush() at a time, since it locks the op_id sequence.
38
45
  // While the MongoDB transaction retry mechanism handles this okay, using an in-process Mutex
@@ -72,6 +79,10 @@ export interface MongoBucketBatchOptions {
72
79
 
73
80
  logger: Logger;
74
81
  tracer?: PerformanceTracer<'storage' | 'evaluate'>;
82
+ /**
83
+ * Aborts in-flight object storage uploads when replication stops.
84
+ */
85
+ signal?: AbortSignal;
75
86
 
76
87
  objectStorage?: ObjectStorage;
77
88
  inlineThresholdBytes?: number;
@@ -104,9 +115,10 @@ export abstract class MongoBucketBatch
104
115
  private readonly storeCurrentData: boolean;
105
116
  public readonly skipExistingRows: boolean;
106
117
  protected readonly mapping: BucketDefinitionMapping;
118
+ protected readonly objectStorageUsageWriterId = createObjectStorageUsageWriterId();
107
119
 
108
120
  private batch: OperationBatch | null = null;
109
- private write_checkpoint_batch: storage.CustomWriteCheckpointOptions[] = [];
121
+ protected write_checkpoint_batch: storage.CustomWriteCheckpointOptions[] = [];
110
122
  private markRecordUnavailable: BucketStorageMarkRecordUnavailable | undefined;
111
123
  private hooks: storage.StorageHooks | undefined;
112
124
  private clearedError = false;
@@ -185,6 +197,11 @@ export abstract class MongoBucketBatch
185
197
  no_checkpoint_before_lsn?: string
186
198
  ): Promise<storage.SourceTable[]>;
187
199
 
200
+ protected abstract batchCreateCustomWriteCheckpoints(session: mongo.ClientSession, opId: InternalOpId): Promise<void>;
201
+
202
+ /** Perform any version-specific setup required before writing custom checkpoints. */
203
+ protected async prepareCustomWriteCheckpoints(): Promise<void> {}
204
+
188
205
  async flush(options?: storage.BatchBucketFlushOptions): Promise<storage.FlushedResult | null> {
189
206
  let result: storage.FlushedResult | null = null;
190
207
  // One flush may be split over multiple transactions.
@@ -207,6 +224,10 @@ export abstract class MongoBucketBatch
207
224
  using _ = this.tracer.span('storage', 'flush');
208
225
 
209
226
  await this.hooks?.beforeBatchFlush?.(this);
227
+ if (this.write_checkpoint_batch.length > 0) {
228
+ // Collection/index creation cannot run inside the replication transaction.
229
+ await this.prepareCustomWriteCheckpoints();
230
+ }
210
231
 
211
232
  await this.withReplicationTransaction(`Flushing ${batch?.length ?? 0} ops`, async (session, opSeq) => {
212
233
  clearedError = false;
@@ -218,13 +239,15 @@ export abstract class MongoBucketBatch
218
239
 
219
240
  if (this.write_checkpoint_batch.length > 0) {
220
241
  this.logger.info(`Writing ${this.write_checkpoint_batch.length} custom write checkpoints`);
221
- await batchCreateCustomWriteCheckpoints(this.db, session, this.write_checkpoint_batch, opSeq.next());
222
- this.write_checkpoint_batch = [];
242
+ await this.batchCreateCustomWriteCheckpoints(session, opSeq.next());
223
243
  }
224
244
 
225
245
  last_op = opSeq.last();
226
246
  });
227
247
 
248
+ // Keep checkpoints available if the transaction retries after writing them.
249
+ this.write_checkpoint_batch = [];
250
+
228
251
  if (clearedError) {
229
252
  this.clearedError = true;
230
253
  }
@@ -686,7 +709,9 @@ export abstract class MongoBucketBatch
686
709
 
687
710
  await callback(session, opSeq);
688
711
 
689
- await this.db.op_id_sequence.updateOne(
712
+ const writes = this.db.createWriteBatch(session, { ordered: false });
713
+ writes.updateOne(
714
+ this.db.op_id_sequence,
690
715
  {
691
716
  _id: 'main'
692
717
  },
@@ -694,13 +719,11 @@ export abstract class MongoBucketBatch
694
719
  $set: {
695
720
  op_id: opSeq.last()
696
721
  }
697
- },
698
- {
699
- session
700
722
  }
701
723
  );
702
724
 
703
- await this.db.sync_rules.updateOne(
725
+ writes.updateOne(
726
+ this.db.sync_rules,
704
727
  {
705
728
  _id: this.replicationStreamId
706
729
  },
@@ -708,13 +731,13 @@ export abstract class MongoBucketBatch
708
731
  $set: {
709
732
  last_keepalive_ts: new Date()
710
733
  }
711
- },
712
- { session }
734
+ }
713
735
  );
714
736
 
715
737
  // Allow subclasses to persist additional flush-time state in the same transaction
716
738
  // (e.g. v3 advances the stream-level last_persisted_op).
717
- await this.onReplicationTransactionFlush(session, opSeq.last());
739
+ this.onReplicationTransactionFlush(writes, opSeq.last());
740
+ await writes.execute();
718
741
  // We don't notify checkpoint here - we don't make any checkpoint updates directly
719
742
  });
720
743
  }
@@ -725,7 +748,7 @@ export abstract class MongoBucketBatch
725
748
  * The base implementation does nothing; v3 storage overrides this to `$max` the stream-level
726
749
  * `last_persisted_op` durably within the same transaction.
727
750
  */
728
- protected async onReplicationTransactionFlush(_session: mongo.ClientSession, _lastOp: InternalOpId): Promise<void> {
751
+ protected onReplicationTransactionFlush(_writes: MongoWriteBatch, _lastOp: InternalOpId): void {
729
752
  // No-op by default (v1 behaviour unchanged).
730
753
  }
731
754
 
@@ -762,11 +785,12 @@ export abstract class MongoBucketBatch
762
785
  async save(record: storage.SaveOptions): Promise<storage.FlushedResult | null> {
763
786
  const { after, before, sourceTable, tag } = record;
764
787
  const storeCurrentData = this.storeCurrentData && sourceTable.storeCurrentData;
765
- // syncEvent is the per-table designation from resolveTables. With v3 storage, multiple
766
- // SourceTables can exist for the same ref, with a row change saved once per table -
767
- // only the designated event carrier may fire events, so each event fires once per row.
788
+ // V3 source tables own disjoint event-definition ids for each physical table. Multiple
789
+ // SourceTables may evaluate different events, but each definition is evaluated through
790
+ // at most one record for this row change.
791
+ // Legacy storage leaves eventDefinitionIds undefined and selects by table ref.
768
792
  if (sourceTable.syncEvent) {
769
- for (const event of this.getTableEvents(sourceTable)) {
793
+ for (const { event, eventId } of this.getTableEvents(sourceTable)) {
770
794
  this.iterateListeners((cb) =>
771
795
  cb.replicationEvent?.({
772
796
  batch: this,
@@ -776,7 +800,8 @@ export abstract class MongoBucketBatch
776
800
  after: after && utils.isCompleteRow(storeCurrentData, after) ? after : undefined,
777
801
  before: before && utils.isCompleteRow(storeCurrentData, before) ? before : undefined
778
802
  },
779
- event
803
+ event,
804
+ event_id: eventId
780
805
  })
781
806
  );
782
807
  }
@@ -934,11 +959,29 @@ export abstract class MongoBucketBatch
934
959
  }
935
960
 
936
961
  /**
937
- * Gets relevant {@link SqlEventDescriptor}s for the given {@link SourceTable}
962
+ * Gets relevant {@link HydratedEventDescriptor}s for the given {@link SourceTable}
938
963
  */
939
- protected getTableEvents(table: storage.SourceTable): SqlEventDescriptor[] {
940
- return this.sync_rules.eventDescriptors.filter((evt) =>
941
- [...evt.getSourceTables()].some((sourceTable) => sourceTable.matches(table.ref))
942
- );
964
+ protected getTableEvents(
965
+ table: storage.SourceTable
966
+ ): { event: HydratedEventDescriptor; eventId?: EventDefinitionId }[] {
967
+ // V3 storage assigns event-definition ids to each source table, so membership is authoritative.
968
+ // Iterate the table's distinct ids and resolve each through the stream's deduped event map, so a
969
+ // definition reused across configs has one evaluator for that id. The evaluator may still return
970
+ // multiple payloads from matching queries. Legacy storage leaves this undefined and selects by table ref.
971
+ if (table.eventDefinitionIds != null) {
972
+ const eventById = this.options.parsedSyncConfig.eventById;
973
+ const events: { event: HydratedEventDescriptor; eventId: EventDefinitionId }[] = [];
974
+ for (const id of table.eventDefinitionIds) {
975
+ const event = eventById.get(id);
976
+ if (event != null && event.tableTriggersEvent(table.ref)) {
977
+ events.push({ event, eventId: id });
978
+ }
979
+ }
980
+ return events;
981
+ }
982
+
983
+ return this.sync_rules.eventDescriptors
984
+ .filter((event) => event.tableTriggersEvent(table.ref))
985
+ .map((event) => ({ event }));
943
986
  }
944
987
  }
@@ -1,64 +1,27 @@
1
1
  import * as timers from 'node:timers/promises';
2
2
 
3
- import { isMongoServerError, mongo, MONGO_OPERATION_TIMEOUT_MS } from '@powersync/lib-service-mongodb';
4
- import {
5
- logger as defaultLogger,
6
- Logger,
7
- ReplicationAssertionError,
8
- ServiceAssertionError
9
- } from '@powersync/lib-services-framework';
10
- import { InternalOpId, isPartialChecksum, PopulateChecksumCacheResults, storage } from '@powersync/service-core';
11
- import { BucketDefinitionId } from '@powersync/service-sync-rules';
3
+ import { isMongoServerError, mongo } from '@powersync/lib-service-mongodb';
4
+ import { logger as defaultLogger, Logger, ReplicationAssertionError } from '@powersync/lib-services-framework';
5
+ import { storage } from '@powersync/service-core';
12
6
 
13
7
  import { BucketKey } from './common/BucketDataDoc.js';
14
8
  import type { VersionedPowerSyncMongo } from './db.js';
15
- import { BucketStateDocumentBase } from './models.js';
16
9
  import type { MongoSyncBucketStorage } from './MongoSyncBucketStorage.js';
17
10
  import { isRetryableObjectStorageError } from './v3/object-storage/ObjectStorage.js';
18
11
 
19
- export interface CurrentBucketState {
20
- /** Bucket name */
21
- bucket: string;
22
- definitionId: BucketDefinitionId;
12
+ export interface MongoCompactOptions extends storage.CompactOptions {
23
13
  /**
24
- * Rows seen in the bucket, with the last op_id of each.
14
+ * Only merge adjacent V3 bucket-data chunks. This is used after initial
15
+ * replication, where reading every operation would defeat the purpose of
16
+ * the lightweight pass.
25
17
  */
26
- seen: Map<string, InternalOpId>;
27
- /**
28
- * Estimated memory usage of the seen Map.
29
- */
30
- trackingSize: number;
31
- /**
32
- * Last (lowest) seen op_id that is not a PUT.
33
- */
34
- lastNotPut: InternalOpId | null;
35
- /**
36
- * Number of REMOVE/MOVE operations seen since lastNotPut.
37
- */
38
- opsSincePut: number;
39
- /**
40
- * Incrementally-updated checksum, up to maxOpId.
41
- */
42
- checksum: number;
43
- /**
44
- * Op count for the checksum.
45
- */
46
- opCount: number;
47
- /**
48
- * Byte size of ops covered by the checksum.
49
- */
50
- opBytes: number;
18
+ compactChunksOnly?: boolean;
51
19
  }
52
20
 
53
- export interface MongoCompactOptions extends storage.CompactOptions {}
54
-
55
21
  const DEFAULT_CLEAR_BATCH_LIMIT = 5000;
56
22
  const DEFAULT_MOVE_BATCH_LIMIT = 2000;
57
23
  const DEFAULT_MOVE_BATCH_QUERY_LIMIT = 10_000;
58
24
  const DEFAULT_MOVE_BATCH_BYTE_LIMIT = 16 * 1024 * 1024;
59
- const DEFAULT_MIN_BUCKET_CHANGES = 10;
60
- const DEFAULT_MIN_CHANGE_RATIO = 0.1;
61
- const DIRTY_BUCKET_SCAN_BATCH_SIZE = 2_000;
62
25
  /** This default is primarily for tests. */
63
26
  const DEFAULT_MEMORY_LIMIT_MB = 64;
64
27
  const COMPACTION_RETRY_LIMIT = 3;
@@ -71,28 +34,30 @@ export class ConcurrentCompactionError extends Error {
71
34
  }
72
35
  }
73
36
 
74
- export interface DirtyBucket {
75
- bucket: string;
76
- definitionId: BucketDefinitionId | null;
77
- estimatedCount: number;
78
- dirtyRatio?: number;
37
+ /**
38
+ * A worker whose bucket lease has been replaced must stop immediately. Unlike
39
+ * a transactional replacement conflict, retrying it with the same compactor
40
+ * instance would still use the stale lease and could race the new owner.
41
+ */
42
+ export class CompactionLeaseLostError extends ConcurrentCompactionError {
43
+ constructor(message: string) {
44
+ super(message);
45
+ this.name = 'CompactionLeaseLostError';
46
+ }
79
47
  }
80
48
 
81
49
  export abstract class MongoCompactor {
82
- protected bucketStateUpdates: mongo.AnyBulkWriteOperation<BucketStateDocumentBase>[] = [];
83
-
84
50
  protected readonly idLimitBytes: number;
85
51
  protected readonly moveBatchLimit: number;
86
52
  protected readonly moveBatchQueryLimit: number;
87
53
  protected readonly moveBatchByteLimit: number;
88
54
  protected readonly clearBatchLimit: number;
89
- protected readonly minBucketChanges: number;
90
- protected readonly minChangeRatio: number;
91
- protected readonly maxOpId: bigint;
92
55
  protected readonly buckets: string[] | undefined;
93
56
  protected readonly deleteCheckpointRequestsBefore: Date | undefined;
94
57
  protected readonly signal?: AbortSignal;
95
58
  protected readonly group_id: number;
59
+ protected readonly compactChunksOnly: boolean;
60
+ protected compactedBucketCount = 0;
96
61
 
97
62
  protected readonly logger: Logger;
98
63
 
@@ -110,35 +75,16 @@ export abstract class MongoCompactor {
110
75
  if (this.clearBatchLimit < 2) {
111
76
  throw new ReplicationAssertionError('clearBatchLimit must be >= 2');
112
77
  }
113
- this.minBucketChanges = options.minBucketChanges ?? DEFAULT_MIN_BUCKET_CHANGES;
114
- this.minChangeRatio = options.minChangeRatio ?? DEFAULT_MIN_CHANGE_RATIO;
115
- this.maxOpId = options.maxOpId ?? 0n;
116
78
  this.buckets = options.compactBuckets;
117
79
  this.deleteCheckpointRequestsBefore = options.deleteCheckpointRequestsBefore;
118
80
  this.signal = options.signal;
81
+ this.compactChunksOnly = options.compactChunksOnly ?? false;
119
82
  this.logger = options.logger ?? defaultLogger;
120
83
  }
121
84
 
122
- /**
123
- * Compact buckets by converting operations into MOVE and/or CLEAR operations.
124
- *
125
- * See /docs/storage/compacting-operations.md for details.
126
- */
127
- async compact() {
128
- await this.deleteOldCheckpointRequests();
129
-
130
- if (this.buckets) {
131
- for (const bucket of this.buckets) {
132
- // We can make this more efficient later on by iterating through the buckets in a single query.
133
- // That makes batching more tricky, so we leave for later.
134
- await this.compactSingleBucketRetried(bucket);
135
- }
136
- } else {
137
- await this.compactDirtyBuckets();
138
- }
139
- }
85
+ abstract compact(): Promise<number>;
140
86
 
141
- private async deleteOldCheckpointRequests() {
87
+ protected async deleteOldCheckpointRequests() {
142
88
  if (this.deleteCheckpointRequestsBefore == null) {
143
89
  return;
144
90
  }
@@ -152,180 +98,17 @@ export abstract class MongoCompactor {
152
98
  checkpoint_requested_at: { $exists: true, $lt: this.deleteCheckpointRequestsBefore },
153
99
  processed_at_lsn: { $ne: null }
154
100
  });
155
- await this.db.custom_write_checkpoints.deleteMany({
156
- checkpoint_requested_at: { $exists: true, $lt: this.deleteCheckpointRequestsBefore }
157
- });
158
- }
159
-
160
- /**
161
- * Subset of compact, only populating checksums where relevant.
162
- */
163
- async populateChecksums(options: { minBucketChanges: number }): Promise<PopulateChecksumCacheResults> {
164
- let count = 0;
165
- // Paginate through dirty buckets in batches until no more buckets meet the criteria.
166
- while (true) {
167
- this.signal?.throwIfAborted();
168
- const buckets = await this.dirtyBucketBatchForChecksums(options);
169
- if (buckets.length == 0) {
170
- break;
171
- }
172
- this.signal?.throwIfAborted();
173
-
174
- const start = Date.now();
175
- // Filter batch by estimated bucket size, to reduce possibility of timeouts.
176
- const checkBuckets: typeof buckets = [];
177
- let totalCountEstimate = 0;
178
- for (const bucket of buckets) {
179
- checkBuckets.push(bucket);
180
- totalCountEstimate += bucket.estimatedCount;
181
- if (totalCountEstimate > 50_000) {
182
- break;
183
- }
184
- }
185
- this.logger.info(
186
- `Calculating checksums for batch of ${buckets.length} buckets, estimated count of ${totalCountEstimate}`
187
- );
188
- await this.updateChecksumsBatch(checkBuckets);
189
- this.logger.info(`Updated checksums for batch of ${checkBuckets.length} buckets in ${Date.now() - start}ms`);
190
- count += checkBuckets.length;
191
- }
192
- return { buckets: count };
101
+ await this.deleteOldCustomCheckpointRequests();
193
102
  }
194
103
 
195
- protected async *dirtyBucketBatchesForCollection<TCollectionBucketState extends BucketStateDocumentBase>(
196
- collection: mongo.Collection<TCollectionBucketState>,
197
- lastId: TCollectionBucketState['_id'],
198
- maxId: TCollectionBucketState['_id'],
199
- options: {
200
- minBucketChanges: number;
201
- minChangeRatio: number;
202
- },
203
- getDefinitionId: (state: TCollectionBucketState) => BucketDefinitionId | null
204
- ): AsyncGenerator<DirtyBucket[]> {
205
- // Paginate through the bucket state collection using cursor-based scanning.
206
- while (true) {
207
- // To avoid timeouts from too many buckets not meeting the minBucketChanges criteria, use an aggregation pipeline
208
- // to scan a fixed batch of buckets at a time, but only return buckets that meet the criteria.
209
- const [result] = await collection
210
- .aggregate<{
211
- buckets: TCollectionBucketState[];
212
- cursor: Pick<TCollectionBucketState, '_id'>[];
213
- }>(
214
- [
215
- {
216
- $match: {
217
- _id: { $gt: lastId, $lt: maxId }
218
- }
219
- },
220
- {
221
- $sort: { _id: 1 }
222
- },
223
- {
224
- // Scan a fixed number of docs each query so sparse matches don't block progress.
225
- $limit: DIRTY_BUCKET_SCAN_BATCH_SIZE
226
- },
227
- {
228
- $facet: {
229
- buckets: [
230
- {
231
- $match: {
232
- 'estimate_since_compact.count': { $gte: options.minBucketChanges }
233
- }
234
- },
235
- {
236
- $project: {
237
- _id: 1,
238
- estimate_since_compact: 1,
239
- compacted_state: 1
240
- }
241
- }
242
- ],
243
- // This is used for the next query.
244
- cursor: [{ $sort: { _id: -1 } }, { $limit: 1 }, { $project: { _id: 1 } }]
245
- }
246
- }
247
- ],
248
- { maxTimeMS: MONGO_OPERATION_TIMEOUT_MS }
249
- )
250
- .toArray();
251
-
252
- const cursor = result?.cursor?.[0];
253
- if (cursor == null) {
254
- break;
255
- }
256
- lastId = cursor._id;
257
-
258
- const mapped = (result?.buckets ?? []).map((bucketState) => {
259
- // The numbers, specifically the bytes, could be a bigint. Convert to Number to allow calculating ratios.
260
- // BigInt precision is not needed here since this is only an estimate.
261
- const updatedCount = bucketState.estimate_since_compact?.count ?? 0;
262
- const totalCount = (bucketState.compacted_state?.count ?? 0) + updatedCount;
263
- const updatedBytes = Number(bucketState.estimate_since_compact?.bytes ?? 0);
264
- const totalBytes = Number(bucketState.compacted_state?.bytes ?? 0) + updatedBytes;
265
- const dirtyChangeNumber = totalCount > 0 ? updatedCount / totalCount : 0;
266
- const dirtyChangeBytes = totalBytes > 0 ? updatedBytes / totalBytes : 0;
267
- return {
268
- bucket: bucketState._id.b,
269
- definitionId: getDefinitionId(bucketState),
270
- estimatedCount: totalCount,
271
- dirtyRatio: Math.max(dirtyChangeNumber, dirtyChangeBytes)
272
- };
273
- });
274
-
275
- yield mapped.filter(
276
- (bucket) => bucket.estimatedCount >= options.minBucketChanges && bucket.dirtyRatio >= options.minChangeRatio
277
- );
104
+ protected async deleteOldCustomCheckpointRequests() {
105
+ if (this.deleteCheckpointRequestsBefore == null) {
106
+ return;
278
107
  }
279
- }
280
-
281
- protected async dirtyBucketBatchForChecksumsForCollection<TBucketState extends BucketStateDocumentBase>(
282
- collection: mongo.Collection<TBucketState>,
283
- filter: mongo.Filter<TBucketState>,
284
- getDefinitionId: (state: mongo.WithId<TBucketState>) => BucketDefinitionId | null
285
- ): Promise<DirtyBucket[]> {
286
- const dirtyBuckets = await collection
287
- .find(filter, {
288
- projection: {
289
- _id: 1,
290
- estimate_since_compact: 1,
291
- compacted_state: 1
292
- },
293
- sort: {
294
- 'estimate_since_compact.count': -1
295
- },
296
- limit: 200,
297
- maxTimeMS: MONGO_OPERATION_TIMEOUT_MS
298
- })
299
- .toArray();
300
-
301
- return dirtyBuckets.map((bucket) => ({
302
- bucket: bucket._id.b,
303
- definitionId: getDefinitionId(bucket),
304
- estimatedCount: Number(bucket.estimate_since_compact!.count) + Number(bucket.compacted_state?.count ?? 0)
305
- }));
306
- }
307
108
 
308
- public abstract dirtyBucketBatches(options: {
309
- minBucketChanges: number;
310
- minChangeRatio: number;
311
- }): AsyncGenerator<DirtyBucket[]>;
312
-
313
- public abstract dirtyBucketBatchForChecksums(options: { minBucketChanges: number }): Promise<DirtyBucket[]>;
314
-
315
- protected async compactDirtyBuckets() {
316
- for await (const buckets of this.dirtyBucketBatches({
317
- minBucketChanges: this.minBucketChanges,
318
- minChangeRatio: this.minChangeRatio
319
- })) {
320
- this.signal?.throwIfAborted();
321
- if (buckets.length == 0) {
322
- continue;
323
- }
324
-
325
- for (const { bucket, definitionId } of buckets) {
326
- await this.compactSingleBucketRetried(bucket, definitionId);
327
- }
328
- }
109
+ await this.db.custom_write_checkpoints.deleteMany({
110
+ checkpoint_requested_at: { $exists: true, $lt: this.deleteCheckpointRequestsBefore }
111
+ });
329
112
  }
330
113
 
331
114
  /**
@@ -336,14 +119,12 @@ export abstract class MongoCompactor {
336
119
  * storage paths are also prepared with lifecycle markers, so a retry can
337
120
  * safely overwrite or eventually clean up uploads from the failed attempt.
338
121
  */
339
- protected async compactSingleBucketRetried(bucket: string, definitionId: BucketDefinitionId | null = null) {
122
+ protected async retryCompaction(bucket: string, compact: () => Promise<void>) {
340
123
  let retryCount = 0;
341
124
  while (true) {
342
125
  this.signal?.throwIfAborted();
343
- // Do not carry queued bucket state writes from a failed attempt into the rescan.
344
- this.bucketStateUpdates = [];
345
126
  try {
346
- await this.compactSingleBucket(bucket, definitionId);
127
+ await compact();
347
128
  return;
348
129
  } catch (e) {
349
130
  if (this.signal?.aborted) {
@@ -372,101 +153,6 @@ export abstract class MongoCompactor {
372
153
  }
373
154
  }
374
155
  }
375
-
376
- protected abstract compactSingleBucket(bucket: string, definitionId?: BucketDefinitionId | null): Promise<void>;
377
-
378
- protected collectBucketStateUpdates(
379
- state: CurrentBucketState,
380
- compactedOpId: InternalOpId
381
- ): mongo.AnyBulkWriteOperation<BucketStateDocumentBase> {
382
- if (state.opCount < 0) {
383
- throw new ServiceAssertionError(
384
- `Invalid opCount: ${state.opCount} checksum ${state.checksum} opsSincePut: ${state.opsSincePut} maxOpId: ${this.maxOpId}`
385
- );
386
- }
387
- return {
388
- updateOne: {
389
- filter: this.bucketStateFilter(state.bucket, state.definitionId),
390
- update: {
391
- $set: {
392
- compacted_state: {
393
- op_id: compactedOpId,
394
- count: state.opCount,
395
- checksum: BigInt(state.checksum),
396
- bytes: state.opBytes
397
- },
398
- estimate_since_compact: {
399
- // There could have been a whole bunch of new operations added to the bucket while compacting,
400
- // which we don't currently cater for. We could potentially query for that, but that adds overhead.
401
- count: 0,
402
- bytes: 0
403
- }
404
- }
405
- } satisfies mongo.UpdateFilter<BucketStateDocumentBase>,
406
- // We generally expect this to have been created before.
407
- // We don't create new ones here, to avoid issues with the unique index on bucket_updates.
408
- upsert: false
409
- }
410
- };
411
- }
412
-
413
- protected updateBucketChecksums(state: CurrentBucketState, compactedOpId: InternalOpId) {
414
- this.bucketStateUpdates.push(this.collectBucketStateUpdates(state, compactedOpId));
415
- }
416
-
417
- protected async flushBucketStateUpdates() {
418
- if (this.bucketStateUpdates.length > 0) {
419
- this.logger.info(`Updating ${this.bucketStateUpdates.length} bucket states`);
420
- await this.writeBucketStateUpdates();
421
- this.bucketStateUpdates = [];
422
- }
423
- }
424
-
425
- protected async updateChecksumsBatch(buckets: Pick<DirtyBucket, 'bucket' | 'definitionId'>[]) {
426
- const checksums = await this.computeChecksumsForBuckets(buckets);
427
- const definitionIdByBucket = new Map(buckets.map((bucket) => [bucket.bucket, bucket.definitionId]));
428
-
429
- for (const bucketChecksum of checksums.values()) {
430
- if (isPartialChecksum(bucketChecksum)) {
431
- // Should never happen since we don't specify `start`.
432
- throw new ServiceAssertionError(`Full checksum expected, got ${JSON.stringify(bucketChecksum)}`);
433
- }
434
-
435
- this.bucketStateUpdates.push({
436
- updateOne: {
437
- filter: this.bucketStateFilter(
438
- bucketChecksum.bucket,
439
- definitionIdByBucket.get(bucketChecksum.bucket) ?? null
440
- ),
441
- update: {
442
- $set: {
443
- compacted_state: {
444
- op_id: this.maxOpId,
445
- count: bucketChecksum.count,
446
- checksum: BigInt(bucketChecksum.checksum),
447
- bytes: null
448
- },
449
- estimate_since_compact: {
450
- count: 0,
451
- bytes: 0
452
- }
453
- }
454
- } satisfies mongo.UpdateFilter<BucketStateDocumentBase>,
455
- // We don't create new ones here - it gets tricky to get the last_op right with the unique index on
456
- // bucket_updates.
457
- upsert: false
458
- }
459
- });
460
- }
461
-
462
- await this.flushBucketStateUpdates();
463
- }
464
-
465
- protected abstract writeBucketStateUpdates(): Promise<void>;
466
- protected abstract computeChecksumsForBuckets(
467
- buckets: Pick<DirtyBucket, 'bucket' | 'definitionId'>[]
468
- ): Promise<storage.PartialChecksumMap>;
469
- protected abstract bucketStateFilter(bucket: string, definitionId: BucketDefinitionId | null): mongo.Document;
470
156
  }
471
157
 
472
158
  export interface BucketDataCollectionContext<TBucketData extends mongo.Document> {
@@ -475,6 +161,9 @@ export interface BucketDataCollectionContext<TBucketData extends mongo.Document>
475
161
  }
476
162
 
477
163
  function compactionRetryReason(error: unknown): string | null {
164
+ if (error instanceof CompactionLeaseLostError) {
165
+ return null;
166
+ }
478
167
  if (error instanceof ConcurrentCompactionError) {
479
168
  return 'concurrent compaction';
480
169
  }