@powersync/service-module-mongodb-storage 0.0.0-dev-20260803144812 → 0.0.0-dev-20260929095716

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 (213) hide show
  1. package/CHANGELOG.md +129 -7
  2. package/dist/storage/MongoBucketStorage.d.ts +42 -5
  3. package/dist/storage/MongoBucketStorage.js +404 -215
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +39 -14
  6. package/dist/storage/implementation/MongoBucketBatch.js +169 -98
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoCompactor.d.ts +21 -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/MongoIdSequence.d.ts +10 -1
  12. package/dist/storage/implementation/MongoIdSequence.js +14 -1
  13. package/dist/storage/implementation/MongoIdSequence.js.map +1 -1
  14. package/dist/storage/implementation/MongoOpIdAllocator.d.ts +27 -0
  15. package/dist/storage/implementation/MongoOpIdAllocator.js +97 -0
  16. package/dist/storage/implementation/MongoOpIdAllocator.js.map +1 -0
  17. package/dist/storage/implementation/MongoParameterCompactor.d.ts +83 -10
  18. package/dist/storage/implementation/MongoParameterCompactor.js +309 -96
  19. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  20. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +9 -1
  21. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +35 -2
  22. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
  23. package/dist/storage/implementation/MongoPersistedReplicationStream.d.ts +2 -1
  24. package/dist/storage/implementation/MongoPersistedReplicationStream.js +7 -4
  25. package/dist/storage/implementation/MongoPersistedReplicationStream.js.map +1 -1
  26. package/dist/storage/implementation/MongoPersistedSyncConfigContent.d.ts +3 -3
  27. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +10 -8
  28. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -1
  29. package/dist/storage/implementation/MongoStorageProvider.js +8 -4
  30. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  31. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +32 -11
  32. package/dist/storage/implementation/MongoSyncBucketStorage.js +95 -31
  33. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  34. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +26 -2
  35. package/dist/storage/implementation/MongoSyncRulesLock.js +59 -10
  36. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  37. package/dist/storage/implementation/MongoWriteBatch.d.ts +49 -0
  38. package/dist/storage/implementation/MongoWriteBatch.js +149 -0
  39. package/dist/storage/implementation/MongoWriteBatch.js.map +1 -0
  40. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +9 -7
  41. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +9 -30
  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 +22 -2
  47. package/dist/storage/implementation/common/PersistedBatch.d.ts +32 -4
  48. package/dist/storage/implementation/common/PersistedBatch.js +57 -14
  49. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  50. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.d.ts +3 -0
  51. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +3 -0
  52. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  53. package/dist/storage/implementation/db.d.ts +9 -1
  54. package/dist/storage/implementation/db.js +28 -1
  55. package/dist/storage/implementation/db.js.map +1 -1
  56. package/dist/storage/implementation/models.d.ts +35 -0
  57. package/dist/storage/implementation/models.js.map +1 -1
  58. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +11 -0
  59. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +227 -221
  60. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  61. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +38 -6
  62. package/dist/storage/implementation/v1/MongoCompactorV1.js +247 -20
  63. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  64. package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
  65. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
  66. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
  67. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +18 -11
  68. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +66 -17
  69. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  70. package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +7 -5
  71. package/dist/storage/implementation/v1/PersistedBatchV1.js +22 -28
  72. package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
  73. package/dist/storage/implementation/v1/models.js +1 -0
  74. package/dist/storage/implementation/v1/models.js.map +1 -1
  75. package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
  76. package/dist/storage/implementation/v3/CompactionLease.js +131 -0
  77. package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
  78. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +14 -2
  79. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +261 -194
  80. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  81. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +83 -25
  82. package/dist/storage/implementation/v3/MongoCompactorV3.js +702 -159
  83. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  84. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +18 -0
  85. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +23 -0
  86. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -0
  87. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +2 -15
  88. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +60 -92
  89. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  90. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +19 -9
  91. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +92 -32
  92. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  93. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
  94. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
  95. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
  96. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +14 -8
  97. package/dist/storage/implementation/v3/PersistedBatchV3.js +77 -45
  98. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  99. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +12 -2
  100. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +42 -3
  101. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  102. package/dist/storage/implementation/v3/bucket-format.js +2 -0
  103. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  104. package/dist/storage/implementation/v3/compact-utils.d.ts +108 -0
  105. package/dist/storage/implementation/v3/compact-utils.js +223 -0
  106. package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
  107. package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
  108. package/dist/storage/implementation/v3/compaction-constants.js +6 -0
  109. package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
  110. package/dist/storage/implementation/v3/models.d.ts +90 -6
  111. package/dist/storage/implementation/v3/models.js.map +1 -1
  112. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +7 -8
  113. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +16 -5
  114. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  115. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +14 -11
  116. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +6 -8
  117. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +17 -11
  118. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  119. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +38 -0
  120. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +164 -0
  121. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
  122. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +37 -12
  123. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +197 -25
  124. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
  125. package/dist/storage/implementation/v3/source-table-utils.d.ts +15 -7
  126. package/dist/storage/implementation/v3/source-table-utils.js +49 -57
  127. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
  128. package/dist/storage/storage-index.d.ts +2 -1
  129. package/dist/storage/storage-index.js +1 -0
  130. package/dist/storage/storage-index.js.map +1 -1
  131. package/dist/types/types.d.ts +28 -1
  132. package/dist/types/types.js +28 -7
  133. package/dist/types/types.js.map +1 -1
  134. package/dist/utils/test-utils.d.ts +4 -2
  135. package/dist/utils/test-utils.js +7 -4
  136. package/dist/utils/test-utils.js.map +1 -1
  137. package/dist/utils/util.js +3 -1
  138. package/dist/utils/util.js.map +1 -1
  139. package/package.json +10 -9
  140. package/src/storage/MongoBucketStorage.ts +545 -277
  141. package/src/storage/implementation/MongoBucketBatch.ts +258 -124
  142. package/src/storage/implementation/MongoCompactor.ts +37 -346
  143. package/src/storage/implementation/MongoIdSequence.ts +21 -1
  144. package/src/storage/implementation/MongoOpIdAllocator.ts +105 -0
  145. package/src/storage/implementation/MongoParameterCompactor.ts +388 -102
  146. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +46 -1
  147. package/src/storage/implementation/MongoPersistedReplicationStream.ts +12 -4
  148. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +48 -29
  149. package/src/storage/implementation/MongoStorageProvider.ts +16 -4
  150. package/src/storage/implementation/MongoSyncBucketStorage.ts +133 -40
  151. package/src/storage/implementation/MongoSyncRulesLock.ts +78 -12
  152. package/src/storage/implementation/MongoWriteBatch.ts +172 -0
  153. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +16 -46
  154. package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
  155. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +24 -2
  156. package/src/storage/implementation/common/PersistedBatch.ts +76 -20
  157. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +4 -0
  158. package/src/storage/implementation/db.ts +36 -2
  159. package/src/storage/implementation/models.ts +39 -0
  160. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +272 -254
  161. package/src/storage/implementation/v1/MongoCompactorV1.ts +327 -40
  162. package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
  163. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +88 -28
  164. package/src/storage/implementation/v1/PersistedBatchV1.ts +25 -30
  165. package/src/storage/implementation/v1/models.ts +1 -0
  166. package/src/storage/implementation/v3/CompactionLease.ts +152 -0
  167. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +339 -247
  168. package/src/storage/implementation/v3/MongoCompactorV3.ts +955 -225
  169. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +34 -0
  170. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +100 -109
  171. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +112 -42
  172. package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
  173. package/src/storage/implementation/v3/PersistedBatchV3.ts +94 -51
  174. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +55 -4
  175. package/src/storage/implementation/v3/bucket-format.ts +2 -0
  176. package/src/storage/implementation/v3/compact-utils.ts +329 -0
  177. package/src/storage/implementation/v3/compaction-constants.ts +5 -0
  178. package/src/storage/implementation/v3/models.ts +97 -4
  179. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +24 -9
  180. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +20 -5
  181. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +26 -14
  182. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +229 -0
  183. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +279 -29
  184. package/src/storage/implementation/v3/source-table-utils.ts +82 -76
  185. package/src/storage/storage-index.ts +2 -0
  186. package/src/types/types.ts +42 -7
  187. package/src/utils/test-utils.ts +9 -5
  188. package/src/utils/util.ts +3 -1
  189. package/test/src/__snapshots__/storage.test.ts.snap +25 -0
  190. package/test/src/__snapshots__/storage_sync.test.ts.snap +794 -205
  191. package/test/src/chunk_compaction_config.test.ts +38 -0
  192. package/test/src/cleanup-stopped-sync-configs.test.ts +59 -26
  193. package/test/src/compact-utils.test.ts +251 -0
  194. package/test/src/concurrent_writers.test.ts +599 -0
  195. package/test/src/connection_config.test.ts +159 -0
  196. package/test/src/helpers/MemoryObjectStorage.ts +14 -6
  197. package/test/src/helpers/concurrentWriter.mjs +29 -0
  198. package/test/src/helpers/s3TestFactory.ts +12 -12
  199. package/test/src/object_storage_usage.test.ts +268 -0
  200. package/test/src/parameter_compacting_v1.test.ts +120 -0
  201. package/test/src/parameter_compacting_v3.test.ts +192 -0
  202. package/test/src/parameter_compaction_fence.test.ts +276 -0
  203. package/test/src/storage.test.ts +280 -24
  204. package/test/src/storage_compacting.test.ts +1097 -253
  205. package/test/src/storage_object_storage_inline_threshold.test.ts +3 -3
  206. package/test/src/storage_s3_checksums.test.ts +6 -7
  207. package/test/src/storage_s3_compaction_lifecycle.test.ts +143 -17
  208. package/test/src/storage_s3_reading.test.ts +294 -6
  209. package/test/src/storage_s3_writing.test.ts +4 -3
  210. package/test/src/storage_sync.test.ts +412 -80
  211. package/test/src/storeCurrentData.test.ts +5 -5
  212. package/test/src/util.ts +1 -1
  213. package/tsconfig.tsbuildinfo +1 -1
