@powersync/service-module-mongodb-storage 0.20.0 → 0.21.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 (176) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +31 -3
  3. package/dist/storage/MongoBucketStorage.js +365 -206
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +15 -4
  6. package/dist/storage/implementation/MongoBucketBatch.js +35 -9
  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 +3 -1
  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/MongoWriteCheckpointAPI.d.ts +9 -7
  27. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +7 -28
  28. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  29. package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +11 -5
  30. package/dist/storage/implementation/SyncRuleStateUpdate.js +14 -6
  31. package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -1
  32. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +22 -2
  33. package/dist/storage/implementation/common/PersistedBatch.d.ts +27 -0
  34. package/dist/storage/implementation/common/PersistedBatch.js +43 -2
  35. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  36. package/dist/storage/implementation/db.d.ts +4 -1
  37. package/dist/storage/implementation/db.js +14 -1
  38. package/dist/storage/implementation/db.js.map +1 -1
  39. package/dist/storage/implementation/models.d.ts +31 -0
  40. package/dist/storage/implementation/models.js.map +1 -1
  41. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +2 -0
  42. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +22 -0
  43. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  44. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +36 -5
  45. package/dist/storage/implementation/v1/MongoCompactorV1.js +229 -8
  46. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  47. package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
  48. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
  49. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
  50. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +18 -11
  51. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +65 -16
  52. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  53. package/dist/storage/implementation/v1/models.js +1 -0
  54. package/dist/storage/implementation/v1/models.js.map +1 -1
  55. package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
  56. package/dist/storage/implementation/v3/CompactionLease.js +131 -0
  57. package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
  58. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +8 -1
  59. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +90 -19
  60. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  61. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +77 -25
  62. package/dist/storage/implementation/v3/MongoCompactorV3.js +585 -141
  63. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  64. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +18 -0
  65. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +23 -0
  66. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -0
  67. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +2 -15
  68. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +60 -92
  69. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  70. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +19 -9
  71. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +91 -31
  72. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  73. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
  74. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
  75. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
  76. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +2 -0
  77. package/dist/storage/implementation/v3/PersistedBatchV3.js +52 -11
  78. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  79. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +12 -2
  80. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +42 -3
  81. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  82. package/dist/storage/implementation/v3/bucket-format.js +2 -0
  83. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  84. package/dist/storage/implementation/v3/compact-utils.d.ts +107 -0
  85. package/dist/storage/implementation/v3/compact-utils.js +216 -0
  86. package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
  87. package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
  88. package/dist/storage/implementation/v3/compaction-constants.js +6 -0
  89. package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
  90. package/dist/storage/implementation/v3/models.d.ts +84 -4
  91. package/dist/storage/implementation/v3/models.js.map +1 -1
  92. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +7 -8
  93. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +8 -4
  94. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  95. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +14 -11
  96. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +3 -5
  97. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +10 -2
  98. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  99. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +37 -0
  100. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +167 -0
  101. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
  102. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +37 -12
  103. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +177 -23
  104. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
  105. package/dist/storage/implementation/v3/source-table-utils.d.ts +6 -6
  106. package/dist/storage/implementation/v3/source-table-utils.js +30 -37
  107. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
  108. package/dist/storage/storage-index.d.ts +2 -1
  109. package/dist/storage/storage-index.js +1 -0
  110. package/dist/storage/storage-index.js.map +1 -1
  111. package/dist/types/types.d.ts +23 -1
  112. package/dist/types/types.js +16 -7
  113. package/dist/types/types.js.map +1 -1
  114. package/dist/utils/util.js +3 -1
  115. package/dist/utils/util.js.map +1 -1
  116. package/package.json +10 -9
  117. package/src/storage/MongoBucketStorage.ts +495 -268
  118. package/src/storage/implementation/MongoBucketBatch.ts +54 -14
  119. package/src/storage/implementation/MongoCompactor.ts +35 -346
  120. package/src/storage/implementation/MongoParameterCompactor.ts +388 -102
  121. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +34 -0
  122. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +4 -2
  123. package/src/storage/implementation/MongoStorageProvider.ts +3 -1
  124. package/src/storage/implementation/MongoSyncBucketStorage.ts +114 -40
  125. package/src/storage/implementation/MongoSyncRulesLock.ts +22 -9
  126. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +12 -44
  127. package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
  128. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +24 -2
  129. package/src/storage/implementation/common/PersistedBatch.ts +54 -2
  130. package/src/storage/implementation/db.ts +21 -2
  131. package/src/storage/implementation/models.ts +34 -0
  132. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +31 -0
  133. package/src/storage/implementation/v1/MongoCompactorV1.ts +303 -19
  134. package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
  135. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +87 -27
  136. package/src/storage/implementation/v1/models.ts +1 -0
  137. package/src/storage/implementation/v3/CompactionLease.ts +152 -0
  138. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +124 -22
  139. package/src/storage/implementation/v3/MongoCompactorV3.ts +744 -190
  140. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +34 -0
  141. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +100 -109
  142. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +111 -41
  143. package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
  144. package/src/storage/implementation/v3/PersistedBatchV3.ts +57 -12
  145. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +55 -4
  146. package/src/storage/implementation/v3/bucket-format.ts +2 -0
  147. package/src/storage/implementation/v3/compact-utils.ts +320 -0
  148. package/src/storage/implementation/v3/compaction-constants.ts +5 -0
  149. package/src/storage/implementation/v3/models.ts +91 -3
  150. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +16 -8
  151. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +20 -5
  152. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +15 -4
  153. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +230 -0
  154. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +257 -27
  155. package/src/storage/implementation/v3/source-table-utils.ts +39 -41
  156. package/src/storage/storage-index.ts +2 -0
  157. package/src/types/types.ts +24 -7
  158. package/src/utils/util.ts +3 -1
  159. package/test/src/__snapshots__/storage.test.ts.snap +25 -0
  160. package/test/src/__snapshots__/storage_sync.test.ts.snap +605 -16
  161. package/test/src/cleanup-stopped-sync-configs.test.ts +40 -13
  162. package/test/src/compact-utils.test.ts +226 -0
  163. package/test/src/helpers/MemoryObjectStorage.ts +14 -6
  164. package/test/src/object_storage_usage.test.ts +255 -0
  165. package/test/src/parameter_compacting_v1.test.ts +120 -0
  166. package/test/src/parameter_compacting_v3.test.ts +192 -0
  167. package/test/src/parameter_compaction_fence.test.ts +276 -0
  168. package/test/src/storage.test.ts +273 -22
  169. package/test/src/storage_compacting.test.ts +714 -244
  170. package/test/src/storage_s3_checksums.test.ts +3 -4
  171. package/test/src/storage_s3_compaction_lifecycle.test.ts +138 -12
  172. package/test/src/storage_s3_reading.test.ts +243 -1
  173. package/test/src/storage_s3_writing.test.ts +1 -0
  174. package/test/src/storage_sync.test.ts +336 -41
  175. package/test/src/util.ts +1 -1
  176. package/tsconfig.tsbuildinfo +1 -1
