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

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