@@ -1,236 +1,863 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
- import { logger, ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
3
- import { addChecksums, InternalOpId, storage, utils } from '@powersync/service-core';
2
+ import { ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
3
+ import { acquireSemaphoreAbortable, addChecksums, formatBytes, InternalOpId, utils } from '@powersync/service-core';
4
4
  import { BucketDefinitionId } from '@powersync/service-sync-rules';
5
+ import { setImmediate } from 'node:timers/promises';
5
6
  import { BucketDataDoc } from '../common/BucketDataDoc.js';
6
- import { BucketDataKey, BucketStateDocumentBase } from '../models.js';
7
- import { ConcurrentCompactionError, DirtyBucket, MongoCompactor } from '../MongoCompactor.js';
7
+ import { BucketDataKey } from '../models.js';
8
+ import { ConcurrentCompactionError, MongoCompactOptions, MongoCompactor } from '../MongoCompactor.js';
9
+ import { MongoWriteBatch } from '../MongoWriteBatch.js';
8
10
  import { cacheKey } from '../OperationBatch.js';
9
11
  import { loadBucketDataDocument, maxOpId, serializeBucketData } from './bucket-format.js';
10
12
  import { BucketDataContextV3 } from './BucketDataContextV3.js';
11
13
  import { DEFAULT_MAX_DOC_SIZE_BYTES } from './chunking.js';
14
+ import {
15
+ applyStatsReplacement,
16
+ bucketStats,
17
+ BucketStatsWithChecksum,
18
+ chooseCompactionKind,
19
+ chooseRequestedCompactionKind,
20
+ combineAdjacentStats,
21
+ combineChunkStats,
22
+ CompactIntervalConfig,
23
+ CompactionContext,
24
+ CompactionDecision,
25
+ CompactionKind,
26
+ CompactionResult,
27
+ CompactTargetConfig,
28
+ emptyBucketStats,
29
+ firstUncompactedWrite,
30
+ PendingCompactionGroup,
31
+ readCompactionBatch,
32
+ ScheduledCompactionOptions,
33
+ statsForDocument,
34
+ statsForDocuments,
35
+ unclaimedSnapshotFilter
36
+ } from './compact-utils.js';
37
+ import { DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS } from './compaction-constants.js';
38
+ import { AVAILABLE_LEASE_EXPR, CompactionLease } from './CompactionLease.js';
12
39
  import { BucketDataDocumentV3, BucketStateDocumentV3 } from './models.js';
13
- import { DefinitionChecksumOperations, MongoChecksumsV3 } from './MongoChecksumsV3.js';
14
40
  import type { MongoSyncBucketStorageV3 } from './MongoSyncBucketStorageV3.js';
15
41
  import { BucketDataObjectStorage, hydrateBucketDataDocuments } from './object-storage/BucketDataObjectStorage.js';
16
42
  import { ObjectStorageLifecycle, PreparedObjectStorageUpload } from './object-storage/ObjectStorageLifecycle.js';
43
+ import { createObjectStorageUsageWriterId, ObjectStorageUsage } from './object-storage/ObjectStorageUsage.js';
17
44
  import { VersionedPowerSyncMongoV3 } from './VersionedPowerSyncMongoV3.js';
18
45
 
19
- interface PendingCompactionGroup {
20
- /**
21
- * Input documents are ordered from oldest to newest, matching `ops`.
22
- * Keeping the inputs intact lets unchanged singletons retain their object.
23
- */
24
- inputs: BucketDataDocumentV3[];
25
- ops: BucketDataDoc[];
26
- changed: boolean;
27
- targetOp: InternalOpId | null;
28
- }
46
+ const DEFAULT_MIN_COMPACT_FULL_INTERVAL_MS = 2 * 60 * 60 * 1000;
47
+ const DEFAULT_MAX_COMPACT_FULL_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
48
+ const DEFAULT_COMPACT_LEASE_DURATION_MS = 10 * 60 * 1000;
49
+ const SCHEDULED_COMPACTION_BATCH_SIZE = 100;
29
50
 
30
- /**
31
- * Read one bounded prefix from a descending compaction cursor.
32
- *
33
- * The document that would cross the byte limit is deliberately not returned:
34
- * pagination resumes below the last returned `_id`, so that document remains
35
- * eligible for the next query. The first document is always accepted to ensure
36
- * progress when a single document exceeds the configured byte limit.
37
- *
38
- * `hasMore` is conservative when the document limit is reached. An extra empty
39
- * query is preferable to exhausting the cursor just to determine whether the
40
- * limited MongoDB query contained another document.
41
- */
42
- async function readCompactionBatch(
43
- cursor: mongo.AggregationCursor<BucketDataDocumentV3>,
44
- options: { byteLimit: number; documentLimit: number }
45
- ): Promise<{ documents: BucketDataDocumentV3[]; hasMore: boolean }> {
46
- const documents: BucketDataDocumentV3[] = [];
47
- let cumulativeBytes = 0;
48
-
49
- try {
50
- for await (const document of cursor) {
51
- if (documents.length > 0 && cumulativeBytes + document.size > options.byteLimit) {
52
- return { documents, hasMore: true };
53
- }
51
+ interface CompactionGroupResult {
52
+ documentId: BucketDataKey;
53
+ stats: BucketStatsWithChecksum;
54
+ }
54
55
 
55
- documents.push(document);
56
- cumulativeBytes += document.size;
56
+ interface CompactionStatsReplacement {
57
+ before: BucketStatsWithChecksum;
58
+ after: BucketStatsWithChecksum;
59
+ }
57
60
 
58
- if (documents.length >= options.documentLimit) {
59
- return { documents, hasMore: true };
60
- }
61
- }
62
- return { documents, hasMore: false };
63
- } finally {
64
- await cursor.close();
65
- }
61
+ interface ClearCompactionResult extends CompactionStatsReplacement {
62
+ opCountDiff: number;
66
63
  }
67
64
 
68
- export class MongoCompactorV3 extends MongoCompactor {
65
+ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalConfig, CompactTargetConfig {
69
66
  declare protected readonly db: VersionedPowerSyncMongoV3;
70
67
  declare protected readonly storage: MongoSyncBucketStorageV3;
71
68
 
72
- override async compact(): Promise<void> {
69
+ readonly minCompactChunkIntervalMs: number;
70
+ readonly minCompactFullIntervalMs: number;
71
+ readonly maxCompactFullIntervalMs: number;
72
+ readonly compactLeaseDurationMs: number;
73
+ readonly maxOpIdCap: InternalOpId | undefined;
74
+ readonly forceChunkCompaction: boolean;
75
+ private readonly objectStorageUsage: ObjectStorageUsage;
76
+
77
+ constructor(bucketStorage: MongoSyncBucketStorageV3, db: VersionedPowerSyncMongoV3, options: MongoCompactOptions) {
78
+ super(bucketStorage, db, options);
79
+ this.minCompactChunkIntervalMs = options.minCompactChunkIntervalMs ?? DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS;
80
+ this.minCompactFullIntervalMs = options.minCompactFullIntervalMs ?? DEFAULT_MIN_COMPACT_FULL_INTERVAL_MS;
81
+ this.maxCompactFullIntervalMs = options.maxCompactFullIntervalMs ?? DEFAULT_MAX_COMPACT_FULL_INTERVAL_MS;
82
+ this.compactLeaseDurationMs = options.compactLeaseDurationMs ?? DEFAULT_COMPACT_LEASE_DURATION_MS;
83
+ this.maxOpIdCap = options.maxOpId;
84
+ this.forceChunkCompaction = options.forceChunkCompaction ?? false;
85
+ this.objectStorageUsage = new ObjectStorageUsage(this.db, this.group_id, createObjectStorageUsageWriterId());
86
+ }
87
+
88
+ override async compact(): Promise<number> {
73
89
  if (this.storage.objectStorage) {
74
90
  // Clean these before compacting - should be quick in most cases.
75
91
  try {
76
- await this.objectStorageLifecycle.cleanup(this.logger);
92
+ await this.objectStorageLifecycle.cleanup(this.logger, { signal: this.signal });
77
93
  } catch (e) {
94
+ if (this.signal?.aborted) {
95
+ throw e;
96
+ }
78
97
  // In this case, still continue normal compact process
79
98
  this.logger.error(`Failed to clean up object storage deletion markers before compaction`, e);
80
99
  }
81
100
  }
82
- await super.compact();
101
+ await this.deleteOldCheckpointRequests();
102
+
103
+ if (this.buckets != null) {
104
+ await this.compactExplicitBuckets(this.buckets);
105
+ } else if (this.compactChunksOnly) {
106
+ // Writers defer their first chunk-compaction check by this fixed default.
107
+ // Include that interval so this synchronous initial-replication pass
108
+ // processes the work that existed when it started.
109
+ await this.compactScheduledBuckets({
110
+ dueAheadMs: DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS,
111
+ requestedKind: CompactionKind.Chunks
112
+ });
113
+ } else {
114
+ await this.compactScheduledBuckets();
115
+ }
83
116
  if (this.storage.objectStorage) {
84
117
  // Cleanup for any produced during compacting.
85
118
  // Note that markers only expire after a delay, so this may skip many produced during this compact
86
119
  // run. However, during long compact runs, this may also have many ones it can clean up.
87
- await this.objectStorageLifecycle.cleanup(this.logger);
120
+ await this.objectStorageLifecycle.cleanup(this.logger, { signal: this.signal });
121
+ await this.objectStorageUsage.foldStaleWriterDeltas();
88
122
  }
123
+ return this.compactedBucketCount;
89
124
  }
90
125
 
91
- private get objectStorageLifecycle(): ObjectStorageLifecycle {
92
- if (!this.storage.objectStorage) {
93
- throw new Error('Object storage is not configured');
126
+ protected override async deleteOldCustomCheckpointRequests() {
127
+ if (this.deleteCheckpointRequestsBefore == null) {
128
+ return;
129
+ }
130
+
131
+ for (const collection of await this.db.listCustomCheckpointRequestCollections(this.group_id)) {
132
+ this.signal?.throwIfAborted();
133
+ await collection.deleteMany({
134
+ checkpoint_requested_at: { $exists: true, $lt: this.deleteCheckpointRequestsBefore }
135
+ });
94
136
  }
95
- return new ObjectStorageLifecycle(this.db, this.group_id, this.storage.objectStorage);
96
137
  }
97
138
 
98
- public async *dirtyBucketBatches(options: {
99
- minBucketChanges: number;
100
- minChangeRatio: number;
101
- }): AsyncGenerator<DirtyBucket[]> {
102
- if (options.minBucketChanges <= 0) {
103
- throw new ReplicationAssertionError('minBucketChanges must be >= 1');
104
- }
105
- const collection = this.db.bucketState(this.group_id) as unknown as mongo.Collection<BucketStateDocumentBase>;
106
- yield* this.dirtyBucketBatchesForCollection(
107
- collection,
108
- { d: new mongo.MinKey(), b: new mongo.MinKey() } as unknown as BucketStateDocumentV3['_id'],
109
- { d: new mongo.MaxKey(), b: new mongo.MaxKey() } as unknown as BucketStateDocumentV3['_id'],
110
- options,
111
- (bucketState) => (bucketState as BucketStateDocumentV3)._id.d
139
+ /** An explicit compact request always runs a full compact for its buckets. */
140
+ private async compactExplicitBuckets(buckets: string[]) {
141
+ for (const bucket of buckets) {
142
+ // This is not a super efficient query, but this is not a common use case.
143
+ // May be optimized later.
144
+ const states = await this.db
145
+ .bucketState(this.group_id)
146
+ .find({ '_id.b': bucket }, { projection: { _id: 1 } })
147
+ .toArray();
148
+ for (const state of states) {
149
+ await using lease = await this.claimBucket({ _id: state._id });
150
+ if (lease == null || lease.state.first_uncompacted_write == null) {
151
+ continue;
152
+ }
153
+ if (this.isCompactionTargetCovered(lease.state, CompactionKind.Full)) {
154
+ continue;
155
+ }
156
+ const decision = chooseCompactionKind(lease.state, lease.startedAt, this);
157
+ await this.compactClaimedBucket(lease, CompactionKind.Full, decision);
158
+ }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Process scheduled work in bounded batches.
164
+ *
165
+ * Batching specifically help to cover cases of many buckets where no compaction is required:
166
+ * Instead of sequentially claiming and then rescheduling a bucket, this handles it in bulk.
167
+ *
168
+ * Chunk merges overlap a bounded number of buckets. Full compaction stays
169
+ * sequential because its working set includes operation deduplication state.
170
+ *
171
+ * Any concurrent workers may read the same batch. Rescheduling filters out buckets handled
172
+ * by a concurrent worker or replication write, while buckets that do need compaction are
173
+ * filtered out when claiming a compaction lease.
174
+ *
175
+ * We filter scheduled jobs by the job start date, so that the same bucket is not compacted
176
+ * multiple times in one run. Reschedules fall beyond the fixed boundary. For the run after
177
+ * initial replication, dueAheadMs extends that boundary to include the first deferred interval.
178
+ */
179
+ private async compactScheduledBuckets(options: ScheduledCompactionOptions = {}) {
180
+ // Writers derive next_compact_check from MongoDB's $$NOW. Use the same
181
+ // clock for the fixed job boundary so clock skew cannot exclude work at
182
+ // the exact initial-replication interval.
183
+ const jobStartedAt = await this.readCompactionTime();
184
+ const dueBefore = new Date(jobStartedAt.getTime() + (options.dueAheadMs ?? 0));
185
+ const requestedKind = options.requestedKind;
186
+ const rescheduleNotBefore = new Date(dueBefore.getTime() + 1);
187
+ // Keep accounting documents bounded by workers, not buckets or scan batches.
188
+ const workerUsage = Array.from(
189
+ { length: this.storage.factory.chunkCompactionConcurrency },
190
+ () => new ObjectStorageUsage(this.db, this.group_id, createObjectStorageUsageWriterId())
112
191
  );
192
+ while (true) {
193
+ this.signal?.throwIfAborted();
194
+ const states = await this.findScheduledBucketBatch(dueBefore);
195
+ if (states.length == 0) {
196
+ break;
197
+ }
198
+ // Keep eligibility bounded by dueBefore, but classify with the current
199
+ // server time so buckets that age into full compaction can advance.
200
+ const batchStartedAt = await this.readCompactionTime();
201
+
202
+ const scheduled: {
203
+ state: BucketStateDocumentV3;
204
+ decision: CompactionDecision;
205
+ selectedKind: CompactionKind | null;
206
+ }[] = [];
207
+ for (const state of states) {
208
+ try {
209
+ scheduled.push({
210
+ state,
211
+ decision: chooseCompactionKind(state, batchStartedAt, this),
212
+ selectedKind: chooseRequestedCompactionKind(state, requestedKind, this)
213
+ });
214
+ } catch (error) {
215
+ await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
216
+ }
217
+ }
218
+ const noOpStates = scheduled.filter(
219
+ ({ state, decision, selectedKind }) =>
220
+ state.compact_lease == null && (requestedKind == null ? decision.kind : selectedKind) == null
221
+ );
222
+ await this.rescheduleUnclaimedBuckets(noOpStates, rescheduleNotBefore);
223
+
224
+ const processBucket = async (
225
+ { state, decision, selectedKind }: (typeof scheduled)[number],
226
+ objectStorageUsage: ObjectStorageUsage,
227
+ chunksOnly = false
228
+ ) => {
229
+ const kind = requestedKind == null ? decision.kind : selectedKind;
230
+ if (state.compact_lease == null && kind == null) {
231
+ return;
232
+ }
233
+
234
+ try {
235
+ await using lease = await this.claimBucket({ _id: state._id, next_compact_check: { $lte: dueBefore } });
236
+ if (lease == null) {
237
+ return;
238
+ }
239
+ const claimedDecision = chooseCompactionKind(lease.state, lease.startedAt, this);
240
+ const claimedKind =
241
+ requestedKind == null
242
+ ? claimedDecision.kind
243
+ : chooseRequestedCompactionKind(lease.state, requestedKind, this);
244
+ if (chunksOnly && claimedKind === CompactionKind.Full) {
245
+ // The decision changed after scanning. Release the lease without
246
+ // rescheduling; the next batch will classify it with a fresh timestamp.
247
+ return;
248
+ }
249
+ if (claimedKind == null) {
250
+ await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
251
+ } else if (this.isCompactionTargetCovered(lease.state, claimedKind)) {
252
+ // The run cannot advance this kind's watermark without regressing
253
+ // already-published progress. Keep any newer work scheduled.
254
+ await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
255
+ } else {
256
+ await this.compactClaimedBucket(
257
+ lease,
258
+ claimedKind,
259
+ claimedDecision,
260
+ rescheduleNotBefore,
261
+ objectStorageUsage
262
+ );
263
+ }
264
+ } catch (error) {
265
+ if (this.signal?.aborted) {
266
+ // When aborted, stop completely, rather than logging and re-scheduling individual buckets.
267
+ // The lease on the current bucket is still released automatically.
268
+ throw error;
269
+ }
270
+ await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
271
+ }
272
+ };
273
+
274
+ const chunkBuckets = scheduled.filter(
275
+ ({ decision, selectedKind }) => (requestedKind == null ? decision.kind : selectedKind) === CompactionKind.Chunks
276
+ );
277
+ const sequentialBuckets = scheduled.filter(
278
+ ({ decision, selectedKind }) => (requestedKind == null ? decision.kind : selectedKind) !== CompactionKind.Chunks
279
+ );
280
+ await this.runChunkCompactionWorkers(chunkBuckets, workerUsage, (entry, usage) =>
281
+ processBucket(entry, usage, true)
282
+ );
283
+ // Full compaction cannot overlap chunk workers from this job, and only
284
+ // one full bucket is processed at a time.
285
+ for (const entry of sequentialBuckets) {
286
+ await processBucket(entry, this.objectStorageUsage);
287
+ }
288
+ }
289
+ }
290
+
291
+ /** Use MongoDB's clock, matching scheduling and lease timestamps. */
292
+ private async readCompactionTime(): Promise<Date> {
293
+ const [{ now }] = await this.db.db
294
+ .aggregate<{ now: Date }>([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
295
+ .toArray();
296
+ return now;
113
297
  }
114
298
 
115
- public async dirtyBucketBatchForChecksums(options: { minBucketChanges: number }): Promise<DirtyBucket[]> {
116
- if (options.minBucketChanges <= 0) {
117
- throw new ReplicationAssertionError('minBucketChanges must be >= 1');
299
+ /** Process one scheduled batch with a fixed pool of workers. */
300
+ private async runChunkCompactionWorkers<T>(
301
+ buckets: readonly T[],
302
+ workerUsage: readonly ObjectStorageUsage[],
303
+ processBucket: (bucket: T, usage: ObjectStorageUsage) => Promise<void>
304
+ ): Promise<void> {
305
+ const signal = this.signal;
306
+ let nextBucket = 0;
307
+ let failed = false;
308
+
309
+ const runWorker = async (usage: ObjectStorageUsage) => {
310
+ try {
311
+ while (!failed && nextBucket < buckets.length) {
312
+ // Taking an entry has no await, so each worker gets a different bucket.
313
+ // A worker takes another only after finishing its current bucket.
314
+ const bucket = buckets[nextBucket++];
315
+
316
+ // This pool bounds one job; the factory semaphore bounds all jobs together.
317
+ // Acquire before claiming the bucket lease, and hold until it is released.
318
+ const acquired = await acquireSemaphoreAbortable(this.storage.factory.chunkCompactionSlots, signal);
319
+ if (acquired === 'aborted') {
320
+ signal?.throwIfAborted();
321
+ return;
322
+ }
323
+ const [, releaseSlot] = acquired;
324
+ try {
325
+ // A sibling may have failed while this worker waited for a slot.
326
+ if (failed) return;
327
+ signal?.throwIfAborted();
328
+ await processBucket(bucket, usage);
329
+ } finally {
330
+ releaseSlot();
331
+ }
332
+ // Let replication and other event-loop work run between buckets.
333
+ await setImmediate();
334
+ }
335
+ } catch (error) {
336
+ // Drain work already started, but do not let siblings start new buckets.
337
+ failed = true;
338
+ throw error;
339
+ }
340
+ };
341
+
342
+ // Concurrent transactions must not all increment the same usage document.
343
+ // Reuse one writer for each worker instead of creating one per bucket.
344
+ const workers = workerUsage.map(runWorker);
345
+
346
+ // Do not release the caller's replication lock or run cleanup while a
347
+ // sibling worker still owns a bucket lease or is finishing a replacement.
348
+ // Wait for every worker even on failure, then propagate the first error.
349
+ const results = await Promise.allSettled(workers);
350
+ for (const result of results) {
351
+ if (result.status === 'rejected') {
352
+ throw result.reason;
353
+ }
118
354
  }
119
- return this.dirtyBucketBatchForChecksumsForCollection(
120
- this.db.bucketState(this.group_id) as unknown as mongo.Collection<BucketStateDocumentBase>,
121
- {
122
- 'estimate_since_compact.count': { $gte: options.minBucketChanges }
123
- } as unknown as mongo.Filter<BucketStateDocumentBase>,
124
- (bucketState) => (bucketState as BucketStateDocumentV3)._id.d
125
- );
126
355
  }
127
356
 
128
- protected async writeBucketStateUpdates(): Promise<void> {
129
- await this.db
357
+ /** Read a bounded, priority-ordered snapshot of currently claimable scheduled work. */
358
+ private async findScheduledBucketBatch(dueBefore: Date): Promise<BucketStateDocumentV3[]> {
359
+ return this.db
130
360
  .bucketState(this.group_id)
131
- .bulkWrite(this.bucketStateUpdates as mongo.AnyBulkWriteOperation<BucketStateDocumentV3>[], {
132
- ordered: false
133
- });
361
+ .find({
362
+ next_compact_check: { $lte: dueBefore },
363
+ ...AVAILABLE_LEASE_EXPR
364
+ })
365
+ .sort({ next_compact_check: 1 })
366
+ .limit(SCHEDULED_COMPACTION_BATCH_SIZE)
367
+ .toArray();
134
368
  }
135
369
 
136
370
  /**
137
- * The compactor operates on persisted definition ids only - never on parsed sources.
138
- * This narrowed view makes the source-resolving checksum methods unreachable here.
371
+ * Reschedule snapshots that were already known to be no-ops without first
372
+ * taking a lease. Every decision input is compared so a concurrent writer
373
+ * or compactor simply makes the update a no-op instead of losing work. A
374
+ * successful reschedule moves beyond this run's fixed selection boundary.
139
375
  */
140
- private get definitionChecksums(): DefinitionChecksumOperations {
141
- return this.storage.checksums as MongoChecksumsV3;
376
+ private async rescheduleUnclaimedBuckets(
377
+ states: { state: BucketStateDocumentV3; decision: CompactionDecision }[],
378
+ notBefore: Date
379
+ ) {
380
+ if (states.length == 0) {
381
+ return;
382
+ }
383
+ await this.db.bucketState(this.group_id).bulkWrite(
384
+ states.map(({ state, decision }) => ({
385
+ updateOne: {
386
+ filter: unclaimedSnapshotFilter(state),
387
+ update: [{ $set: { next_compact_check: this.rescheduleAtOrAfter(decision.nextCompactCheck, notBefore) } }]
388
+ }
389
+ })),
390
+ { ordered: false }
391
+ );
142
392
  }
143
393
 
144
- protected async computeChecksumsForBuckets(
145
- buckets: Pick<DirtyBucket, 'bucket' | 'definitionId'>[]
146
- ): Promise<storage.PartialChecksumMap> {
147
- return this.definitionChecksums.computePartialChecksumsDirectByDefinition(
148
- buckets.map(({ bucket, definitionId }) => {
149
- if (definitionId == null) {
150
- throw new ServiceAssertionError(`Missing definitionId for bucket checksum update on bucket ${bucket}`);
151
- }
152
- return {
153
- bucket,
154
- definitionId,
155
- end: this.maxOpId
156
- };
157
- })
394
+ /**
395
+ * Isolate a malformed bucket so it cannot prevent other scheduled buckets
396
+ * from compacting. The snapshot filter preserves any concurrent write or
397
+ * compactor result instead of overwriting its next check.
398
+ */
399
+ private async rescheduleFailedBucket(state: BucketStateDocumentV3, notBefore: Date, error: unknown) {
400
+ this.logger.error(`Failed to compact scheduled bucket ${state._id.b}; rescheduling it`, error);
401
+ try {
402
+ await this.db
403
+ .bucketState(this.group_id)
404
+ .updateOne(unclaimedSnapshotFilter(state), [{ $set: { next_compact_check: notBefore } }]);
405
+ } catch (rescheduleError) {
406
+ this.logger.error(`Failed to reschedule bucket ${state._id.b} after a compaction error`, rescheduleError);
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Given a bucket filter, claim a lease on the bucket. The filter should include a filter on _id.
412
+ *
413
+ * Resolves to null if the bucket is already claimed, not found, or filtered out.
414
+ */
415
+ private async claimBucket(
416
+ filter: mongo.Filter<BucketStateDocumentV3>,
417
+ sort?: mongo.Sort
418
+ ): Promise<CompactionLease | null> {
419
+ return CompactionLease.claim(this.db.bucketState(this.group_id), filter, sort, this.compactLeaseDurationMs);
420
+ }
421
+
422
+ private async compactClaimedBucket(
423
+ lease: CompactionLease,
424
+ kind: CompactionKind,
425
+ decision: CompactionDecision,
426
+ rescheduleNotBefore?: Date,
427
+ objectStorageUsage = this.objectStorageUsage
428
+ ) {
429
+ const context = new CompactionContext(
430
+ lease,
431
+ kind,
432
+ decision,
433
+ rescheduleNotBefore,
434
+ this.compactionTarget(lease.state)
158
435
  );
436
+ lease.startRenewal();
437
+ await this.compactSingleBucket(context, objectStorageUsage);
159
438
  }
160
439
 
161
- protected bucketStateFilter(
162
- bucket: string,
163
- definitionId: BucketDefinitionId | null
164
- ): mongo.Filter<BucketStateDocumentBase> {
165
- if (definitionId == null) {
166
- throw new ServiceAssertionError(`Missing definitionId for V3 bucket state filter on bucket ${bucket}`);
440
+ private async rescheduleClaimedBucket(lease: CompactionLease, decision: CompactionDecision, notBefore?: Date) {
441
+ await lease.reschedule(this.rescheduleAtOrAfter(decision.nextCompactCheck, notBefore));
442
+ }
443
+
444
+ private rescheduleAtOrAfter(nextCompactCheck: mongo.Document, notBefore: Date | undefined): mongo.Document {
445
+ return notBefore == null ? nextCompactCheck : { $max: [nextCompactCheck, notBefore] };
446
+ }
447
+
448
+ private compactionTarget(state: BucketStateDocumentV3): InternalOpId {
449
+ return this.maxOpIdCap == null || state.last_op < this.maxOpIdCap ? state.last_op : this.maxOpIdCap;
450
+ }
451
+
452
+ private isCompactionTargetCovered(state: BucketStateDocumentV3, kind: CompactionKind): boolean {
453
+ const target = this.compactionTarget(state);
454
+ if (kind == CompactionKind.Chunks) {
455
+ return state.compacted_state != null && state.compacted_state.op_id >= target;
167
456
  }
168
- return {
169
- _id: {
170
- d: definitionId,
171
- b: bucket
457
+ if (state.last_full_compact != null && state.last_full_compact.op_id >= target) {
458
+ return true;
459
+ }
460
+ // A full compact may change counts before the checksum-cache boundary.
461
+ // Wait for the safe target to catch up instead of publishing an older or
462
+ // stale cache. At the same boundary, full coverage can still advance.
463
+ return state.compacted_state != null && state.compacted_state.op_id > target;
464
+ }
465
+
466
+ private get objectStorageLifecycle(): ObjectStorageLifecycle {
467
+ if (!this.storage.objectStorage) {
468
+ throw new Error('Object storage is not configured');
469
+ }
470
+ return new ObjectStorageLifecycle(this.db, this.group_id, this.storage.objectStorage);
471
+ }
472
+
473
+ private async compactSingleBucket(context: CompactionContext, objectStorageUsage: ObjectStorageUsage) {
474
+ if (context.kind == CompactionKind.Chunks) {
475
+ return this.compactSingleBucketChunks(context, objectStorageUsage);
476
+ }
477
+
478
+ return this.compactSingleBucketFully(context, objectStorageUsage);
479
+ }
480
+
481
+ /**
482
+ * Merge adjacent bucket-data chunks without inspecting their operations
483
+ * unless a merge is possible. The metadata contains enough information to
484
+ * update the persisted checksum state and to decide whether a group can fit
485
+ * in one chunk.
486
+ */
487
+ private async compactSingleBucketChunks(context: CompactionContext, objectStorageUsage: ObjectStorageUsage) {
488
+ const bucket = context.state._id.b;
489
+ const resolvedDefinitionId = context.state._id.d;
490
+ const bucketContext = new BucketDataContextV3(this.db, {
491
+ bucket,
492
+ definitionId: resolvedDefinitionId,
493
+ replicationStreamId: this.group_id
494
+ });
495
+ const collection = this.db.bucketData(this.group_id, resolvedDefinitionId);
496
+ const dataContext = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
497
+ let previousCompactedState = context.state.compacted_state;
498
+ // A zero boundary represents an empty prefix, so there is no stored chunk
499
+ // whose statistics need to be carried into this pass.
500
+ if (previousCompactedState?.op_id === 0n) {
501
+ previousCompactedState = undefined;
502
+ }
503
+ // Include the last previously compacted chunk as well as new chunks. It
504
+ // is the only old chunk which can become mergeable with the new tail.
505
+ let lowerBound =
506
+ previousCompactedState != null ? bucketContext.docId(previousCompactedState.op_id - 1n) : bucketContext.minId;
507
+ const upperBound = bucketContext.docId(context.targetOp + 1n);
508
+ let cachedBoundaryToVerify = previousCompactedState?.op_id;
509
+
510
+ let compactedOpId: bigint | null = null;
511
+ let overlappingCompactedChunk: BucketStatsWithChecksum | undefined;
512
+ let compactedTail = emptyBucketStats();
513
+ let pendingChunks: BucketDataDocumentV3[] = [];
514
+ let pendingSize = 0;
515
+
516
+ while (true) {
517
+ this.signal?.throwIfAborted();
518
+ await context.lease.throwIfLost();
519
+
520
+ const batch = await readCompactionBatch(
521
+ collection.aggregate<BucketDataDocumentV3>(
522
+ [
523
+ {
524
+ $match: {
525
+ _id: {
526
+ $gt: lowerBound,
527
+ $lt: upperBound
528
+ }
529
+ }
530
+ },
531
+ { $sort: { _id: 1 } },
532
+ { $limit: this.moveBatchQueryLimit },
533
+ {
534
+ $project: {
535
+ _id: 1,
536
+ min_op: 1,
537
+ checksum: 1,
538
+ count: 1,
539
+ size: 1,
540
+ target_op: 1,
541
+ storage_ref: 1
542
+ }
543
+ }
544
+ ],
545
+ { batchSize: this.moveBatchQueryLimit + 1 }
546
+ ),
547
+ {
548
+ byteLimit: this.moveBatchByteLimit,
549
+ documentLimit: this.moveBatchQueryLimit
550
+ }
551
+ );
552
+
553
+ if (cachedBoundaryToVerify != null) {
554
+ const cachedBoundary = cachedBoundaryToVerify;
555
+ cachedBoundaryToVerify = undefined;
556
+ if (batch.documents[0]?._id.o !== cachedBoundary) {
557
+ // A previous attempt may have replaced the cached boundary before
558
+ // finalizing bucket state. Keep the persisted cache available to
559
+ // readers, but ignore it in this attempt and calculate its
560
+ // replacement through the normal scan from the bucket beginning.
561
+ previousCompactedState = undefined;
562
+ lowerBound = bucketContext.minId;
563
+ continue;
564
+ }
565
+ }
566
+
567
+ if (batch.documents.length == 0) {
568
+ break;
569
+ }
570
+
571
+ for (const doc of batch.documents) {
572
+ compactedOpId = maxOpId(compactedOpId, doc._id.o);
573
+ const documentStats = statsForDocument(doc);
574
+ if (previousCompactedState?.op_id === doc._id.o) {
575
+ overlappingCompactedChunk = documentStats;
576
+ }
577
+
578
+ const nextSize = pendingSize + doc.size;
579
+ if (pendingChunks.length > 0 && nextSize > DEFAULT_MAX_DOC_SIZE_BYTES) {
580
+ const groupStats = await this.flushChunkMerge(
581
+ bucket,
582
+ pendingChunks,
583
+ collection,
584
+ dataContext,
585
+ bucketContext,
586
+ objectStorageUsage
587
+ );
588
+ compactedTail = combineAdjacentStats(compactedTail, groupStats);
589
+ pendingChunks = [];
590
+ pendingSize = 0;
591
+ }
592
+
593
+ pendingChunks.push(doc);
594
+ pendingSize += doc.size;
172
595
  }
596
+
597
+ lowerBound = batch.documents[batch.documents.length - 1]._id;
598
+ if (!batch.hasMore) {
599
+ break;
600
+ }
601
+ }
602
+
603
+ if (pendingChunks.length > 0) {
604
+ const groupStats = await this.flushChunkMerge(
605
+ bucket,
606
+ pendingChunks,
607
+ collection,
608
+ dataContext,
609
+ bucketContext,
610
+ objectStorageUsage
611
+ );
612
+ compactedTail = combineAdjacentStats(compactedTail, groupStats);
613
+ }
614
+
615
+ if (compactedOpId == null) {
616
+ await this.finalizeSkippedBucket(context);
617
+ return;
618
+ }
619
+
620
+ const compactedState =
621
+ previousCompactedState == null
622
+ ? compactedTail
623
+ : combineChunkStats(previousCompactedState, compactedTail, overlappingCompactedChunk!);
624
+ const tailStats =
625
+ compactedOpId == context.lastOp
626
+ ? undefined
627
+ : await this.readBucketStats(bucket, resolvedDefinitionId, context.lastOp, bucketContext.docId(compactedOpId));
628
+ const result: CompactionResult = {
629
+ compactedState,
630
+ bucketStats: tailStats == null ? compactedState : combineAdjacentStats(compactedState, tailStats)
173
631
  };
632
+
633
+ await this.finalizeCompactedBucket({ context, compactedOpId, compactionResult: result, puts: 0 });
634
+ this.compactedBucketCount++;
635
+ this.logger.info(
636
+ `Compacted bucket chunks ${bucket}: ${result.bucketStats.count} ops, ${result.bucketStats.chunks} chunks, ${formatBytes(result.bucketStats.bytes)}`
637
+ );
174
638
  }
175
639
 
176
- private async getBucketDataContext(
640
+ private async flushChunkMerge(
177
641
  bucket: string,
178
- definitionId: BucketDefinitionId | null
179
- ): Promise<BucketDataContextV3 | null> {
180
- let resolvedDefinitionId = definitionId;
181
-
182
- if (resolvedDefinitionId == null) {
183
- const allDefinitionIds = this.storage.storageIds.bucketDefinitionIds;
184
- if (allDefinitionIds.length > 0) {
185
- const potentialIds = allDefinitionIds.map((id) => ({ d: id, b: bucket }));
186
- const bucketState = await this.db.bucketState(this.group_id).findOne({
187
- _id: { $in: potentialIds }
188
- });
189
- if (bucketState != null) {
190
- resolvedDefinitionId = bucketState._id.d;
642
+ inputs: BucketDataDocumentV3[],
643
+ collection: mongo.Collection<BucketDataDocumentV3>,
644
+ context: { replicationStreamId: number; definitionId: string },
645
+ bucketContext: BucketDataContextV3,
646
+ objectStorageUsage: ObjectStorageUsage
647
+ ): Promise<BucketStatsWithChecksum> {
648
+ if (inputs.length == 1) {
649
+ return statsForDocument(inputs[0]);
650
+ }
651
+
652
+ try {
653
+ this.signal?.throwIfAborted();
654
+
655
+ // The metadata scan deliberately excluded ops. Read inline payloads only
656
+ // for this merge group; object-storage payloads are fetched below using
657
+ // the same rule.
658
+ const inlineInputs = inputs.filter((input) => input.storage_ref == null);
659
+ if (inlineInputs.length > 0) {
660
+ const inlineDocuments = await collection
661
+ .find({ _id: { $in: inlineInputs.map((input) => input._id) } }, { projection: { _id: 1, ops: 1 } })
662
+ .toArray();
663
+ const opsById = new Map(inlineDocuments.map((document) => [document._id.o.toString(), document.ops]));
664
+ for (const input of inlineInputs) {
665
+ input.ops = opsById.get(input._id.o.toString());
191
666
  }
192
667
  }
668
+ await hydrateBucketDataDocuments(inputs, this.storage.objectStorage, { signal: this.signal });
669
+
670
+ const operations = inputs.flatMap((input) => Array.from(loadBucketDataDocument(context, input)));
671
+ const targetOp = inputs.reduce<InternalOpId | null>(
672
+ (maxTarget, input) => maxOpId(maxTarget, input.target_op),
673
+ null
674
+ );
675
+ const result = await this.flushCompactionGroup(
676
+ bucket,
677
+ {
678
+ inputs,
679
+ ops: operations,
680
+ changed: true,
681
+ targetOp
682
+ },
683
+ bucketContext,
684
+ context,
685
+ objectStorageUsage
686
+ );
687
+ return result.stats;
688
+ } finally {
689
+ // The scan batch also references these documents. Do not retain hydrated
690
+ // operations after finishing this merge group.
691
+ for (const input of inputs) {
692
+ delete input.ops;
693
+ }
694
+ await setImmediate();
193
695
  }
696
+ }
194
697
 
195
- if (resolvedDefinitionId == null) {
196
- return null;
698
+ private async finalizeCompactedBucket({
699
+ context,
700
+ compactedOpId,
701
+ compactionResult,
702
+ puts
703
+ }: {
704
+ context: CompactionContext;
705
+ compactedOpId: InternalOpId;
706
+ compactionResult: CompactionResult;
707
+ puts: number;
708
+ }) {
709
+ await context.lease.throwIfLost();
710
+ const startedStats = bucketStats(context.state);
711
+ const delta = {
712
+ count: compactionResult.bucketStats.count - startedStats.count,
713
+ bytes: compactionResult.bucketStats.bytes - startedStats.bytes,
714
+ chunks: compactionResult.bucketStats.chunks - startedStats.chunks
715
+ };
716
+ const coveredClaimedHead = compactedOpId >= context.lastOp;
717
+ const concurrentWriteCheck = { $gt: ['$last_op', context.lastOp] };
718
+ const remainingFullWorkCheck = coveredClaimedHead ? concurrentWriteCheck : true;
719
+ const nextAfterPartialFullCompact = this.rescheduleAtOrAfter(
720
+ { $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } },
721
+ context.rescheduleNotBefore
722
+ );
723
+ const nextCheckForUncompactedWork = this.rescheduleAtOrAfter(
724
+ {
725
+ $min: [
726
+ new Date(firstUncompactedWrite(context.state).getTime() + this.maxCompactFullIntervalMs),
727
+ { $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } }
728
+ ]
729
+ },
730
+ context.rescheduleNotBefore
731
+ );
732
+ const update: mongo.Document = {
733
+ compacted_state: {
734
+ op_id: compactedOpId,
735
+ checksum: BigInt(compactionResult.compactedState.checksum),
736
+ count: compactionResult.compactedState.count,
737
+ bytes: compactionResult.compactedState.bytes,
738
+ chunks: compactionResult.compactedState.chunks,
739
+ at: '$$NOW'
740
+ },
741
+ bucket_stats: {
742
+ count: { $add: ['$bucket_stats.count', delta.count] },
743
+ bytes: { $add: ['$bucket_stats.bytes', delta.bytes] },
744
+ chunks: { $add: ['$bucket_stats.chunks', delta.chunks] }
745
+ },
746
+ first_uncompacted_write:
747
+ context.kind == CompactionKind.Full
748
+ ? { $cond: [remainingFullWorkCheck, '$$NOW', '$$REMOVE'] }
749
+ : '$first_uncompacted_write',
750
+ next_compact_check:
751
+ context.kind == CompactionKind.Full
752
+ ? { $cond: [remainingFullWorkCheck, nextAfterPartialFullCompact, '$$REMOVE'] }
753
+ : nextCheckForUncompactedWork
754
+ };
755
+ if (context.kind == CompactionKind.Full) {
756
+ update.last_full_compact = {
757
+ op_id: compactedOpId,
758
+ count: compactionResult.compactedState.count,
759
+ puts,
760
+ at: '$$NOW'
761
+ };
197
762
  }
198
763
 
199
- return new BucketDataContextV3(this.db, {
764
+ await context.lease.finalize(update);
765
+ }
766
+
767
+ private async finalizeSkippedBucket(context: CompactionContext) {
768
+ // A maxOpId cap can exclude the first remaining document entirely. Avoid
769
+ // immediately claiming the same no-progress bucket again in this run.
770
+ await this.rescheduleClaimedBucket(
771
+ context.lease,
772
+ {
773
+ ...context.decision,
774
+ nextCompactCheck: {
775
+ $max: [
776
+ context.decision.nextCompactCheck,
777
+ { $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } }
778
+ ]
779
+ }
780
+ },
781
+ context.rescheduleNotBefore
782
+ );
783
+ }
784
+
785
+ /**
786
+ * Read bucket stats directly from bucket_data documents.
787
+ */
788
+ private async readBucketStats(
789
+ bucket: string,
790
+ definitionId: BucketDefinitionId,
791
+ maxOp: InternalOpId,
792
+ lowerBound?: BucketDataKey
793
+ ): Promise<BucketStatsWithChecksum> {
794
+ const context = new BucketDataContextV3(this.db, {
200
795
  bucket,
201
- definitionId: resolvedDefinitionId,
796
+ definitionId,
202
797
  replicationStreamId: this.group_id
203
798
  });
799
+ const [stats] = await this.db
800
+ .bucketData(this.group_id, definitionId)
801
+ .aggregate<{ count: number; bytes: number | bigint; chunks: number; checksum: bigint }>([
802
+ {
803
+ $match: {
804
+ _id:
805
+ lowerBound == null
806
+ ? { $gte: context.minId, $lte: context.docId(maxOp) }
807
+ : { $gt: lowerBound, $lte: context.docId(maxOp) }
808
+ }
809
+ },
810
+ {
811
+ $group: {
812
+ _id: null,
813
+ count: { $sum: '$count' },
814
+ bytes: { $sum: '$size' },
815
+ chunks: { $sum: 1 },
816
+ checksum: { $sum: '$checksum' }
817
+ }
818
+ }
819
+ ])
820
+ .toArray();
821
+ return {
822
+ count: Number(stats?.count ?? 0),
823
+ bytes: BigInt(stats?.bytes ?? 0),
824
+ chunks: Number(stats?.chunks ?? 0),
825
+ checksum:
826
+ typeof stats?.checksum == 'bigint'
827
+ ? Number(BigInt.asIntN(32, stats.checksum))
828
+ : addChecksums(0, Number(stats?.checksum ?? 0))
829
+ };
204
830
  }
205
831
 
206
- protected override async compactSingleBucket(bucket: string, definitionId: BucketDefinitionId | null = null) {
207
- const bucketContext = await this.getBucketDataContext(bucket, definitionId);
208
- if (bucketContext == null) {
209
- return;
210
- }
211
-
212
- const resolvedDefinitionId = bucketContext.key.definitionId;
832
+ private async compactSingleBucketFully(context: CompactionContext, objectStorageUsage: ObjectStorageUsage) {
833
+ const bucket = context.state._id.b;
834
+ const resolvedDefinitionId = context.state._id.d;
835
+ const bucketContext = new BucketDataContextV3(this.db, {
836
+ bucket,
837
+ definitionId: resolvedDefinitionId,
838
+ replicationStreamId: this.group_id
839
+ });
213
840
  const collection = this.db.bucketData(this.group_id, resolvedDefinitionId);
214
- const context = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
215
-
841
+ const dataContext = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
216
842
  const lowerBound = bucketContext.minId;
217
- let upperBound = bucketContext.docId(this.maxOpId + 1n);
843
+ let upperBound = bucketContext.docId(context.targetOp + 1n);
218
844
 
219
- let totalChecksum = 0;
220
845
  let totalOpCount = 0;
221
- let totalOpBytes = 0;
222
846
 
223
847
  let lastNotPut: bigint | null = null;
224
848
  let opsSincePut = 0;
225
849
  let compactedOpId: bigint | null = null;
226
850
  let clearBoundary: { opId: bigint; documentId: BucketDataKey } | null = null;
851
+ let compactedStats = emptyBucketStats();
227
852
  const seen = new Map<string, bigint>();
228
853
  let trackingSize = 0;
854
+ let putCount = 0;
229
855
  let pendingGroup: PendingCompactionGroup | null = null;
230
856
 
231
857
  // --- Read batch from MongoDB ---
232
858
  while (true) {
233
859
  this.signal?.throwIfAborted();
860
+ await context.lease.throwIfLost();
234
861
 
235
862
  const pipeline: mongo.Document[] = [
236
863
  {
@@ -280,7 +907,7 @@ export class MongoCompactorV3 extends MongoCompactor {
280
907
  // merging is useful, and writes each final object at most once.
281
908
  for (const doc of batchDocs) {
282
909
  compactedOpId ??= doc._id.o;
283
- const originalOps = Array.from(loadBucketDataDocument(context, doc));
910
+ const originalOps = Array.from(loadBucketDataDocument(dataContext, doc));
284
911
 
285
912
  let changed = false;
286
913
  const compactedOps: BucketDataDoc[] = [];
@@ -299,6 +926,7 @@ export class MongoCompactorV3 extends MongoCompactor {
299
926
  row_id: undefined,
300
927
  source_table: undefined,
301
928
  source_key: undefined,
929
+ subkey: undefined,
302
930
  data: null
303
931
  });
304
932
  changed = true;
@@ -313,6 +941,7 @@ export class MongoCompactorV3 extends MongoCompactor {
313
941
  }
314
942
  compactedOps.push(op);
315
943
  if (op.op == 'PUT') {
944
+ putCount++;
316
945
  lastNotPut = null;
317
946
  opsSincePut = 0;
318
947
  } else {
@@ -334,10 +963,6 @@ export class MongoCompactorV3 extends MongoCompactor {
334
963
  }
335
964
  compactedOps.reverse();
336
965
 
337
- for (const op of compactedOps) {
338
- totalChecksum = addChecksums(totalChecksum, Number(op.checksum));
339
- totalOpBytes += op.data?.length ?? 0;
340
- }
341
966
  totalOpCount += compactedOps.length;
342
967
 
343
968
  const candidate: PendingCompactionGroup = {
@@ -361,13 +986,20 @@ export class MongoCompactorV3 extends MongoCompactor {
361
986
  };
362
987
  } else {
363
988
  const flushedGroup = pendingGroup;
364
- const documentId = await this.flushCompactionGroup(bucket, flushedGroup, bucketContext, context);
989
+ const result = await this.flushCompactionGroup(
990
+ bucket,
991
+ flushedGroup,
992
+ bucketContext,
993
+ dataContext,
994
+ objectStorageUsage
995
+ );
996
+ compactedStats = combineAdjacentStats(compactedStats, result.stats);
365
997
  if (
366
998
  lastNotPut != null &&
367
999
  flushedGroup.ops[0].o <= lastNotPut &&
368
1000
  flushedGroup.ops[flushedGroup.ops.length - 1].o >= lastNotPut
369
1001
  ) {
370
- clearBoundary = { opId: lastNotPut, documentId };
1002
+ clearBoundary = { opId: lastNotPut, documentId: result.documentId };
371
1003
  }
372
1004
  pendingGroup = candidate;
373
1005
  }
@@ -385,16 +1017,24 @@ export class MongoCompactorV3 extends MongoCompactor {
385
1017
  }
386
1018
 
387
1019
  if (pendingGroup != null) {
388
- const documentId = await this.flushCompactionGroup(bucket, pendingGroup, bucketContext, context);
1020
+ const result = await this.flushCompactionGroup(
1021
+ bucket,
1022
+ pendingGroup,
1023
+ bucketContext,
1024
+ dataContext,
1025
+ objectStorageUsage
1026
+ );
1027
+ compactedStats = combineAdjacentStats(compactedStats, result.stats);
389
1028
  if (
390
1029
  lastNotPut != null &&
391
1030
  pendingGroup.ops[0].o <= lastNotPut &&
392
1031
  pendingGroup.ops[pendingGroup.ops.length - 1].o >= lastNotPut
393
1032
  ) {
394
- clearBoundary = { opId: lastNotPut, documentId };
1033
+ clearBoundary = { opId: lastNotPut, documentId: result.documentId };
395
1034
  }
396
1035
  }
397
1036
  if (compactedOpId == null) {
1037
+ await this.finalizeSkippedBucket(context);
398
1038
  return;
399
1039
  }
400
1040
 
@@ -404,36 +1044,34 @@ export class MongoCompactorV3 extends MongoCompactor {
404
1044
  throw new ReplicationAssertionError(`Missing CLEAR boundary document for bucket ${bucket}`);
405
1045
  }
406
1046
 
407
- totalOpCount += await this.clearBucketLeading(
1047
+ const clearResult = await this.clearBucketLeading(
408
1048
  lastNotPut,
409
1049
  clearBoundary.documentId,
410
1050
  bucketContext,
411
1051
  collection,
412
- context
1052
+ dataContext,
1053
+ objectStorageUsage
413
1054
  );
1055
+ totalOpCount += clearResult.opCountDiff;
1056
+ compactedStats = applyStatsReplacement(compactedStats, clearResult.before, clearResult.after);
414
1057
  }
415
1058
 
1059
+ const tailStats =
1060
+ compactedOpId == context.lastOp
1061
+ ? undefined
1062
+ : await this.readBucketStats(bucket, resolvedDefinitionId, context.lastOp, bucketContext.docId(compactedOpId));
1063
+ const result: CompactionResult = {
1064
+ compactedState: compactedStats,
1065
+ bucketStats: tailStats == null ? compactedStats : combineAdjacentStats(compactedStats, tailStats)
1066
+ };
1067
+
416
1068
  // --- Finalize: update bucket checksums and state ---
417
- this.updateBucketChecksums(
418
- {
419
- bucket,
420
- definitionId: resolvedDefinitionId,
421
- seen: new Map(),
422
- trackingSize: 0,
423
- lastNotPut: lastNotPut,
424
- opsSincePut: opsSincePut,
425
- checksum: totalChecksum,
426
- opCount: totalOpCount,
427
- opBytes: totalOpBytes
428
- },
429
- compactedOpId
430
- );
431
- if (this.bucketStateUpdates.length > 0) {
432
- await this.writeBucketStateUpdates();
433
- this.bucketStateUpdates = [];
434
- }
1069
+ await this.finalizeCompactedBucket({ context, compactedOpId, compactionResult: result, puts: putCount });
435
1070
 
436
- logger.info(`Compacted bucket ${bucket}: ${totalOpCount} surviving ops`);
1071
+ this.compactedBucketCount++;
1072
+ this.logger.info(
1073
+ `Compacted bucket ${bucket}: ${totalOpCount} ops, ${result.bucketStats.chunks} chunks, ${formatBytes(result.bucketStats.bytes)}`
1074
+ );
437
1075
  }
438
1076
 
439
1077
  /**
@@ -446,10 +1084,14 @@ export class MongoCompactorV3 extends MongoCompactor {
446
1084
  bucket: string,
447
1085
  group: PendingCompactionGroup,
448
1086
  bucketContext: BucketDataContextV3,
449
- context: { replicationStreamId: number; definitionId: string }
450
- ): Promise<BucketDataKey> {
1087
+ context: { replicationStreamId: number; definitionId: string },
1088
+ objectStorageUsage: ObjectStorageUsage
1089
+ ): Promise<CompactionGroupResult> {
451
1090
  if (group.inputs.length == 1 && !group.changed) {
452
- return group.inputs[0]._id;
1091
+ return {
1092
+ documentId: group.inputs[0]._id,
1093
+ stats: statsForDocument(group.inputs[0])
1094
+ };
453
1095
  }
454
1096
 
455
1097
  const inputs = group.inputs;
@@ -458,6 +1100,7 @@ export class MongoCompactorV3 extends MongoCompactor {
458
1100
  const expectedChecksum = inputs.reduce((sum, doc) => sum + doc.checksum, 0n);
459
1101
  const expectedOpCount = inputs.reduce((sum, doc) => sum + doc.count, 0);
460
1102
  const oldStoragePaths = inputs.flatMap((doc) => (doc.storage_ref ? [doc.storage_ref.path] : []));
1103
+ const oldStorageBytes = inputs.reduce((sum, document) => sum + ObjectStorageUsage.bytes(document), 0n);
461
1104
  const {
462
1105
  documents,
463
1106
  storagePaths: newStoragePaths,
@@ -495,9 +1138,19 @@ export class MongoCompactorV3 extends MongoCompactor {
495
1138
  );
496
1139
  }
497
1140
 
498
- await bucketContext.collection.deleteMany({ _id: { $in: idsToDelete } }, { session });
499
- await bucketContext.collection.insertMany(documents, { session });
500
- await this.finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, session);
1141
+ // Replacement documents can reuse deleted IDs, so retain delete-before-insert ordering.
1142
+ const writes = this.db.createWriteBatch(session, { ordered: true });
1143
+ writes.deleteMany(bucketContext.collection, { _id: { $in: idsToDelete } });
1144
+ writes.insertMany(bucketContext.collection, documents);
1145
+ this.finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, writes);
1146
+ this.recordObjectStorageReplacement(
1147
+ oldStorageBytes,
1148
+ documents,
1149
+ context.definitionId,
1150
+ writes,
1151
+ objectStorageUsage
1152
+ );
1153
+ await writes.execute();
501
1154
  },
502
1155
  {
503
1156
  writeConcern: { w: 'majority' },
@@ -507,7 +1160,10 @@ export class MongoCompactorV3 extends MongoCompactor {
507
1160
  } finally {
508
1161
  await session.endSession();
509
1162
  }
510
- return documents[0]._id;
1163
+ return {
1164
+ documentId: documents[0]._id,
1165
+ stats: statsForDocuments(documents)
1166
+ };
511
1167
  }
512
1168
 
513
1169
  /**
@@ -516,16 +1172,20 @@ export class MongoCompactorV3 extends MongoCompactor {
516
1172
  * before the known boundary document, then splits that boundary document
517
1173
  * if it contains ops on both sides of lastNotPut.
518
1174
  *
519
- * Returns the op count diff after replacing cleared ops with CLEAR ops.
1175
+ * Returns the op count and stored-stat changes after replacing cleared ops
1176
+ * with CLEAR ops.
520
1177
  */
521
1178
  private async clearBucketLeading(
522
1179
  lastNotPut: bigint,
523
1180
  boundaryDocId: BucketDataKey,
524
1181
  bucketContext: BucketDataContextV3,
525
1182
  collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
526
- context: { replicationStreamId: number; definitionId: string }
527
- ): Promise<number> {
1183
+ context: { replicationStreamId: number; definitionId: string },
1184
+ objectStorageUsage: ObjectStorageUsage
1185
+ ): Promise<ClearCompactionResult> {
528
1186
  let opCountDiff = 0;
1187
+ let before = emptyBucketStats();
1188
+ let after = emptyBucketStats();
529
1189
  const session = this.db.client.startSession();
530
1190
  try {
531
1191
  let done = false;
@@ -538,27 +1198,34 @@ export class MongoCompactorV3 extends MongoCompactor {
538
1198
  boundaryDocId,
539
1199
  bucketContext,
540
1200
  collection,
541
- context
1201
+ context,
1202
+ objectStorageUsage
542
1203
  );
543
1204
  done = batch.done;
544
1205
  opCountDiff += batch.opCountDiff;
1206
+ before = combineAdjacentStats(before, batch.before);
1207
+ after = combineAdjacentStats(after, batch.after);
545
1208
  }
546
1209
 
547
1210
  // The final step is to process the "boundary" document: It may contain some CLEAR/MOVE/REMOVE operations,
548
1211
  // potentially followed by PUT operations. This is only a single document, so no need for batching.
549
- opCountDiff += await this.clearBoundaryDocument(
1212
+ const boundaryResult = await this.clearBoundaryDocument(
550
1213
  session,
551
1214
  lastNotPut,
552
1215
  boundaryDocId,
553
1216
  bucketContext,
554
1217
  collection,
555
- context
1218
+ context,
1219
+ objectStorageUsage
556
1220
  );
1221
+ opCountDiff += boundaryResult.opCountDiff;
1222
+ before = combineAdjacentStats(before, boundaryResult.before);
1223
+ after = combineAdjacentStats(after, boundaryResult.after);
557
1224
  } finally {
558
1225
  await session.endSession();
559
1226
  }
560
1227
 
561
- return opCountDiff;
1228
+ return { opCountDiff, before, after };
562
1229
  }
563
1230
 
564
1231
  private async clearLeadingFullDocuments(
@@ -567,19 +1234,25 @@ export class MongoCompactorV3 extends MongoCompactor {
567
1234
  boundaryDocId: BucketDataKey,
568
1235
  bucketContext: BucketDataContextV3,
569
1236
  collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
570
- context: { replicationStreamId: number; definitionId: string }
571
- ): Promise<{ done: boolean; opCountDiff: number }> {
1237
+ context: { replicationStreamId: number; definitionId: string },
1238
+ objectStorageUsage: ObjectStorageUsage
1239
+ ): Promise<{ done: boolean; opCountDiff: number } & CompactionStatsReplacement> {
572
1240
  const bucket = bucketContext.key.bucket;
573
1241
  this.signal?.throwIfAborted();
574
- const prepared = await this.prepareCompactionUploads(bucket, context, [lastNotPut]);
1242
+ let prepared: PreparedObjectStorageUpload[] | undefined;
575
1243
  let done = false;
576
1244
  let opCountDiff = 0;
1245
+ let before = emptyBucketStats();
1246
+ let after = emptyBucketStats();
577
1247
 
578
1248
  await session.withTransaction(
579
1249
  async () => {
580
1250
  done = false;
581
1251
  opCountDiff = 0;
1252
+ before = emptyBucketStats();
1253
+ after = emptyBucketStats();
582
1254
  const oldStoragePaths: string[] = [];
1255
+ let oldStorageBytes = 0n;
583
1256
  const query = collection.find(
584
1257
  {
585
1258
  _id: {
@@ -595,6 +1268,7 @@ export class MongoCompactorV3 extends MongoCompactor {
595
1268
  min_op: 1,
596
1269
  checksum: 1,
597
1270
  count: 1,
1271
+ size: 1,
598
1272
  target_op: 1,
599
1273
  has_clear_op: 1,
600
1274
  storage_ref: 1
@@ -609,6 +1283,7 @@ export class MongoCompactorV3 extends MongoCompactor {
609
1283
  let lastDocId: BucketDataKey | null = null;
610
1284
  let clearOpCount = 0;
611
1285
  let gotNonClearOp = false;
1286
+ const inputStats = emptyBucketStats();
612
1287
 
613
1288
  for await (const doc of query.stream()) {
614
1289
  if (doc.min_op > lastNotPut) {
@@ -618,9 +1293,15 @@ export class MongoCompactorV3 extends MongoCompactor {
618
1293
  }
619
1294
 
620
1295
  lastDocId = doc._id;
1296
+ const documentStats = statsForDocument(doc);
1297
+ inputStats.count += documentStats.count;
1298
+ inputStats.bytes += documentStats.bytes;
1299
+ inputStats.chunks += documentStats.chunks;
1300
+ inputStats.checksum = addChecksums(inputStats.checksum, documentStats.checksum);
621
1301
  if (doc.storage_ref) {
622
1302
  oldStoragePaths.push(doc.storage_ref.path);
623
1303
  }
1304
+ oldStorageBytes += ObjectStorageUsage.bytes(doc);
624
1305
 
625
1306
  // The compaction scan established that every operation before the
626
1307
  // boundary is MOVE/REMOVE/CLEAR. Root metadata is sufficient to fold
@@ -646,16 +1327,15 @@ export class MongoCompactorV3 extends MongoCompactor {
646
1327
  return;
647
1328
  }
648
1329
 
1330
+ prepared ??= await this.prepareCompactionUploads(bucket, context, [lastNotPut]);
649
1331
  this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastDocId?.o}`);
650
- await collection.deleteMany(
651
- {
652
- _id: {
653
- $gte: bucketContext.minId,
654
- $lte: lastDocId!
655
- }
656
- },
657
- { session }
658
- );
1332
+ const writes = this.db.createWriteBatch(session, { ordered: true });
1333
+ writes.deleteMany(collection, {
1334
+ _id: {
1335
+ $gte: bucketContext.minId,
1336
+ $lte: lastDocId!
1337
+ }
1338
+ });
659
1339
 
660
1340
  const clearOp = {
661
1341
  bucketKey: { ...context, bucket },
@@ -667,10 +1347,20 @@ export class MongoCompactorV3 extends MongoCompactor {
667
1347
  const persisted = await this.persistBucketData(bucket, [[clearOp]], context, prepared, {
668
1348
  targetOp: maxTargetOp
669
1349
  });
670
- await collection.insertOne(persisted.documents[0], { session });
671
- await this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, session);
1350
+ writes.insertOne(collection, persisted.documents[0]);
1351
+ this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, writes);
1352
+ this.recordObjectStorageReplacement(
1353
+ oldStorageBytes,
1354
+ persisted.documents,
1355
+ context.definitionId,
1356
+ writes,
1357
+ objectStorageUsage
1358
+ );
1359
+ await writes.execute();
672
1360
 
673
1361
  opCountDiff = -clearedOpCount + 1;
1362
+ before = inputStats;
1363
+ after = statsForDocuments(persisted.documents);
674
1364
  },
675
1365
  {
676
1366
  writeConcern: { w: 'majority' },
@@ -678,7 +1368,7 @@ export class MongoCompactorV3 extends MongoCompactor {
678
1368
  }
679
1369
  );
680
1370
 
681
- return { done, opCountDiff };
1371
+ return { done, opCountDiff, before, after };
682
1372
  }
683
1373
 
684
1374
  private async clearBoundaryDocument(
@@ -687,17 +1377,23 @@ export class MongoCompactorV3 extends MongoCompactor {
687
1377
  boundaryDocId: BucketDataKey,
688
1378
  bucketContext: BucketDataContextV3,
689
1379
  collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
690
- context: { replicationStreamId: number; definitionId: string }
691
- ): Promise<number> {
1380
+ context: { replicationStreamId: number; definitionId: string },
1381
+ objectStorageUsage: ObjectStorageUsage
1382
+ ): Promise<ClearCompactionResult> {
692
1383
  const bucket = bucketContext.key.bucket;
693
1384
  this.signal?.throwIfAborted();
694
1385
  const prepared = await this.prepareCompactionUploads(bucket, context, [lastNotPut, boundaryDocId.o]);
695
1386
  let opCountDiff = 0;
1387
+ let before = emptyBucketStats();
1388
+ let after = emptyBucketStats();
696
1389
 
697
1390
  await session.withTransaction(
698
1391
  async () => {
699
1392
  opCountDiff = 0;
1393
+ before = emptyBucketStats();
1394
+ after = emptyBucketStats();
700
1395
  const oldStoragePaths: string[] = [];
1396
+ let oldStorageBytes = 0n;
701
1397
  const query = collection.find(
702
1398
  {
703
1399
  // This is a range query, but should only ever return two documents:
@@ -716,6 +1412,7 @@ export class MongoCompactorV3 extends MongoCompactor {
716
1412
  min_op: 1,
717
1413
  checksum: 1,
718
1414
  count: 1,
1415
+ size: 1,
719
1416
  target_op: 1,
720
1417
  ops: 1,
721
1418
  storage_ref: 1
@@ -729,6 +1426,7 @@ export class MongoCompactorV3 extends MongoCompactor {
729
1426
  let clearedOpCount = 0;
730
1427
  let maxTargetOp: bigint | null = null;
731
1428
  const boundarySurvivors: BucketDataDoc[] = [];
1429
+ const inputStats = emptyBucketStats();
732
1430
 
733
1431
  for await (const doc of query.stream()) {
734
1432
  docsRead++;
@@ -736,10 +1434,17 @@ export class MongoCompactorV3 extends MongoCompactor {
736
1434
  throw new ReplicationAssertionError(`Unexpected extra document before CLEAR boundary in bucket ${bucket}`);
737
1435
  }
738
1436
 
1437
+ const documentStats = statsForDocument(doc);
1438
+ inputStats.count += documentStats.count;
1439
+ inputStats.bytes += documentStats.bytes;
1440
+ inputStats.chunks += documentStats.chunks;
1441
+ inputStats.checksum = addChecksums(inputStats.checksum, documentStats.checksum);
1442
+
739
1443
  const isBoundaryDoc = doc._id.o == boundaryDocId.o;
740
1444
  if (doc.storage_ref) {
741
1445
  oldStoragePaths.push(doc.storage_ref.path);
742
1446
  }
1447
+ oldStorageBytes += ObjectStorageUsage.bytes(doc);
743
1448
  await hydrateBucketDataDocuments([doc], this.storage.objectStorage, { signal: this.signal });
744
1449
  maxTargetOp = maxOpId(maxTargetOp, doc.target_op);
745
1450
  for (const op of loadBucketDataDocument(context, doc)) {
@@ -772,15 +1477,13 @@ export class MongoCompactorV3 extends MongoCompactor {
772
1477
  }
773
1478
 
774
1479
  this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastNotPut}`);
775
- await collection.deleteMany(
776
- {
777
- _id: {
778
- $gte: bucketContext.minId,
779
- $lte: boundaryDocId
780
- }
781
- },
782
- { session }
783
- );
1480
+ const writes = this.db.createWriteBatch(session, { ordered: true });
1481
+ writes.deleteMany(collection, {
1482
+ _id: {
1483
+ $gte: bucketContext.minId,
1484
+ $lte: boundaryDocId
1485
+ }
1486
+ });
784
1487
 
785
1488
  const clearOp = {
786
1489
  bucketKey: { ...context, bucket },
@@ -798,10 +1501,20 @@ export class MongoCompactorV3 extends MongoCompactor {
798
1501
  const persisted = await this.persistBucketData(bucket, chunks, context, prepared, {
799
1502
  targetOp: maxTargetOp ?? undefined
800
1503
  });
801
- await collection.insertMany(persisted.documents, { session });
802
- await this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, session);
1504
+ writes.insertMany(collection, persisted.documents);
1505
+ this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, writes);
1506
+ this.recordObjectStorageReplacement(
1507
+ oldStorageBytes,
1508
+ persisted.documents,
1509
+ context.definitionId,
1510
+ writes,
1511
+ objectStorageUsage
1512
+ );
1513
+ await writes.execute();
803
1514
 
804
1515
  opCountDiff = -clearedOpCount + 1;
1516
+ before = inputStats;
1517
+ after = statsForDocuments(persisted.documents);
805
1518
  },
806
1519
  {
807
1520
  writeConcern: { w: 'majority' },
@@ -809,7 +1522,7 @@ export class MongoCompactorV3 extends MongoCompactor {
809
1522
  }
810
1523
  );
811
1524
 
812
- return opCountDiff;
1525
+ return { opCountDiff, before, after };
813
1526
  }
814
1527
 
815
1528
  /**
@@ -836,22 +1549,39 @@ export class MongoCompactorV3 extends MongoCompactor {
836
1549
  }
837
1550
 
838
1551
  /** Publish replacement uploads and retire superseded objects in the same transaction. */
839
- private async finishObjectStorageReplacement(
1552
+ private finishObjectStorageReplacement(
840
1553
  oldStoragePaths: Iterable<string>,
841
1554
  newStoragePaths: Set<string>,
842
1555
  uploads: PreparedObjectStorageUpload[],
843
- session: mongo.ClientSession
844
- ): Promise<void> {
1556
+ writes: MongoWriteBatch
1557
+ ): void {
845
1558
  if (!this.storage.objectStorage) {
846
1559
  return;
847
1560
  }
848
- await this.objectStorageLifecycle.publishUploads(uploads, session);
849
- await this.objectStorageLifecycle.retire(
1561
+ this.objectStorageLifecycle.publishUploads(uploads, writes);
1562
+ this.objectStorageLifecycle.retire(
850
1563
  Array.from(oldStoragePaths).filter((path) => !newStoragePaths.has(path)),
851
- session
1564
+ writes
852
1565
  );
853
1566
  }
854
1567
 
1568
+ private recordObjectStorageReplacement(
1569
+ oldBytes: bigint,
1570
+ newDocuments: Iterable<Pick<BucketDataDocumentV3, 'storage_ref'>>,
1571
+ definitionId: BucketDefinitionId,
1572
+ writes: MongoWriteBatch,
1573
+ objectStorageUsage: ObjectStorageUsage
1574
+ ): void {
1575
+ if (!this.storage.objectStorage) {
1576
+ return;
1577
+ }
1578
+ let newBytes = 0n;
1579
+ for (const document of newDocuments) {
1580
+ newBytes += ObjectStorageUsage.bytes(document);
1581
+ }
1582
+ objectStorageUsage.applyDelta(definitionId, newBytes - oldBytes, writes);
1583
+ }
1584
+
855
1585
  private async persistBucketData(
856
1586
  bucket: string,
857
1587
  chunks: BucketDataDoc[][],
@@ -908,7 +1638,7 @@ export class MongoCompactorV3 extends MongoCompactor {
908
1638
  }
909
1639
 
910
1640
  const { ops, ...metadata } = serialized;
911
- const { fileSize } = await store.store(upload.path, ops!);
1641
+ const { fileSize } = await store.store(upload.path, ops!, { signal: this.signal });
912
1642
  storagePaths.add(upload.path);
913
1643
  return {
914
1644
  ...metadata,