@@ -1,236 +1,726 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
- import { logger, ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
3
- import { addChecksums, InternalOpId, storage, utils } from '@powersync/service-core';
2
+ import { ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
3
+ import { addChecksums, formatBytes, InternalOpId, storage, utils } from '@powersync/service-core';
4
4
  import { BucketDefinitionId } from '@powersync/service-sync-rules';
5
5
  import { BucketDataDoc } from '../common/BucketDataDoc.js';
6
- import { BucketDataKey, BucketStateDocumentBase } from '../models.js';
7
- import { ConcurrentCompactionError, DirtyBucket, MongoCompactor } from '../MongoCompactor.js';
6
+ import { BucketDataKey } from '../models.js';
7
+ import { ConcurrentCompactionError, MongoCompactor } from '../MongoCompactor.js';
8
8
  import { cacheKey } from '../OperationBatch.js';
9
9
  import { loadBucketDataDocument, maxOpId, serializeBucketData } from './bucket-format.js';
10
10
  import { BucketDataContextV3 } from './BucketDataContextV3.js';
11
11
  import { DEFAULT_MAX_DOC_SIZE_BYTES } from './chunking.js';
12
+ import {
13
+ applyStatsReplacement,
14
+ bucketStats,
15
+ BucketStatsWithChecksum,
16
+ chooseCompactionKind,
17
+ combineAdjacentStats,
18
+ combineChunkStats,
19
+ CompactIntervalConfig,
20
+ CompactionContext,
21
+ CompactionDecision,
22
+ CompactionKind,
23
+ CompactionResult,
24
+ CompactTargetConfig,
25
+ emptyBucketStats,
26
+ firstUncompactedWrite,
27
+ forcedCompactionKind,
28
+ PendingCompactionGroup,
29
+ readCompactionBatch,
30
+ ScheduledCompactionOptions,
31
+ statsForDocument,
32
+ statsForDocuments,
33
+ unclaimedSnapshotFilter
34
+ } from './compact-utils.js';
35
+ import { DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS } from './compaction-constants.js';
36
+ import { AVAILABLE_LEASE_EXPR, CompactionLease } from './CompactionLease.js';
12
37
  import { BucketDataDocumentV3, BucketStateDocumentV3 } from './models.js';
13
- import { DefinitionChecksumOperations, MongoChecksumsV3 } from './MongoChecksumsV3.js';
14
38
  import type { MongoSyncBucketStorageV3 } from './MongoSyncBucketStorageV3.js';
15
39
  import { BucketDataObjectStorage, hydrateBucketDataDocuments } from './object-storage/BucketDataObjectStorage.js';
16
40
  import { ObjectStorageLifecycle, PreparedObjectStorageUpload } from './object-storage/ObjectStorageLifecycle.js';
41
+ import { createObjectStorageUsageWriterId, ObjectStorageUsage } from './object-storage/ObjectStorageUsage.js';
17
42
  import { VersionedPowerSyncMongoV3 } from './VersionedPowerSyncMongoV3.js';
18
43
 
19
- interface PendingCompactionGroup {
20
- /**
21
- * Input documents are ordered from oldest to newest, matching `ops`.
22
- * Keeping the inputs intact lets unchanged singletons retain their object.
23
- */
24
- inputs: BucketDataDocumentV3[];
25
- ops: BucketDataDoc[];
26
- changed: boolean;
27
- targetOp: InternalOpId | null;
28
- }
44
+ const DEFAULT_MIN_COMPACT_FULL_INTERVAL_MS = 2 * 60 * 60 * 1000;
45
+ const DEFAULT_MAX_COMPACT_FULL_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
46
+ const DEFAULT_COMPACT_LEASE_DURATION_MS = 10 * 60 * 1000;
47
+ const SCHEDULED_COMPACTION_BATCH_SIZE = 100;
29
48
 
30
- /**
31
- * Read one bounded prefix from a descending compaction cursor.
32
- *
33
- * The document that would cross the byte limit is deliberately not returned:
34
- * pagination resumes below the last returned `_id`, so that document remains
35
- * eligible for the next query. The first document is always accepted to ensure
36
- * progress when a single document exceeds the configured byte limit.
37
- *
38
- * `hasMore` is conservative when the document limit is reached. An extra empty
39
- * query is preferable to exhausting the cursor just to determine whether the
40
- * limited MongoDB query contained another document.
41
- */
42
- async function readCompactionBatch(
43
- cursor: mongo.AggregationCursor<BucketDataDocumentV3>,
44
- options: { byteLimit: number; documentLimit: number }
45
- ): Promise<{ documents: BucketDataDocumentV3[]; hasMore: boolean }> {
46
- const documents: BucketDataDocumentV3[] = [];
47
- let cumulativeBytes = 0;
48
-
49
- try {
50
- for await (const document of cursor) {
51
- if (documents.length > 0 && cumulativeBytes + document.size > options.byteLimit) {
52
- return { documents, hasMore: true };
53
- }
49
+ interface CompactionGroupResult {
50
+ documentId: BucketDataKey;
51
+ stats: BucketStatsWithChecksum;
52
+ }
54
53
 
55
- documents.push(document);
56
- cumulativeBytes += document.size;
54
+ interface CompactionStatsReplacement {
55
+ before: BucketStatsWithChecksum;
56
+ after: BucketStatsWithChecksum;
57
+ }
57
58
 
58
- if (documents.length >= options.documentLimit) {
59
- return { documents, hasMore: true };
60
- }
61
- }
62
- return { documents, hasMore: false };
63
- } finally {
64
- await cursor.close();
65
- }
59
+ interface ClearCompactionResult extends CompactionStatsReplacement {
60
+ opCountDiff: number;
66
61
  }
67
62
 
68
- export class MongoCompactorV3 extends MongoCompactor {
63
+ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalConfig, CompactTargetConfig {
69
64
  declare protected readonly db: VersionedPowerSyncMongoV3;
70
65
  declare protected readonly storage: MongoSyncBucketStorageV3;
71
66
 
72
- override async compact(): Promise<void> {
67
+ readonly minCompactChunkIntervalMs: number;
68
+ readonly minCompactFullIntervalMs: number;
69
+ readonly maxCompactFullIntervalMs: number;
70
+ readonly compactLeaseDurationMs: number;
71
+ readonly maxOpIdCap: InternalOpId | undefined;
72
+ private readonly objectStorageUsage: ObjectStorageUsage;
73
+
74
+ constructor(bucketStorage: MongoSyncBucketStorageV3, db: VersionedPowerSyncMongoV3, options: storage.CompactOptions) {
75
+ super(bucketStorage, db, options);
76
+ this.minCompactChunkIntervalMs = options.minCompactChunkIntervalMs ?? DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS;
77
+ this.minCompactFullIntervalMs = options.minCompactFullIntervalMs ?? DEFAULT_MIN_COMPACT_FULL_INTERVAL_MS;
78
+ this.maxCompactFullIntervalMs = options.maxCompactFullIntervalMs ?? DEFAULT_MAX_COMPACT_FULL_INTERVAL_MS;
79
+ this.compactLeaseDurationMs = options.compactLeaseDurationMs ?? DEFAULT_COMPACT_LEASE_DURATION_MS;
80
+ this.maxOpIdCap = options.maxOpId;
81
+ this.objectStorageUsage = new ObjectStorageUsage(this.db, this.group_id, createObjectStorageUsageWriterId());
82
+ }
83
+
84
+ override async compact(): Promise<number> {
73
85
  if (this.storage.objectStorage) {
74
86
  // Clean these before compacting - should be quick in most cases.
75
87
  try {
76
- await this.objectStorageLifecycle.cleanup(this.logger);
88
+ await this.objectStorageLifecycle.cleanup(this.logger, { signal: this.signal });
77
89
  } catch (e) {
90
+ if (this.signal?.aborted) {
91
+ throw e;
92
+ }
78
93
  // In this case, still continue normal compact process
79
94
  this.logger.error(`Failed to clean up object storage deletion markers before compaction`, e);
80
95
  }
81
96
  }
82
- await super.compact();
97
+ await this.deleteOldCheckpointRequests();
98
+
99
+ if (this.buckets != null) {
100
+ await this.compactExplicitBuckets(this.buckets);
101
+ } else if (this.compactChunksOnly) {
102
+ // Writers defer their first chunk-compaction check by this fixed default.
103
+ // Include that interval so this synchronous initial-replication pass
104
+ // processes the work that existed when it started.
105
+ await this.compactScheduledBuckets({
106
+ dueAheadMs: DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS,
107
+ forceKind: CompactionKind.Chunks
108
+ });
109
+ } else {
110
+ await this.compactScheduledBuckets();
111
+ }
83
112
  if (this.storage.objectStorage) {
84
113
  // Cleanup for any produced during compacting.
85
114
  // Note that markers only expire after a delay, so this may skip many produced during this compact
86
115
  // run. However, during long compact runs, this may also have many ones it can clean up.
87
- await this.objectStorageLifecycle.cleanup(this.logger);
116
+ await this.objectStorageLifecycle.cleanup(this.logger, { signal: this.signal });
117
+ await this.objectStorageUsage.foldStaleWriterDeltas();
88
118
  }
119
+ return this.compactedBucketCount;
89
120
  }
90
121
 
91
- private get objectStorageLifecycle(): ObjectStorageLifecycle {
92
- if (!this.storage.objectStorage) {
93
- throw new Error('Object storage is not configured');
122
+ protected override async deleteOldCustomCheckpointRequests() {
123
+ if (this.deleteCheckpointRequestsBefore == null) {
124
+ return;
125
+ }
126
+
127
+ for (const collection of await this.db.listCustomCheckpointRequestCollections(this.group_id)) {
128
+ this.signal?.throwIfAborted();
129
+ await collection.deleteMany({
130
+ checkpoint_requested_at: { $exists: true, $lt: this.deleteCheckpointRequestsBefore }
131
+ });
94
132
  }
95
- return new ObjectStorageLifecycle(this.db, this.group_id, this.storage.objectStorage);
96
133
  }
97
134
 
98
- public async *dirtyBucketBatches(options: {
99
- minBucketChanges: number;
100
- minChangeRatio: number;
101
- }): AsyncGenerator<DirtyBucket[]> {
102
- if (options.minBucketChanges <= 0) {
103
- throw new ReplicationAssertionError('minBucketChanges must be >= 1');
104
- }
105
- const collection = this.db.bucketState(this.group_id) as unknown as mongo.Collection<BucketStateDocumentBase>;
106
- yield* this.dirtyBucketBatchesForCollection(
107
- collection,
108
- { d: new mongo.MinKey(), b: new mongo.MinKey() } as unknown as BucketStateDocumentV3['_id'],
109
- { d: new mongo.MaxKey(), b: new mongo.MaxKey() } as unknown as BucketStateDocumentV3['_id'],
110
- options,
111
- (bucketState) => (bucketState as BucketStateDocumentV3)._id.d
112
- );
135
+ /** An explicit compact request always runs a full compact for its buckets. */
136
+ private async compactExplicitBuckets(buckets: string[]) {
137
+ for (const bucket of buckets) {
138
+ // This is not a super efficient query, but this is not a common use case.
139
+ // May be optimized later.
140
+ const states = await this.db
141
+ .bucketState(this.group_id)
142
+ .find({ '_id.b': bucket }, { projection: { _id: 1 } })
143
+ .toArray();
144
+ for (const state of states) {
145
+ await using lease = await this.claimBucket({ _id: state._id });
146
+ if (lease == null || lease.state.first_uncompacted_write == null) {
147
+ continue;
148
+ }
149
+ if (this.isCompactionTargetCovered(lease.state, CompactionKind.Full)) {
150
+ continue;
151
+ }
152
+ const decision = chooseCompactionKind(lease.state, lease.startedAt, this);
153
+ await this.compactClaimedBucket(lease, CompactionKind.Full, decision);
154
+ }
155
+ }
113
156
  }
114
157
 
115
- public async dirtyBucketBatchForChecksums(options: { minBucketChanges: number }): Promise<DirtyBucket[]> {
116
- if (options.minBucketChanges <= 0) {
117
- throw new ReplicationAssertionError('minBucketChanges must be >= 1');
158
+ /**
159
+ * Process scheduled work in bounded batches.
160
+ *
161
+ * Batching specifically help to cover cases of many buckets where no compaction is required:
162
+ * Instead of sequentially claiming and then rescheduling a bucket, this handles it in bulk.
163
+ *
164
+ * Buckets that do need compaction are still claimed and processed sequentially.
165
+ *
166
+ * Any concurrent workers may read the same batch. Rescheduling filters out buckets handled
167
+ * by a concurrent worker or replication write, while buckets that do need compaction are
168
+ * filtered out when claiming a compaction lease.
169
+ *
170
+ * We filter scheduled jobs by the job start date, so that the same bucket is not compacted
171
+ * multiple times in one run. Reschedules fall beyond the fixed boundary. For the run after
172
+ * initial replication, dueAheadMs extends that boundary to include the first deferred interval.
173
+ */
174
+ private async compactScheduledBuckets(options: ScheduledCompactionOptions = {}) {
175
+ // Writers derive next_compact_check from MongoDB's $$NOW. Use the same
176
+ // clock for the fixed job boundary so clock skew cannot exclude work at
177
+ // the exact initial-replication interval.
178
+ const [{ now: jobStartedAt }] = await this.db.db
179
+ .aggregate<{ now: Date }>([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
180
+ .toArray();
181
+ const dueBefore = new Date(jobStartedAt.getTime() + (options.dueAheadMs ?? 0));
182
+ const forceKind = options.forceKind;
183
+ const rescheduleNotBefore = new Date(dueBefore.getTime() + 1);
184
+ while (true) {
185
+ this.signal?.throwIfAborted();
186
+ const states = await this.findScheduledBucketBatch(dueBefore);
187
+ if (states.length == 0) {
188
+ break;
189
+ }
190
+
191
+ const scheduled: {
192
+ state: BucketStateDocumentV3;
193
+ decision: CompactionDecision;
194
+ forcedKind: CompactionKind | null;
195
+ }[] = [];
196
+ for (const state of states) {
197
+ try {
198
+ scheduled.push({
199
+ state,
200
+ decision: chooseCompactionKind(state, jobStartedAt, this),
201
+ forcedKind: forcedCompactionKind(state, forceKind, this)
202
+ });
203
+ } catch (error) {
204
+ await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
205
+ }
206
+ }
207
+ const noOpStates = scheduled.filter(
208
+ ({ state, decision, forcedKind }) =>
209
+ state.compact_lease == null && (forceKind == null ? decision.kind : forcedKind) == null
210
+ );
211
+ await this.rescheduleUnclaimedBuckets(noOpStates, rescheduleNotBefore);
212
+
213
+ for (const { state, decision, forcedKind } of scheduled) {
214
+ const kind = forceKind == null ? decision.kind : forcedKind;
215
+ if (state.compact_lease == null && kind == null) {
216
+ continue;
217
+ }
218
+
219
+ try {
220
+ await using lease = await this.claimBucket({ _id: state._id, next_compact_check: { $lte: dueBefore } });
221
+ if (lease == null) {
222
+ continue;
223
+ }
224
+ const claimedDecision = chooseCompactionKind(lease.state, lease.startedAt, this);
225
+ const claimedKind =
226
+ forceKind == null ? claimedDecision.kind : forcedCompactionKind(lease.state, forceKind, this);
227
+ if (claimedKind == null) {
228
+ await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
229
+ } else if (this.isCompactionTargetCovered(lease.state, claimedKind)) {
230
+ // The run cannot advance this kind's watermark without regressing
231
+ // already-published progress. Keep any newer work scheduled.
232
+ await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
233
+ } else {
234
+ await this.compactClaimedBucket(lease, claimedKind, claimedDecision, rescheduleNotBefore);
235
+ }
236
+ } catch (error) {
237
+ if (this.signal?.aborted) {
238
+ // When aborted, stop completely, rather than logging and re-scheduling individual buckets.
239
+ // The lease on the current bucket is still released automatically.
240
+ throw error;
241
+ }
242
+ await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
243
+ }
244
+ }
118
245
  }
119
- return this.dirtyBucketBatchForChecksumsForCollection(
120
- this.db.bucketState(this.group_id) as unknown as mongo.Collection<BucketStateDocumentBase>,
121
- {
122
- 'estimate_since_compact.count': { $gte: options.minBucketChanges }
123
- } as unknown as mongo.Filter<BucketStateDocumentBase>,
124
- (bucketState) => (bucketState as BucketStateDocumentV3)._id.d
125
- );
126
246
  }
127
247
 
128
- protected async writeBucketStateUpdates(): Promise<void> {
129
- await this.db
248
+ /** Read a bounded, priority-ordered snapshot of currently claimable scheduled work. */
249
+ private async findScheduledBucketBatch(dueBefore: Date): Promise<BucketStateDocumentV3[]> {
250
+ return this.db
130
251
  .bucketState(this.group_id)
131
- .bulkWrite(this.bucketStateUpdates as mongo.AnyBulkWriteOperation<BucketStateDocumentV3>[], {
132
- ordered: false
133
- });
252
+ .find({
253
+ next_compact_check: { $lte: dueBefore },
254
+ ...AVAILABLE_LEASE_EXPR
255
+ })
256
+ .sort({ next_compact_check: 1 })
257
+ .limit(SCHEDULED_COMPACTION_BATCH_SIZE)
258
+ .toArray();
134
259
  }
135
260
 
136
261
  /**
137
- * The compactor operates on persisted definition ids only - never on parsed sources.
138
- * This narrowed view makes the source-resolving checksum methods unreachable here.
262
+ * Reschedule snapshots that were already known to be no-ops without first
263
+ * taking a lease. Every decision input is compared so a concurrent writer
264
+ * or compactor simply makes the update a no-op instead of losing work. A
265
+ * successful reschedule moves beyond this run's fixed selection boundary.
139
266
  */
140
- private get definitionChecksums(): DefinitionChecksumOperations {
141
- return this.storage.checksums as MongoChecksumsV3;
267
+ private async rescheduleUnclaimedBuckets(
268
+ states: { state: BucketStateDocumentV3; decision: CompactionDecision }[],
269
+ notBefore: Date
270
+ ) {
271
+ if (states.length == 0) {
272
+ return;
273
+ }
274
+ await this.db.bucketState(this.group_id).bulkWrite(
275
+ states.map(({ state, decision }) => ({
276
+ updateOne: {
277
+ filter: unclaimedSnapshotFilter(state),
278
+ update: [{ $set: { next_compact_check: this.rescheduleAtOrAfter(decision.nextCompactCheck, notBefore) } }]
279
+ }
280
+ })),
281
+ { ordered: false }
282
+ );
142
283
  }
143
284
 
144
- protected async computeChecksumsForBuckets(
145
- buckets: Pick<DirtyBucket, 'bucket' | 'definitionId'>[]
146
- ): Promise<storage.PartialChecksumMap> {
147
- return this.definitionChecksums.computePartialChecksumsDirectByDefinition(
148
- buckets.map(({ bucket, definitionId }) => {
149
- if (definitionId == null) {
150
- throw new ServiceAssertionError(`Missing definitionId for bucket checksum update on bucket ${bucket}`);
151
- }
152
- return {
153
- bucket,
154
- definitionId,
155
- end: this.maxOpId
156
- };
157
- })
285
+ /**
286
+ * Isolate a malformed bucket so it cannot prevent other scheduled buckets
287
+ * from compacting. The snapshot filter preserves any concurrent write or
288
+ * compactor result instead of overwriting its next check.
289
+ */
290
+ private async rescheduleFailedBucket(state: BucketStateDocumentV3, notBefore: Date, error: unknown) {
291
+ this.logger.error(`Failed to compact scheduled bucket ${state._id.b}; rescheduling it`, error);
292
+ try {
293
+ await this.db
294
+ .bucketState(this.group_id)
295
+ .updateOne(unclaimedSnapshotFilter(state), [{ $set: { next_compact_check: notBefore } }]);
296
+ } catch (rescheduleError) {
297
+ this.logger.error(`Failed to reschedule bucket ${state._id.b} after a compaction error`, rescheduleError);
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Given a bucket filter, claim a lease on the bucket. The filter should include a filter on _id.
303
+ *
304
+ * Resolves to null if the bucket is already claimed, not found, or filtered out.
305
+ */
306
+ private async claimBucket(
307
+ filter: mongo.Filter<BucketStateDocumentV3>,
308
+ sort?: mongo.Sort
309
+ ): Promise<CompactionLease | null> {
310
+ return CompactionLease.claim(this.db.bucketState(this.group_id), filter, sort, this.compactLeaseDurationMs);
311
+ }
312
+
313
+ private async compactClaimedBucket(
314
+ lease: CompactionLease,
315
+ kind: CompactionKind,
316
+ decision: CompactionDecision,
317
+ rescheduleNotBefore?: Date
318
+ ) {
319
+ const context = new CompactionContext(
320
+ lease,
321
+ kind,
322
+ decision,
323
+ rescheduleNotBefore,
324
+ this.compactionTarget(lease.state)
158
325
  );
326
+ lease.startRenewal();
327
+ await this.compactSingleBucket(context);
159
328
  }
160
329
 
161
- protected bucketStateFilter(
162
- bucket: string,
163
- definitionId: BucketDefinitionId | null
164
- ): mongo.Filter<BucketStateDocumentBase> {
165
- if (definitionId == null) {
166
- throw new ServiceAssertionError(`Missing definitionId for V3 bucket state filter on bucket ${bucket}`);
330
+ private async rescheduleClaimedBucket(lease: CompactionLease, decision: CompactionDecision, notBefore?: Date) {
331
+ await lease.reschedule(this.rescheduleAtOrAfter(decision.nextCompactCheck, notBefore));
332
+ }
333
+
334
+ private rescheduleAtOrAfter(nextCompactCheck: mongo.Document, notBefore: Date | undefined): mongo.Document {
335
+ return notBefore == null ? nextCompactCheck : { $max: [nextCompactCheck, notBefore] };
336
+ }
337
+
338
+ private compactionTarget(state: BucketStateDocumentV3): InternalOpId {
339
+ return this.maxOpIdCap == null || state.last_op < this.maxOpIdCap ? state.last_op : this.maxOpIdCap;
340
+ }
341
+
342
+ private isCompactionTargetCovered(state: BucketStateDocumentV3, kind: CompactionKind): boolean {
343
+ const target = this.compactionTarget(state);
344
+ if (kind == CompactionKind.Chunks) {
345
+ return state.compacted_state != null && state.compacted_state.op_id >= target;
167
346
  }
168
- return {
169
- _id: {
170
- d: definitionId,
171
- b: bucket
347
+ if (state.last_full_compact != null && state.last_full_compact.op_id >= target) {
348
+ return true;
349
+ }
350
+ // A full compact may change counts before the checksum-cache boundary.
351
+ // Wait for the safe target to catch up instead of publishing an older or
352
+ // stale cache. At the same boundary, full coverage can still advance.
353
+ return state.compacted_state != null && state.compacted_state.op_id > target;
354
+ }
355
+
356
+ private get objectStorageLifecycle(): ObjectStorageLifecycle {
357
+ if (!this.storage.objectStorage) {
358
+ throw new Error('Object storage is not configured');
359
+ }
360
+ return new ObjectStorageLifecycle(this.db, this.group_id, this.storage.objectStorage);
361
+ }
362
+
363
+ private async compactSingleBucket(context: CompactionContext) {
364
+ if (context.kind == CompactionKind.Chunks) {
365
+ return this.compactSingleBucketChunks(context);
366
+ }
367
+
368
+ return this.compactSingleBucketFully(context);
369
+ }
370
+
371
+ /**
372
+ * Merge adjacent bucket-data chunks without inspecting their operations
373
+ * unless a merge is possible. The metadata contains enough information to
374
+ * update the persisted checksum state and to decide whether a group can fit
375
+ * in one chunk.
376
+ */
377
+ private async compactSingleBucketChunks(context: CompactionContext) {
378
+ const bucket = context.state._id.b;
379
+ const resolvedDefinitionId = context.state._id.d;
380
+ const bucketContext = new BucketDataContextV3(this.db, {
381
+ bucket,
382
+ definitionId: resolvedDefinitionId,
383
+ replicationStreamId: this.group_id
384
+ });
385
+ const collection = this.db.bucketData(this.group_id, resolvedDefinitionId);
386
+ const dataContext = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
387
+ let previousCompactedState = context.state.compacted_state;
388
+ // A zero boundary represents an empty prefix, so there is no stored chunk
389
+ // whose statistics need to be carried into this pass.
390
+ if (previousCompactedState?.op_id === 0n) {
391
+ previousCompactedState = undefined;
392
+ }
393
+ // Include the last previously compacted chunk as well as new chunks. It
394
+ // is the only old chunk which can become mergeable with the new tail.
395
+ let lowerBound =
396
+ previousCompactedState != null ? bucketContext.docId(previousCompactedState.op_id - 1n) : bucketContext.minId;
397
+ const upperBound = bucketContext.docId(context.targetOp + 1n);
398
+ let cachedBoundaryToVerify = previousCompactedState?.op_id;
399
+
400
+ let compactedOpId: bigint | null = null;
401
+ let overlappingCompactedChunk: BucketStatsWithChecksum | undefined;
402
+ let compactedTail = emptyBucketStats();
403
+ let pendingChunks: BucketDataDocumentV3[] = [];
404
+ let pendingSize = 0;
405
+
406
+ while (true) {
407
+ this.signal?.throwIfAborted();
408
+ await context.lease.throwIfLost();
409
+
410
+ const batch = await readCompactionBatch(
411
+ collection.aggregate<BucketDataDocumentV3>(
412
+ [
413
+ {
414
+ $match: {
415
+ _id: {
416
+ $gt: lowerBound,
417
+ $lt: upperBound
418
+ }
419
+ }
420
+ },
421
+ { $sort: { _id: 1 } },
422
+ { $limit: this.moveBatchQueryLimit },
423
+ {
424
+ $project: {
425
+ _id: 1,
426
+ min_op: 1,
427
+ checksum: 1,
428
+ count: 1,
429
+ size: 1,
430
+ target_op: 1,
431
+ storage_ref: 1
432
+ }
433
+ }
434
+ ],
435
+ { batchSize: this.moveBatchQueryLimit + 1 }
436
+ ),
437
+ {
438
+ byteLimit: this.moveBatchByteLimit,
439
+ documentLimit: this.moveBatchQueryLimit
440
+ }
441
+ );
442
+
443
+ if (cachedBoundaryToVerify != null) {
444
+ const cachedBoundary = cachedBoundaryToVerify;
445
+ cachedBoundaryToVerify = undefined;
446
+ if (batch.documents[0]?._id.o !== cachedBoundary) {
447
+ // A previous attempt may have replaced the cached boundary before
448
+ // finalizing bucket state. Keep the persisted cache available to
449
+ // readers, but ignore it in this attempt and calculate its
450
+ // replacement through the normal scan from the bucket beginning.
451
+ previousCompactedState = undefined;
452
+ lowerBound = bucketContext.minId;
453
+ continue;
454
+ }
172
455
  }
456
+
457
+ if (batch.documents.length == 0) {
458
+ break;
459
+ }
460
+
461
+ for (const doc of batch.documents) {
462
+ compactedOpId = maxOpId(compactedOpId, doc._id.o);
463
+ const documentStats = statsForDocument(doc);
464
+ if (previousCompactedState?.op_id === doc._id.o) {
465
+ overlappingCompactedChunk = documentStats;
466
+ }
467
+
468
+ const nextSize = pendingSize + doc.size;
469
+ if (pendingChunks.length > 0 && nextSize > DEFAULT_MAX_DOC_SIZE_BYTES) {
470
+ const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext);
471
+ compactedTail = combineAdjacentStats(compactedTail, groupStats);
472
+ pendingChunks = [];
473
+ pendingSize = 0;
474
+ }
475
+
476
+ pendingChunks.push(doc);
477
+ pendingSize += doc.size;
478
+ }
479
+
480
+ lowerBound = batch.documents[batch.documents.length - 1]._id;
481
+ if (!batch.hasMore) {
482
+ break;
483
+ }
484
+ }
485
+
486
+ if (pendingChunks.length > 0) {
487
+ const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext);
488
+ compactedTail = combineAdjacentStats(compactedTail, groupStats);
489
+ }
490
+
491
+ if (compactedOpId == null) {
492
+ await this.finalizeSkippedBucket(context);
493
+ return;
494
+ }
495
+
496
+ const compactedState =
497
+ previousCompactedState == null
498
+ ? compactedTail
499
+ : combineChunkStats(previousCompactedState, compactedTail, overlappingCompactedChunk!);
500
+ const tailStats =
501
+ compactedOpId == context.lastOp
502
+ ? undefined
503
+ : await this.readBucketStats(bucket, resolvedDefinitionId, context.lastOp, bucketContext.docId(compactedOpId));
504
+ const result: CompactionResult = {
505
+ compactedState,
506
+ bucketStats: tailStats == null ? compactedState : combineAdjacentStats(compactedState, tailStats)
173
507
  };
508
+
509
+ await this.finalizeCompactedBucket({ context, compactedOpId, compactionResult: result, puts: 0 });
510
+ this.compactedBucketCount++;
511
+ this.logger.info(
512
+ `Compacted bucket chunks ${bucket}: ${result.bucketStats.count} ops, ${result.bucketStats.chunks} chunks, ${formatBytes(result.bucketStats.bytes)}`
513
+ );
174
514
  }
175
515
 
176
- private async getBucketDataContext(
516
+ private async flushChunkMerge(
177
517
  bucket: string,
178
- definitionId: BucketDefinitionId | null
179
- ): Promise<BucketDataContextV3 | null> {
180
- let resolvedDefinitionId = definitionId;
181
-
182
- if (resolvedDefinitionId == null) {
183
- const allDefinitionIds = this.storage.storageIds.bucketDefinitionIds;
184
- if (allDefinitionIds.length > 0) {
185
- const potentialIds = allDefinitionIds.map((id) => ({ d: id, b: bucket }));
186
- const bucketState = await this.db.bucketState(this.group_id).findOne({
187
- _id: { $in: potentialIds }
188
- });
189
- if (bucketState != null) {
190
- resolvedDefinitionId = bucketState._id.d;
191
- }
518
+ inputs: BucketDataDocumentV3[],
519
+ collection: mongo.Collection<BucketDataDocumentV3>,
520
+ context: { replicationStreamId: number; definitionId: string },
521
+ bucketContext: BucketDataContextV3
522
+ ): Promise<BucketStatsWithChecksum> {
523
+ if (inputs.length == 1) {
524
+ return statsForDocument(inputs[0]);
525
+ }
526
+
527
+ // The metadata scan deliberately excluded ops. Read inline payloads only
528
+ // for this merge group; object-storage payloads are fetched below using
529
+ // the same rule.
530
+ const inlineInputs = inputs.filter((input) => input.storage_ref == null);
531
+ if (inlineInputs.length > 0) {
532
+ const inlineDocuments = await collection
533
+ .find({ _id: { $in: inlineInputs.map((input) => input._id) } }, { projection: { _id: 1, ops: 1 } })
534
+ .toArray();
535
+ const opsById = new Map(inlineDocuments.map((document) => [document._id.o.toString(), document.ops]));
536
+ for (const input of inlineInputs) {
537
+ input.ops = opsById.get(input._id.o.toString());
192
538
  }
193
539
  }
540
+ await hydrateBucketDataDocuments(inputs, this.storage.objectStorage, { signal: this.signal });
194
541
 
195
- if (resolvedDefinitionId == null) {
196
- return null;
542
+ const operations = inputs.flatMap((input) => Array.from(loadBucketDataDocument(context, input)));
543
+ const targetOp = inputs.reduce<InternalOpId | null>(
544
+ (maxTarget, input) => maxOpId(maxTarget, input.target_op),
545
+ null
546
+ );
547
+ const result = await this.flushCompactionGroup(
548
+ bucket,
549
+ {
550
+ inputs,
551
+ ops: operations,
552
+ changed: true,
553
+ targetOp
554
+ },
555
+ bucketContext,
556
+ context
557
+ );
558
+ return result.stats;
559
+ }
560
+
561
+ private async finalizeCompactedBucket({
562
+ context,
563
+ compactedOpId,
564
+ compactionResult,
565
+ puts
566
+ }: {
567
+ context: CompactionContext;
568
+ compactedOpId: InternalOpId;
569
+ compactionResult: CompactionResult;
570
+ puts: number;
571
+ }) {
572
+ await context.lease.throwIfLost();
573
+ const startedStats = bucketStats(context.state);
574
+ const delta = {
575
+ count: compactionResult.bucketStats.count - startedStats.count,
576
+ bytes: compactionResult.bucketStats.bytes - startedStats.bytes,
577
+ chunks: compactionResult.bucketStats.chunks - startedStats.chunks
578
+ };
579
+ const coveredClaimedHead = compactedOpId >= context.lastOp;
580
+ const concurrentWriteCheck = { $gt: ['$last_op', context.lastOp] };
581
+ const remainingFullWorkCheck = coveredClaimedHead ? concurrentWriteCheck : true;
582
+ const nextAfterPartialFullCompact = this.rescheduleAtOrAfter(
583
+ { $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } },
584
+ context.rescheduleNotBefore
585
+ );
586
+ const nextCheckForUncompactedWork = this.rescheduleAtOrAfter(
587
+ {
588
+ $min: [
589
+ new Date(firstUncompactedWrite(context.state).getTime() + this.maxCompactFullIntervalMs),
590
+ { $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } }
591
+ ]
592
+ },
593
+ context.rescheduleNotBefore
594
+ );
595
+ const update: mongo.Document = {
596
+ compacted_state: {
597
+ op_id: compactedOpId,
598
+ checksum: BigInt(compactionResult.compactedState.checksum),
599
+ count: compactionResult.compactedState.count,
600
+ bytes: compactionResult.compactedState.bytes,
601
+ chunks: compactionResult.compactedState.chunks,
602
+ at: '$$NOW'
603
+ },
604
+ bucket_stats: {
605
+ count: { $add: ['$bucket_stats.count', delta.count] },
606
+ bytes: { $add: ['$bucket_stats.bytes', delta.bytes] },
607
+ chunks: { $add: ['$bucket_stats.chunks', delta.chunks] }
608
+ },
609
+ first_uncompacted_write:
610
+ context.kind == CompactionKind.Full
611
+ ? { $cond: [remainingFullWorkCheck, '$$NOW', '$$REMOVE'] }
612
+ : '$first_uncompacted_write',
613
+ next_compact_check:
614
+ context.kind == CompactionKind.Full
615
+ ? { $cond: [remainingFullWorkCheck, nextAfterPartialFullCompact, '$$REMOVE'] }
616
+ : nextCheckForUncompactedWork
617
+ };
618
+ if (context.kind == CompactionKind.Full) {
619
+ update.last_full_compact = {
620
+ op_id: compactedOpId,
621
+ count: compactionResult.compactedState.count,
622
+ puts,
623
+ at: '$$NOW'
624
+ };
197
625
  }
198
626
 
199
- return new BucketDataContextV3(this.db, {
627
+ await context.lease.finalize(update);
628
+ }
629
+
630
+ private async finalizeSkippedBucket(context: CompactionContext) {
631
+ // A maxOpId cap can exclude the first remaining document entirely. Avoid
632
+ // immediately claiming the same no-progress bucket again in this run.
633
+ await this.rescheduleClaimedBucket(
634
+ context.lease,
635
+ {
636
+ ...context.decision,
637
+ nextCompactCheck: {
638
+ $max: [
639
+ context.decision.nextCompactCheck,
640
+ { $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } }
641
+ ]
642
+ }
643
+ },
644
+ context.rescheduleNotBefore
645
+ );
646
+ }
647
+
648
+ /**
649
+ * Read bucket stats directly from bucket_data documents.
650
+ */
651
+ private async readBucketStats(
652
+ bucket: string,
653
+ definitionId: BucketDefinitionId,
654
+ maxOp: InternalOpId,
655
+ lowerBound?: BucketDataKey
656
+ ): Promise<BucketStatsWithChecksum> {
657
+ const context = new BucketDataContextV3(this.db, {
200
658
  bucket,
201
- definitionId: resolvedDefinitionId,
659
+ definitionId,
202
660
  replicationStreamId: this.group_id
203
661
  });
662
+ const [stats] = await this.db
663
+ .bucketData(this.group_id, definitionId)
664
+ .aggregate<{ count: number; bytes: number | bigint; chunks: number; checksum: bigint }>([
665
+ {
666
+ $match: {
667
+ _id:
668
+ lowerBound == null
669
+ ? { $gte: context.minId, $lte: context.docId(maxOp) }
670
+ : { $gt: lowerBound, $lte: context.docId(maxOp) }
671
+ }
672
+ },
673
+ {
674
+ $group: {
675
+ _id: null,
676
+ count: { $sum: '$count' },
677
+ bytes: { $sum: '$size' },
678
+ chunks: { $sum: 1 },
679
+ checksum: { $sum: '$checksum' }
680
+ }
681
+ }
682
+ ])
683
+ .toArray();
684
+ return {
685
+ count: Number(stats?.count ?? 0),
686
+ bytes: BigInt(stats?.bytes ?? 0),
687
+ chunks: Number(stats?.chunks ?? 0),
688
+ checksum:
689
+ typeof stats?.checksum == 'bigint'
690
+ ? Number(BigInt.asIntN(32, stats.checksum))
691
+ : addChecksums(0, Number(stats?.checksum ?? 0))
692
+ };
204
693
  }
205
694
 
206
- protected override async compactSingleBucket(bucket: string, definitionId: BucketDefinitionId | null = null) {
207
- const bucketContext = await this.getBucketDataContext(bucket, definitionId);
208
- if (bucketContext == null) {
209
- return;
210
- }
211
-
212
- const resolvedDefinitionId = bucketContext.key.definitionId;
695
+ private async compactSingleBucketFully(context: CompactionContext) {
696
+ const bucket = context.state._id.b;
697
+ const resolvedDefinitionId = context.state._id.d;
698
+ const bucketContext = new BucketDataContextV3(this.db, {
699
+ bucket,
700
+ definitionId: resolvedDefinitionId,
701
+ replicationStreamId: this.group_id
702
+ });
213
703
  const collection = this.db.bucketData(this.group_id, resolvedDefinitionId);
214
- const context = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
215
-
704
+ const dataContext = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
216
705
  const lowerBound = bucketContext.minId;
217
- let upperBound = bucketContext.docId(this.maxOpId + 1n);
706
+ let upperBound = bucketContext.docId(context.targetOp + 1n);
218
707
 
219
- let totalChecksum = 0;
220
708
  let totalOpCount = 0;
221
- let totalOpBytes = 0;
222
709
 
223
710
  let lastNotPut: bigint | null = null;
224
711
  let opsSincePut = 0;
225
712
  let compactedOpId: bigint | null = null;
226
713
  let clearBoundary: { opId: bigint; documentId: BucketDataKey } | null = null;
714
+ let compactedStats = emptyBucketStats();
227
715
  const seen = new Map<string, bigint>();
228
716
  let trackingSize = 0;
717
+ let putCount = 0;
229
718
  let pendingGroup: PendingCompactionGroup | null = null;
230
719
 
231
720
  // --- Read batch from MongoDB ---
232
721
  while (true) {
233
722
  this.signal?.throwIfAborted();
723
+ await context.lease.throwIfLost();
234
724
 
235
725
  const pipeline: mongo.Document[] = [
236
726
  {
@@ -280,7 +770,7 @@ export class MongoCompactorV3 extends MongoCompactor {
280
770
  // merging is useful, and writes each final object at most once.
281
771
  for (const doc of batchDocs) {
282
772
  compactedOpId ??= doc._id.o;
283
- const originalOps = Array.from(loadBucketDataDocument(context, doc));
773
+ const originalOps = Array.from(loadBucketDataDocument(dataContext, doc));
284
774
 
285
775
  let changed = false;
286
776
  const compactedOps: BucketDataDoc[] = [];
@@ -299,6 +789,7 @@ export class MongoCompactorV3 extends MongoCompactor {
299
789
  row_id: undefined,
300
790
  source_table: undefined,
301
791
  source_key: undefined,
792
+ subkey: undefined,
302
793
  data: null
303
794
  });
304
795
  changed = true;
@@ -313,6 +804,7 @@ export class MongoCompactorV3 extends MongoCompactor {
313
804
  }
314
805
  compactedOps.push(op);
315
806
  if (op.op == 'PUT') {
807
+ putCount++;
316
808
  lastNotPut = null;
317
809
  opsSincePut = 0;
318
810
  } else {
@@ -334,10 +826,6 @@ export class MongoCompactorV3 extends MongoCompactor {
334
826
  }
335
827
  compactedOps.reverse();
336
828
 
337
- for (const op of compactedOps) {
338
- totalChecksum = addChecksums(totalChecksum, Number(op.checksum));
339
- totalOpBytes += op.data?.length ?? 0;
340
- }
341
829
  totalOpCount += compactedOps.length;
342
830
 
343
831
  const candidate: PendingCompactionGroup = {
@@ -361,13 +849,14 @@ export class MongoCompactorV3 extends MongoCompactor {
361
849
  };
362
850
  } else {
363
851
  const flushedGroup = pendingGroup;
364
- const documentId = await this.flushCompactionGroup(bucket, flushedGroup, bucketContext, context);
852
+ const result = await this.flushCompactionGroup(bucket, flushedGroup, bucketContext, dataContext);
853
+ compactedStats = combineAdjacentStats(compactedStats, result.stats);
365
854
  if (
366
855
  lastNotPut != null &&
367
856
  flushedGroup.ops[0].o <= lastNotPut &&
368
857
  flushedGroup.ops[flushedGroup.ops.length - 1].o >= lastNotPut
369
858
  ) {
370
- clearBoundary = { opId: lastNotPut, documentId };
859
+ clearBoundary = { opId: lastNotPut, documentId: result.documentId };
371
860
  }
372
861
  pendingGroup = candidate;
373
862
  }
@@ -385,16 +874,18 @@ export class MongoCompactorV3 extends MongoCompactor {
385
874
  }
386
875
 
387
876
  if (pendingGroup != null) {
388
- const documentId = await this.flushCompactionGroup(bucket, pendingGroup, bucketContext, context);
877
+ const result = await this.flushCompactionGroup(bucket, pendingGroup, bucketContext, dataContext);
878
+ compactedStats = combineAdjacentStats(compactedStats, result.stats);
389
879
  if (
390
880
  lastNotPut != null &&
391
881
  pendingGroup.ops[0].o <= lastNotPut &&
392
882
  pendingGroup.ops[pendingGroup.ops.length - 1].o >= lastNotPut
393
883
  ) {
394
- clearBoundary = { opId: lastNotPut, documentId };
884
+ clearBoundary = { opId: lastNotPut, documentId: result.documentId };
395
885
  }
396
886
  }
397
887
  if (compactedOpId == null) {
888
+ await this.finalizeSkippedBucket(context);
398
889
  return;
399
890
  }
400
891
 
@@ -404,36 +895,33 @@ export class MongoCompactorV3 extends MongoCompactor {
404
895
  throw new ReplicationAssertionError(`Missing CLEAR boundary document for bucket ${bucket}`);
405
896
  }
406
897
 
407
- totalOpCount += await this.clearBucketLeading(
898
+ const clearResult = await this.clearBucketLeading(
408
899
  lastNotPut,
409
900
  clearBoundary.documentId,
410
901
  bucketContext,
411
902
  collection,
412
- context
903
+ dataContext
413
904
  );
905
+ totalOpCount += clearResult.opCountDiff;
906
+ compactedStats = applyStatsReplacement(compactedStats, clearResult.before, clearResult.after);
414
907
  }
415
908
 
909
+ const tailStats =
910
+ compactedOpId == context.lastOp
911
+ ? undefined
912
+ : await this.readBucketStats(bucket, resolvedDefinitionId, context.lastOp, bucketContext.docId(compactedOpId));
913
+ const result: CompactionResult = {
914
+ compactedState: compactedStats,
915
+ bucketStats: tailStats == null ? compactedStats : combineAdjacentStats(compactedStats, tailStats)
916
+ };
917
+
416
918
  // --- Finalize: update bucket checksums and state ---
417
- this.updateBucketChecksums(
418
- {
419
- bucket,
420
- definitionId: resolvedDefinitionId,
421
- seen: new Map(),
422
- trackingSize: 0,
423
- lastNotPut: lastNotPut,
424
- opsSincePut: opsSincePut,
425
- checksum: totalChecksum,
426
- opCount: totalOpCount,
427
- opBytes: totalOpBytes
428
- },
429
- compactedOpId
430
- );
431
- if (this.bucketStateUpdates.length > 0) {
432
- await this.writeBucketStateUpdates();
433
- this.bucketStateUpdates = [];
434
- }
919
+ await this.finalizeCompactedBucket({ context, compactedOpId, compactionResult: result, puts: putCount });
435
920
 
436
- logger.info(`Compacted bucket ${bucket}: ${totalOpCount} surviving ops`);
921
+ this.compactedBucketCount++;
922
+ this.logger.info(
923
+ `Compacted bucket ${bucket}: ${totalOpCount} ops, ${result.bucketStats.chunks} chunks, ${formatBytes(result.bucketStats.bytes)}`
924
+ );
437
925
  }
438
926
 
439
927
  /**
@@ -447,9 +935,12 @@ export class MongoCompactorV3 extends MongoCompactor {
447
935
  group: PendingCompactionGroup,
448
936
  bucketContext: BucketDataContextV3,
449
937
  context: { replicationStreamId: number; definitionId: string }
450
- ): Promise<BucketDataKey> {
938
+ ): Promise<CompactionGroupResult> {
451
939
  if (group.inputs.length == 1 && !group.changed) {
452
- return group.inputs[0]._id;
940
+ return {
941
+ documentId: group.inputs[0]._id,
942
+ stats: statsForDocument(group.inputs[0])
943
+ };
453
944
  }
454
945
 
455
946
  const inputs = group.inputs;
@@ -458,6 +949,7 @@ export class MongoCompactorV3 extends MongoCompactor {
458
949
  const expectedChecksum = inputs.reduce((sum, doc) => sum + doc.checksum, 0n);
459
950
  const expectedOpCount = inputs.reduce((sum, doc) => sum + doc.count, 0);
460
951
  const oldStoragePaths = inputs.flatMap((doc) => (doc.storage_ref ? [doc.storage_ref.path] : []));
952
+ const oldStorageBytes = inputs.reduce((sum, document) => sum + ObjectStorageUsage.bytes(document), 0n);
461
953
  const {
462
954
  documents,
463
955
  storagePaths: newStoragePaths,
@@ -498,6 +990,7 @@ export class MongoCompactorV3 extends MongoCompactor {
498
990
  await bucketContext.collection.deleteMany({ _id: { $in: idsToDelete } }, { session });
499
991
  await bucketContext.collection.insertMany(documents, { session });
500
992
  await this.finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, session);
993
+ await this.recordObjectStorageReplacement(oldStorageBytes, documents, context.definitionId, session);
501
994
  },
502
995
  {
503
996
  writeConcern: { w: 'majority' },
@@ -507,7 +1000,10 @@ export class MongoCompactorV3 extends MongoCompactor {
507
1000
  } finally {
508
1001
  await session.endSession();
509
1002
  }
510
- return documents[0]._id;
1003
+ return {
1004
+ documentId: documents[0]._id,
1005
+ stats: statsForDocuments(documents)
1006
+ };
511
1007
  }
512
1008
 
513
1009
  /**
@@ -516,7 +1012,8 @@ export class MongoCompactorV3 extends MongoCompactor {
516
1012
  * before the known boundary document, then splits that boundary document
517
1013
  * if it contains ops on both sides of lastNotPut.
518
1014
  *
519
- * Returns the op count diff after replacing cleared ops with CLEAR ops.
1015
+ * Returns the op count and stored-stat changes after replacing cleared ops
1016
+ * with CLEAR ops.
520
1017
  */
521
1018
  private async clearBucketLeading(
522
1019
  lastNotPut: bigint,
@@ -524,8 +1021,10 @@ export class MongoCompactorV3 extends MongoCompactor {
524
1021
  bucketContext: BucketDataContextV3,
525
1022
  collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
526
1023
  context: { replicationStreamId: number; definitionId: string }
527
- ): Promise<number> {
1024
+ ): Promise<ClearCompactionResult> {
528
1025
  let opCountDiff = 0;
1026
+ let before = emptyBucketStats();
1027
+ let after = emptyBucketStats();
529
1028
  const session = this.db.client.startSession();
530
1029
  try {
531
1030
  let done = false;
@@ -542,11 +1041,13 @@ export class MongoCompactorV3 extends MongoCompactor {
542
1041
  );
543
1042
  done = batch.done;
544
1043
  opCountDiff += batch.opCountDiff;
1044
+ before = combineAdjacentStats(before, batch.before);
1045
+ after = combineAdjacentStats(after, batch.after);
545
1046
  }
546
1047
 
547
1048
  // The final step is to process the "boundary" document: It may contain some CLEAR/MOVE/REMOVE operations,
548
1049
  // potentially followed by PUT operations. This is only a single document, so no need for batching.
549
- opCountDiff += await this.clearBoundaryDocument(
1050
+ const boundaryResult = await this.clearBoundaryDocument(
550
1051
  session,
551
1052
  lastNotPut,
552
1053
  boundaryDocId,
@@ -554,11 +1055,14 @@ export class MongoCompactorV3 extends MongoCompactor {
554
1055
  collection,
555
1056
  context
556
1057
  );
1058
+ opCountDiff += boundaryResult.opCountDiff;
1059
+ before = combineAdjacentStats(before, boundaryResult.before);
1060
+ after = combineAdjacentStats(after, boundaryResult.after);
557
1061
  } finally {
558
1062
  await session.endSession();
559
1063
  }
560
1064
 
561
- return opCountDiff;
1065
+ return { opCountDiff, before, after };
562
1066
  }
563
1067
 
564
1068
  private async clearLeadingFullDocuments(
@@ -568,18 +1072,23 @@ export class MongoCompactorV3 extends MongoCompactor {
568
1072
  bucketContext: BucketDataContextV3,
569
1073
  collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
570
1074
  context: { replicationStreamId: number; definitionId: string }
571
- ): Promise<{ done: boolean; opCountDiff: number }> {
1075
+ ): Promise<{ done: boolean; opCountDiff: number } & CompactionStatsReplacement> {
572
1076
  const bucket = bucketContext.key.bucket;
573
1077
  this.signal?.throwIfAborted();
574
- const prepared = await this.prepareCompactionUploads(bucket, context, [lastNotPut]);
1078
+ let prepared: PreparedObjectStorageUpload[] | undefined;
575
1079
  let done = false;
576
1080
  let opCountDiff = 0;
1081
+ let before = emptyBucketStats();
1082
+ let after = emptyBucketStats();
577
1083
 
578
1084
  await session.withTransaction(
579
1085
  async () => {
580
1086
  done = false;
581
1087
  opCountDiff = 0;
1088
+ before = emptyBucketStats();
1089
+ after = emptyBucketStats();
582
1090
  const oldStoragePaths: string[] = [];
1091
+ let oldStorageBytes = 0n;
583
1092
  const query = collection.find(
584
1093
  {
585
1094
  _id: {
@@ -595,6 +1104,7 @@ export class MongoCompactorV3 extends MongoCompactor {
595
1104
  min_op: 1,
596
1105
  checksum: 1,
597
1106
  count: 1,
1107
+ size: 1,
598
1108
  target_op: 1,
599
1109
  has_clear_op: 1,
600
1110
  storage_ref: 1
@@ -609,6 +1119,7 @@ export class MongoCompactorV3 extends MongoCompactor {
609
1119
  let lastDocId: BucketDataKey | null = null;
610
1120
  let clearOpCount = 0;
611
1121
  let gotNonClearOp = false;
1122
+ const inputStats = emptyBucketStats();
612
1123
 
613
1124
  for await (const doc of query.stream()) {
614
1125
  if (doc.min_op > lastNotPut) {
@@ -618,9 +1129,15 @@ export class MongoCompactorV3 extends MongoCompactor {
618
1129
  }
619
1130
 
620
1131
  lastDocId = doc._id;
1132
+ const documentStats = statsForDocument(doc);
1133
+ inputStats.count += documentStats.count;
1134
+ inputStats.bytes += documentStats.bytes;
1135
+ inputStats.chunks += documentStats.chunks;
1136
+ inputStats.checksum = addChecksums(inputStats.checksum, documentStats.checksum);
621
1137
  if (doc.storage_ref) {
622
1138
  oldStoragePaths.push(doc.storage_ref.path);
623
1139
  }
1140
+ oldStorageBytes += ObjectStorageUsage.bytes(doc);
624
1141
 
625
1142
  // The compaction scan established that every operation before the
626
1143
  // boundary is MOVE/REMOVE/CLEAR. Root metadata is sufficient to fold
@@ -646,6 +1163,7 @@ export class MongoCompactorV3 extends MongoCompactor {
646
1163
  return;
647
1164
  }
648
1165
 
1166
+ prepared ??= await this.prepareCompactionUploads(bucket, context, [lastNotPut]);
649
1167
  this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastDocId?.o}`);
650
1168
  await collection.deleteMany(
651
1169
  {
@@ -669,8 +1187,11 @@ export class MongoCompactorV3 extends MongoCompactor {
669
1187
  });
670
1188
  await collection.insertOne(persisted.documents[0], { session });
671
1189
  await this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, session);
1190
+ await this.recordObjectStorageReplacement(oldStorageBytes, persisted.documents, context.definitionId, session);
672
1191
 
673
1192
  opCountDiff = -clearedOpCount + 1;
1193
+ before = inputStats;
1194
+ after = statsForDocuments(persisted.documents);
674
1195
  },
675
1196
  {
676
1197
  writeConcern: { w: 'majority' },
@@ -678,7 +1199,7 @@ export class MongoCompactorV3 extends MongoCompactor {
678
1199
  }
679
1200
  );
680
1201
 
681
- return { done, opCountDiff };
1202
+ return { done, opCountDiff, before, after };
682
1203
  }
683
1204
 
684
1205
  private async clearBoundaryDocument(
@@ -688,16 +1209,21 @@ export class MongoCompactorV3 extends MongoCompactor {
688
1209
  bucketContext: BucketDataContextV3,
689
1210
  collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
690
1211
  context: { replicationStreamId: number; definitionId: string }
691
- ): Promise<number> {
1212
+ ): Promise<ClearCompactionResult> {
692
1213
  const bucket = bucketContext.key.bucket;
693
1214
  this.signal?.throwIfAborted();
694
1215
  const prepared = await this.prepareCompactionUploads(bucket, context, [lastNotPut, boundaryDocId.o]);
695
1216
  let opCountDiff = 0;
1217
+ let before = emptyBucketStats();
1218
+ let after = emptyBucketStats();
696
1219
 
697
1220
  await session.withTransaction(
698
1221
  async () => {
699
1222
  opCountDiff = 0;
1223
+ before = emptyBucketStats();
1224
+ after = emptyBucketStats();
700
1225
  const oldStoragePaths: string[] = [];
1226
+ let oldStorageBytes = 0n;
701
1227
  const query = collection.find(
702
1228
  {
703
1229
  // This is a range query, but should only ever return two documents:
@@ -716,6 +1242,7 @@ export class MongoCompactorV3 extends MongoCompactor {
716
1242
  min_op: 1,
717
1243
  checksum: 1,
718
1244
  count: 1,
1245
+ size: 1,
719
1246
  target_op: 1,
720
1247
  ops: 1,
721
1248
  storage_ref: 1
@@ -729,6 +1256,7 @@ export class MongoCompactorV3 extends MongoCompactor {
729
1256
  let clearedOpCount = 0;
730
1257
  let maxTargetOp: bigint | null = null;
731
1258
  const boundarySurvivors: BucketDataDoc[] = [];
1259
+ const inputStats = emptyBucketStats();
732
1260
 
733
1261
  for await (const doc of query.stream()) {
734
1262
  docsRead++;
@@ -736,10 +1264,17 @@ export class MongoCompactorV3 extends MongoCompactor {
736
1264
  throw new ReplicationAssertionError(`Unexpected extra document before CLEAR boundary in bucket ${bucket}`);
737
1265
  }
738
1266
 
1267
+ const documentStats = statsForDocument(doc);
1268
+ inputStats.count += documentStats.count;
1269
+ inputStats.bytes += documentStats.bytes;
1270
+ inputStats.chunks += documentStats.chunks;
1271
+ inputStats.checksum = addChecksums(inputStats.checksum, documentStats.checksum);
1272
+
739
1273
  const isBoundaryDoc = doc._id.o == boundaryDocId.o;
740
1274
  if (doc.storage_ref) {
741
1275
  oldStoragePaths.push(doc.storage_ref.path);
742
1276
  }
1277
+ oldStorageBytes += ObjectStorageUsage.bytes(doc);
743
1278
  await hydrateBucketDataDocuments([doc], this.storage.objectStorage, { signal: this.signal });
744
1279
  maxTargetOp = maxOpId(maxTargetOp, doc.target_op);
745
1280
  for (const op of loadBucketDataDocument(context, doc)) {
@@ -800,8 +1335,11 @@ export class MongoCompactorV3 extends MongoCompactor {
800
1335
  });
801
1336
  await collection.insertMany(persisted.documents, { session });
802
1337
  await this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, session);
1338
+ await this.recordObjectStorageReplacement(oldStorageBytes, persisted.documents, context.definitionId, session);
803
1339
 
804
1340
  opCountDiff = -clearedOpCount + 1;
1341
+ before = inputStats;
1342
+ after = statsForDocuments(persisted.documents);
805
1343
  },
806
1344
  {
807
1345
  writeConcern: { w: 'majority' },
@@ -809,7 +1347,7 @@ export class MongoCompactorV3 extends MongoCompactor {
809
1347
  }
810
1348
  );
811
1349
 
812
- return opCountDiff;
1350
+ return { opCountDiff, before, after };
813
1351
  }
814
1352
 
815
1353
  /**
@@ -852,6 +1390,22 @@ export class MongoCompactorV3 extends MongoCompactor {
852
1390
  );
853
1391
  }
854
1392
 
1393
+ private async recordObjectStorageReplacement(
1394
+ oldBytes: bigint,
1395
+ newDocuments: Iterable<Pick<BucketDataDocumentV3, 'storage_ref'>>,
1396
+ definitionId: BucketDefinitionId,
1397
+ session: mongo.ClientSession
1398
+ ): Promise<void> {
1399
+ if (!this.storage.objectStorage) {
1400
+ return;
1401
+ }
1402
+ let newBytes = 0n;
1403
+ for (const document of newDocuments) {
1404
+ newBytes += ObjectStorageUsage.bytes(document);
1405
+ }
1406
+ await this.objectStorageUsage.applyDelta(definitionId, newBytes - oldBytes, session);
1407
+ }
1408
+
855
1409
  private async persistBucketData(
856
1410
  bucket: string,
857
1411
  chunks: BucketDataDoc[][],
@@ -908,7 +1462,7 @@ export class MongoCompactorV3 extends MongoCompactor {
908
1462
  }
909
1463
 
910
1464
  const { ops, ...metadata } = serialized;
911
- const { fileSize } = await store.store(upload.path, ops!);
1465
+ const { fileSize } = await store.store(upload.path, ops!, { signal: this.signal });
912
1466
  storagePaths.add(upload.path);
913
1467
  return {
914
1468
  ...metadata,