@powersync/service-module-mongodb-storage 0.0.0-dev-20260602073133 → 0.0.0-dev-20260909133214

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 (275) hide show
  1. package/CHANGELOG.md +177 -5
  2. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.d.ts +3 -0
  3. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js +41 -0
  4. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js.map +1 -0
  5. package/dist/storage/MongoBucketStorage.d.ts +71 -18
  6. package/dist/storage/MongoBucketStorage.js +516 -202
  7. package/dist/storage/MongoBucketStorage.js.map +1 -1
  8. package/dist/storage/implementation/MongoBucketBatch.d.ts +71 -29
  9. package/dist/storage/implementation/MongoBucketBatch.js +140 -66
  10. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  11. package/dist/storage/implementation/MongoChecksums.d.ts +41 -13
  12. package/dist/storage/implementation/MongoChecksums.js +36 -121
  13. package/dist/storage/implementation/MongoChecksums.js.map +1 -1
  14. package/dist/storage/implementation/MongoCompactor.d.ts +28 -90
  15. package/dist/storage/implementation/MongoCompactor.js +87 -481
  16. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  17. package/dist/storage/implementation/MongoHydrationState.d.ts +11 -0
  18. package/dist/storage/implementation/MongoHydrationState.js +49 -0
  19. package/dist/storage/implementation/MongoHydrationState.js.map +1 -0
  20. package/dist/storage/implementation/MongoParameterCompactor.d.ts +82 -8
  21. package/dist/storage/implementation/MongoParameterCompactor.js +312 -76
  22. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  23. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +21 -0
  24. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +93 -0
  25. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -0
  26. package/dist/storage/implementation/MongoPersistedReplicationStream.d.ts +36 -0
  27. package/dist/storage/implementation/MongoPersistedReplicationStream.js +90 -0
  28. package/dist/storage/implementation/MongoPersistedReplicationStream.js.map +1 -0
  29. package/dist/storage/implementation/MongoPersistedSyncConfigContent.d.ts +25 -0
  30. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +125 -0
  31. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -0
  32. package/dist/storage/implementation/MongoStorageProvider.js +41 -4
  33. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  34. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +81 -37
  35. package/dist/storage/implementation/MongoSyncBucketStorage.js +159 -86
  36. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  37. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +1 -1
  38. package/dist/storage/implementation/MongoSyncRulesLock.js +23 -11
  39. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  40. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +11 -8
  41. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +159 -33
  42. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  43. package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +11 -5
  44. package/dist/storage/implementation/SyncRuleStateUpdate.js +14 -6
  45. package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -1
  46. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +28 -0
  47. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js +2 -0
  48. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js.map +1 -0
  49. package/dist/storage/implementation/common/PersistedBatch.d.ts +34 -2
  50. package/dist/storage/implementation/common/PersistedBatch.js +63 -10
  51. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  52. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +1 -1
  53. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  54. package/dist/storage/implementation/createMongoSyncBucketStorage.d.ts +2 -2
  55. package/dist/storage/implementation/createMongoSyncBucketStorage.js +4 -4
  56. package/dist/storage/implementation/createMongoSyncBucketStorage.js.map +1 -1
  57. package/dist/storage/implementation/db.d.ts +4 -1
  58. package/dist/storage/implementation/db.js +31 -16
  59. package/dist/storage/implementation/db.js.map +1 -1
  60. package/dist/storage/implementation/models.d.ts +55 -13
  61. package/dist/storage/implementation/models.js.map +1 -1
  62. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +15 -1
  63. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +135 -107
  64. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  65. package/dist/storage/implementation/v1/MongoChecksumsV1.d.ts +4 -4
  66. package/dist/storage/implementation/v1/MongoChecksumsV1.js +119 -7
  67. package/dist/storage/implementation/v1/MongoChecksumsV1.js.map +1 -1
  68. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +47 -7
  69. package/dist/storage/implementation/v1/MongoCompactorV1.js +478 -8
  70. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  71. package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
  72. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
  73. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
  74. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +33 -27
  75. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +146 -73
  76. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  77. package/dist/storage/implementation/v1/SingleBucketStoreV1.d.ts +9 -10
  78. package/dist/storage/implementation/v1/SingleBucketStoreV1.js +2 -8
  79. package/dist/storage/implementation/v1/SingleBucketStoreV1.js.map +1 -1
  80. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.d.ts +2 -1
  81. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js +3 -0
  82. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js.map +1 -1
  83. package/dist/storage/implementation/v1/models.d.ts +10 -1
  84. package/dist/storage/implementation/v1/models.js +1 -0
  85. package/dist/storage/implementation/v1/models.js.map +1 -1
  86. package/dist/storage/implementation/v3/BucketDataContextV3.d.ts +13 -0
  87. package/dist/storage/implementation/v3/BucketDataContextV3.js +23 -0
  88. package/dist/storage/implementation/v3/BucketDataContextV3.js.map +1 -0
  89. package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
  90. package/dist/storage/implementation/v3/CompactionLease.js +131 -0
  91. package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
  92. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +16 -5
  93. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +393 -262
  94. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  95. package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +30 -9
  96. package/dist/storage/implementation/v3/MongoChecksumsV3.js +161 -20
  97. package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
  98. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +108 -18
  99. package/dist/storage/implementation/v3/MongoCompactorV3.js +1115 -47
  100. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  101. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +12 -3
  102. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +13 -8
  103. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -1
  104. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +54 -0
  105. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +319 -0
  106. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -0
  107. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +61 -36
  108. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +353 -161
  109. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  110. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
  111. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
  112. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
  113. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +8 -3
  114. package/dist/storage/implementation/v3/PersistedBatchV3.js +131 -29
  115. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  116. package/dist/storage/implementation/v3/SourceRecordStoreV3.d.ts +3 -3
  117. package/dist/storage/implementation/v3/SourceRecordStoreV3.js +13 -11
  118. package/dist/storage/implementation/v3/SourceRecordStoreV3.js.map +1 -1
  119. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +26 -14
  120. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +73 -29
  121. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  122. package/dist/storage/implementation/v3/bucket-format.d.ts +13 -0
  123. package/dist/storage/implementation/v3/bucket-format.js +79 -0
  124. package/dist/storage/implementation/v3/bucket-format.js.map +1 -0
  125. package/dist/storage/implementation/v3/chunking.d.ts +10 -0
  126. package/dist/storage/implementation/v3/chunking.js +30 -0
  127. package/dist/storage/implementation/v3/chunking.js.map +1 -0
  128. package/dist/storage/implementation/v3/compact-utils.d.ts +107 -0
  129. package/dist/storage/implementation/v3/compact-utils.js +216 -0
  130. package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
  131. package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
  132. package/dist/storage/implementation/v3/compaction-constants.js +6 -0
  133. package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
  134. package/dist/storage/implementation/v3/models.d.ts +187 -23
  135. package/dist/storage/implementation/v3/models.js +11 -28
  136. package/dist/storage/implementation/v3/models.js.map +1 -1
  137. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +20 -0
  138. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +48 -0
  139. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -0
  140. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +33 -0
  141. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js +12 -0
  142. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js.map +1 -0
  143. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +68 -0
  144. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +154 -0
  145. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -0
  146. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +37 -0
  147. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +167 -0
  148. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
  149. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +67 -0
  150. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +439 -0
  151. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -0
  152. package/dist/storage/implementation/v3/source-table-utils.d.ts +63 -0
  153. package/dist/storage/implementation/v3/source-table-utils.js +206 -0
  154. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -0
  155. package/dist/storage/storage-index.d.ts +5 -4
  156. package/dist/storage/storage-index.js +5 -4
  157. package/dist/storage/storage-index.js.map +1 -1
  158. package/dist/types/types.d.ts +71 -3
  159. package/dist/types/types.js +58 -2
  160. package/dist/types/types.js.map +1 -1
  161. package/dist/utils/test-utils.d.ts +1 -2
  162. package/dist/utils/test-utils.js +7 -1
  163. package/dist/utils/test-utils.js.map +1 -1
  164. package/dist/utils/util.d.ts +9 -2
  165. package/dist/utils/util.js +131 -5
  166. package/dist/utils/util.js.map +1 -1
  167. package/package.json +13 -19
  168. package/src/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.ts +55 -0
  169. package/src/storage/MongoBucketStorage.ts +728 -242
  170. package/src/storage/implementation/MongoBucketBatch.ts +196 -91
  171. package/src/storage/implementation/MongoChecksums.ts +93 -153
  172. package/src/storage/implementation/MongoCompactor.ts +106 -631
  173. package/src/storage/implementation/MongoHydrationState.ts +61 -0
  174. package/src/storage/implementation/MongoParameterCompactor.ts +392 -87
  175. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +122 -0
  176. package/src/storage/implementation/MongoPersistedReplicationStream.ts +118 -0
  177. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +156 -0
  178. package/src/storage/implementation/MongoStorageProvider.ts +48 -4
  179. package/src/storage/implementation/MongoSyncBucketStorage.ts +226 -118
  180. package/src/storage/implementation/MongoSyncRulesLock.ts +28 -12
  181. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +194 -52
  182. package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
  183. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +31 -0
  184. package/src/storage/implementation/common/PersistedBatch.ts +83 -15
  185. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +1 -1
  186. package/src/storage/implementation/createMongoSyncBucketStorage.ts +21 -7
  187. package/src/storage/implementation/db.ts +40 -22
  188. package/src/storage/implementation/models.ts +58 -14
  189. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +174 -125
  190. package/src/storage/implementation/v1/MongoChecksumsV1.ts +154 -12
  191. package/src/storage/implementation/v1/MongoCompactorV1.ts +594 -25
  192. package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
  193. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +226 -129
  194. package/src/storage/implementation/v1/SingleBucketStoreV1.ts +18 -27
  195. package/src/storage/implementation/v1/VersionedPowerSyncMongoV1.ts +11 -1
  196. package/src/storage/implementation/v1/models.ts +12 -1
  197. package/src/storage/implementation/v3/BucketDataContextV3.ts +32 -0
  198. package/src/storage/implementation/v3/CompactionLease.ts +152 -0
  199. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +502 -324
  200. package/src/storage/implementation/v3/MongoChecksumsV3.ts +250 -32
  201. package/src/storage/implementation/v3/MongoCompactorV3.ts +1439 -70
  202. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +19 -9
  203. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +473 -0
  204. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +453 -211
  205. package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
  206. package/src/storage/implementation/v3/PersistedBatchV3.ts +169 -45
  207. package/src/storage/implementation/v3/SourceRecordStoreV3.ts +12 -12
  208. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +99 -38
  209. package/src/storage/implementation/v3/bucket-format.ts +99 -0
  210. package/src/storage/implementation/v3/chunking.ts +40 -0
  211. package/src/storage/implementation/v3/compact-utils.ts +320 -0
  212. package/src/storage/implementation/v3/compaction-constants.ts +5 -0
  213. package/src/storage/implementation/v3/models.ts +228 -58
  214. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +62 -0
  215. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +44 -0
  216. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +182 -0
  217. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +230 -0
  218. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +573 -0
  219. package/src/storage/implementation/v3/source-table-utils.ts +398 -0
  220. package/src/storage/storage-index.ts +14 -4
  221. package/src/types/types.ts +76 -2
  222. package/src/utils/test-utils.ts +8 -3
  223. package/src/utils/util.ts +188 -7
  224. package/test/src/__snapshots__/storage.test.ts.snap +4 -54
  225. package/test/src/__snapshots__/storage_sync.test.ts.snap +652 -645
  226. package/test/src/cleanup-stopped-sync-configs.test.ts +698 -0
  227. package/test/src/compact-utils.test.ts +226 -0
  228. package/test/src/helpers/MemoryObjectStorage.ts +61 -0
  229. package/test/src/helpers/s3TestFactory.ts +69 -0
  230. package/test/src/object_storage_lifecycle.test.ts +59 -0
  231. package/test/src/object_storage_usage.test.ts +255 -0
  232. package/test/src/parameter_compacting_v1.test.ts +120 -0
  233. package/test/src/parameter_compacting_v3.test.ts +192 -0
  234. package/test/src/parameter_compaction_fence.test.ts +276 -0
  235. package/test/src/setup.ts +6 -1
  236. package/test/src/storage.test.ts +478 -13
  237. package/test/src/storage_compacting.test.ts +2516 -107
  238. package/test/src/storage_object_storage_inline_threshold.test.ts +116 -0
  239. package/test/src/storage_s3_checksums.test.ts +190 -0
  240. package/test/src/storage_s3_compaction_lifecycle.test.ts +543 -0
  241. package/test/src/storage_s3_reading.test.ts +768 -0
  242. package/test/src/storage_s3_writing.test.ts +136 -0
  243. package/test/src/storage_sync.test.ts +1507 -76
  244. package/test/src/storeCurrentData.test.ts +227 -0
  245. package/test/src/util.ts +3 -2
  246. package/tsconfig.json +1 -4
  247. package/tsconfig.tsbuildinfo +1 -1
  248. package/dist/storage/implementation/BucketDefinitionMapping.d.ts +0 -15
  249. package/dist/storage/implementation/BucketDefinitionMapping.js +0 -58
  250. package/dist/storage/implementation/BucketDefinitionMapping.js.map +0 -1
  251. package/dist/storage/implementation/MongoPersistedSyncRules.d.ts +0 -14
  252. package/dist/storage/implementation/MongoPersistedSyncRules.js +0 -67
  253. package/dist/storage/implementation/MongoPersistedSyncRules.js.map +0 -1
  254. package/dist/storage/implementation/MongoPersistedSyncRulesContent.d.ts +0 -29
  255. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js +0 -76
  256. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js.map +0 -1
  257. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.d.ts +0 -13
  258. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js +0 -2
  259. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js.map +0 -1
  260. package/dist/storage/implementation/common/SingleBucketStore.d.ts +0 -54
  261. package/dist/storage/implementation/common/SingleBucketStore.js +0 -3
  262. package/dist/storage/implementation/common/SingleBucketStore.js.map +0 -1
  263. package/dist/storage/implementation/v3/MongoParameterLookupV3.d.ts +0 -4
  264. package/dist/storage/implementation/v3/MongoParameterLookupV3.js +0 -9
  265. package/dist/storage/implementation/v3/MongoParameterLookupV3.js.map +0 -1
  266. package/dist/storage/implementation/v3/SingleBucketStoreV3.d.ts +0 -18
  267. package/dist/storage/implementation/v3/SingleBucketStoreV3.js +0 -48
  268. package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +0 -1
  269. package/src/storage/implementation/BucketDefinitionMapping.ts +0 -75
  270. package/src/storage/implementation/MongoPersistedSyncRules.ts +0 -82
  271. package/src/storage/implementation/MongoPersistedSyncRulesContent.ts +0 -101
  272. package/src/storage/implementation/common/MongoSyncBucketStorageContext.ts +0 -15
  273. package/src/storage/implementation/common/SingleBucketStore.ts +0 -63
  274. package/src/storage/implementation/v3/MongoParameterLookupV3.ts +0 -11
  275. package/src/storage/implementation/v3/SingleBucketStoreV3.ts +0 -68
