@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,169 +1,649 @@
1
- import { mongo } from '@powersync/lib-service-mongodb';
2
- import { logger, ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
3
- import { addChecksums, utils } from '@powersync/service-core';
1
+ import { ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
2
+ import { acquireSemaphoreAbortable, addChecksums, formatBytes, utils } from '@powersync/service-core';
3
+ import { setImmediate } from 'node:timers/promises';
4
4
  import { ConcurrentCompactionError, MongoCompactor } from '../MongoCompactor.js';
5
5
  import { cacheKey } from '../OperationBatch.js';
6
6
  import { loadBucketDataDocument, maxOpId, serializeBucketData } from './bucket-format.js';
7
7
  import { BucketDataContextV3 } from './BucketDataContextV3.js';
8
8
  import { DEFAULT_MAX_DOC_SIZE_BYTES } from './chunking.js';
9
+ import { applyStatsReplacement, bucketStats, chooseCompactionKind, chooseRequestedCompactionKind, combineAdjacentStats, combineChunkStats, CompactionContext, CompactionKind, emptyBucketStats, firstUncompactedWrite, readCompactionBatch, statsForDocument, statsForDocuments, unclaimedSnapshotFilter } from './compact-utils.js';
10
+ import { DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS } from './compaction-constants.js';
11
+ import { AVAILABLE_LEASE_EXPR, CompactionLease } from './CompactionLease.js';
9
12
  import { BucketDataObjectStorage, hydrateBucketDataDocuments } from './object-storage/BucketDataObjectStorage.js';
10
13
  import { ObjectStorageLifecycle } from './object-storage/ObjectStorageLifecycle.js';
11
- /**
12
- * Read one bounded prefix from a descending compaction cursor.
13
- *
14
- * The document that would cross the byte limit is deliberately not returned:
15
- * pagination resumes below the last returned `_id`, so that document remains
16
- * eligible for the next query. The first document is always accepted to ensure
17
- * progress when a single document exceeds the configured byte limit.
18
- *
19
- * `hasMore` is conservative when the document limit is reached. An extra empty
20
- * query is preferable to exhausting the cursor just to determine whether the
21
- * limited MongoDB query contained another document.
22
- */
23
- async function readCompactionBatch(cursor, options) {
24
- const documents = [];
25
- let cumulativeBytes = 0;
26
- try {
27
- for await (const document of cursor) {
28
- if (documents.length > 0 && cumulativeBytes + document.size > options.byteLimit) {
29
- return { documents, hasMore: true };
30
- }
31
- documents.push(document);
32
- cumulativeBytes += document.size;
33
- if (documents.length >= options.documentLimit) {
34
- return { documents, hasMore: true };
35
- }
36
- }
37
- return { documents, hasMore: false };
38
- }
39
- finally {
40
- await cursor.close();
41
- }
42
- }
14
+ import { createObjectStorageUsageWriterId, ObjectStorageUsage } from './object-storage/ObjectStorageUsage.js';
15
+ const DEFAULT_MIN_COMPACT_FULL_INTERVAL_MS = 2 * 60 * 60 * 1000;
16
+ const DEFAULT_MAX_COMPACT_FULL_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
17
+ const DEFAULT_COMPACT_LEASE_DURATION_MS = 10 * 60 * 1000;
18
+ const SCHEDULED_COMPACTION_BATCH_SIZE = 100;
43
19
  export class MongoCompactorV3 extends MongoCompactor {
20
+ minCompactChunkIntervalMs;
21
+ minCompactFullIntervalMs;
22
+ maxCompactFullIntervalMs;
23
+ compactLeaseDurationMs;
24
+ maxOpIdCap;
25
+ forceChunkCompaction;
26
+ objectStorageUsage;
27
+ constructor(bucketStorage, db, options) {
28
+ super(bucketStorage, db, options);
29
+ this.minCompactChunkIntervalMs = options.minCompactChunkIntervalMs ?? DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS;
30
+ this.minCompactFullIntervalMs = options.minCompactFullIntervalMs ?? DEFAULT_MIN_COMPACT_FULL_INTERVAL_MS;
31
+ this.maxCompactFullIntervalMs = options.maxCompactFullIntervalMs ?? DEFAULT_MAX_COMPACT_FULL_INTERVAL_MS;
32
+ this.compactLeaseDurationMs = options.compactLeaseDurationMs ?? DEFAULT_COMPACT_LEASE_DURATION_MS;
33
+ this.maxOpIdCap = options.maxOpId;
34
+ this.forceChunkCompaction = options.forceChunkCompaction ?? false;
35
+ this.objectStorageUsage = new ObjectStorageUsage(this.db, this.group_id, createObjectStorageUsageWriterId());
36
+ }
44
37
  async compact() {
45
38
  if (this.storage.objectStorage) {
46
39
  // Clean these before compacting - should be quick in most cases.
47
40
  try {
48
- await this.objectStorageLifecycle.cleanup(this.logger);
41
+ await this.objectStorageLifecycle.cleanup(this.logger, { signal: this.signal });
49
42
  }
50
43
  catch (e) {
44
+ if (this.signal?.aborted) {
45
+ throw e;
46
+ }
51
47
  // In this case, still continue normal compact process
52
48
  this.logger.error(`Failed to clean up object storage deletion markers before compaction`, e);
53
49
  }
54
50
  }
55
- await super.compact();
51
+ await this.deleteOldCheckpointRequests();
52
+ if (this.buckets != null) {
53
+ await this.compactExplicitBuckets(this.buckets);
54
+ }
55
+ else if (this.compactChunksOnly) {
56
+ // Writers defer their first chunk-compaction check by this fixed default.
57
+ // Include that interval so this synchronous initial-replication pass
58
+ // processes the work that existed when it started.
59
+ await this.compactScheduledBuckets({
60
+ dueAheadMs: DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS,
61
+ requestedKind: CompactionKind.Chunks
62
+ });
63
+ }
64
+ else {
65
+ await this.compactScheduledBuckets();
66
+ }
56
67
  if (this.storage.objectStorage) {
57
68
  // Cleanup for any produced during compacting.
58
69
  // Note that markers only expire after a delay, so this may skip many produced during this compact
59
70
  // run. However, during long compact runs, this may also have many ones it can clean up.
60
- await this.objectStorageLifecycle.cleanup(this.logger);
71
+ await this.objectStorageLifecycle.cleanup(this.logger, { signal: this.signal });
72
+ await this.objectStorageUsage.foldStaleWriterDeltas();
61
73
  }
74
+ return this.compactedBucketCount;
62
75
  }
63
- get objectStorageLifecycle() {
64
- if (!this.storage.objectStorage) {
65
- throw new Error('Object storage is not configured');
76
+ async deleteOldCustomCheckpointRequests() {
77
+ if (this.deleteCheckpointRequestsBefore == null) {
78
+ return;
79
+ }
80
+ for (const collection of await this.db.listCustomCheckpointRequestCollections(this.group_id)) {
81
+ this.signal?.throwIfAborted();
82
+ await collection.deleteMany({
83
+ checkpoint_requested_at: { $exists: true, $lt: this.deleteCheckpointRequestsBefore }
84
+ });
66
85
  }
67
- return new ObjectStorageLifecycle(this.db, this.group_id, this.storage.objectStorage);
68
86
  }
69
- async *dirtyBucketBatches(options) {
70
- if (options.minBucketChanges <= 0) {
71
- throw new ReplicationAssertionError('minBucketChanges must be >= 1');
87
+ /** An explicit compact request always runs a full compact for its buckets. */
88
+ async compactExplicitBuckets(buckets) {
89
+ for (const bucket of buckets) {
90
+ // This is not a super efficient query, but this is not a common use case.
91
+ // May be optimized later.
92
+ const states = await this.db
93
+ .bucketState(this.group_id)
94
+ .find({ '_id.b': bucket }, { projection: { _id: 1 } })
95
+ .toArray();
96
+ for (const state of states) {
97
+ await using lease = await this.claimBucket({ _id: state._id });
98
+ if (lease == null || lease.state.first_uncompacted_write == null) {
99
+ continue;
100
+ }
101
+ if (this.isCompactionTargetCovered(lease.state, CompactionKind.Full)) {
102
+ continue;
103
+ }
104
+ const decision = chooseCompactionKind(lease.state, lease.startedAt, this);
105
+ await this.compactClaimedBucket(lease, CompactionKind.Full, decision);
106
+ }
72
107
  }
73
- const collection = this.db.bucketState(this.group_id);
74
- yield* this.dirtyBucketBatchesForCollection(collection, { d: new mongo.MinKey(), b: new mongo.MinKey() }, { d: new mongo.MaxKey(), b: new mongo.MaxKey() }, options, (bucketState) => bucketState._id.d);
75
108
  }
76
- async dirtyBucketBatchForChecksums(options) {
77
- if (options.minBucketChanges <= 0) {
78
- throw new ReplicationAssertionError('minBucketChanges must be >= 1');
109
+ /**
110
+ * Process scheduled work in bounded batches.
111
+ *
112
+ * Batching specifically help to cover cases of many buckets where no compaction is required:
113
+ * Instead of sequentially claiming and then rescheduling a bucket, this handles it in bulk.
114
+ *
115
+ * Chunk merges overlap a bounded number of buckets. Full compaction stays
116
+ * sequential because its working set includes operation deduplication state.
117
+ *
118
+ * Any concurrent workers may read the same batch. Rescheduling filters out buckets handled
119
+ * by a concurrent worker or replication write, while buckets that do need compaction are
120
+ * filtered out when claiming a compaction lease.
121
+ *
122
+ * We filter scheduled jobs by the job start date, so that the same bucket is not compacted
123
+ * multiple times in one run. Reschedules fall beyond the fixed boundary. For the run after
124
+ * initial replication, dueAheadMs extends that boundary to include the first deferred interval.
125
+ */
126
+ async compactScheduledBuckets(options = {}) {
127
+ // Writers derive next_compact_check from MongoDB's $$NOW. Use the same
128
+ // clock for the fixed job boundary so clock skew cannot exclude work at
129
+ // the exact initial-replication interval.
130
+ const jobStartedAt = await this.readCompactionTime();
131
+ const dueBefore = new Date(jobStartedAt.getTime() + (options.dueAheadMs ?? 0));
132
+ const requestedKind = options.requestedKind;
133
+ const rescheduleNotBefore = new Date(dueBefore.getTime() + 1);
134
+ // Keep accounting documents bounded by workers, not buckets or scan batches.
135
+ const workerUsage = Array.from({ length: this.storage.factory.chunkCompactionConcurrency }, () => new ObjectStorageUsage(this.db, this.group_id, createObjectStorageUsageWriterId()));
136
+ while (true) {
137
+ this.signal?.throwIfAborted();
138
+ const states = await this.findScheduledBucketBatch(dueBefore);
139
+ if (states.length == 0) {
140
+ break;
141
+ }
142
+ // Keep eligibility bounded by dueBefore, but classify with the current
143
+ // server time so buckets that age into full compaction can advance.
144
+ const batchStartedAt = await this.readCompactionTime();
145
+ const scheduled = [];
146
+ for (const state of states) {
147
+ try {
148
+ scheduled.push({
149
+ state,
150
+ decision: chooseCompactionKind(state, batchStartedAt, this),
151
+ selectedKind: chooseRequestedCompactionKind(state, requestedKind, this)
152
+ });
153
+ }
154
+ catch (error) {
155
+ await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
156
+ }
157
+ }
158
+ const noOpStates = scheduled.filter(({ state, decision, selectedKind }) => state.compact_lease == null && (requestedKind == null ? decision.kind : selectedKind) == null);
159
+ await this.rescheduleUnclaimedBuckets(noOpStates, rescheduleNotBefore);
160
+ const processBucket = async ({ state, decision, selectedKind }, objectStorageUsage, chunksOnly = false) => {
161
+ const kind = requestedKind == null ? decision.kind : selectedKind;
162
+ if (state.compact_lease == null && kind == null) {
163
+ return;
164
+ }
165
+ try {
166
+ await using lease = await this.claimBucket({ _id: state._id, next_compact_check: { $lte: dueBefore } });
167
+ if (lease == null) {
168
+ return;
169
+ }
170
+ const claimedDecision = chooseCompactionKind(lease.state, lease.startedAt, this);
171
+ const claimedKind = requestedKind == null
172
+ ? claimedDecision.kind
173
+ : chooseRequestedCompactionKind(lease.state, requestedKind, this);
174
+ if (chunksOnly && claimedKind === CompactionKind.Full) {
175
+ // The decision changed after scanning. Release the lease without
176
+ // rescheduling; the next batch will classify it with a fresh timestamp.
177
+ return;
178
+ }
179
+ if (claimedKind == null) {
180
+ await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
181
+ }
182
+ else if (this.isCompactionTargetCovered(lease.state, claimedKind)) {
183
+ // The run cannot advance this kind's watermark without regressing
184
+ // already-published progress. Keep any newer work scheduled.
185
+ await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
186
+ }
187
+ else {
188
+ await this.compactClaimedBucket(lease, claimedKind, claimedDecision, rescheduleNotBefore, objectStorageUsage);
189
+ }
190
+ }
191
+ catch (error) {
192
+ if (this.signal?.aborted) {
193
+ // When aborted, stop completely, rather than logging and re-scheduling individual buckets.
194
+ // The lease on the current bucket is still released automatically.
195
+ throw error;
196
+ }
197
+ await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
198
+ }
199
+ };
200
+ const chunkBuckets = scheduled.filter(({ decision, selectedKind }) => (requestedKind == null ? decision.kind : selectedKind) === CompactionKind.Chunks);
201
+ const sequentialBuckets = scheduled.filter(({ decision, selectedKind }) => (requestedKind == null ? decision.kind : selectedKind) !== CompactionKind.Chunks);
202
+ await this.runChunkCompactionWorkers(chunkBuckets, workerUsage, (entry, usage) => processBucket(entry, usage, true));
203
+ // Full compaction cannot overlap chunk workers from this job, and only
204
+ // one full bucket is processed at a time.
205
+ for (const entry of sequentialBuckets) {
206
+ await processBucket(entry, this.objectStorageUsage);
207
+ }
79
208
  }
80
- return this.dirtyBucketBatchForChecksumsForCollection(this.db.bucketState(this.group_id), {
81
- 'estimate_since_compact.count': { $gte: options.minBucketChanges }
82
- }, (bucketState) => bucketState._id.d);
83
209
  }
84
- async writeBucketStateUpdates() {
85
- await this.db
210
+ /** Use MongoDB's clock, matching scheduling and lease timestamps. */
211
+ async readCompactionTime() {
212
+ const [{ now }] = await this.db.db
213
+ .aggregate([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
214
+ .toArray();
215
+ return now;
216
+ }
217
+ /** Process one scheduled batch with a fixed pool of workers. */
218
+ async runChunkCompactionWorkers(buckets, workerUsage, processBucket) {
219
+ const signal = this.signal;
220
+ let nextBucket = 0;
221
+ let failed = false;
222
+ const runWorker = async (usage) => {
223
+ try {
224
+ while (!failed && nextBucket < buckets.length) {
225
+ // Taking an entry has no await, so each worker gets a different bucket.
226
+ // A worker takes another only after finishing its current bucket.
227
+ const bucket = buckets[nextBucket++];
228
+ // This pool bounds one job; the factory semaphore bounds all jobs together.
229
+ // Acquire before claiming the bucket lease, and hold until it is released.
230
+ const acquired = await acquireSemaphoreAbortable(this.storage.factory.chunkCompactionSlots, signal);
231
+ if (acquired === 'aborted') {
232
+ signal?.throwIfAborted();
233
+ return;
234
+ }
235
+ const [, releaseSlot] = acquired;
236
+ try {
237
+ // A sibling may have failed while this worker waited for a slot.
238
+ if (failed)
239
+ return;
240
+ signal?.throwIfAborted();
241
+ await processBucket(bucket, usage);
242
+ }
243
+ finally {
244
+ releaseSlot();
245
+ }
246
+ // Let replication and other event-loop work run between buckets.
247
+ await setImmediate();
248
+ }
249
+ }
250
+ catch (error) {
251
+ // Drain work already started, but do not let siblings start new buckets.
252
+ failed = true;
253
+ throw error;
254
+ }
255
+ };
256
+ // Concurrent transactions must not all increment the same usage document.
257
+ // Reuse one writer for each worker instead of creating one per bucket.
258
+ const workers = workerUsage.map(runWorker);
259
+ // Do not release the caller's replication lock or run cleanup while a
260
+ // sibling worker still owns a bucket lease or is finishing a replacement.
261
+ // Wait for every worker even on failure, then propagate the first error.
262
+ const results = await Promise.allSettled(workers);
263
+ for (const result of results) {
264
+ if (result.status === 'rejected') {
265
+ throw result.reason;
266
+ }
267
+ }
268
+ }
269
+ /** Read a bounded, priority-ordered snapshot of currently claimable scheduled work. */
270
+ async findScheduledBucketBatch(dueBefore) {
271
+ return this.db
86
272
  .bucketState(this.group_id)
87
- .bulkWrite(this.bucketStateUpdates, {
88
- ordered: false
89
- });
273
+ .find({
274
+ next_compact_check: { $lte: dueBefore },
275
+ ...AVAILABLE_LEASE_EXPR
276
+ })
277
+ .sort({ next_compact_check: 1 })
278
+ .limit(SCHEDULED_COMPACTION_BATCH_SIZE)
279
+ .toArray();
90
280
  }
91
281
  /**
92
- * The compactor operates on persisted definition ids only - never on parsed sources.
93
- * This narrowed view makes the source-resolving checksum methods unreachable here.
282
+ * Reschedule snapshots that were already known to be no-ops without first
283
+ * taking a lease. Every decision input is compared so a concurrent writer
284
+ * or compactor simply makes the update a no-op instead of losing work. A
285
+ * successful reschedule moves beyond this run's fixed selection boundary.
94
286
  */
95
- get definitionChecksums() {
96
- return this.storage.checksums;
97
- }
98
- async computeChecksumsForBuckets(buckets) {
99
- return this.definitionChecksums.computePartialChecksumsDirectByDefinition(buckets.map(({ bucket, definitionId }) => {
100
- if (definitionId == null) {
101
- throw new ServiceAssertionError(`Missing definitionId for bucket checksum update on bucket ${bucket}`);
287
+ async rescheduleUnclaimedBuckets(states, notBefore) {
288
+ if (states.length == 0) {
289
+ return;
290
+ }
291
+ await this.db.bucketState(this.group_id).bulkWrite(states.map(({ state, decision }) => ({
292
+ updateOne: {
293
+ filter: unclaimedSnapshotFilter(state),
294
+ update: [{ $set: { next_compact_check: this.rescheduleAtOrAfter(decision.nextCompactCheck, notBefore) } }]
102
295
  }
103
- return {
104
- bucket,
105
- definitionId,
106
- end: this.maxOpId
107
- };
108
- }));
296
+ })), { ordered: false });
297
+ }
298
+ /**
299
+ * Isolate a malformed bucket so it cannot prevent other scheduled buckets
300
+ * from compacting. The snapshot filter preserves any concurrent write or
301
+ * compactor result instead of overwriting its next check.
302
+ */
303
+ async rescheduleFailedBucket(state, notBefore, error) {
304
+ this.logger.error(`Failed to compact scheduled bucket ${state._id.b}; rescheduling it`, error);
305
+ try {
306
+ await this.db
307
+ .bucketState(this.group_id)
308
+ .updateOne(unclaimedSnapshotFilter(state), [{ $set: { next_compact_check: notBefore } }]);
309
+ }
310
+ catch (rescheduleError) {
311
+ this.logger.error(`Failed to reschedule bucket ${state._id.b} after a compaction error`, rescheduleError);
312
+ }
313
+ }
314
+ /**
315
+ * Given a bucket filter, claim a lease on the bucket. The filter should include a filter on _id.
316
+ *
317
+ * Resolves to null if the bucket is already claimed, not found, or filtered out.
318
+ */
319
+ async claimBucket(filter, sort) {
320
+ return CompactionLease.claim(this.db.bucketState(this.group_id), filter, sort, this.compactLeaseDurationMs);
321
+ }
322
+ async compactClaimedBucket(lease, kind, decision, rescheduleNotBefore, objectStorageUsage = this.objectStorageUsage) {
323
+ const context = new CompactionContext(lease, kind, decision, rescheduleNotBefore, this.compactionTarget(lease.state));
324
+ lease.startRenewal();
325
+ await this.compactSingleBucket(context, objectStorageUsage);
326
+ }
327
+ async rescheduleClaimedBucket(lease, decision, notBefore) {
328
+ await lease.reschedule(this.rescheduleAtOrAfter(decision.nextCompactCheck, notBefore));
329
+ }
330
+ rescheduleAtOrAfter(nextCompactCheck, notBefore) {
331
+ return notBefore == null ? nextCompactCheck : { $max: [nextCompactCheck, notBefore] };
109
332
  }
110
- bucketStateFilter(bucket, definitionId) {
111
- if (definitionId == null) {
112
- throw new ServiceAssertionError(`Missing definitionId for V3 bucket state filter on bucket ${bucket}`);
333
+ compactionTarget(state) {
334
+ return this.maxOpIdCap == null || state.last_op < this.maxOpIdCap ? state.last_op : this.maxOpIdCap;
335
+ }
336
+ isCompactionTargetCovered(state, kind) {
337
+ const target = this.compactionTarget(state);
338
+ if (kind == CompactionKind.Chunks) {
339
+ return state.compacted_state != null && state.compacted_state.op_id >= target;
113
340
  }
114
- return {
115
- _id: {
116
- d: definitionId,
117
- b: bucket
341
+ if (state.last_full_compact != null && state.last_full_compact.op_id >= target) {
342
+ return true;
343
+ }
344
+ // A full compact may change counts before the checksum-cache boundary.
345
+ // Wait for the safe target to catch up instead of publishing an older or
346
+ // stale cache. At the same boundary, full coverage can still advance.
347
+ return state.compacted_state != null && state.compacted_state.op_id > target;
348
+ }
349
+ get objectStorageLifecycle() {
350
+ if (!this.storage.objectStorage) {
351
+ throw new Error('Object storage is not configured');
352
+ }
353
+ return new ObjectStorageLifecycle(this.db, this.group_id, this.storage.objectStorage);
354
+ }
355
+ async compactSingleBucket(context, objectStorageUsage) {
356
+ if (context.kind == CompactionKind.Chunks) {
357
+ return this.compactSingleBucketChunks(context, objectStorageUsage);
358
+ }
359
+ return this.compactSingleBucketFully(context, objectStorageUsage);
360
+ }
361
+ /**
362
+ * Merge adjacent bucket-data chunks without inspecting their operations
363
+ * unless a merge is possible. The metadata contains enough information to
364
+ * update the persisted checksum state and to decide whether a group can fit
365
+ * in one chunk.
366
+ */
367
+ async compactSingleBucketChunks(context, objectStorageUsage) {
368
+ const bucket = context.state._id.b;
369
+ const resolvedDefinitionId = context.state._id.d;
370
+ const bucketContext = new BucketDataContextV3(this.db, {
371
+ bucket,
372
+ definitionId: resolvedDefinitionId,
373
+ replicationStreamId: this.group_id
374
+ });
375
+ const collection = this.db.bucketData(this.group_id, resolvedDefinitionId);
376
+ const dataContext = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
377
+ let previousCompactedState = context.state.compacted_state;
378
+ // A zero boundary represents an empty prefix, so there is no stored chunk
379
+ // whose statistics need to be carried into this pass.
380
+ if (previousCompactedState?.op_id === 0n) {
381
+ previousCompactedState = undefined;
382
+ }
383
+ // Include the last previously compacted chunk as well as new chunks. It
384
+ // is the only old chunk which can become mergeable with the new tail.
385
+ let lowerBound = previousCompactedState != null ? bucketContext.docId(previousCompactedState.op_id - 1n) : bucketContext.minId;
386
+ const upperBound = bucketContext.docId(context.targetOp + 1n);
387
+ let cachedBoundaryToVerify = previousCompactedState?.op_id;
388
+ let compactedOpId = null;
389
+ let overlappingCompactedChunk;
390
+ let compactedTail = emptyBucketStats();
391
+ let pendingChunks = [];
392
+ let pendingSize = 0;
393
+ while (true) {
394
+ this.signal?.throwIfAborted();
395
+ await context.lease.throwIfLost();
396
+ const batch = await readCompactionBatch(collection.aggregate([
397
+ {
398
+ $match: {
399
+ _id: {
400
+ $gt: lowerBound,
401
+ $lt: upperBound
402
+ }
403
+ }
404
+ },
405
+ { $sort: { _id: 1 } },
406
+ { $limit: this.moveBatchQueryLimit },
407
+ {
408
+ $project: {
409
+ _id: 1,
410
+ min_op: 1,
411
+ checksum: 1,
412
+ count: 1,
413
+ size: 1,
414
+ target_op: 1,
415
+ storage_ref: 1
416
+ }
417
+ }
418
+ ], { batchSize: this.moveBatchQueryLimit + 1 }), {
419
+ byteLimit: this.moveBatchByteLimit,
420
+ documentLimit: this.moveBatchQueryLimit
421
+ });
422
+ if (cachedBoundaryToVerify != null) {
423
+ const cachedBoundary = cachedBoundaryToVerify;
424
+ cachedBoundaryToVerify = undefined;
425
+ if (batch.documents[0]?._id.o !== cachedBoundary) {
426
+ // A previous attempt may have replaced the cached boundary before
427
+ // finalizing bucket state. Keep the persisted cache available to
428
+ // readers, but ignore it in this attempt and calculate its
429
+ // replacement through the normal scan from the bucket beginning.
430
+ previousCompactedState = undefined;
431
+ lowerBound = bucketContext.minId;
432
+ continue;
433
+ }
118
434
  }
435
+ if (batch.documents.length == 0) {
436
+ break;
437
+ }
438
+ for (const doc of batch.documents) {
439
+ compactedOpId = maxOpId(compactedOpId, doc._id.o);
440
+ const documentStats = statsForDocument(doc);
441
+ if (previousCompactedState?.op_id === doc._id.o) {
442
+ overlappingCompactedChunk = documentStats;
443
+ }
444
+ const nextSize = pendingSize + doc.size;
445
+ if (pendingChunks.length > 0 && nextSize > DEFAULT_MAX_DOC_SIZE_BYTES) {
446
+ const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext, objectStorageUsage);
447
+ compactedTail = combineAdjacentStats(compactedTail, groupStats);
448
+ pendingChunks = [];
449
+ pendingSize = 0;
450
+ }
451
+ pendingChunks.push(doc);
452
+ pendingSize += doc.size;
453
+ }
454
+ lowerBound = batch.documents[batch.documents.length - 1]._id;
455
+ if (!batch.hasMore) {
456
+ break;
457
+ }
458
+ }
459
+ if (pendingChunks.length > 0) {
460
+ const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext, objectStorageUsage);
461
+ compactedTail = combineAdjacentStats(compactedTail, groupStats);
462
+ }
463
+ if (compactedOpId == null) {
464
+ await this.finalizeSkippedBucket(context);
465
+ return;
466
+ }
467
+ const compactedState = previousCompactedState == null
468
+ ? compactedTail
469
+ : combineChunkStats(previousCompactedState, compactedTail, overlappingCompactedChunk);
470
+ const tailStats = compactedOpId == context.lastOp
471
+ ? undefined
472
+ : await this.readBucketStats(bucket, resolvedDefinitionId, context.lastOp, bucketContext.docId(compactedOpId));
473
+ const result = {
474
+ compactedState,
475
+ bucketStats: tailStats == null ? compactedState : combineAdjacentStats(compactedState, tailStats)
119
476
  };
477
+ await this.finalizeCompactedBucket({ context, compactedOpId, compactionResult: result, puts: 0 });
478
+ this.compactedBucketCount++;
479
+ this.logger.info(`Compacted bucket chunks ${bucket}: ${result.bucketStats.count} ops, ${result.bucketStats.chunks} chunks, ${formatBytes(result.bucketStats.bytes)}`);
120
480
  }
121
- async getBucketDataContext(bucket, definitionId) {
122
- let resolvedDefinitionId = definitionId;
123
- if (resolvedDefinitionId == null) {
124
- const allDefinitionIds = this.storage.storageIds.bucketDefinitionIds;
125
- if (allDefinitionIds.length > 0) {
126
- const potentialIds = allDefinitionIds.map((id) => ({ d: id, b: bucket }));
127
- const bucketState = await this.db.bucketState(this.group_id).findOne({
128
- _id: { $in: potentialIds }
129
- });
130
- if (bucketState != null) {
131
- resolvedDefinitionId = bucketState._id.d;
481
+ async flushChunkMerge(bucket, inputs, collection, context, bucketContext, objectStorageUsage) {
482
+ if (inputs.length == 1) {
483
+ return statsForDocument(inputs[0]);
484
+ }
485
+ try {
486
+ this.signal?.throwIfAborted();
487
+ // The metadata scan deliberately excluded ops. Read inline payloads only
488
+ // for this merge group; object-storage payloads are fetched below using
489
+ // the same rule.
490
+ const inlineInputs = inputs.filter((input) => input.storage_ref == null);
491
+ if (inlineInputs.length > 0) {
492
+ const inlineDocuments = await collection
493
+ .find({ _id: { $in: inlineInputs.map((input) => input._id) } }, { projection: { _id: 1, ops: 1 } })
494
+ .toArray();
495
+ const opsById = new Map(inlineDocuments.map((document) => [document._id.o.toString(), document.ops]));
496
+ for (const input of inlineInputs) {
497
+ input.ops = opsById.get(input._id.o.toString());
132
498
  }
133
499
  }
500
+ await hydrateBucketDataDocuments(inputs, this.storage.objectStorage, { signal: this.signal });
501
+ const operations = inputs.flatMap((input) => Array.from(loadBucketDataDocument(context, input)));
502
+ const targetOp = inputs.reduce((maxTarget, input) => maxOpId(maxTarget, input.target_op), null);
503
+ const result = await this.flushCompactionGroup(bucket, {
504
+ inputs,
505
+ ops: operations,
506
+ changed: true,
507
+ targetOp
508
+ }, bucketContext, context, objectStorageUsage);
509
+ return result.stats;
510
+ }
511
+ finally {
512
+ // The scan batch also references these documents. Do not retain hydrated
513
+ // operations after finishing this merge group.
514
+ for (const input of inputs) {
515
+ delete input.ops;
516
+ }
517
+ await setImmediate();
134
518
  }
135
- if (resolvedDefinitionId == null) {
136
- return null;
519
+ }
520
+ async finalizeCompactedBucket({ context, compactedOpId, compactionResult, puts }) {
521
+ await context.lease.throwIfLost();
522
+ const startedStats = bucketStats(context.state);
523
+ const delta = {
524
+ count: compactionResult.bucketStats.count - startedStats.count,
525
+ bytes: compactionResult.bucketStats.bytes - startedStats.bytes,
526
+ chunks: compactionResult.bucketStats.chunks - startedStats.chunks
527
+ };
528
+ const coveredClaimedHead = compactedOpId >= context.lastOp;
529
+ const concurrentWriteCheck = { $gt: ['$last_op', context.lastOp] };
530
+ const remainingFullWorkCheck = coveredClaimedHead ? concurrentWriteCheck : true;
531
+ const nextAfterPartialFullCompact = this.rescheduleAtOrAfter({ $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } }, context.rescheduleNotBefore);
532
+ const nextCheckForUncompactedWork = this.rescheduleAtOrAfter({
533
+ $min: [
534
+ new Date(firstUncompactedWrite(context.state).getTime() + this.maxCompactFullIntervalMs),
535
+ { $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } }
536
+ ]
537
+ }, context.rescheduleNotBefore);
538
+ const update = {
539
+ compacted_state: {
540
+ op_id: compactedOpId,
541
+ checksum: BigInt(compactionResult.compactedState.checksum),
542
+ count: compactionResult.compactedState.count,
543
+ bytes: compactionResult.compactedState.bytes,
544
+ chunks: compactionResult.compactedState.chunks,
545
+ at: '$$NOW'
546
+ },
547
+ bucket_stats: {
548
+ count: { $add: ['$bucket_stats.count', delta.count] },
549
+ bytes: { $add: ['$bucket_stats.bytes', delta.bytes] },
550
+ chunks: { $add: ['$bucket_stats.chunks', delta.chunks] }
551
+ },
552
+ first_uncompacted_write: context.kind == CompactionKind.Full
553
+ ? { $cond: [remainingFullWorkCheck, '$$NOW', '$$REMOVE'] }
554
+ : '$first_uncompacted_write',
555
+ next_compact_check: context.kind == CompactionKind.Full
556
+ ? { $cond: [remainingFullWorkCheck, nextAfterPartialFullCompact, '$$REMOVE'] }
557
+ : nextCheckForUncompactedWork
558
+ };
559
+ if (context.kind == CompactionKind.Full) {
560
+ update.last_full_compact = {
561
+ op_id: compactedOpId,
562
+ count: compactionResult.compactedState.count,
563
+ puts,
564
+ at: '$$NOW'
565
+ };
137
566
  }
138
- return new BucketDataContextV3(this.db, {
567
+ await context.lease.finalize(update);
568
+ }
569
+ async finalizeSkippedBucket(context) {
570
+ // A maxOpId cap can exclude the first remaining document entirely. Avoid
571
+ // immediately claiming the same no-progress bucket again in this run.
572
+ await this.rescheduleClaimedBucket(context.lease, {
573
+ ...context.decision,
574
+ nextCompactCheck: {
575
+ $max: [
576
+ context.decision.nextCompactCheck,
577
+ { $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } }
578
+ ]
579
+ }
580
+ }, context.rescheduleNotBefore);
581
+ }
582
+ /**
583
+ * Read bucket stats directly from bucket_data documents.
584
+ */
585
+ async readBucketStats(bucket, definitionId, maxOp, lowerBound) {
586
+ const context = new BucketDataContextV3(this.db, {
139
587
  bucket,
140
- definitionId: resolvedDefinitionId,
588
+ definitionId,
141
589
  replicationStreamId: this.group_id
142
590
  });
591
+ const [stats] = await this.db
592
+ .bucketData(this.group_id, definitionId)
593
+ .aggregate([
594
+ {
595
+ $match: {
596
+ _id: lowerBound == null
597
+ ? { $gte: context.minId, $lte: context.docId(maxOp) }
598
+ : { $gt: lowerBound, $lte: context.docId(maxOp) }
599
+ }
600
+ },
601
+ {
602
+ $group: {
603
+ _id: null,
604
+ count: { $sum: '$count' },
605
+ bytes: { $sum: '$size' },
606
+ chunks: { $sum: 1 },
607
+ checksum: { $sum: '$checksum' }
608
+ }
609
+ }
610
+ ])
611
+ .toArray();
612
+ return {
613
+ count: Number(stats?.count ?? 0),
614
+ bytes: BigInt(stats?.bytes ?? 0),
615
+ chunks: Number(stats?.chunks ?? 0),
616
+ checksum: typeof stats?.checksum == 'bigint'
617
+ ? Number(BigInt.asIntN(32, stats.checksum))
618
+ : addChecksums(0, Number(stats?.checksum ?? 0))
619
+ };
143
620
  }
144
- async compactSingleBucket(bucket, definitionId = null) {
145
- const bucketContext = await this.getBucketDataContext(bucket, definitionId);
146
- if (bucketContext == null) {
147
- return;
148
- }
149
- const resolvedDefinitionId = bucketContext.key.definitionId;
621
+ async compactSingleBucketFully(context, objectStorageUsage) {
622
+ const bucket = context.state._id.b;
623
+ const resolvedDefinitionId = context.state._id.d;
624
+ const bucketContext = new BucketDataContextV3(this.db, {
625
+ bucket,
626
+ definitionId: resolvedDefinitionId,
627
+ replicationStreamId: this.group_id
628
+ });
150
629
  const collection = this.db.bucketData(this.group_id, resolvedDefinitionId);
151
- const context = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
630
+ const dataContext = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
152
631
  const lowerBound = bucketContext.minId;
153
- let upperBound = bucketContext.docId(this.maxOpId + 1n);
154
- let totalChecksum = 0;
632
+ let upperBound = bucketContext.docId(context.targetOp + 1n);
155
633
  let totalOpCount = 0;
156
- let totalOpBytes = 0;
157
634
  let lastNotPut = null;
158
635
  let opsSincePut = 0;
159
636
  let compactedOpId = null;
160
637
  let clearBoundary = null;
638
+ let compactedStats = emptyBucketStats();
161
639
  const seen = new Map();
162
640
  let trackingSize = 0;
641
+ let putCount = 0;
163
642
  let pendingGroup = null;
164
643
  // --- Read batch from MongoDB ---
165
644
  while (true) {
166
645
  this.signal?.throwIfAborted();
646
+ await context.lease.throwIfLost();
167
647
  const pipeline = [
168
648
  {
169
649
  $match: {
@@ -205,7 +685,7 @@ export class MongoCompactorV3 extends MongoCompactor {
205
685
  // merging is useful, and writes each final object at most once.
206
686
  for (const doc of batchDocs) {
207
687
  compactedOpId ??= doc._id.o;
208
- const originalOps = Array.from(loadBucketDataDocument(context, doc));
688
+ const originalOps = Array.from(loadBucketDataDocument(dataContext, doc));
209
689
  let changed = false;
210
690
  const compactedOps = [];
211
691
  let maxTargetOp = doc.target_op ?? null;
@@ -223,6 +703,7 @@ export class MongoCompactorV3 extends MongoCompactor {
223
703
  row_id: undefined,
224
704
  source_table: undefined,
225
705
  source_key: undefined,
706
+ subkey: undefined,
226
707
  data: null
227
708
  });
228
709
  changed = true;
@@ -238,6 +719,7 @@ export class MongoCompactorV3 extends MongoCompactor {
238
719
  }
239
720
  compactedOps.push(op);
240
721
  if (op.op == 'PUT') {
722
+ putCount++;
241
723
  lastNotPut = null;
242
724
  opsSincePut = 0;
243
725
  }
@@ -260,10 +742,6 @@ export class MongoCompactorV3 extends MongoCompactor {
260
742
  }
261
743
  }
262
744
  compactedOps.reverse();
263
- for (const op of compactedOps) {
264
- totalChecksum = addChecksums(totalChecksum, Number(op.checksum));
265
- totalOpBytes += op.data?.length ?? 0;
266
- }
267
745
  totalOpCount += compactedOps.length;
268
746
  const candidate = {
269
747
  inputs: [doc],
@@ -287,11 +765,12 @@ export class MongoCompactorV3 extends MongoCompactor {
287
765
  }
288
766
  else {
289
767
  const flushedGroup = pendingGroup;
290
- const documentId = await this.flushCompactionGroup(bucket, flushedGroup, bucketContext, context);
768
+ const result = await this.flushCompactionGroup(bucket, flushedGroup, bucketContext, dataContext, objectStorageUsage);
769
+ compactedStats = combineAdjacentStats(compactedStats, result.stats);
291
770
  if (lastNotPut != null &&
292
771
  flushedGroup.ops[0].o <= lastNotPut &&
293
772
  flushedGroup.ops[flushedGroup.ops.length - 1].o >= lastNotPut) {
294
- clearBoundary = { opId: lastNotPut, documentId };
773
+ clearBoundary = { opId: lastNotPut, documentId: result.documentId };
295
774
  }
296
775
  pendingGroup = candidate;
297
776
  }
@@ -305,14 +784,16 @@ export class MongoCompactorV3 extends MongoCompactor {
305
784
  this.logger.info(`Compacted batch of ${batchDocs.length} documents for bucket ${bucket}`);
306
785
  }
307
786
  if (pendingGroup != null) {
308
- const documentId = await this.flushCompactionGroup(bucket, pendingGroup, bucketContext, context);
787
+ const result = await this.flushCompactionGroup(bucket, pendingGroup, bucketContext, dataContext, objectStorageUsage);
788
+ compactedStats = combineAdjacentStats(compactedStats, result.stats);
309
789
  if (lastNotPut != null &&
310
790
  pendingGroup.ops[0].o <= lastNotPut &&
311
791
  pendingGroup.ops[pendingGroup.ops.length - 1].o >= lastNotPut) {
312
- clearBoundary = { opId: lastNotPut, documentId };
792
+ clearBoundary = { opId: lastNotPut, documentId: result.documentId };
313
793
  }
314
794
  }
315
795
  if (compactedOpId == null) {
796
+ await this.finalizeSkippedBucket(context);
316
797
  return;
317
798
  }
318
799
  // --- Clear: collapse leading MOVE/REMOVE/CLEAR sequence ---
@@ -320,25 +801,21 @@ export class MongoCompactorV3 extends MongoCompactor {
320
801
  if (clearBoundary == null || clearBoundary.opId != lastNotPut) {
321
802
  throw new ReplicationAssertionError(`Missing CLEAR boundary document for bucket ${bucket}`);
322
803
  }
323
- totalOpCount += await this.clearBucketLeading(lastNotPut, clearBoundary.documentId, bucketContext, collection, context);
804
+ const clearResult = await this.clearBucketLeading(lastNotPut, clearBoundary.documentId, bucketContext, collection, dataContext, objectStorageUsage);
805
+ totalOpCount += clearResult.opCountDiff;
806
+ compactedStats = applyStatsReplacement(compactedStats, clearResult.before, clearResult.after);
324
807
  }
808
+ const tailStats = compactedOpId == context.lastOp
809
+ ? undefined
810
+ : await this.readBucketStats(bucket, resolvedDefinitionId, context.lastOp, bucketContext.docId(compactedOpId));
811
+ const result = {
812
+ compactedState: compactedStats,
813
+ bucketStats: tailStats == null ? compactedStats : combineAdjacentStats(compactedStats, tailStats)
814
+ };
325
815
  // --- Finalize: update bucket checksums and state ---
326
- this.updateBucketChecksums({
327
- bucket,
328
- definitionId: resolvedDefinitionId,
329
- seen: new Map(),
330
- trackingSize: 0,
331
- lastNotPut: lastNotPut,
332
- opsSincePut: opsSincePut,
333
- checksum: totalChecksum,
334
- opCount: totalOpCount,
335
- opBytes: totalOpBytes
336
- }, compactedOpId);
337
- if (this.bucketStateUpdates.length > 0) {
338
- await this.writeBucketStateUpdates();
339
- this.bucketStateUpdates = [];
340
- }
341
- logger.info(`Compacted bucket ${bucket}: ${totalOpCount} surviving ops`);
816
+ await this.finalizeCompactedBucket({ context, compactedOpId, compactionResult: result, puts: putCount });
817
+ this.compactedBucketCount++;
818
+ this.logger.info(`Compacted bucket ${bucket}: ${totalOpCount} ops, ${result.bucketStats.chunks} chunks, ${formatBytes(result.bucketStats.bytes)}`);
342
819
  }
343
820
  /**
344
821
  * Persist replacement objects before starting the transaction, then atomically
@@ -346,9 +823,12 @@ export class MongoCompactorV3 extends MongoCompactor {
346
823
  * If verification or the transaction fails, the prepared markers retain enough
347
824
  * information for the uploaded objects to be cleaned up later.
348
825
  */
349
- async flushCompactionGroup(bucket, group, bucketContext, context) {
826
+ async flushCompactionGroup(bucket, group, bucketContext, context, objectStorageUsage) {
350
827
  if (group.inputs.length == 1 && !group.changed) {
351
- return group.inputs[0]._id;
828
+ return {
829
+ documentId: group.inputs[0]._id,
830
+ stats: statsForDocument(group.inputs[0])
831
+ };
352
832
  }
353
833
  const inputs = group.inputs;
354
834
  const idsToDelete = inputs.map((doc) => doc._id);
@@ -356,6 +836,7 @@ export class MongoCompactorV3 extends MongoCompactor {
356
836
  const expectedChecksum = inputs.reduce((sum, doc) => sum + doc.checksum, 0n);
357
837
  const expectedOpCount = inputs.reduce((sum, doc) => sum + doc.count, 0);
358
838
  const oldStoragePaths = inputs.flatMap((doc) => (doc.storage_ref ? [doc.storage_ref.path] : []));
839
+ const oldStorageBytes = inputs.reduce((sum, document) => sum + ObjectStorageUsage.bytes(document), 0n);
359
840
  const { documents, storagePaths: newStoragePaths, uploads } = await this.persistBucketData(bucket, [group.ops], context, undefined, { targetOp: group.targetOp });
360
841
  const session = this.db.client.startSession();
361
842
  try {
@@ -379,9 +860,13 @@ export class MongoCompactorV3 extends MongoCompactor {
379
860
  verification.opCountSum !== expectedOpCount) {
380
861
  throw new ConcurrentCompactionError(`Inputs changed while compacting bucket ${bucket}; restarting from the latest bucket state`);
381
862
  }
382
- await bucketContext.collection.deleteMany({ _id: { $in: idsToDelete } }, { session });
383
- await bucketContext.collection.insertMany(documents, { session });
384
- await this.finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, session);
863
+ // Replacement documents can reuse deleted IDs, so retain delete-before-insert ordering.
864
+ const writes = this.db.createWriteBatch(session, { ordered: true });
865
+ writes.deleteMany(bucketContext.collection, { _id: { $in: idsToDelete } });
866
+ writes.insertMany(bucketContext.collection, documents);
867
+ this.finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, writes);
868
+ this.recordObjectStorageReplacement(oldStorageBytes, documents, context.definitionId, writes, objectStorageUsage);
869
+ await writes.execute();
385
870
  }, {
386
871
  writeConcern: { w: 'majority' },
387
872
  readConcern: { level: 'snapshot' }
@@ -390,7 +875,10 @@ export class MongoCompactorV3 extends MongoCompactor {
390
875
  finally {
391
876
  await session.endSession();
392
877
  }
393
- return documents[0]._id;
878
+ return {
879
+ documentId: documents[0]._id,
880
+ stats: statsForDocuments(documents)
881
+ };
394
882
  }
395
883
  /**
396
884
  * Collapse the leading sequence of MOVE/REMOVE/CLEAR ops at the start
@@ -398,39 +886,52 @@ export class MongoCompactorV3 extends MongoCompactor {
398
886
  * before the known boundary document, then splits that boundary document
399
887
  * if it contains ops on both sides of lastNotPut.
400
888
  *
401
- * Returns the op count diff after replacing cleared ops with CLEAR ops.
889
+ * Returns the op count and stored-stat changes after replacing cleared ops
890
+ * with CLEAR ops.
402
891
  */
403
- async clearBucketLeading(lastNotPut, boundaryDocId, bucketContext, collection, context) {
892
+ async clearBucketLeading(lastNotPut, boundaryDocId, bucketContext, collection, context, objectStorageUsage) {
404
893
  let opCountDiff = 0;
894
+ let before = emptyBucketStats();
895
+ let after = emptyBucketStats();
405
896
  const session = this.db.client.startSession();
406
897
  try {
407
898
  let done = false;
408
899
  // First step is to clear full chunks that contain only CLEAR/MOVE/REMOVE operations.
409
900
  // There can be many of them, so we do one batch at a time.
410
901
  while (!done) {
411
- const batch = await this.clearLeadingFullDocuments(session, lastNotPut, boundaryDocId, bucketContext, collection, context);
902
+ const batch = await this.clearLeadingFullDocuments(session, lastNotPut, boundaryDocId, bucketContext, collection, context, objectStorageUsage);
412
903
  done = batch.done;
413
904
  opCountDiff += batch.opCountDiff;
905
+ before = combineAdjacentStats(before, batch.before);
906
+ after = combineAdjacentStats(after, batch.after);
414
907
  }
415
908
  // The final step is to process the "boundary" document: It may contain some CLEAR/MOVE/REMOVE operations,
416
909
  // potentially followed by PUT operations. This is only a single document, so no need for batching.
417
- opCountDiff += await this.clearBoundaryDocument(session, lastNotPut, boundaryDocId, bucketContext, collection, context);
910
+ const boundaryResult = await this.clearBoundaryDocument(session, lastNotPut, boundaryDocId, bucketContext, collection, context, objectStorageUsage);
911
+ opCountDiff += boundaryResult.opCountDiff;
912
+ before = combineAdjacentStats(before, boundaryResult.before);
913
+ after = combineAdjacentStats(after, boundaryResult.after);
418
914
  }
419
915
  finally {
420
916
  await session.endSession();
421
917
  }
422
- return opCountDiff;
918
+ return { opCountDiff, before, after };
423
919
  }
424
- async clearLeadingFullDocuments(session, lastNotPut, boundaryDocId, bucketContext, collection, context) {
920
+ async clearLeadingFullDocuments(session, lastNotPut, boundaryDocId, bucketContext, collection, context, objectStorageUsage) {
425
921
  const bucket = bucketContext.key.bucket;
426
922
  this.signal?.throwIfAborted();
427
- const prepared = await this.prepareCompactionUploads(bucket, context, [lastNotPut]);
923
+ let prepared;
428
924
  let done = false;
429
925
  let opCountDiff = 0;
926
+ let before = emptyBucketStats();
927
+ let after = emptyBucketStats();
430
928
  await session.withTransaction(async () => {
431
929
  done = false;
432
930
  opCountDiff = 0;
931
+ before = emptyBucketStats();
932
+ after = emptyBucketStats();
433
933
  const oldStoragePaths = [];
934
+ let oldStorageBytes = 0n;
434
935
  const query = collection.find({
435
936
  _id: {
436
937
  $gte: bucketContext.minId,
@@ -444,6 +945,7 @@ export class MongoCompactorV3 extends MongoCompactor {
444
945
  min_op: 1,
445
946
  checksum: 1,
446
947
  count: 1,
948
+ size: 1,
447
949
  target_op: 1,
448
950
  has_clear_op: 1,
449
951
  storage_ref: 1
@@ -456,14 +958,21 @@ export class MongoCompactorV3 extends MongoCompactor {
456
958
  let lastDocId = null;
457
959
  let clearOpCount = 0;
458
960
  let gotNonClearOp = false;
961
+ const inputStats = emptyBucketStats();
459
962
  for await (const doc of query.stream()) {
460
963
  if (doc.min_op > lastNotPut) {
461
964
  throw new ReplicationAssertionError(`Unexpected document before CLEAR boundary with min_op ${doc.min_op} > ${lastNotPut} in bucket ${bucket}`);
462
965
  }
463
966
  lastDocId = doc._id;
967
+ const documentStats = statsForDocument(doc);
968
+ inputStats.count += documentStats.count;
969
+ inputStats.bytes += documentStats.bytes;
970
+ inputStats.chunks += documentStats.chunks;
971
+ inputStats.checksum = addChecksums(inputStats.checksum, documentStats.checksum);
464
972
  if (doc.storage_ref) {
465
973
  oldStoragePaths.push(doc.storage_ref.path);
466
974
  }
975
+ oldStorageBytes += ObjectStorageUsage.bytes(doc);
467
976
  // The compaction scan established that every operation before the
468
977
  // boundary is MOVE/REMOVE/CLEAR. Root metadata is sufficient to fold
469
978
  // whole documents into one CLEAR, so avoid downloading their payloads.
@@ -486,13 +995,15 @@ export class MongoCompactorV3 extends MongoCompactor {
486
995
  done = true;
487
996
  return;
488
997
  }
998
+ prepared ??= await this.prepareCompactionUploads(bucket, context, [lastNotPut]);
489
999
  this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastDocId?.o}`);
490
- await collection.deleteMany({
1000
+ const writes = this.db.createWriteBatch(session, { ordered: true });
1001
+ writes.deleteMany(collection, {
491
1002
  _id: {
492
1003
  $gte: bucketContext.minId,
493
1004
  $lte: lastDocId
494
1005
  }
495
- }, { session });
1006
+ });
496
1007
  const clearOp = {
497
1008
  bucketKey: { ...context, bucket },
498
1009
  o: lastDocId.o,
@@ -503,23 +1014,32 @@ export class MongoCompactorV3 extends MongoCompactor {
503
1014
  const persisted = await this.persistBucketData(bucket, [[clearOp]], context, prepared, {
504
1015
  targetOp: maxTargetOp
505
1016
  });
506
- await collection.insertOne(persisted.documents[0], { session });
507
- await this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, session);
1017
+ writes.insertOne(collection, persisted.documents[0]);
1018
+ this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, writes);
1019
+ this.recordObjectStorageReplacement(oldStorageBytes, persisted.documents, context.definitionId, writes, objectStorageUsage);
1020
+ await writes.execute();
508
1021
  opCountDiff = -clearedOpCount + 1;
1022
+ before = inputStats;
1023
+ after = statsForDocuments(persisted.documents);
509
1024
  }, {
510
1025
  writeConcern: { w: 'majority' },
511
1026
  readConcern: { level: 'snapshot' }
512
1027
  });
513
- return { done, opCountDiff };
1028
+ return { done, opCountDiff, before, after };
514
1029
  }
515
- async clearBoundaryDocument(session, lastNotPut, boundaryDocId, bucketContext, collection, context) {
1030
+ async clearBoundaryDocument(session, lastNotPut, boundaryDocId, bucketContext, collection, context, objectStorageUsage) {
516
1031
  const bucket = bucketContext.key.bucket;
517
1032
  this.signal?.throwIfAborted();
518
1033
  const prepared = await this.prepareCompactionUploads(bucket, context, [lastNotPut, boundaryDocId.o]);
519
1034
  let opCountDiff = 0;
1035
+ let before = emptyBucketStats();
1036
+ let after = emptyBucketStats();
520
1037
  await session.withTransaction(async () => {
521
1038
  opCountDiff = 0;
1039
+ before = emptyBucketStats();
1040
+ after = emptyBucketStats();
522
1041
  const oldStoragePaths = [];
1042
+ let oldStorageBytes = 0n;
523
1043
  const query = collection.find({
524
1044
  // This is a range query, but should only ever return two documents:
525
1045
  // 1. The CLEAR op from the previous clearLeadingFullDocuments.
@@ -536,6 +1056,7 @@ export class MongoCompactorV3 extends MongoCompactor {
536
1056
  min_op: 1,
537
1057
  checksum: 1,
538
1058
  count: 1,
1059
+ size: 1,
539
1060
  target_op: 1,
540
1061
  ops: 1,
541
1062
  storage_ref: 1
@@ -547,15 +1068,22 @@ export class MongoCompactorV3 extends MongoCompactor {
547
1068
  let clearedOpCount = 0;
548
1069
  let maxTargetOp = null;
549
1070
  const boundarySurvivors = [];
1071
+ const inputStats = emptyBucketStats();
550
1072
  for await (const doc of query.stream()) {
551
1073
  docsRead++;
552
1074
  if (docsRead > 2) {
553
1075
  throw new ReplicationAssertionError(`Unexpected extra document before CLEAR boundary in bucket ${bucket}`);
554
1076
  }
1077
+ const documentStats = statsForDocument(doc);
1078
+ inputStats.count += documentStats.count;
1079
+ inputStats.bytes += documentStats.bytes;
1080
+ inputStats.chunks += documentStats.chunks;
1081
+ inputStats.checksum = addChecksums(inputStats.checksum, documentStats.checksum);
555
1082
  const isBoundaryDoc = doc._id.o == boundaryDocId.o;
556
1083
  if (doc.storage_ref) {
557
1084
  oldStoragePaths.push(doc.storage_ref.path);
558
1085
  }
1086
+ oldStorageBytes += ObjectStorageUsage.bytes(doc);
559
1087
  await hydrateBucketDataDocuments([doc], this.storage.objectStorage, { signal: this.signal });
560
1088
  maxTargetOp = maxOpId(maxTargetOp, doc.target_op);
561
1089
  for (const op of loadBucketDataDocument(context, doc)) {
@@ -581,12 +1109,13 @@ export class MongoCompactorV3 extends MongoCompactor {
581
1109
  throw new Error(`CLEAR boundary document not found for bucket ${bucket}`);
582
1110
  }
583
1111
  this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastNotPut}`);
584
- await collection.deleteMany({
1112
+ const writes = this.db.createWriteBatch(session, { ordered: true });
1113
+ writes.deleteMany(collection, {
585
1114
  _id: {
586
1115
  $gte: bucketContext.minId,
587
1116
  $lte: boundaryDocId
588
1117
  }
589
- }, { session });
1118
+ });
590
1119
  const clearOp = {
591
1120
  bucketKey: { ...context, bucket },
592
1121
  o: lastNotPut,
@@ -603,14 +1132,18 @@ export class MongoCompactorV3 extends MongoCompactor {
603
1132
  const persisted = await this.persistBucketData(bucket, chunks, context, prepared, {
604
1133
  targetOp: maxTargetOp ?? undefined
605
1134
  });
606
- await collection.insertMany(persisted.documents, { session });
607
- await this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, session);
1135
+ writes.insertMany(collection, persisted.documents);
1136
+ this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, writes);
1137
+ this.recordObjectStorageReplacement(oldStorageBytes, persisted.documents, context.definitionId, writes, objectStorageUsage);
1138
+ await writes.execute();
608
1139
  opCountDiff = -clearedOpCount + 1;
1140
+ before = inputStats;
1141
+ after = statsForDocuments(persisted.documents);
609
1142
  }, {
610
1143
  writeConcern: { w: 'majority' },
611
1144
  readConcern: { level: 'snapshot' }
612
1145
  });
613
- return opCountDiff;
1146
+ return { opCountDiff, before, after };
614
1147
  }
615
1148
  /**
616
1149
  * Reserve stable object paths before starting a retryable MongoDB transaction.
@@ -630,12 +1163,22 @@ export class MongoCompactorV3 extends MongoCompactor {
630
1163
  return lifecycle.prepareUploads(paths);
631
1164
  }
632
1165
  /** Publish replacement uploads and retire superseded objects in the same transaction. */
633
- async finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, session) {
1166
+ finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, writes) {
634
1167
  if (!this.storage.objectStorage) {
635
1168
  return;
636
1169
  }
637
- await this.objectStorageLifecycle.publishUploads(uploads, session);
638
- await this.objectStorageLifecycle.retire(Array.from(oldStoragePaths).filter((path) => !newStoragePaths.has(path)), session);
1170
+ this.objectStorageLifecycle.publishUploads(uploads, writes);
1171
+ this.objectStorageLifecycle.retire(Array.from(oldStoragePaths).filter((path) => !newStoragePaths.has(path)), writes);
1172
+ }
1173
+ recordObjectStorageReplacement(oldBytes, newDocuments, definitionId, writes, objectStorageUsage) {
1174
+ if (!this.storage.objectStorage) {
1175
+ return;
1176
+ }
1177
+ let newBytes = 0n;
1178
+ for (const document of newDocuments) {
1179
+ newBytes += ObjectStorageUsage.bytes(document);
1180
+ }
1181
+ objectStorageUsage.applyDelta(definitionId, newBytes - oldBytes, writes);
639
1182
  }
640
1183
  async persistBucketData(bucket, chunks, context, preparedUploads, options) {
641
1184
  const serializedChunks = chunks.map((chunk) => serializeBucketData(bucket, chunk, options));
@@ -679,7 +1222,7 @@ export class MongoCompactorV3 extends MongoCompactor {
679
1222
  return serialized;
680
1223
  }
681
1224
  const { ops, ...metadata } = serialized;
682
- const { fileSize } = await store.store(upload.path, ops);
1225
+ const { fileSize } = await store.store(upload.path, ops, { signal: this.signal });
683
1226
  storagePaths.add(upload.path);
684
1227
  return {
685
1228
  ...metadata,