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