@@ -1,107 +1,1476 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
2
  import { ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
3
- import { storage } from '@powersync/service-core';
3
+ import { addChecksums, formatBytes, InternalOpId, storage, utils } from '@powersync/service-core';
4
4
  import { BucketDefinitionId } from '@powersync/service-sync-rules';
5
- import { SingleBucketStore } from '../common/SingleBucketStore.js';
6
- import { BucketStateDocumentBase } from '../models.js';
7
- import { DirtyBucket, MongoCompactor } from '../MongoCompactor.js';
8
- import { BucketStateDocumentV3 } from './models.js';
5
+ import { BucketDataDoc } from '../common/BucketDataDoc.js';
6
+ import { BucketDataKey } from '../models.js';
7
+ import { ConcurrentCompactionError, MongoCompactor } from '../MongoCompactor.js';
8
+ import { cacheKey } from '../OperationBatch.js';
9
+ import { loadBucketDataDocument, maxOpId, serializeBucketData } from './bucket-format.js';
10
+ import { BucketDataContextV3 } from './BucketDataContextV3.js';
11
+ import { DEFAULT_MAX_DOC_SIZE_BYTES } from './chunking.js';
12
+ import {
13
+ applyStatsReplacement,
14
+ bucketStats,
15
+ BucketStatsWithChecksum,
16
+ chooseCompactionKind,
17
+ combineAdjacentStats,
18
+ combineChunkStats,
19
+ CompactIntervalConfig,
20
+ CompactionContext,
21
+ CompactionDecision,
22
+ CompactionKind,
23
+ CompactionResult,
24
+ CompactTargetConfig,
25
+ emptyBucketStats,
26
+ firstUncompactedWrite,
27
+ forcedCompactionKind,
28
+ PendingCompactionGroup,
29
+ readCompactionBatch,
30
+ ScheduledCompactionOptions,
31
+ statsForDocument,
32
+ statsForDocuments,
33
+ unclaimedSnapshotFilter
34
+ } from './compact-utils.js';
35
+ import { DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS } from './compaction-constants.js';
36
+ import { AVAILABLE_LEASE_EXPR, CompactionLease } from './CompactionLease.js';
37
+ import { BucketDataDocumentV3, BucketStateDocumentV3 } from './models.js';
9
38
  import type { MongoSyncBucketStorageV3 } from './MongoSyncBucketStorageV3.js';
10
- import { SingleBucketStoreV3 } from './SingleBucketStoreV3.js';
39
+ import { BucketDataObjectStorage, hydrateBucketDataDocuments } from './object-storage/BucketDataObjectStorage.js';
40
+ import { ObjectStorageLifecycle, PreparedObjectStorageUpload } from './object-storage/ObjectStorageLifecycle.js';
41
+ import { createObjectStorageUsageWriterId, ObjectStorageUsage } from './object-storage/ObjectStorageUsage.js';
11
42
  import { VersionedPowerSyncMongoV3 } from './VersionedPowerSyncMongoV3.js';
12
43
 
13
- export class MongoCompactorV3 extends MongoCompactor {
44
+ const DEFAULT_MIN_COMPACT_FULL_INTERVAL_MS = 2 * 60 * 60 * 1000;
45
+ const DEFAULT_MAX_COMPACT_FULL_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
46
+ const DEFAULT_COMPACT_LEASE_DURATION_MS = 10 * 60 * 1000;
47
+ const SCHEDULED_COMPACTION_BATCH_SIZE = 100;
48
+
49
+ interface CompactionGroupResult {
50
+ documentId: BucketDataKey;
51
+ stats: BucketStatsWithChecksum;
52
+ }
53
+
54
+ interface CompactionStatsReplacement {
55
+ before: BucketStatsWithChecksum;
56
+ after: BucketStatsWithChecksum;
57
+ }
58
+
59
+ interface ClearCompactionResult extends CompactionStatsReplacement {
60
+ opCountDiff: number;
61
+ }
62
+
63
+ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalConfig, CompactTargetConfig {
14
64
  declare protected readonly db: VersionedPowerSyncMongoV3;
15
65
  declare protected readonly storage: MongoSyncBucketStorageV3;
16
66
 
17
- public async *dirtyBucketBatches(options: {
18
- minBucketChanges: number;
19
- minChangeRatio: number;
20
- }): AsyncGenerator<DirtyBucket[]> {
21
- if (options.minBucketChanges <= 0) {
22
- throw new ReplicationAssertionError('minBucketChanges must be >= 1');
23
- }
24
- // Same scan strategy as V1, but with the V3 bucket_state key shape.
25
- yield* this.dirtyBucketBatchesForCollection(
26
- this.db.bucketStateV3(this.group_id),
27
- { d: new mongo.MinKey() as any, b: new mongo.MinKey() as any },
28
- { d: new mongo.MaxKey() as any, b: new mongo.MaxKey() as any },
29
- options,
30
- (bucketState) => (bucketState as BucketStateDocumentV3)._id.d
67
+ readonly minCompactChunkIntervalMs: number;
68
+ readonly minCompactFullIntervalMs: number;
69
+ readonly maxCompactFullIntervalMs: number;
70
+ readonly compactLeaseDurationMs: number;
71
+ readonly maxOpIdCap: InternalOpId | undefined;
72
+ private readonly objectStorageUsage: ObjectStorageUsage;
73
+
74
+ constructor(bucketStorage: MongoSyncBucketStorageV3, db: VersionedPowerSyncMongoV3, options: storage.CompactOptions) {
75
+ super(bucketStorage, db, options);
76
+ this.minCompactChunkIntervalMs = options.minCompactChunkIntervalMs ?? DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS;
77
+ this.minCompactFullIntervalMs = options.minCompactFullIntervalMs ?? DEFAULT_MIN_COMPACT_FULL_INTERVAL_MS;
78
+ this.maxCompactFullIntervalMs = options.maxCompactFullIntervalMs ?? DEFAULT_MAX_COMPACT_FULL_INTERVAL_MS;
79
+ this.compactLeaseDurationMs = options.compactLeaseDurationMs ?? DEFAULT_COMPACT_LEASE_DURATION_MS;
80
+ this.maxOpIdCap = options.maxOpId;
81
+ this.objectStorageUsage = new ObjectStorageUsage(this.db, this.group_id, createObjectStorageUsageWriterId());
82
+ }
83
+
84
+ override async compact(): Promise<number> {
85
+ if (this.storage.objectStorage) {
86
+ // Clean these before compacting - should be quick in most cases.
87
+ try {
88
+ await this.objectStorageLifecycle.cleanup(this.logger, { signal: this.signal });
89
+ } catch (e) {
90
+ if (this.signal?.aborted) {
91
+ throw e;
92
+ }
93
+ // In this case, still continue normal compact process
94
+ this.logger.error(`Failed to clean up object storage deletion markers before compaction`, e);
95
+ }
96
+ }
97
+ await this.deleteOldCheckpointRequests();
98
+
99
+ if (this.buckets != null) {
100
+ await this.compactExplicitBuckets(this.buckets);
101
+ } else if (this.compactChunksOnly) {
102
+ // Writers defer their first chunk-compaction check by this fixed default.
103
+ // Include that interval so this synchronous initial-replication pass
104
+ // processes the work that existed when it started.
105
+ await this.compactScheduledBuckets({
106
+ dueAheadMs: DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS,
107
+ forceKind: CompactionKind.Chunks
108
+ });
109
+ } else {
110
+ await this.compactScheduledBuckets();
111
+ }
112
+ if (this.storage.objectStorage) {
113
+ // Cleanup for any produced during compacting.
114
+ // Note that markers only expire after a delay, so this may skip many produced during this compact
115
+ // run. However, during long compact runs, this may also have many ones it can clean up.
116
+ await this.objectStorageLifecycle.cleanup(this.logger, { signal: this.signal });
117
+ await this.objectStorageUsage.foldStaleWriterDeltas();
118
+ }
119
+ return this.compactedBucketCount;
120
+ }
121
+
122
+ protected override async deleteOldCustomCheckpointRequests() {
123
+ if (this.deleteCheckpointRequestsBefore == null) {
124
+ return;
125
+ }
126
+
127
+ for (const collection of await this.db.listCustomCheckpointRequestCollections(this.group_id)) {
128
+ this.signal?.throwIfAborted();
129
+ await collection.deleteMany({
130
+ checkpoint_requested_at: { $exists: true, $lt: this.deleteCheckpointRequestsBefore }
131
+ });
132
+ }
133
+ }
134
+
135
+ /** An explicit compact request always runs a full compact for its buckets. */
136
+ private async compactExplicitBuckets(buckets: string[]) {
137
+ for (const bucket of buckets) {
138
+ // This is not a super efficient query, but this is not a common use case.
139
+ // May be optimized later.
140
+ const states = await this.db
141
+ .bucketState(this.group_id)
142
+ .find({ '_id.b': bucket }, { projection: { _id: 1 } })
143
+ .toArray();
144
+ for (const state of states) {
145
+ await using lease = await this.claimBucket({ _id: state._id });
146
+ if (lease == null || lease.state.first_uncompacted_write == null) {
147
+ continue;
148
+ }
149
+ if (this.isCompactionTargetCovered(lease.state, CompactionKind.Full)) {
150
+ continue;
151
+ }
152
+ const decision = chooseCompactionKind(lease.state, lease.startedAt, this);
153
+ await this.compactClaimedBucket(lease, CompactionKind.Full, decision);
154
+ }
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Process scheduled work in bounded batches.
160
+ *
161
+ * Batching specifically help to cover cases of many buckets where no compaction is required:
162
+ * Instead of sequentially claiming and then rescheduling a bucket, this handles it in bulk.
163
+ *
164
+ * Buckets that do need compaction are still claimed and processed sequentially.
165
+ *
166
+ * Any concurrent workers may read the same batch. Rescheduling filters out buckets handled
167
+ * by a concurrent worker or replication write, while buckets that do need compaction are
168
+ * filtered out when claiming a compaction lease.
169
+ *
170
+ * We filter scheduled jobs by the job start date, so that the same bucket is not compacted
171
+ * multiple times in one run. Reschedules fall beyond the fixed boundary. For the run after
172
+ * initial replication, dueAheadMs extends that boundary to include the first deferred interval.
173
+ */
174
+ private async compactScheduledBuckets(options: ScheduledCompactionOptions = {}) {
175
+ // Writers derive next_compact_check from MongoDB's $$NOW. Use the same
176
+ // clock for the fixed job boundary so clock skew cannot exclude work at
177
+ // the exact initial-replication interval.
178
+ const [{ now: jobStartedAt }] = await this.db.db
179
+ .aggregate<{ now: Date }>([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
180
+ .toArray();
181
+ const dueBefore = new Date(jobStartedAt.getTime() + (options.dueAheadMs ?? 0));
182
+ const forceKind = options.forceKind;
183
+ const rescheduleNotBefore = new Date(dueBefore.getTime() + 1);
184
+ while (true) {
185
+ this.signal?.throwIfAborted();
186
+ const states = await this.findScheduledBucketBatch(dueBefore);
187
+ if (states.length == 0) {
188
+ break;
189
+ }
190
+
191
+ const scheduled: {
192
+ state: BucketStateDocumentV3;
193
+ decision: CompactionDecision;
194
+ forcedKind: CompactionKind | null;
195
+ }[] = [];
196
+ for (const state of states) {
197
+ try {
198
+ scheduled.push({
199
+ state,
200
+ decision: chooseCompactionKind(state, jobStartedAt, this),
201
+ forcedKind: forcedCompactionKind(state, forceKind, this)
202
+ });
203
+ } catch (error) {
204
+ await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
205
+ }
206
+ }
207
+ const noOpStates = scheduled.filter(
208
+ ({ state, decision, forcedKind }) =>
209
+ state.compact_lease == null && (forceKind == null ? decision.kind : forcedKind) == null
210
+ );
211
+ await this.rescheduleUnclaimedBuckets(noOpStates, rescheduleNotBefore);
212
+
213
+ for (const { state, decision, forcedKind } of scheduled) {
214
+ const kind = forceKind == null ? decision.kind : forcedKind;
215
+ if (state.compact_lease == null && kind == null) {
216
+ continue;
217
+ }
218
+
219
+ try {
220
+ await using lease = await this.claimBucket({ _id: state._id, next_compact_check: { $lte: dueBefore } });
221
+ if (lease == null) {
222
+ continue;
223
+ }
224
+ const claimedDecision = chooseCompactionKind(lease.state, lease.startedAt, this);
225
+ const claimedKind =
226
+ forceKind == null ? claimedDecision.kind : forcedCompactionKind(lease.state, forceKind, this);
227
+ if (claimedKind == null) {
228
+ await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
229
+ } else if (this.isCompactionTargetCovered(lease.state, claimedKind)) {
230
+ // The run cannot advance this kind's watermark without regressing
231
+ // already-published progress. Keep any newer work scheduled.
232
+ await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
233
+ } else {
234
+ await this.compactClaimedBucket(lease, claimedKind, claimedDecision, rescheduleNotBefore);
235
+ }
236
+ } catch (error) {
237
+ if (this.signal?.aborted) {
238
+ // When aborted, stop completely, rather than logging and re-scheduling individual buckets.
239
+ // The lease on the current bucket is still released automatically.
240
+ throw error;
241
+ }
242
+ await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
243
+ }
244
+ }
245
+ }
246
+ }
247
+
248
+ /** Read a bounded, priority-ordered snapshot of currently claimable scheduled work. */
249
+ private async findScheduledBucketBatch(dueBefore: Date): Promise<BucketStateDocumentV3[]> {
250
+ return this.db
251
+ .bucketState(this.group_id)
252
+ .find({
253
+ next_compact_check: { $lte: dueBefore },
254
+ ...AVAILABLE_LEASE_EXPR
255
+ })
256
+ .sort({ next_compact_check: 1 })
257
+ .limit(SCHEDULED_COMPACTION_BATCH_SIZE)
258
+ .toArray();
259
+ }
260
+
261
+ /**
262
+ * Reschedule snapshots that were already known to be no-ops without first
263
+ * taking a lease. Every decision input is compared so a concurrent writer
264
+ * or compactor simply makes the update a no-op instead of losing work. A
265
+ * successful reschedule moves beyond this run's fixed selection boundary.
266
+ */
267
+ private async rescheduleUnclaimedBuckets(
268
+ states: { state: BucketStateDocumentV3; decision: CompactionDecision }[],
269
+ notBefore: Date
270
+ ) {
271
+ if (states.length == 0) {
272
+ return;
273
+ }
274
+ await this.db.bucketState(this.group_id).bulkWrite(
275
+ states.map(({ state, decision }) => ({
276
+ updateOne: {
277
+ filter: unclaimedSnapshotFilter(state),
278
+ update: [{ $set: { next_compact_check: this.rescheduleAtOrAfter(decision.nextCompactCheck, notBefore) } }]
279
+ }
280
+ })),
281
+ { ordered: false }
282
+ );
283
+ }
284
+
285
+ /**
286
+ * Isolate a malformed bucket so it cannot prevent other scheduled buckets
287
+ * from compacting. The snapshot filter preserves any concurrent write or
288
+ * compactor result instead of overwriting its next check.
289
+ */
290
+ private async rescheduleFailedBucket(state: BucketStateDocumentV3, notBefore: Date, error: unknown) {
291
+ this.logger.error(`Failed to compact scheduled bucket ${state._id.b}; rescheduling it`, error);
292
+ try {
293
+ await this.db
294
+ .bucketState(this.group_id)
295
+ .updateOne(unclaimedSnapshotFilter(state), [{ $set: { next_compact_check: notBefore } }]);
296
+ } catch (rescheduleError) {
297
+ this.logger.error(`Failed to reschedule bucket ${state._id.b} after a compaction error`, rescheduleError);
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Given a bucket filter, claim a lease on the bucket. The filter should include a filter on _id.
303
+ *
304
+ * Resolves to null if the bucket is already claimed, not found, or filtered out.
305
+ */
306
+ private async claimBucket(
307
+ filter: mongo.Filter<BucketStateDocumentV3>,
308
+ sort?: mongo.Sort
309
+ ): Promise<CompactionLease | null> {
310
+ return CompactionLease.claim(this.db.bucketState(this.group_id), filter, sort, this.compactLeaseDurationMs);
311
+ }
312
+
313
+ private async compactClaimedBucket(
314
+ lease: CompactionLease,
315
+ kind: CompactionKind,
316
+ decision: CompactionDecision,
317
+ rescheduleNotBefore?: Date
318
+ ) {
319
+ const context = new CompactionContext(
320
+ lease,
321
+ kind,
322
+ decision,
323
+ rescheduleNotBefore,
324
+ this.compactionTarget(lease.state)
325
+ );
326
+ lease.startRenewal();
327
+ await this.compactSingleBucket(context);
328
+ }
329
+
330
+ private async rescheduleClaimedBucket(lease: CompactionLease, decision: CompactionDecision, notBefore?: Date) {
331
+ await lease.reschedule(this.rescheduleAtOrAfter(decision.nextCompactCheck, notBefore));
332
+ }
333
+
334
+ private rescheduleAtOrAfter(nextCompactCheck: mongo.Document, notBefore: Date | undefined): mongo.Document {
335
+ return notBefore == null ? nextCompactCheck : { $max: [nextCompactCheck, notBefore] };
336
+ }
337
+
338
+ private compactionTarget(state: BucketStateDocumentV3): InternalOpId {
339
+ return this.maxOpIdCap == null || state.last_op < this.maxOpIdCap ? state.last_op : this.maxOpIdCap;
340
+ }
341
+
342
+ private isCompactionTargetCovered(state: BucketStateDocumentV3, kind: CompactionKind): boolean {
343
+ const target = this.compactionTarget(state);
344
+ if (kind == CompactionKind.Chunks) {
345
+ return state.compacted_state != null && state.compacted_state.op_id >= target;
346
+ }
347
+ if (state.last_full_compact != null && state.last_full_compact.op_id >= target) {
348
+ return true;
349
+ }
350
+ // A full compact may change counts before the checksum-cache boundary.
351
+ // Wait for the safe target to catch up instead of publishing an older or
352
+ // stale cache. At the same boundary, full coverage can still advance.
353
+ return state.compacted_state != null && state.compacted_state.op_id > target;
354
+ }
355
+
356
+ private get objectStorageLifecycle(): ObjectStorageLifecycle {
357
+ if (!this.storage.objectStorage) {
358
+ throw new Error('Object storage is not configured');
359
+ }
360
+ return new ObjectStorageLifecycle(this.db, this.group_id, this.storage.objectStorage);
361
+ }
362
+
363
+ private async compactSingleBucket(context: CompactionContext) {
364
+ if (context.kind == CompactionKind.Chunks) {
365
+ return this.compactSingleBucketChunks(context);
366
+ }
367
+
368
+ return this.compactSingleBucketFully(context);
369
+ }
370
+
371
+ /**
372
+ * Merge adjacent bucket-data chunks without inspecting their operations
373
+ * unless a merge is possible. The metadata contains enough information to
374
+ * update the persisted checksum state and to decide whether a group can fit
375
+ * in one chunk.
376
+ */
377
+ private async compactSingleBucketChunks(context: CompactionContext) {
378
+ const bucket = context.state._id.b;
379
+ const resolvedDefinitionId = context.state._id.d;
380
+ const bucketContext = new BucketDataContextV3(this.db, {
381
+ bucket,
382
+ definitionId: resolvedDefinitionId,
383
+ replicationStreamId: this.group_id
384
+ });
385
+ const collection = this.db.bucketData(this.group_id, resolvedDefinitionId);
386
+ const dataContext = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
387
+ let previousCompactedState = context.state.compacted_state;
388
+ // A zero boundary represents an empty prefix, so there is no stored chunk
389
+ // whose statistics need to be carried into this pass.
390
+ if (previousCompactedState?.op_id === 0n) {
391
+ previousCompactedState = undefined;
392
+ }
393
+ // Include the last previously compacted chunk as well as new chunks. It
394
+ // is the only old chunk which can become mergeable with the new tail.
395
+ let lowerBound =
396
+ previousCompactedState != null ? bucketContext.docId(previousCompactedState.op_id - 1n) : bucketContext.minId;
397
+ const upperBound = bucketContext.docId(context.targetOp + 1n);
398
+ let cachedBoundaryToVerify = previousCompactedState?.op_id;
399
+
400
+ let compactedOpId: bigint | null = null;
401
+ let overlappingCompactedChunk: BucketStatsWithChecksum | undefined;
402
+ let compactedTail = emptyBucketStats();
403
+ let pendingChunks: BucketDataDocumentV3[] = [];
404
+ let pendingSize = 0;
405
+
406
+ while (true) {
407
+ this.signal?.throwIfAborted();
408
+ await context.lease.throwIfLost();
409
+
410
+ const batch = await readCompactionBatch(
411
+ collection.aggregate<BucketDataDocumentV3>(
412
+ [
413
+ {
414
+ $match: {
415
+ _id: {
416
+ $gt: lowerBound,
417
+ $lt: upperBound
418
+ }
419
+ }
420
+ },
421
+ { $sort: { _id: 1 } },
422
+ { $limit: this.moveBatchQueryLimit },
423
+ {
424
+ $project: {
425
+ _id: 1,
426
+ min_op: 1,
427
+ checksum: 1,
428
+ count: 1,
429
+ size: 1,
430
+ target_op: 1,
431
+ storage_ref: 1
432
+ }
433
+ }
434
+ ],
435
+ { batchSize: this.moveBatchQueryLimit + 1 }
436
+ ),
437
+ {
438
+ byteLimit: this.moveBatchByteLimit,
439
+ documentLimit: this.moveBatchQueryLimit
440
+ }
441
+ );
442
+
443
+ if (cachedBoundaryToVerify != null) {
444
+ const cachedBoundary = cachedBoundaryToVerify;
445
+ cachedBoundaryToVerify = undefined;
446
+ if (batch.documents[0]?._id.o !== cachedBoundary) {
447
+ // A previous attempt may have replaced the cached boundary before
448
+ // finalizing bucket state. Keep the persisted cache available to
449
+ // readers, but ignore it in this attempt and calculate its
450
+ // replacement through the normal scan from the bucket beginning.
451
+ previousCompactedState = undefined;
452
+ lowerBound = bucketContext.minId;
453
+ continue;
454
+ }
455
+ }
456
+
457
+ if (batch.documents.length == 0) {
458
+ break;
459
+ }
460
+
461
+ for (const doc of batch.documents) {
462
+ compactedOpId = maxOpId(compactedOpId, doc._id.o);
463
+ const documentStats = statsForDocument(doc);
464
+ if (previousCompactedState?.op_id === doc._id.o) {
465
+ overlappingCompactedChunk = documentStats;
466
+ }
467
+
468
+ const nextSize = pendingSize + doc.size;
469
+ if (pendingChunks.length > 0 && nextSize > DEFAULT_MAX_DOC_SIZE_BYTES) {
470
+ const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext);
471
+ compactedTail = combineAdjacentStats(compactedTail, groupStats);
472
+ pendingChunks = [];
473
+ pendingSize = 0;
474
+ }
475
+
476
+ pendingChunks.push(doc);
477
+ pendingSize += doc.size;
478
+ }
479
+
480
+ lowerBound = batch.documents[batch.documents.length - 1]._id;
481
+ if (!batch.hasMore) {
482
+ break;
483
+ }
484
+ }
485
+
486
+ if (pendingChunks.length > 0) {
487
+ const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext);
488
+ compactedTail = combineAdjacentStats(compactedTail, groupStats);
489
+ }
490
+
491
+ if (compactedOpId == null) {
492
+ await this.finalizeSkippedBucket(context);
493
+ return;
494
+ }
495
+
496
+ const compactedState =
497
+ previousCompactedState == null
498
+ ? compactedTail
499
+ : combineChunkStats(previousCompactedState, compactedTail, overlappingCompactedChunk!);
500
+ const tailStats =
501
+ compactedOpId == context.lastOp
502
+ ? undefined
503
+ : await this.readBucketStats(bucket, resolvedDefinitionId, context.lastOp, bucketContext.docId(compactedOpId));
504
+ const result: CompactionResult = {
505
+ compactedState,
506
+ bucketStats: tailStats == null ? compactedState : combineAdjacentStats(compactedState, tailStats)
507
+ };
508
+
509
+ await this.finalizeCompactedBucket({ context, compactedOpId, compactionResult: result, puts: 0 });
510
+ this.compactedBucketCount++;
511
+ this.logger.info(
512
+ `Compacted bucket chunks ${bucket}: ${result.bucketStats.count} ops, ${result.bucketStats.chunks} chunks, ${formatBytes(result.bucketStats.bytes)}`
31
513
  );
32
514
  }
33
515
 
34
- public async dirtyBucketBatchForChecksums(options: { minBucketChanges: number }): Promise<DirtyBucket[]> {
35
- if (options.minBucketChanges <= 0) {
36
- throw new ReplicationAssertionError('minBucketChanges must be >= 1');
516
+ private async flushChunkMerge(
517
+ bucket: string,
518
+ inputs: BucketDataDocumentV3[],
519
+ collection: mongo.Collection<BucketDataDocumentV3>,
520
+ context: { replicationStreamId: number; definitionId: string },
521
+ bucketContext: BucketDataContextV3
522
+ ): Promise<BucketStatsWithChecksum> {
523
+ if (inputs.length == 1) {
524
+ return statsForDocument(inputs[0]);
37
525
  }
38
- return this.dirtyBucketBatchForChecksumsForCollection(
39
- this.db.bucketStateV3(this.group_id),
526
+
527
+ // The metadata scan deliberately excluded ops. Read inline payloads only
528
+ // for this merge group; object-storage payloads are fetched below using
529
+ // the same rule.
530
+ const inlineInputs = inputs.filter((input) => input.storage_ref == null);
531
+ if (inlineInputs.length > 0) {
532
+ const inlineDocuments = await collection
533
+ .find({ _id: { $in: inlineInputs.map((input) => input._id) } }, { projection: { _id: 1, ops: 1 } })
534
+ .toArray();
535
+ const opsById = new Map(inlineDocuments.map((document) => [document._id.o.toString(), document.ops]));
536
+ for (const input of inlineInputs) {
537
+ input.ops = opsById.get(input._id.o.toString());
538
+ }
539
+ }
540
+ await hydrateBucketDataDocuments(inputs, this.storage.objectStorage, { signal: this.signal });
541
+
542
+ const operations = inputs.flatMap((input) => Array.from(loadBucketDataDocument(context, input)));
543
+ const targetOp = inputs.reduce<InternalOpId | null>(
544
+ (maxTarget, input) => maxOpId(maxTarget, input.target_op),
545
+ null
546
+ );
547
+ const result = await this.flushCompactionGroup(
548
+ bucket,
40
549
  {
41
- 'estimate_since_compact.count': { $gte: options.minBucketChanges }
550
+ inputs,
551
+ ops: operations,
552
+ changed: true,
553
+ targetOp
42
554
  },
43
- (bucketState) => (bucketState as BucketStateDocumentV3)._id.d
555
+ bucketContext,
556
+ context
44
557
  );
558
+ return result.stats;
45
559
  }
46
560
 
47
- protected async writeBucketStateUpdates(): Promise<void> {
48
- await this.db
49
- .bucketStateV3(this.group_id)
50
- .bulkWrite(this.bucketStateUpdates as mongo.AnyBulkWriteOperation<BucketStateDocumentV3>[], { ordered: false });
561
+ private async finalizeCompactedBucket({
562
+ context,
563
+ compactedOpId,
564
+ compactionResult,
565
+ puts
566
+ }: {
567
+ context: CompactionContext;
568
+ compactedOpId: InternalOpId;
569
+ compactionResult: CompactionResult;
570
+ puts: number;
571
+ }) {
572
+ await context.lease.throwIfLost();
573
+ const startedStats = bucketStats(context.state);
574
+ const delta = {
575
+ count: compactionResult.bucketStats.count - startedStats.count,
576
+ bytes: compactionResult.bucketStats.bytes - startedStats.bytes,
577
+ chunks: compactionResult.bucketStats.chunks - startedStats.chunks
578
+ };
579
+ const coveredClaimedHead = compactedOpId >= context.lastOp;
580
+ const concurrentWriteCheck = { $gt: ['$last_op', context.lastOp] };
581
+ const remainingFullWorkCheck = coveredClaimedHead ? concurrentWriteCheck : true;
582
+ const nextAfterPartialFullCompact = this.rescheduleAtOrAfter(
583
+ { $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } },
584
+ context.rescheduleNotBefore
585
+ );
586
+ const nextCheckForUncompactedWork = this.rescheduleAtOrAfter(
587
+ {
588
+ $min: [
589
+ new Date(firstUncompactedWrite(context.state).getTime() + this.maxCompactFullIntervalMs),
590
+ { $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } }
591
+ ]
592
+ },
593
+ context.rescheduleNotBefore
594
+ );
595
+ const update: mongo.Document = {
596
+ compacted_state: {
597
+ op_id: compactedOpId,
598
+ checksum: BigInt(compactionResult.compactedState.checksum),
599
+ count: compactionResult.compactedState.count,
600
+ bytes: compactionResult.compactedState.bytes,
601
+ chunks: compactionResult.compactedState.chunks,
602
+ at: '$$NOW'
603
+ },
604
+ bucket_stats: {
605
+ count: { $add: ['$bucket_stats.count', delta.count] },
606
+ bytes: { $add: ['$bucket_stats.bytes', delta.bytes] },
607
+ chunks: { $add: ['$bucket_stats.chunks', delta.chunks] }
608
+ },
609
+ first_uncompacted_write:
610
+ context.kind == CompactionKind.Full
611
+ ? { $cond: [remainingFullWorkCheck, '$$NOW', '$$REMOVE'] }
612
+ : '$first_uncompacted_write',
613
+ next_compact_check:
614
+ context.kind == CompactionKind.Full
615
+ ? { $cond: [remainingFullWorkCheck, nextAfterPartialFullCompact, '$$REMOVE'] }
616
+ : nextCheckForUncompactedWork
617
+ };
618
+ if (context.kind == CompactionKind.Full) {
619
+ update.last_full_compact = {
620
+ op_id: compactedOpId,
621
+ count: compactionResult.compactedState.count,
622
+ puts,
623
+ at: '$$NOW'
624
+ };
625
+ }
626
+
627
+ await context.lease.finalize(update);
51
628
  }
52
629
 
53
- protected async computeChecksumsForBuckets(
54
- buckets: Pick<DirtyBucket, 'bucket' | 'definitionId'>[]
55
- ): Promise<storage.PartialChecksumMap> {
56
- return this.storage.checksums.computePartialChecksumsDirectByDefinition(
57
- buckets.map(({ bucket, definitionId }) => {
58
- if (definitionId == null) {
59
- throw new ServiceAssertionError(`Missing definitionId for V3 bucket checksum update on bucket ${bucket}`);
630
+ private async finalizeSkippedBucket(context: CompactionContext) {
631
+ // A maxOpId cap can exclude the first remaining document entirely. Avoid
632
+ // immediately claiming the same no-progress bucket again in this run.
633
+ await this.rescheduleClaimedBucket(
634
+ context.lease,
635
+ {
636
+ ...context.decision,
637
+ nextCompactCheck: {
638
+ $max: [
639
+ context.decision.nextCompactCheck,
640
+ { $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } }
641
+ ]
60
642
  }
61
- return {
62
- bucket,
63
- definitionId,
64
- end: this.maxOpId
643
+ },
644
+ context.rescheduleNotBefore
645
+ );
646
+ }
647
+
648
+ /**
649
+ * Read bucket stats directly from bucket_data documents.
650
+ */
651
+ private async readBucketStats(
652
+ bucket: string,
653
+ definitionId: BucketDefinitionId,
654
+ maxOp: InternalOpId,
655
+ lowerBound?: BucketDataKey
656
+ ): Promise<BucketStatsWithChecksum> {
657
+ const context = new BucketDataContextV3(this.db, {
658
+ bucket,
659
+ definitionId,
660
+ replicationStreamId: this.group_id
661
+ });
662
+ const [stats] = await this.db
663
+ .bucketData(this.group_id, definitionId)
664
+ .aggregate<{ count: number; bytes: number | bigint; chunks: number; checksum: bigint }>([
665
+ {
666
+ $match: {
667
+ _id:
668
+ lowerBound == null
669
+ ? { $gte: context.minId, $lte: context.docId(maxOp) }
670
+ : { $gt: lowerBound, $lte: context.docId(maxOp) }
671
+ }
672
+ },
673
+ {
674
+ $group: {
675
+ _id: null,
676
+ count: { $sum: '$count' },
677
+ bytes: { $sum: '$size' },
678
+ chunks: { $sum: 1 },
679
+ checksum: { $sum: '$checksum' }
680
+ }
681
+ }
682
+ ])
683
+ .toArray();
684
+ return {
685
+ count: Number(stats?.count ?? 0),
686
+ bytes: BigInt(stats?.bytes ?? 0),
687
+ chunks: Number(stats?.chunks ?? 0),
688
+ checksum:
689
+ typeof stats?.checksum == 'bigint'
690
+ ? Number(BigInt.asIntN(32, stats.checksum))
691
+ : addChecksums(0, Number(stats?.checksum ?? 0))
692
+ };
693
+ }
694
+
695
+ private async compactSingleBucketFully(context: CompactionContext) {
696
+ const bucket = context.state._id.b;
697
+ const resolvedDefinitionId = context.state._id.d;
698
+ const bucketContext = new BucketDataContextV3(this.db, {
699
+ bucket,
700
+ definitionId: resolvedDefinitionId,
701
+ replicationStreamId: this.group_id
702
+ });
703
+ const collection = this.db.bucketData(this.group_id, resolvedDefinitionId);
704
+ const dataContext = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
705
+ const lowerBound = bucketContext.minId;
706
+ let upperBound = bucketContext.docId(context.targetOp + 1n);
707
+
708
+ let totalOpCount = 0;
709
+
710
+ let lastNotPut: bigint | null = null;
711
+ let opsSincePut = 0;
712
+ let compactedOpId: bigint | null = null;
713
+ let clearBoundary: { opId: bigint; documentId: BucketDataKey } | null = null;
714
+ let compactedStats = emptyBucketStats();
715
+ const seen = new Map<string, bigint>();
716
+ let trackingSize = 0;
717
+ let putCount = 0;
718
+ let pendingGroup: PendingCompactionGroup | null = null;
719
+
720
+ // --- Read batch from MongoDB ---
721
+ while (true) {
722
+ this.signal?.throwIfAborted();
723
+ await context.lease.throwIfLost();
724
+
725
+ const pipeline: mongo.Document[] = [
726
+ {
727
+ $match: {
728
+ _id: {
729
+ $gte: lowerBound,
730
+ $lt: upperBound
731
+ }
732
+ }
733
+ },
734
+ { $sort: { _id: -1 } },
735
+ { $limit: this.moveBatchQueryLimit },
736
+ {
737
+ $project: {
738
+ _id: 1,
739
+ min_op: 1,
740
+ checksum: 1,
741
+ count: 1,
742
+ size: 1,
743
+ target_op: 1,
744
+ ops: 1,
745
+ storage_ref: 1
746
+ }
747
+ }
748
+ ];
749
+
750
+ const batch = await readCompactionBatch(
751
+ collection.aggregate<BucketDataDocumentV3>(pipeline, {
752
+ batchSize: this.moveBatchQueryLimit + 1
753
+ }),
754
+ {
755
+ byteLimit: this.moveBatchByteLimit,
756
+ documentLimit: this.moveBatchQueryLimit
757
+ }
758
+ );
759
+ const batchDocs = batch.documents;
760
+
761
+ if (batchDocs.length == 0) {
762
+ // No more documents in this bucket — compaction complete.
763
+ break;
764
+ }
765
+
766
+ await hydrateBucketDataDocuments(batchDocs, this.storage.objectStorage, { signal: this.signal });
767
+
768
+ // Compact each document independently, then greedily merge adjacent
769
+ // post-compaction results. This preserves existing boundaries unless
770
+ // merging is useful, and writes each final object at most once.
771
+ for (const doc of batchDocs) {
772
+ compactedOpId ??= doc._id.o;
773
+ const originalOps = Array.from(loadBucketDataDocument(dataContext, doc));
774
+
775
+ let changed = false;
776
+ const compactedOps: BucketDataDoc[] = [];
777
+ let maxTargetOp: InternalOpId | null = doc.target_op ?? null;
778
+ for (let index = originalOps.length - 1; index >= 0; index--) {
779
+ const op = originalOps[index];
780
+ if (op.op == 'PUT' || op.op == 'REMOVE') {
781
+ const key = `${op.table}/${op.row_id}/${cacheKey(op.source_table!, op.source_key!)}`;
782
+ const targetOp = seen.get(key);
783
+ if (targetOp != null) {
784
+ maxTargetOp = maxOpId(maxTargetOp, targetOp);
785
+ compactedOps.push({
786
+ ...op,
787
+ op: 'MOVE',
788
+ table: undefined,
789
+ row_id: undefined,
790
+ source_table: undefined,
791
+ source_key: undefined,
792
+ subkey: undefined,
793
+ data: null
794
+ });
795
+ changed = true;
796
+ if (lastNotPut == null) {
797
+ lastNotPut = op.o;
798
+ }
799
+ opsSincePut += 1;
800
+ } else {
801
+ if (trackingSize < this.idLimitBytes) {
802
+ seen.set(utils.flatstr(key), op.o);
803
+ trackingSize += key.length + 140;
804
+ }
805
+ compactedOps.push(op);
806
+ if (op.op == 'PUT') {
807
+ putCount++;
808
+ lastNotPut = null;
809
+ opsSincePut = 0;
810
+ } else {
811
+ if (lastNotPut == null) {
812
+ lastNotPut = op.o;
813
+ }
814
+ opsSincePut += 1;
815
+ }
816
+ }
817
+ } else {
818
+ compactedOps.push(op);
819
+ if (op.op != 'CLEAR') {
820
+ if (lastNotPut == null) {
821
+ lastNotPut = op.o;
822
+ }
823
+ opsSincePut += 1;
824
+ }
825
+ }
826
+ }
827
+ compactedOps.reverse();
828
+
829
+ totalOpCount += compactedOps.length;
830
+
831
+ const candidate: PendingCompactionGroup = {
832
+ inputs: [doc],
833
+ ops: compactedOps,
834
+ changed,
835
+ targetOp: maxTargetOp
65
836
  };
66
- })
837
+
838
+ if (pendingGroup == null) {
839
+ pendingGroup = candidate;
840
+ } else {
841
+ const mergedOps: BucketDataDoc[] = [...candidate.ops, ...pendingGroup.ops];
842
+ const mergedSize = serializeBucketData(bucket, mergedOps, { targetOp: maxTargetOp }).size;
843
+ if (mergedSize <= DEFAULT_MAX_DOC_SIZE_BYTES) {
844
+ pendingGroup = {
845
+ inputs: [...candidate.inputs, ...pendingGroup.inputs],
846
+ ops: mergedOps,
847
+ changed: candidate.changed || pendingGroup.changed,
848
+ targetOp: maxOpId(maxTargetOp, pendingGroup.targetOp)
849
+ };
850
+ } else {
851
+ const flushedGroup = pendingGroup;
852
+ const result = await this.flushCompactionGroup(bucket, flushedGroup, bucketContext, dataContext);
853
+ compactedStats = combineAdjacentStats(compactedStats, result.stats);
854
+ if (
855
+ lastNotPut != null &&
856
+ flushedGroup.ops[0].o <= lastNotPut &&
857
+ flushedGroup.ops[flushedGroup.ops.length - 1].o >= lastNotPut
858
+ ) {
859
+ clearBoundary = { opId: lastNotPut, documentId: result.documentId };
860
+ }
861
+ pendingGroup = candidate;
862
+ }
863
+ }
864
+ }
865
+
866
+ // --- Advance to next batch ---
867
+ upperBound = batchDocs[batchDocs.length - 1]._id as typeof upperBound;
868
+
869
+ if (!batch.hasMore) {
870
+ break;
871
+ }
872
+
873
+ this.logger.info(`Compacted batch of ${batchDocs.length} documents for bucket ${bucket}`);
874
+ }
875
+
876
+ if (pendingGroup != null) {
877
+ const result = await this.flushCompactionGroup(bucket, pendingGroup, bucketContext, dataContext);
878
+ compactedStats = combineAdjacentStats(compactedStats, result.stats);
879
+ if (
880
+ lastNotPut != null &&
881
+ pendingGroup.ops[0].o <= lastNotPut &&
882
+ pendingGroup.ops[pendingGroup.ops.length - 1].o >= lastNotPut
883
+ ) {
884
+ clearBoundary = { opId: lastNotPut, documentId: result.documentId };
885
+ }
886
+ }
887
+ if (compactedOpId == null) {
888
+ await this.finalizeSkippedBucket(context);
889
+ return;
890
+ }
891
+
892
+ // --- Clear: collapse leading MOVE/REMOVE/CLEAR sequence ---
893
+ if (lastNotPut != null && opsSincePut >= 2) {
894
+ if (clearBoundary == null || clearBoundary.opId != lastNotPut) {
895
+ throw new ReplicationAssertionError(`Missing CLEAR boundary document for bucket ${bucket}`);
896
+ }
897
+
898
+ const clearResult = await this.clearBucketLeading(
899
+ lastNotPut,
900
+ clearBoundary.documentId,
901
+ bucketContext,
902
+ collection,
903
+ dataContext
904
+ );
905
+ totalOpCount += clearResult.opCountDiff;
906
+ compactedStats = applyStatsReplacement(compactedStats, clearResult.before, clearResult.after);
907
+ }
908
+
909
+ const tailStats =
910
+ compactedOpId == context.lastOp
911
+ ? undefined
912
+ : await this.readBucketStats(bucket, resolvedDefinitionId, context.lastOp, bucketContext.docId(compactedOpId));
913
+ const result: CompactionResult = {
914
+ compactedState: compactedStats,
915
+ bucketStats: tailStats == null ? compactedStats : combineAdjacentStats(compactedStats, tailStats)
916
+ };
917
+
918
+ // --- Finalize: update bucket checksums and state ---
919
+ await this.finalizeCompactedBucket({ context, compactedOpId, compactionResult: result, puts: putCount });
920
+
921
+ this.compactedBucketCount++;
922
+ this.logger.info(
923
+ `Compacted bucket ${bucket}: ${totalOpCount} ops, ${result.bucketStats.chunks} chunks, ${formatBytes(result.bucketStats.bytes)}`
67
924
  );
68
925
  }
69
926
 
70
- protected bucketStateFilter(
927
+ /**
928
+ * Persist replacement objects before starting the transaction, then atomically
929
+ * publish their lifecycle markers alongside the MongoDB document replacement.
930
+ * If verification or the transaction fails, the prepared markers retain enough
931
+ * information for the uploaded objects to be cleaned up later.
932
+ */
933
+ private async flushCompactionGroup(
71
934
  bucket: string,
72
- definitionId: BucketDefinitionId | null
73
- ): mongo.Filter<BucketStateDocumentBase> {
74
- if (definitionId == null) {
75
- throw new ServiceAssertionError(`Missing definitionId for V3 bucket state filter on bucket ${bucket}`);
935
+ group: PendingCompactionGroup,
936
+ bucketContext: BucketDataContextV3,
937
+ context: { replicationStreamId: number; definitionId: string }
938
+ ): Promise<CompactionGroupResult> {
939
+ if (group.inputs.length == 1 && !group.changed) {
940
+ return {
941
+ documentId: group.inputs[0]._id,
942
+ stats: statsForDocument(group.inputs[0])
943
+ };
944
+ }
945
+
946
+ const inputs = group.inputs;
947
+ const idsToDelete = inputs.map((doc) => doc._id);
948
+ const expectedDocCount = inputs.length;
949
+ const expectedChecksum = inputs.reduce((sum, doc) => sum + doc.checksum, 0n);
950
+ const expectedOpCount = inputs.reduce((sum, doc) => sum + doc.count, 0);
951
+ const oldStoragePaths = inputs.flatMap((doc) => (doc.storage_ref ? [doc.storage_ref.path] : []));
952
+ const oldStorageBytes = inputs.reduce((sum, document) => sum + ObjectStorageUsage.bytes(document), 0n);
953
+ const {
954
+ documents,
955
+ storagePaths: newStoragePaths,
956
+ uploads
957
+ } = await this.persistBucketData(bucket, [group.ops], context, undefined, { targetOp: group.targetOp });
958
+ const session = this.db.client.startSession();
959
+ try {
960
+ await session.withTransaction(
961
+ async () => {
962
+ const verification = await bucketContext.collection
963
+ .aggregate<{ docCount: number; checksumSum: bigint | null; opCountSum: number | null }>(
964
+ [
965
+ { $match: { _id: { $in: idsToDelete } } },
966
+ {
967
+ $group: {
968
+ _id: null,
969
+ docCount: { $sum: 1 },
970
+ checksumSum: { $sum: '$checksum' },
971
+ opCountSum: { $sum: '$count' }
972
+ }
973
+ }
974
+ ],
975
+ { session }
976
+ )
977
+ .next();
978
+
979
+ if (
980
+ verification == null ||
981
+ verification.docCount !== expectedDocCount ||
982
+ verification.checksumSum !== expectedChecksum ||
983
+ verification.opCountSum !== expectedOpCount
984
+ ) {
985
+ throw new ConcurrentCompactionError(
986
+ `Inputs changed while compacting bucket ${bucket}; restarting from the latest bucket state`
987
+ );
988
+ }
989
+
990
+ await bucketContext.collection.deleteMany({ _id: { $in: idsToDelete } }, { session });
991
+ await bucketContext.collection.insertMany(documents, { session });
992
+ await this.finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, session);
993
+ await this.recordObjectStorageReplacement(oldStorageBytes, documents, context.definitionId, session);
994
+ },
995
+ {
996
+ writeConcern: { w: 'majority' },
997
+ readConcern: { level: 'snapshot' }
998
+ }
999
+ );
1000
+ } finally {
1001
+ await session.endSession();
76
1002
  }
77
1003
  return {
78
- _id: {
79
- d: definitionId,
80
- b: bucket
81
- }
1004
+ documentId: documents[0]._id,
1005
+ stats: statsForDocuments(documents)
82
1006
  };
83
1007
  }
84
1008
 
85
- protected async getBucketDataContext(
1009
+ /**
1010
+ * Collapse the leading sequence of MOVE/REMOVE/CLEAR ops at the start
1011
+ * of the bucket into a single CLEAR op. Reads whole clearable documents
1012
+ * before the known boundary document, then splits that boundary document
1013
+ * if it contains ops on both sides of lastNotPut.
1014
+ *
1015
+ * Returns the op count and stored-stat changes after replacing cleared ops
1016
+ * with CLEAR ops.
1017
+ */
1018
+ private async clearBucketLeading(
1019
+ lastNotPut: bigint,
1020
+ boundaryDocId: BucketDataKey,
1021
+ bucketContext: BucketDataContextV3,
1022
+ collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
1023
+ context: { replicationStreamId: number; definitionId: string }
1024
+ ): Promise<ClearCompactionResult> {
1025
+ let opCountDiff = 0;
1026
+ let before = emptyBucketStats();
1027
+ let after = emptyBucketStats();
1028
+ const session = this.db.client.startSession();
1029
+ try {
1030
+ let done = false;
1031
+ // First step is to clear full chunks that contain only CLEAR/MOVE/REMOVE operations.
1032
+ // There can be many of them, so we do one batch at a time.
1033
+ while (!done) {
1034
+ const batch = await this.clearLeadingFullDocuments(
1035
+ session,
1036
+ lastNotPut,
1037
+ boundaryDocId,
1038
+ bucketContext,
1039
+ collection,
1040
+ context
1041
+ );
1042
+ done = batch.done;
1043
+ opCountDiff += batch.opCountDiff;
1044
+ before = combineAdjacentStats(before, batch.before);
1045
+ after = combineAdjacentStats(after, batch.after);
1046
+ }
1047
+
1048
+ // The final step is to process the "boundary" document: It may contain some CLEAR/MOVE/REMOVE operations,
1049
+ // potentially followed by PUT operations. This is only a single document, so no need for batching.
1050
+ const boundaryResult = await this.clearBoundaryDocument(
1051
+ session,
1052
+ lastNotPut,
1053
+ boundaryDocId,
1054
+ bucketContext,
1055
+ collection,
1056
+ context
1057
+ );
1058
+ opCountDiff += boundaryResult.opCountDiff;
1059
+ before = combineAdjacentStats(before, boundaryResult.before);
1060
+ after = combineAdjacentStats(after, boundaryResult.after);
1061
+ } finally {
1062
+ await session.endSession();
1063
+ }
1064
+
1065
+ return { opCountDiff, before, after };
1066
+ }
1067
+
1068
+ private async clearLeadingFullDocuments(
1069
+ session: mongo.ClientSession,
1070
+ lastNotPut: bigint,
1071
+ boundaryDocId: BucketDataKey,
1072
+ bucketContext: BucketDataContextV3,
1073
+ collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
1074
+ context: { replicationStreamId: number; definitionId: string }
1075
+ ): Promise<{ done: boolean; opCountDiff: number } & CompactionStatsReplacement> {
1076
+ const bucket = bucketContext.key.bucket;
1077
+ this.signal?.throwIfAborted();
1078
+ let prepared: PreparedObjectStorageUpload[] | undefined;
1079
+ let done = false;
1080
+ let opCountDiff = 0;
1081
+ let before = emptyBucketStats();
1082
+ let after = emptyBucketStats();
1083
+
1084
+ await session.withTransaction(
1085
+ async () => {
1086
+ done = false;
1087
+ opCountDiff = 0;
1088
+ before = emptyBucketStats();
1089
+ after = emptyBucketStats();
1090
+ const oldStoragePaths: string[] = [];
1091
+ let oldStorageBytes = 0n;
1092
+ const query = collection.find(
1093
+ {
1094
+ _id: {
1095
+ $gte: bucketContext.minId,
1096
+ $lt: boundaryDocId
1097
+ }
1098
+ },
1099
+ {
1100
+ session,
1101
+ sort: { _id: 1 },
1102
+ projection: {
1103
+ _id: 1,
1104
+ min_op: 1,
1105
+ checksum: 1,
1106
+ count: 1,
1107
+ size: 1,
1108
+ target_op: 1,
1109
+ has_clear_op: 1,
1110
+ storage_ref: 1
1111
+ },
1112
+ limit: this.clearBatchLimit
1113
+ }
1114
+ );
1115
+
1116
+ let combinedChecksum = 0;
1117
+ let clearedOpCount = 0;
1118
+ let maxTargetOp: bigint | null = null;
1119
+ let lastDocId: BucketDataKey | null = null;
1120
+ let clearOpCount = 0;
1121
+ let gotNonClearOp = false;
1122
+ const inputStats = emptyBucketStats();
1123
+
1124
+ for await (const doc of query.stream()) {
1125
+ if (doc.min_op > lastNotPut) {
1126
+ throw new ReplicationAssertionError(
1127
+ `Unexpected document before CLEAR boundary with min_op ${doc.min_op} > ${lastNotPut} in bucket ${bucket}`
1128
+ );
1129
+ }
1130
+
1131
+ lastDocId = doc._id;
1132
+ const documentStats = statsForDocument(doc);
1133
+ inputStats.count += documentStats.count;
1134
+ inputStats.bytes += documentStats.bytes;
1135
+ inputStats.chunks += documentStats.chunks;
1136
+ inputStats.checksum = addChecksums(inputStats.checksum, documentStats.checksum);
1137
+ if (doc.storage_ref) {
1138
+ oldStoragePaths.push(doc.storage_ref.path);
1139
+ }
1140
+ oldStorageBytes += ObjectStorageUsage.bytes(doc);
1141
+
1142
+ // The compaction scan established that every operation before the
1143
+ // boundary is MOVE/REMOVE/CLEAR. Root metadata is sufficient to fold
1144
+ // whole documents into one CLEAR, so avoid downloading their payloads.
1145
+ if (doc.has_clear_op) {
1146
+ clearOpCount++;
1147
+ if (clearOpCount > 1) {
1148
+ throw new ReplicationAssertionError(`Unexpected multiple CLEAR operations in bucket ${bucket}`);
1149
+ }
1150
+ }
1151
+ if (!doc.has_clear_op || doc.count > 1) {
1152
+ gotNonClearOp = true;
1153
+ }
1154
+ combinedChecksum = addChecksums(combinedChecksum, Number(doc.checksum));
1155
+ clearedOpCount += doc.count;
1156
+ if (doc.target_op != null && (maxTargetOp == null || doc.target_op > maxTargetOp)) {
1157
+ maxTargetOp = doc.target_op;
1158
+ }
1159
+ }
1160
+
1161
+ if (!gotNonClearOp) {
1162
+ done = true;
1163
+ return;
1164
+ }
1165
+
1166
+ prepared ??= await this.prepareCompactionUploads(bucket, context, [lastNotPut]);
1167
+ this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastDocId?.o}`);
1168
+ await collection.deleteMany(
1169
+ {
1170
+ _id: {
1171
+ $gte: bucketContext.minId,
1172
+ $lte: lastDocId!
1173
+ }
1174
+ },
1175
+ { session }
1176
+ );
1177
+
1178
+ const clearOp = {
1179
+ bucketKey: { ...context, bucket },
1180
+ o: lastDocId!.o,
1181
+ op: 'CLEAR' as const,
1182
+ checksum: BigInt(combinedChecksum),
1183
+ data: null
1184
+ } satisfies BucketDataDoc;
1185
+ const persisted = await this.persistBucketData(bucket, [[clearOp]], context, prepared, {
1186
+ targetOp: maxTargetOp
1187
+ });
1188
+ await collection.insertOne(persisted.documents[0], { session });
1189
+ await this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, session);
1190
+ await this.recordObjectStorageReplacement(oldStorageBytes, persisted.documents, context.definitionId, session);
1191
+
1192
+ opCountDiff = -clearedOpCount + 1;
1193
+ before = inputStats;
1194
+ after = statsForDocuments(persisted.documents);
1195
+ },
1196
+ {
1197
+ writeConcern: { w: 'majority' },
1198
+ readConcern: { level: 'snapshot' }
1199
+ }
1200
+ );
1201
+
1202
+ return { done, opCountDiff, before, after };
1203
+ }
1204
+
1205
+ private async clearBoundaryDocument(
1206
+ session: mongo.ClientSession,
1207
+ lastNotPut: bigint,
1208
+ boundaryDocId: BucketDataKey,
1209
+ bucketContext: BucketDataContextV3,
1210
+ collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
1211
+ context: { replicationStreamId: number; definitionId: string }
1212
+ ): Promise<ClearCompactionResult> {
1213
+ const bucket = bucketContext.key.bucket;
1214
+ this.signal?.throwIfAborted();
1215
+ const prepared = await this.prepareCompactionUploads(bucket, context, [lastNotPut, boundaryDocId.o]);
1216
+ let opCountDiff = 0;
1217
+ let before = emptyBucketStats();
1218
+ let after = emptyBucketStats();
1219
+
1220
+ await session.withTransaction(
1221
+ async () => {
1222
+ opCountDiff = 0;
1223
+ before = emptyBucketStats();
1224
+ after = emptyBucketStats();
1225
+ const oldStoragePaths: string[] = [];
1226
+ let oldStorageBytes = 0n;
1227
+ const query = collection.find(
1228
+ {
1229
+ // This is a range query, but should only ever return two documents:
1230
+ // 1. The CLEAR op from the previous clearLeadingFullDocuments.
1231
+ // 2. The boundary document.
1232
+ _id: {
1233
+ $gte: bucketContext.minId,
1234
+ $lte: boundaryDocId
1235
+ }
1236
+ },
1237
+ {
1238
+ session,
1239
+ sort: { _id: 1 },
1240
+ projection: {
1241
+ _id: 1,
1242
+ min_op: 1,
1243
+ checksum: 1,
1244
+ count: 1,
1245
+ size: 1,
1246
+ target_op: 1,
1247
+ ops: 1,
1248
+ storage_ref: 1
1249
+ },
1250
+ limit: 3
1251
+ }
1252
+ );
1253
+
1254
+ let docsRead = 0;
1255
+ let combinedChecksum = 0;
1256
+ let clearedOpCount = 0;
1257
+ let maxTargetOp: bigint | null = null;
1258
+ const boundarySurvivors: BucketDataDoc[] = [];
1259
+ const inputStats = emptyBucketStats();
1260
+
1261
+ for await (const doc of query.stream()) {
1262
+ docsRead++;
1263
+ if (docsRead > 2) {
1264
+ throw new ReplicationAssertionError(`Unexpected extra document before CLEAR boundary in bucket ${bucket}`);
1265
+ }
1266
+
1267
+ const documentStats = statsForDocument(doc);
1268
+ inputStats.count += documentStats.count;
1269
+ inputStats.bytes += documentStats.bytes;
1270
+ inputStats.chunks += documentStats.chunks;
1271
+ inputStats.checksum = addChecksums(inputStats.checksum, documentStats.checksum);
1272
+
1273
+ const isBoundaryDoc = doc._id.o == boundaryDocId.o;
1274
+ if (doc.storage_ref) {
1275
+ oldStoragePaths.push(doc.storage_ref.path);
1276
+ }
1277
+ oldStorageBytes += ObjectStorageUsage.bytes(doc);
1278
+ await hydrateBucketDataDocuments([doc], this.storage.objectStorage, { signal: this.signal });
1279
+ maxTargetOp = maxOpId(maxTargetOp, doc.target_op);
1280
+ for (const op of loadBucketDataDocument(context, doc)) {
1281
+ if (!isBoundaryDoc && op.op != 'CLEAR') {
1282
+ throw new ReplicationAssertionError(
1283
+ `Unexpected ${op.op} operation before CLEAR boundary in bucket ${bucket}`
1284
+ );
1285
+ }
1286
+
1287
+ if (op.o <= lastNotPut) {
1288
+ if (op.op == 'PUT') {
1289
+ throw new ReplicationAssertionError(
1290
+ `Unexpected PUT at op ${op.o} in CLEAR region for bucket ${bucket}`
1291
+ );
1292
+ }
1293
+ combinedChecksum = addChecksums(combinedChecksum, Number(op.checksum));
1294
+ clearedOpCount++;
1295
+ } else if (isBoundaryDoc) {
1296
+ boundarySurvivors.push(op);
1297
+ } else {
1298
+ throw new ReplicationAssertionError(
1299
+ `Unexpected op ${op.o} after CLEAR boundary ${lastNotPut} in bucket ${bucket}`
1300
+ );
1301
+ }
1302
+ }
1303
+ }
1304
+
1305
+ if (clearedOpCount == 0) {
1306
+ throw new Error(`CLEAR boundary document not found for bucket ${bucket}`);
1307
+ }
1308
+
1309
+ this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastNotPut}`);
1310
+ await collection.deleteMany(
1311
+ {
1312
+ _id: {
1313
+ $gte: bucketContext.minId,
1314
+ $lte: boundaryDocId
1315
+ }
1316
+ },
1317
+ { session }
1318
+ );
1319
+
1320
+ const clearOp = {
1321
+ bucketKey: { ...context, bucket },
1322
+ o: lastNotPut,
1323
+ op: 'CLEAR' as const,
1324
+ checksum: BigInt(combinedChecksum),
1325
+ data: null
1326
+ } satisfies BucketDataDoc;
1327
+ const chunks: BucketDataDoc[][] = [[clearOp]];
1328
+ if (boundarySurvivors.length > 0) {
1329
+ // These operations are a subset of one existing document, so keeping
1330
+ // them together cannot increase its stored ops payload.
1331
+ chunks.push(boundarySurvivors);
1332
+ }
1333
+ const persisted = await this.persistBucketData(bucket, chunks, context, prepared, {
1334
+ targetOp: maxTargetOp ?? undefined
1335
+ });
1336
+ await collection.insertMany(persisted.documents, { session });
1337
+ await this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, session);
1338
+ await this.recordObjectStorageReplacement(oldStorageBytes, persisted.documents, context.definitionId, session);
1339
+
1340
+ opCountDiff = -clearedOpCount + 1;
1341
+ before = inputStats;
1342
+ after = statsForDocuments(persisted.documents);
1343
+ },
1344
+ {
1345
+ writeConcern: { w: 'majority' },
1346
+ readConcern: { level: 'snapshot' }
1347
+ }
1348
+ );
1349
+
1350
+ return { opCountDiff, before, after };
1351
+ }
1352
+
1353
+ /**
1354
+ * Reserve stable object paths before starting a retryable MongoDB transaction.
1355
+ * Each retry can safely overwrite the same paths, while the pre-existing
1356
+ * deletion markers remain visible to the transaction that publishes them.
1357
+ *
1358
+ * CLEAR compaction reserves its maximum output count. Unused markers remain
1359
+ * pending so they can clean up a path that an earlier transaction attempt may
1360
+ * have uploaded before retrying with fewer output documents.
1361
+ */
1362
+ private async prepareCompactionUploads(
86
1363
  bucket: string,
87
- definitionId: BucketDefinitionId | null
88
- ): Promise<SingleBucketStore | null> {
89
- if (definitionId == null) {
90
- // Not the _most_ efficient approach, but this is not used often
91
- const allDefinitionIds = this.storage.mapping.allBucketDefinitionIds();
92
- if (allDefinitionIds.length == 0) {
93
- return null;
94
- }
95
- const potentialIds = allDefinitionIds.map((definitionId) => ({ d: definitionId, b: bucket }));
96
- const bucketState = await this.db.bucketStateV3(this.group_id).findOne({
97
- _id: { $in: potentialIds }
98
- });
99
- if (bucketState == null) {
100
- return null;
1364
+ context: { replicationStreamId: number; definitionId: string },
1365
+ opIdHints: bigint[]
1366
+ ): Promise<PreparedObjectStorageUpload[]> {
1367
+ if (!this.storage.objectStorage) {
1368
+ return [];
1369
+ }
1370
+
1371
+ const lifecycle = this.objectStorageLifecycle;
1372
+ const paths = opIdHints.map((opIdHint) => lifecycle.allocatePath(context.definitionId, bucket, opIdHint, opIdHint));
1373
+ return lifecycle.prepareUploads(paths);
1374
+ }
1375
+
1376
+ /** Publish replacement uploads and retire superseded objects in the same transaction. */
1377
+ private async finishObjectStorageReplacement(
1378
+ oldStoragePaths: Iterable<string>,
1379
+ newStoragePaths: Set<string>,
1380
+ uploads: PreparedObjectStorageUpload[],
1381
+ session: mongo.ClientSession
1382
+ ): Promise<void> {
1383
+ if (!this.storage.objectStorage) {
1384
+ return;
1385
+ }
1386
+ await this.objectStorageLifecycle.publishUploads(uploads, session);
1387
+ await this.objectStorageLifecycle.retire(
1388
+ Array.from(oldStoragePaths).filter((path) => !newStoragePaths.has(path)),
1389
+ session
1390
+ );
1391
+ }
1392
+
1393
+ private async recordObjectStorageReplacement(
1394
+ oldBytes: bigint,
1395
+ newDocuments: Iterable<Pick<BucketDataDocumentV3, 'storage_ref'>>,
1396
+ definitionId: BucketDefinitionId,
1397
+ session: mongo.ClientSession
1398
+ ): Promise<void> {
1399
+ if (!this.storage.objectStorage) {
1400
+ return;
1401
+ }
1402
+ let newBytes = 0n;
1403
+ for (const document of newDocuments) {
1404
+ newBytes += ObjectStorageUsage.bytes(document);
1405
+ }
1406
+ await this.objectStorageUsage.applyDelta(definitionId, newBytes - oldBytes, session);
1407
+ }
1408
+
1409
+ private async persistBucketData(
1410
+ bucket: string,
1411
+ chunks: BucketDataDoc[][],
1412
+ context: { replicationStreamId: number; definitionId: string },
1413
+ preparedUploads?: PreparedObjectStorageUpload[],
1414
+ options?: { targetOp?: InternalOpId | null }
1415
+ ): Promise<{ documents: BucketDataDocumentV3[]; storagePaths: Set<string>; uploads: PreparedObjectStorageUpload[] }> {
1416
+ const serializedChunks = chunks.map((chunk) => serializeBucketData(bucket, chunk, options));
1417
+ if (!this.storage.objectStorage) {
1418
+ return {
1419
+ documents: serializedChunks,
1420
+ storagePaths: new Set(),
1421
+ uploads: []
1422
+ };
1423
+ }
1424
+
1425
+ const store = new BucketDataObjectStorage(this.storage.objectStorage);
1426
+ const storagePaths = new Set<string>();
1427
+ const lifecycle = this.objectStorageLifecycle;
1428
+ // Base placement on the final compacted size. Unchanged documents are not
1429
+ // rewritten, while small MOVE/merge results and CLEAR ops stay inline.
1430
+ const storedIndexes = serializedChunks.flatMap((document, index) =>
1431
+ document.size > this.storage.inlineThresholdBytes ? [index] : []
1432
+ );
1433
+ const uploadsByIndex = new Map<number, PreparedObjectStorageUpload>();
1434
+
1435
+ if (preparedUploads) {
1436
+ for (const index of storedIndexes) {
1437
+ const upload = preparedUploads[index];
1438
+ if (!upload) {
1439
+ throw new ServiceAssertionError(
1440
+ `Missing prepared object storage path for compacted document at index ${index}`
1441
+ );
1442
+ }
1443
+ uploadsByIndex.set(index, upload);
101
1444
  }
102
- definitionId = bucketState._id.d;
1445
+ } else {
1446
+ const paths = storedIndexes.map((index) => {
1447
+ const chunk = chunks[index];
1448
+ return lifecycle.allocatePath(context.definitionId, bucket, chunk[0].o, chunk[chunk.length - 1].o);
1449
+ });
1450
+ const prepared = await lifecycle.prepareUploads(paths);
1451
+ storedIndexes.forEach((index, preparedIndex) => uploadsByIndex.set(index, prepared[preparedIndex]));
103
1452
  }
104
1453
 
105
- return new SingleBucketStoreV3(this.db, { bucket, definitionId, replicationStreamId: this.group_id });
1454
+ // S3ObjectStorage applies one shared concurrency limit across all callers,
1455
+ // so compaction can schedule its uploads together without creating a
1456
+ // separate limiter here.
1457
+ const documents = await Promise.all(
1458
+ serializedChunks.map(async (serialized, index) => {
1459
+ const upload = uploadsByIndex.get(index);
1460
+ if (!upload) {
1461
+ return serialized;
1462
+ }
1463
+
1464
+ const { ops, ...metadata } = serialized;
1465
+ const { fileSize } = await store.store(upload.path, ops!, { signal: this.signal });
1466
+ storagePaths.add(upload.path);
1467
+ return {
1468
+ ...metadata,
1469
+ storage_ref: { path: upload.path, file_size: fileSize }
1470
+ };
1471
+ })
1472
+ );
1473
+
1474
+ return { documents, storagePaths, uploads: Array.from(uploadsByIndex.values()) };
106
1475
  }
107
1476
  }