@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,169 +1,557 @@
1
- import { mongo } from '@powersync/lib-service-mongodb';
2
- import { logger, ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
3
- import { addChecksums, utils } from '@powersync/service-core';
1
+ import { ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
2
+ import { addChecksums, formatBytes, utils } from '@powersync/service-core';
4
3
  import { ConcurrentCompactionError, MongoCompactor } from '../MongoCompactor.js';
5
4
  import { cacheKey } from '../OperationBatch.js';
6
5
  import { loadBucketDataDocument, maxOpId, serializeBucketData } from './bucket-format.js';
7
6
  import { BucketDataContextV3 } from './BucketDataContextV3.js';
8
7
  import { DEFAULT_MAX_DOC_SIZE_BYTES } from './chunking.js';
8
+ import { applyStatsReplacement, bucketStats, chooseCompactionKind, combineAdjacentStats, combineChunkStats, CompactionContext, CompactionKind, emptyBucketStats, firstUncompactedWrite, forcedCompactionKind, readCompactionBatch, statsForDocument, statsForDocuments, unclaimedSnapshotFilter } from './compact-utils.js';
9
+ import { DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS } from './compaction-constants.js';
10
+ import { AVAILABLE_LEASE_EXPR, CompactionLease } from './CompactionLease.js';
9
11
  import { BucketDataObjectStorage, hydrateBucketDataDocuments } from './object-storage/BucketDataObjectStorage.js';
10
12
  import { ObjectStorageLifecycle } from './object-storage/ObjectStorageLifecycle.js';
11
- /**
12
- * Read one bounded prefix from a descending compaction cursor.
13
- *
14
- * The document that would cross the byte limit is deliberately not returned:
15
- * pagination resumes below the last returned `_id`, so that document remains
16
- * eligible for the next query. The first document is always accepted to ensure
17
- * progress when a single document exceeds the configured byte limit.
18
- *
19
- * `hasMore` is conservative when the document limit is reached. An extra empty
20
- * query is preferable to exhausting the cursor just to determine whether the
21
- * limited MongoDB query contained another document.
22
- */
23
- async function readCompactionBatch(cursor, options) {
24
- const documents = [];
25
- let cumulativeBytes = 0;
26
- try {
27
- for await (const document of cursor) {
28
- if (documents.length > 0 && cumulativeBytes + document.size > options.byteLimit) {
29
- return { documents, hasMore: true };
30
- }
31
- documents.push(document);
32
- cumulativeBytes += document.size;
33
- if (documents.length >= options.documentLimit) {
34
- return { documents, hasMore: true };
35
- }
36
- }
37
- return { documents, hasMore: false };
38
- }
39
- finally {
40
- await cursor.close();
41
- }
42
- }
13
+ import { createObjectStorageUsageWriterId, ObjectStorageUsage } from './object-storage/ObjectStorageUsage.js';
14
+ const DEFAULT_MIN_COMPACT_FULL_INTERVAL_MS = 2 * 60 * 60 * 1000;
15
+ const DEFAULT_MAX_COMPACT_FULL_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
16
+ const DEFAULT_COMPACT_LEASE_DURATION_MS = 10 * 60 * 1000;
17
+ const SCHEDULED_COMPACTION_BATCH_SIZE = 100;
43
18
  export class MongoCompactorV3 extends MongoCompactor {
19
+ minCompactChunkIntervalMs;
20
+ minCompactFullIntervalMs;
21
+ maxCompactFullIntervalMs;
22
+ compactLeaseDurationMs;
23
+ maxOpIdCap;
24
+ objectStorageUsage;
25
+ constructor(bucketStorage, db, options) {
26
+ super(bucketStorage, db, options);
27
+ this.minCompactChunkIntervalMs = options.minCompactChunkIntervalMs ?? DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS;
28
+ this.minCompactFullIntervalMs = options.minCompactFullIntervalMs ?? DEFAULT_MIN_COMPACT_FULL_INTERVAL_MS;
29
+ this.maxCompactFullIntervalMs = options.maxCompactFullIntervalMs ?? DEFAULT_MAX_COMPACT_FULL_INTERVAL_MS;
30
+ this.compactLeaseDurationMs = options.compactLeaseDurationMs ?? DEFAULT_COMPACT_LEASE_DURATION_MS;
31
+ this.maxOpIdCap = options.maxOpId;
32
+ this.objectStorageUsage = new ObjectStorageUsage(this.db, this.group_id, createObjectStorageUsageWriterId());
33
+ }
44
34
  async compact() {
45
35
  if (this.storage.objectStorage) {
46
36
  // Clean these before compacting - should be quick in most cases.
47
37
  try {
48
- await this.objectStorageLifecycle.cleanup(this.logger);
38
+ await this.objectStorageLifecycle.cleanup(this.logger, { signal: this.signal });
49
39
  }
50
40
  catch (e) {
41
+ if (this.signal?.aborted) {
42
+ throw e;
43
+ }
51
44
  // In this case, still continue normal compact process
52
45
  this.logger.error(`Failed to clean up object storage deletion markers before compaction`, e);
53
46
  }
54
47
  }
55
- await super.compact();
48
+ await this.deleteOldCheckpointRequests();
49
+ if (this.buckets != null) {
50
+ await this.compactExplicitBuckets(this.buckets);
51
+ }
52
+ else if (this.compactChunksOnly) {
53
+ // Writers defer their first chunk-compaction check by this fixed default.
54
+ // Include that interval so this synchronous initial-replication pass
55
+ // processes the work that existed when it started.
56
+ await this.compactScheduledBuckets({
57
+ dueAheadMs: DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS,
58
+ forceKind: CompactionKind.Chunks
59
+ });
60
+ }
61
+ else {
62
+ await this.compactScheduledBuckets();
63
+ }
56
64
  if (this.storage.objectStorage) {
57
65
  // Cleanup for any produced during compacting.
58
66
  // Note that markers only expire after a delay, so this may skip many produced during this compact
59
67
  // run. However, during long compact runs, this may also have many ones it can clean up.
60
- await this.objectStorageLifecycle.cleanup(this.logger);
68
+ await this.objectStorageLifecycle.cleanup(this.logger, { signal: this.signal });
69
+ await this.objectStorageUsage.foldStaleWriterDeltas();
61
70
  }
71
+ return this.compactedBucketCount;
62
72
  }
63
- get objectStorageLifecycle() {
64
- if (!this.storage.objectStorage) {
65
- throw new Error('Object storage is not configured');
73
+ async deleteOldCustomCheckpointRequests() {
74
+ if (this.deleteCheckpointRequestsBefore == null) {
75
+ return;
76
+ }
77
+ for (const collection of await this.db.listCustomCheckpointRequestCollections(this.group_id)) {
78
+ this.signal?.throwIfAborted();
79
+ await collection.deleteMany({
80
+ checkpoint_requested_at: { $exists: true, $lt: this.deleteCheckpointRequestsBefore }
81
+ });
66
82
  }
67
- return new ObjectStorageLifecycle(this.db, this.group_id, this.storage.objectStorage);
68
83
  }
69
- async *dirtyBucketBatches(options) {
70
- if (options.minBucketChanges <= 0) {
71
- throw new ReplicationAssertionError('minBucketChanges must be >= 1');
84
+ /** An explicit compact request always runs a full compact for its buckets. */
85
+ async compactExplicitBuckets(buckets) {
86
+ for (const bucket of buckets) {
87
+ // This is not a super efficient query, but this is not a common use case.
88
+ // May be optimized later.
89
+ const states = await this.db
90
+ .bucketState(this.group_id)
91
+ .find({ '_id.b': bucket }, { projection: { _id: 1 } })
92
+ .toArray();
93
+ for (const state of states) {
94
+ await using lease = await this.claimBucket({ _id: state._id });
95
+ if (lease == null || lease.state.first_uncompacted_write == null) {
96
+ continue;
97
+ }
98
+ if (this.isCompactionTargetCovered(lease.state, CompactionKind.Full)) {
99
+ continue;
100
+ }
101
+ const decision = chooseCompactionKind(lease.state, lease.startedAt, this);
102
+ await this.compactClaimedBucket(lease, CompactionKind.Full, decision);
103
+ }
72
104
  }
73
- const collection = this.db.bucketState(this.group_id);
74
- yield* this.dirtyBucketBatchesForCollection(collection, { d: new mongo.MinKey(), b: new mongo.MinKey() }, { d: new mongo.MaxKey(), b: new mongo.MaxKey() }, options, (bucketState) => bucketState._id.d);
75
105
  }
76
- async dirtyBucketBatchForChecksums(options) {
77
- if (options.minBucketChanges <= 0) {
78
- throw new ReplicationAssertionError('minBucketChanges must be >= 1');
106
+ /**
107
+ * Process scheduled work in bounded batches.
108
+ *
109
+ * Batching specifically help to cover cases of many buckets where no compaction is required:
110
+ * Instead of sequentially claiming and then rescheduling a bucket, this handles it in bulk.
111
+ *
112
+ * Buckets that do need compaction are still claimed and processed sequentially.
113
+ *
114
+ * Any concurrent workers may read the same batch. Rescheduling filters out buckets handled
115
+ * by a concurrent worker or replication write, while buckets that do need compaction are
116
+ * filtered out when claiming a compaction lease.
117
+ *
118
+ * We filter scheduled jobs by the job start date, so that the same bucket is not compacted
119
+ * multiple times in one run. Reschedules fall beyond the fixed boundary. For the run after
120
+ * initial replication, dueAheadMs extends that boundary to include the first deferred interval.
121
+ */
122
+ async compactScheduledBuckets(options = {}) {
123
+ // Writers derive next_compact_check from MongoDB's $$NOW. Use the same
124
+ // clock for the fixed job boundary so clock skew cannot exclude work at
125
+ // the exact initial-replication interval.
126
+ const [{ now: jobStartedAt }] = await this.db.db
127
+ .aggregate([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
128
+ .toArray();
129
+ const dueBefore = new Date(jobStartedAt.getTime() + (options.dueAheadMs ?? 0));
130
+ const forceKind = options.forceKind;
131
+ const rescheduleNotBefore = new Date(dueBefore.getTime() + 1);
132
+ while (true) {
133
+ this.signal?.throwIfAborted();
134
+ const states = await this.findScheduledBucketBatch(dueBefore);
135
+ if (states.length == 0) {
136
+ break;
137
+ }
138
+ const scheduled = [];
139
+ for (const state of states) {
140
+ try {
141
+ scheduled.push({
142
+ state,
143
+ decision: chooseCompactionKind(state, jobStartedAt, this),
144
+ forcedKind: forcedCompactionKind(state, forceKind, this)
145
+ });
146
+ }
147
+ catch (error) {
148
+ await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
149
+ }
150
+ }
151
+ const noOpStates = scheduled.filter(({ state, decision, forcedKind }) => state.compact_lease == null && (forceKind == null ? decision.kind : forcedKind) == null);
152
+ await this.rescheduleUnclaimedBuckets(noOpStates, rescheduleNotBefore);
153
+ for (const { state, decision, forcedKind } of scheduled) {
154
+ const kind = forceKind == null ? decision.kind : forcedKind;
155
+ if (state.compact_lease == null && kind == null) {
156
+ continue;
157
+ }
158
+ try {
159
+ await using lease = await this.claimBucket({ _id: state._id, next_compact_check: { $lte: dueBefore } });
160
+ if (lease == null) {
161
+ continue;
162
+ }
163
+ const claimedDecision = chooseCompactionKind(lease.state, lease.startedAt, this);
164
+ const claimedKind = forceKind == null ? claimedDecision.kind : forcedCompactionKind(lease.state, forceKind, this);
165
+ if (claimedKind == null) {
166
+ await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
167
+ }
168
+ else if (this.isCompactionTargetCovered(lease.state, claimedKind)) {
169
+ // The run cannot advance this kind's watermark without regressing
170
+ // already-published progress. Keep any newer work scheduled.
171
+ await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
172
+ }
173
+ else {
174
+ await this.compactClaimedBucket(lease, claimedKind, claimedDecision, rescheduleNotBefore);
175
+ }
176
+ }
177
+ catch (error) {
178
+ if (this.signal?.aborted) {
179
+ // When aborted, stop completely, rather than logging and re-scheduling individual buckets.
180
+ // The lease on the current bucket is still released automatically.
181
+ throw error;
182
+ }
183
+ await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
184
+ }
185
+ }
79
186
  }
80
- return this.dirtyBucketBatchForChecksumsForCollection(this.db.bucketState(this.group_id), {
81
- 'estimate_since_compact.count': { $gte: options.minBucketChanges }
82
- }, (bucketState) => bucketState._id.d);
83
187
  }
84
- async writeBucketStateUpdates() {
85
- await this.db
188
+ /** Read a bounded, priority-ordered snapshot of currently claimable scheduled work. */
189
+ async findScheduledBucketBatch(dueBefore) {
190
+ return this.db
86
191
  .bucketState(this.group_id)
87
- .bulkWrite(this.bucketStateUpdates, {
88
- ordered: false
89
- });
192
+ .find({
193
+ next_compact_check: { $lte: dueBefore },
194
+ ...AVAILABLE_LEASE_EXPR
195
+ })
196
+ .sort({ next_compact_check: 1 })
197
+ .limit(SCHEDULED_COMPACTION_BATCH_SIZE)
198
+ .toArray();
90
199
  }
91
200
  /**
92
- * The compactor operates on persisted definition ids only - never on parsed sources.
93
- * This narrowed view makes the source-resolving checksum methods unreachable here.
201
+ * Reschedule snapshots that were already known to be no-ops without first
202
+ * taking a lease. Every decision input is compared so a concurrent writer
203
+ * or compactor simply makes the update a no-op instead of losing work. A
204
+ * successful reschedule moves beyond this run's fixed selection boundary.
94
205
  */
95
- get definitionChecksums() {
96
- return this.storage.checksums;
97
- }
98
- async computeChecksumsForBuckets(buckets) {
99
- return this.definitionChecksums.computePartialChecksumsDirectByDefinition(buckets.map(({ bucket, definitionId }) => {
100
- if (definitionId == null) {
101
- throw new ServiceAssertionError(`Missing definitionId for bucket checksum update on bucket ${bucket}`);
206
+ async rescheduleUnclaimedBuckets(states, notBefore) {
207
+ if (states.length == 0) {
208
+ return;
209
+ }
210
+ await this.db.bucketState(this.group_id).bulkWrite(states.map(({ state, decision }) => ({
211
+ updateOne: {
212
+ filter: unclaimedSnapshotFilter(state),
213
+ update: [{ $set: { next_compact_check: this.rescheduleAtOrAfter(decision.nextCompactCheck, notBefore) } }]
102
214
  }
103
- return {
104
- bucket,
105
- definitionId,
106
- end: this.maxOpId
107
- };
108
- }));
215
+ })), { ordered: false });
109
216
  }
110
- bucketStateFilter(bucket, definitionId) {
111
- if (definitionId == null) {
112
- throw new ServiceAssertionError(`Missing definitionId for V3 bucket state filter on bucket ${bucket}`);
217
+ /**
218
+ * Isolate a malformed bucket so it cannot prevent other scheduled buckets
219
+ * from compacting. The snapshot filter preserves any concurrent write or
220
+ * compactor result instead of overwriting its next check.
221
+ */
222
+ async rescheduleFailedBucket(state, notBefore, error) {
223
+ this.logger.error(`Failed to compact scheduled bucket ${state._id.b}; rescheduling it`, error);
224
+ try {
225
+ await this.db
226
+ .bucketState(this.group_id)
227
+ .updateOne(unclaimedSnapshotFilter(state), [{ $set: { next_compact_check: notBefore } }]);
113
228
  }
114
- return {
115
- _id: {
116
- d: definitionId,
117
- b: bucket
229
+ catch (rescheduleError) {
230
+ this.logger.error(`Failed to reschedule bucket ${state._id.b} after a compaction error`, rescheduleError);
231
+ }
232
+ }
233
+ /**
234
+ * Given a bucket filter, claim a lease on the bucket. The filter should include a filter on _id.
235
+ *
236
+ * Resolves to null if the bucket is already claimed, not found, or filtered out.
237
+ */
238
+ async claimBucket(filter, sort) {
239
+ return CompactionLease.claim(this.db.bucketState(this.group_id), filter, sort, this.compactLeaseDurationMs);
240
+ }
241
+ async compactClaimedBucket(lease, kind, decision, rescheduleNotBefore) {
242
+ const context = new CompactionContext(lease, kind, decision, rescheduleNotBefore, this.compactionTarget(lease.state));
243
+ lease.startRenewal();
244
+ await this.compactSingleBucket(context);
245
+ }
246
+ async rescheduleClaimedBucket(lease, decision, notBefore) {
247
+ await lease.reschedule(this.rescheduleAtOrAfter(decision.nextCompactCheck, notBefore));
248
+ }
249
+ rescheduleAtOrAfter(nextCompactCheck, notBefore) {
250
+ return notBefore == null ? nextCompactCheck : { $max: [nextCompactCheck, notBefore] };
251
+ }
252
+ compactionTarget(state) {
253
+ return this.maxOpIdCap == null || state.last_op < this.maxOpIdCap ? state.last_op : this.maxOpIdCap;
254
+ }
255
+ isCompactionTargetCovered(state, kind) {
256
+ const target = this.compactionTarget(state);
257
+ if (kind == CompactionKind.Chunks) {
258
+ return state.compacted_state != null && state.compacted_state.op_id >= target;
259
+ }
260
+ if (state.last_full_compact != null && state.last_full_compact.op_id >= target) {
261
+ return true;
262
+ }
263
+ // A full compact may change counts before the checksum-cache boundary.
264
+ // Wait for the safe target to catch up instead of publishing an older or
265
+ // stale cache. At the same boundary, full coverage can still advance.
266
+ return state.compacted_state != null && state.compacted_state.op_id > target;
267
+ }
268
+ get objectStorageLifecycle() {
269
+ if (!this.storage.objectStorage) {
270
+ throw new Error('Object storage is not configured');
271
+ }
272
+ return new ObjectStorageLifecycle(this.db, this.group_id, this.storage.objectStorage);
273
+ }
274
+ async compactSingleBucket(context) {
275
+ if (context.kind == CompactionKind.Chunks) {
276
+ return this.compactSingleBucketChunks(context);
277
+ }
278
+ return this.compactSingleBucketFully(context);
279
+ }
280
+ /**
281
+ * Merge adjacent bucket-data chunks without inspecting their operations
282
+ * unless a merge is possible. The metadata contains enough information to
283
+ * update the persisted checksum state and to decide whether a group can fit
284
+ * in one chunk.
285
+ */
286
+ async compactSingleBucketChunks(context) {
287
+ const bucket = context.state._id.b;
288
+ const resolvedDefinitionId = context.state._id.d;
289
+ const bucketContext = new BucketDataContextV3(this.db, {
290
+ bucket,
291
+ definitionId: resolvedDefinitionId,
292
+ replicationStreamId: this.group_id
293
+ });
294
+ const collection = this.db.bucketData(this.group_id, resolvedDefinitionId);
295
+ const dataContext = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
296
+ let previousCompactedState = context.state.compacted_state;
297
+ // A zero boundary represents an empty prefix, so there is no stored chunk
298
+ // whose statistics need to be carried into this pass.
299
+ if (previousCompactedState?.op_id === 0n) {
300
+ previousCompactedState = undefined;
301
+ }
302
+ // Include the last previously compacted chunk as well as new chunks. It
303
+ // is the only old chunk which can become mergeable with the new tail.
304
+ let lowerBound = previousCompactedState != null ? bucketContext.docId(previousCompactedState.op_id - 1n) : bucketContext.minId;
305
+ const upperBound = bucketContext.docId(context.targetOp + 1n);
306
+ let cachedBoundaryToVerify = previousCompactedState?.op_id;
307
+ let compactedOpId = null;
308
+ let overlappingCompactedChunk;
309
+ let compactedTail = emptyBucketStats();
310
+ let pendingChunks = [];
311
+ let pendingSize = 0;
312
+ while (true) {
313
+ this.signal?.throwIfAborted();
314
+ await context.lease.throwIfLost();
315
+ const batch = await readCompactionBatch(collection.aggregate([
316
+ {
317
+ $match: {
318
+ _id: {
319
+ $gt: lowerBound,
320
+ $lt: upperBound
321
+ }
322
+ }
323
+ },
324
+ { $sort: { _id: 1 } },
325
+ { $limit: this.moveBatchQueryLimit },
326
+ {
327
+ $project: {
328
+ _id: 1,
329
+ min_op: 1,
330
+ checksum: 1,
331
+ count: 1,
332
+ size: 1,
333
+ target_op: 1,
334
+ storage_ref: 1
335
+ }
336
+ }
337
+ ], { batchSize: this.moveBatchQueryLimit + 1 }), {
338
+ byteLimit: this.moveBatchByteLimit,
339
+ documentLimit: this.moveBatchQueryLimit
340
+ });
341
+ if (cachedBoundaryToVerify != null) {
342
+ const cachedBoundary = cachedBoundaryToVerify;
343
+ cachedBoundaryToVerify = undefined;
344
+ if (batch.documents[0]?._id.o !== cachedBoundary) {
345
+ // A previous attempt may have replaced the cached boundary before
346
+ // finalizing bucket state. Keep the persisted cache available to
347
+ // readers, but ignore it in this attempt and calculate its
348
+ // replacement through the normal scan from the bucket beginning.
349
+ previousCompactedState = undefined;
350
+ lowerBound = bucketContext.minId;
351
+ continue;
352
+ }
353
+ }
354
+ if (batch.documents.length == 0) {
355
+ break;
356
+ }
357
+ for (const doc of batch.documents) {
358
+ compactedOpId = maxOpId(compactedOpId, doc._id.o);
359
+ const documentStats = statsForDocument(doc);
360
+ if (previousCompactedState?.op_id === doc._id.o) {
361
+ overlappingCompactedChunk = documentStats;
362
+ }
363
+ const nextSize = pendingSize + doc.size;
364
+ if (pendingChunks.length > 0 && nextSize > DEFAULT_MAX_DOC_SIZE_BYTES) {
365
+ const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext);
366
+ compactedTail = combineAdjacentStats(compactedTail, groupStats);
367
+ pendingChunks = [];
368
+ pendingSize = 0;
369
+ }
370
+ pendingChunks.push(doc);
371
+ pendingSize += doc.size;
118
372
  }
373
+ lowerBound = batch.documents[batch.documents.length - 1]._id;
374
+ if (!batch.hasMore) {
375
+ break;
376
+ }
377
+ }
378
+ if (pendingChunks.length > 0) {
379
+ const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext);
380
+ compactedTail = combineAdjacentStats(compactedTail, groupStats);
381
+ }
382
+ if (compactedOpId == null) {
383
+ await this.finalizeSkippedBucket(context);
384
+ return;
385
+ }
386
+ const compactedState = previousCompactedState == null
387
+ ? compactedTail
388
+ : combineChunkStats(previousCompactedState, compactedTail, overlappingCompactedChunk);
389
+ const tailStats = compactedOpId == context.lastOp
390
+ ? undefined
391
+ : await this.readBucketStats(bucket, resolvedDefinitionId, context.lastOp, bucketContext.docId(compactedOpId));
392
+ const result = {
393
+ compactedState,
394
+ bucketStats: tailStats == null ? compactedState : combineAdjacentStats(compactedState, tailStats)
119
395
  };
396
+ await this.finalizeCompactedBucket({ context, compactedOpId, compactionResult: result, puts: 0 });
397
+ this.compactedBucketCount++;
398
+ this.logger.info(`Compacted bucket chunks ${bucket}: ${result.bucketStats.count} ops, ${result.bucketStats.chunks} chunks, ${formatBytes(result.bucketStats.bytes)}`);
120
399
  }
121
- async getBucketDataContext(bucket, definitionId) {
122
- let resolvedDefinitionId = definitionId;
123
- if (resolvedDefinitionId == null) {
124
- const allDefinitionIds = this.storage.storageIds.bucketDefinitionIds;
125
- if (allDefinitionIds.length > 0) {
126
- const potentialIds = allDefinitionIds.map((id) => ({ d: id, b: bucket }));
127
- const bucketState = await this.db.bucketState(this.group_id).findOne({
128
- _id: { $in: potentialIds }
129
- });
130
- if (bucketState != null) {
131
- resolvedDefinitionId = bucketState._id.d;
132
- }
400
+ async flushChunkMerge(bucket, inputs, collection, context, bucketContext) {
401
+ if (inputs.length == 1) {
402
+ return statsForDocument(inputs[0]);
403
+ }
404
+ // The metadata scan deliberately excluded ops. Read inline payloads only
405
+ // for this merge group; object-storage payloads are fetched below using
406
+ // the same rule.
407
+ const inlineInputs = inputs.filter((input) => input.storage_ref == null);
408
+ if (inlineInputs.length > 0) {
409
+ const inlineDocuments = await collection
410
+ .find({ _id: { $in: inlineInputs.map((input) => input._id) } }, { projection: { _id: 1, ops: 1 } })
411
+ .toArray();
412
+ const opsById = new Map(inlineDocuments.map((document) => [document._id.o.toString(), document.ops]));
413
+ for (const input of inlineInputs) {
414
+ input.ops = opsById.get(input._id.o.toString());
133
415
  }
134
416
  }
135
- if (resolvedDefinitionId == null) {
136
- return null;
417
+ await hydrateBucketDataDocuments(inputs, this.storage.objectStorage, { signal: this.signal });
418
+ const operations = inputs.flatMap((input) => Array.from(loadBucketDataDocument(context, input)));
419
+ const targetOp = inputs.reduce((maxTarget, input) => maxOpId(maxTarget, input.target_op), null);
420
+ const result = await this.flushCompactionGroup(bucket, {
421
+ inputs,
422
+ ops: operations,
423
+ changed: true,
424
+ targetOp
425
+ }, bucketContext, context);
426
+ return result.stats;
427
+ }
428
+ async finalizeCompactedBucket({ context, compactedOpId, compactionResult, puts }) {
429
+ await context.lease.throwIfLost();
430
+ const startedStats = bucketStats(context.state);
431
+ const delta = {
432
+ count: compactionResult.bucketStats.count - startedStats.count,
433
+ bytes: compactionResult.bucketStats.bytes - startedStats.bytes,
434
+ chunks: compactionResult.bucketStats.chunks - startedStats.chunks
435
+ };
436
+ const coveredClaimedHead = compactedOpId >= context.lastOp;
437
+ const concurrentWriteCheck = { $gt: ['$last_op', context.lastOp] };
438
+ const remainingFullWorkCheck = coveredClaimedHead ? concurrentWriteCheck : true;
439
+ const nextAfterPartialFullCompact = this.rescheduleAtOrAfter({ $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } }, context.rescheduleNotBefore);
440
+ const nextCheckForUncompactedWork = this.rescheduleAtOrAfter({
441
+ $min: [
442
+ new Date(firstUncompactedWrite(context.state).getTime() + this.maxCompactFullIntervalMs),
443
+ { $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } }
444
+ ]
445
+ }, context.rescheduleNotBefore);
446
+ const update = {
447
+ compacted_state: {
448
+ op_id: compactedOpId,
449
+ checksum: BigInt(compactionResult.compactedState.checksum),
450
+ count: compactionResult.compactedState.count,
451
+ bytes: compactionResult.compactedState.bytes,
452
+ chunks: compactionResult.compactedState.chunks,
453
+ at: '$$NOW'
454
+ },
455
+ bucket_stats: {
456
+ count: { $add: ['$bucket_stats.count', delta.count] },
457
+ bytes: { $add: ['$bucket_stats.bytes', delta.bytes] },
458
+ chunks: { $add: ['$bucket_stats.chunks', delta.chunks] }
459
+ },
460
+ first_uncompacted_write: context.kind == CompactionKind.Full
461
+ ? { $cond: [remainingFullWorkCheck, '$$NOW', '$$REMOVE'] }
462
+ : '$first_uncompacted_write',
463
+ next_compact_check: context.kind == CompactionKind.Full
464
+ ? { $cond: [remainingFullWorkCheck, nextAfterPartialFullCompact, '$$REMOVE'] }
465
+ : nextCheckForUncompactedWork
466
+ };
467
+ if (context.kind == CompactionKind.Full) {
468
+ update.last_full_compact = {
469
+ op_id: compactedOpId,
470
+ count: compactionResult.compactedState.count,
471
+ puts,
472
+ at: '$$NOW'
473
+ };
137
474
  }
138
- return new BucketDataContextV3(this.db, {
475
+ await context.lease.finalize(update);
476
+ }
477
+ async finalizeSkippedBucket(context) {
478
+ // A maxOpId cap can exclude the first remaining document entirely. Avoid
479
+ // immediately claiming the same no-progress bucket again in this run.
480
+ await this.rescheduleClaimedBucket(context.lease, {
481
+ ...context.decision,
482
+ nextCompactCheck: {
483
+ $max: [
484
+ context.decision.nextCompactCheck,
485
+ { $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } }
486
+ ]
487
+ }
488
+ }, context.rescheduleNotBefore);
489
+ }
490
+ /**
491
+ * Read bucket stats directly from bucket_data documents.
492
+ */
493
+ async readBucketStats(bucket, definitionId, maxOp, lowerBound) {
494
+ const context = new BucketDataContextV3(this.db, {
139
495
  bucket,
140
- definitionId: resolvedDefinitionId,
496
+ definitionId,
141
497
  replicationStreamId: this.group_id
142
498
  });
499
+ const [stats] = await this.db
500
+ .bucketData(this.group_id, definitionId)
501
+ .aggregate([
502
+ {
503
+ $match: {
504
+ _id: lowerBound == null
505
+ ? { $gte: context.minId, $lte: context.docId(maxOp) }
506
+ : { $gt: lowerBound, $lte: context.docId(maxOp) }
507
+ }
508
+ },
509
+ {
510
+ $group: {
511
+ _id: null,
512
+ count: { $sum: '$count' },
513
+ bytes: { $sum: '$size' },
514
+ chunks: { $sum: 1 },
515
+ checksum: { $sum: '$checksum' }
516
+ }
517
+ }
518
+ ])
519
+ .toArray();
520
+ return {
521
+ count: Number(stats?.count ?? 0),
522
+ bytes: BigInt(stats?.bytes ?? 0),
523
+ chunks: Number(stats?.chunks ?? 0),
524
+ checksum: typeof stats?.checksum == 'bigint'
525
+ ? Number(BigInt.asIntN(32, stats.checksum))
526
+ : addChecksums(0, Number(stats?.checksum ?? 0))
527
+ };
143
528
  }
144
- async compactSingleBucket(bucket, definitionId = null) {
145
- const bucketContext = await this.getBucketDataContext(bucket, definitionId);
146
- if (bucketContext == null) {
147
- return;
148
- }
149
- const resolvedDefinitionId = bucketContext.key.definitionId;
529
+ async compactSingleBucketFully(context) {
530
+ const bucket = context.state._id.b;
531
+ const resolvedDefinitionId = context.state._id.d;
532
+ const bucketContext = new BucketDataContextV3(this.db, {
533
+ bucket,
534
+ definitionId: resolvedDefinitionId,
535
+ replicationStreamId: this.group_id
536
+ });
150
537
  const collection = this.db.bucketData(this.group_id, resolvedDefinitionId);
151
- const context = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
538
+ const dataContext = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
152
539
  const lowerBound = bucketContext.minId;
153
- let upperBound = bucketContext.docId(this.maxOpId + 1n);
154
- let totalChecksum = 0;
540
+ let upperBound = bucketContext.docId(context.targetOp + 1n);
155
541
  let totalOpCount = 0;
156
- let totalOpBytes = 0;
157
542
  let lastNotPut = null;
158
543
  let opsSincePut = 0;
159
544
  let compactedOpId = null;
160
545
  let clearBoundary = null;
546
+ let compactedStats = emptyBucketStats();
161
547
  const seen = new Map();
162
548
  let trackingSize = 0;
549
+ let putCount = 0;
163
550
  let pendingGroup = null;
164
551
  // --- Read batch from MongoDB ---
165
552
  while (true) {
166
553
  this.signal?.throwIfAborted();
554
+ await context.lease.throwIfLost();
167
555
  const pipeline = [
168
556
  {
169
557
  $match: {
@@ -205,7 +593,7 @@ export class MongoCompactorV3 extends MongoCompactor {
205
593
  // merging is useful, and writes each final object at most once.
206
594
  for (const doc of batchDocs) {
207
595
  compactedOpId ??= doc._id.o;
208
- const originalOps = Array.from(loadBucketDataDocument(context, doc));
596
+ const originalOps = Array.from(loadBucketDataDocument(dataContext, doc));
209
597
  let changed = false;
210
598
  const compactedOps = [];
211
599
  let maxTargetOp = doc.target_op ?? null;
@@ -223,6 +611,7 @@ export class MongoCompactorV3 extends MongoCompactor {
223
611
  row_id: undefined,
224
612
  source_table: undefined,
225
613
  source_key: undefined,
614
+ subkey: undefined,
226
615
  data: null
227
616
  });
228
617
  changed = true;
@@ -238,6 +627,7 @@ export class MongoCompactorV3 extends MongoCompactor {
238
627
  }
239
628
  compactedOps.push(op);
240
629
  if (op.op == 'PUT') {
630
+ putCount++;
241
631
  lastNotPut = null;
242
632
  opsSincePut = 0;
243
633
  }
@@ -260,10 +650,6 @@ export class MongoCompactorV3 extends MongoCompactor {
260
650
  }
261
651
  }
262
652
  compactedOps.reverse();
263
- for (const op of compactedOps) {
264
- totalChecksum = addChecksums(totalChecksum, Number(op.checksum));
265
- totalOpBytes += op.data?.length ?? 0;
266
- }
267
653
  totalOpCount += compactedOps.length;
268
654
  const candidate = {
269
655
  inputs: [doc],
@@ -287,11 +673,12 @@ export class MongoCompactorV3 extends MongoCompactor {
287
673
  }
288
674
  else {
289
675
  const flushedGroup = pendingGroup;
290
- const documentId = await this.flushCompactionGroup(bucket, flushedGroup, bucketContext, context);
676
+ const result = await this.flushCompactionGroup(bucket, flushedGroup, bucketContext, dataContext);
677
+ compactedStats = combineAdjacentStats(compactedStats, result.stats);
291
678
  if (lastNotPut != null &&
292
679
  flushedGroup.ops[0].o <= lastNotPut &&
293
680
  flushedGroup.ops[flushedGroup.ops.length - 1].o >= lastNotPut) {
294
- clearBoundary = { opId: lastNotPut, documentId };
681
+ clearBoundary = { opId: lastNotPut, documentId: result.documentId };
295
682
  }
296
683
  pendingGroup = candidate;
297
684
  }
@@ -305,14 +692,16 @@ export class MongoCompactorV3 extends MongoCompactor {
305
692
  this.logger.info(`Compacted batch of ${batchDocs.length} documents for bucket ${bucket}`);
306
693
  }
307
694
  if (pendingGroup != null) {
308
- const documentId = await this.flushCompactionGroup(bucket, pendingGroup, bucketContext, context);
695
+ const result = await this.flushCompactionGroup(bucket, pendingGroup, bucketContext, dataContext);
696
+ compactedStats = combineAdjacentStats(compactedStats, result.stats);
309
697
  if (lastNotPut != null &&
310
698
  pendingGroup.ops[0].o <= lastNotPut &&
311
699
  pendingGroup.ops[pendingGroup.ops.length - 1].o >= lastNotPut) {
312
- clearBoundary = { opId: lastNotPut, documentId };
700
+ clearBoundary = { opId: lastNotPut, documentId: result.documentId };
313
701
  }
314
702
  }
315
703
  if (compactedOpId == null) {
704
+ await this.finalizeSkippedBucket(context);
316
705
  return;
317
706
  }
318
707
  // --- Clear: collapse leading MOVE/REMOVE/CLEAR sequence ---
@@ -320,25 +709,21 @@ export class MongoCompactorV3 extends MongoCompactor {
320
709
  if (clearBoundary == null || clearBoundary.opId != lastNotPut) {
321
710
  throw new ReplicationAssertionError(`Missing CLEAR boundary document for bucket ${bucket}`);
322
711
  }
323
- totalOpCount += await this.clearBucketLeading(lastNotPut, clearBoundary.documentId, bucketContext, collection, context);
712
+ const clearResult = await this.clearBucketLeading(lastNotPut, clearBoundary.documentId, bucketContext, collection, dataContext);
713
+ totalOpCount += clearResult.opCountDiff;
714
+ compactedStats = applyStatsReplacement(compactedStats, clearResult.before, clearResult.after);
324
715
  }
716
+ const tailStats = compactedOpId == context.lastOp
717
+ ? undefined
718
+ : await this.readBucketStats(bucket, resolvedDefinitionId, context.lastOp, bucketContext.docId(compactedOpId));
719
+ const result = {
720
+ compactedState: compactedStats,
721
+ bucketStats: tailStats == null ? compactedStats : combineAdjacentStats(compactedStats, tailStats)
722
+ };
325
723
  // --- Finalize: update bucket checksums and state ---
326
- this.updateBucketChecksums({
327
- bucket,
328
- definitionId: resolvedDefinitionId,
329
- seen: new Map(),
330
- trackingSize: 0,
331
- lastNotPut: lastNotPut,
332
- opsSincePut: opsSincePut,
333
- checksum: totalChecksum,
334
- opCount: totalOpCount,
335
- opBytes: totalOpBytes
336
- }, compactedOpId);
337
- if (this.bucketStateUpdates.length > 0) {
338
- await this.writeBucketStateUpdates();
339
- this.bucketStateUpdates = [];
340
- }
341
- logger.info(`Compacted bucket ${bucket}: ${totalOpCount} surviving ops`);
724
+ await this.finalizeCompactedBucket({ context, compactedOpId, compactionResult: result, puts: putCount });
725
+ this.compactedBucketCount++;
726
+ this.logger.info(`Compacted bucket ${bucket}: ${totalOpCount} ops, ${result.bucketStats.chunks} chunks, ${formatBytes(result.bucketStats.bytes)}`);
342
727
  }
343
728
  /**
344
729
  * Persist replacement objects before starting the transaction, then atomically
@@ -348,7 +733,10 @@ export class MongoCompactorV3 extends MongoCompactor {
348
733
  */
349
734
  async flushCompactionGroup(bucket, group, bucketContext, context) {
350
735
  if (group.inputs.length == 1 && !group.changed) {
351
- return group.inputs[0]._id;
736
+ return {
737
+ documentId: group.inputs[0]._id,
738
+ stats: statsForDocument(group.inputs[0])
739
+ };
352
740
  }
353
741
  const inputs = group.inputs;
354
742
  const idsToDelete = inputs.map((doc) => doc._id);
@@ -356,6 +744,7 @@ export class MongoCompactorV3 extends MongoCompactor {
356
744
  const expectedChecksum = inputs.reduce((sum, doc) => sum + doc.checksum, 0n);
357
745
  const expectedOpCount = inputs.reduce((sum, doc) => sum + doc.count, 0);
358
746
  const oldStoragePaths = inputs.flatMap((doc) => (doc.storage_ref ? [doc.storage_ref.path] : []));
747
+ const oldStorageBytes = inputs.reduce((sum, document) => sum + ObjectStorageUsage.bytes(document), 0n);
359
748
  const { documents, storagePaths: newStoragePaths, uploads } = await this.persistBucketData(bucket, [group.ops], context, undefined, { targetOp: group.targetOp });
360
749
  const session = this.db.client.startSession();
361
750
  try {
@@ -382,6 +771,7 @@ export class MongoCompactorV3 extends MongoCompactor {
382
771
  await bucketContext.collection.deleteMany({ _id: { $in: idsToDelete } }, { session });
383
772
  await bucketContext.collection.insertMany(documents, { session });
384
773
  await this.finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, session);
774
+ await this.recordObjectStorageReplacement(oldStorageBytes, documents, context.definitionId, session);
385
775
  }, {
386
776
  writeConcern: { w: 'majority' },
387
777
  readConcern: { level: 'snapshot' }
@@ -390,7 +780,10 @@ export class MongoCompactorV3 extends MongoCompactor {
390
780
  finally {
391
781
  await session.endSession();
392
782
  }
393
- return documents[0]._id;
783
+ return {
784
+ documentId: documents[0]._id,
785
+ stats: statsForDocuments(documents)
786
+ };
394
787
  }
395
788
  /**
396
789
  * Collapse the leading sequence of MOVE/REMOVE/CLEAR ops at the start
@@ -398,10 +791,13 @@ export class MongoCompactorV3 extends MongoCompactor {
398
791
  * before the known boundary document, then splits that boundary document
399
792
  * if it contains ops on both sides of lastNotPut.
400
793
  *
401
- * Returns the op count diff after replacing cleared ops with CLEAR ops.
794
+ * Returns the op count and stored-stat changes after replacing cleared ops
795
+ * with CLEAR ops.
402
796
  */
403
797
  async clearBucketLeading(lastNotPut, boundaryDocId, bucketContext, collection, context) {
404
798
  let opCountDiff = 0;
799
+ let before = emptyBucketStats();
800
+ let after = emptyBucketStats();
405
801
  const session = this.db.client.startSession();
406
802
  try {
407
803
  let done = false;
@@ -411,26 +807,36 @@ export class MongoCompactorV3 extends MongoCompactor {
411
807
  const batch = await this.clearLeadingFullDocuments(session, lastNotPut, boundaryDocId, bucketContext, collection, context);
412
808
  done = batch.done;
413
809
  opCountDiff += batch.opCountDiff;
810
+ before = combineAdjacentStats(before, batch.before);
811
+ after = combineAdjacentStats(after, batch.after);
414
812
  }
415
813
  // The final step is to process the "boundary" document: It may contain some CLEAR/MOVE/REMOVE operations,
416
814
  // potentially followed by PUT operations. This is only a single document, so no need for batching.
417
- opCountDiff += await this.clearBoundaryDocument(session, lastNotPut, boundaryDocId, bucketContext, collection, context);
815
+ const boundaryResult = await this.clearBoundaryDocument(session, lastNotPut, boundaryDocId, bucketContext, collection, context);
816
+ opCountDiff += boundaryResult.opCountDiff;
817
+ before = combineAdjacentStats(before, boundaryResult.before);
818
+ after = combineAdjacentStats(after, boundaryResult.after);
418
819
  }
419
820
  finally {
420
821
  await session.endSession();
421
822
  }
422
- return opCountDiff;
823
+ return { opCountDiff, before, after };
423
824
  }
424
825
  async clearLeadingFullDocuments(session, lastNotPut, boundaryDocId, bucketContext, collection, context) {
425
826
  const bucket = bucketContext.key.bucket;
426
827
  this.signal?.throwIfAborted();
427
- const prepared = await this.prepareCompactionUploads(bucket, context, [lastNotPut]);
828
+ let prepared;
428
829
  let done = false;
429
830
  let opCountDiff = 0;
831
+ let before = emptyBucketStats();
832
+ let after = emptyBucketStats();
430
833
  await session.withTransaction(async () => {
431
834
  done = false;
432
835
  opCountDiff = 0;
836
+ before = emptyBucketStats();
837
+ after = emptyBucketStats();
433
838
  const oldStoragePaths = [];
839
+ let oldStorageBytes = 0n;
434
840
  const query = collection.find({
435
841
  _id: {
436
842
  $gte: bucketContext.minId,
@@ -444,6 +850,7 @@ export class MongoCompactorV3 extends MongoCompactor {
444
850
  min_op: 1,
445
851
  checksum: 1,
446
852
  count: 1,
853
+ size: 1,
447
854
  target_op: 1,
448
855
  has_clear_op: 1,
449
856
  storage_ref: 1
@@ -456,14 +863,21 @@ export class MongoCompactorV3 extends MongoCompactor {
456
863
  let lastDocId = null;
457
864
  let clearOpCount = 0;
458
865
  let gotNonClearOp = false;
866
+ const inputStats = emptyBucketStats();
459
867
  for await (const doc of query.stream()) {
460
868
  if (doc.min_op > lastNotPut) {
461
869
  throw new ReplicationAssertionError(`Unexpected document before CLEAR boundary with min_op ${doc.min_op} > ${lastNotPut} in bucket ${bucket}`);
462
870
  }
463
871
  lastDocId = doc._id;
872
+ const documentStats = statsForDocument(doc);
873
+ inputStats.count += documentStats.count;
874
+ inputStats.bytes += documentStats.bytes;
875
+ inputStats.chunks += documentStats.chunks;
876
+ inputStats.checksum = addChecksums(inputStats.checksum, documentStats.checksum);
464
877
  if (doc.storage_ref) {
465
878
  oldStoragePaths.push(doc.storage_ref.path);
466
879
  }
880
+ oldStorageBytes += ObjectStorageUsage.bytes(doc);
467
881
  // The compaction scan established that every operation before the
468
882
  // boundary is MOVE/REMOVE/CLEAR. Root metadata is sufficient to fold
469
883
  // whole documents into one CLEAR, so avoid downloading their payloads.
@@ -486,6 +900,7 @@ export class MongoCompactorV3 extends MongoCompactor {
486
900
  done = true;
487
901
  return;
488
902
  }
903
+ prepared ??= await this.prepareCompactionUploads(bucket, context, [lastNotPut]);
489
904
  this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastDocId?.o}`);
490
905
  await collection.deleteMany({
491
906
  _id: {
@@ -505,21 +920,29 @@ export class MongoCompactorV3 extends MongoCompactor {
505
920
  });
506
921
  await collection.insertOne(persisted.documents[0], { session });
507
922
  await this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, session);
923
+ await this.recordObjectStorageReplacement(oldStorageBytes, persisted.documents, context.definitionId, session);
508
924
  opCountDiff = -clearedOpCount + 1;
925
+ before = inputStats;
926
+ after = statsForDocuments(persisted.documents);
509
927
  }, {
510
928
  writeConcern: { w: 'majority' },
511
929
  readConcern: { level: 'snapshot' }
512
930
  });
513
- return { done, opCountDiff };
931
+ return { done, opCountDiff, before, after };
514
932
  }
515
933
  async clearBoundaryDocument(session, lastNotPut, boundaryDocId, bucketContext, collection, context) {
516
934
  const bucket = bucketContext.key.bucket;
517
935
  this.signal?.throwIfAborted();
518
936
  const prepared = await this.prepareCompactionUploads(bucket, context, [lastNotPut, boundaryDocId.o]);
519
937
  let opCountDiff = 0;
938
+ let before = emptyBucketStats();
939
+ let after = emptyBucketStats();
520
940
  await session.withTransaction(async () => {
521
941
  opCountDiff = 0;
942
+ before = emptyBucketStats();
943
+ after = emptyBucketStats();
522
944
  const oldStoragePaths = [];
945
+ let oldStorageBytes = 0n;
523
946
  const query = collection.find({
524
947
  // This is a range query, but should only ever return two documents:
525
948
  // 1. The CLEAR op from the previous clearLeadingFullDocuments.
@@ -536,6 +959,7 @@ export class MongoCompactorV3 extends MongoCompactor {
536
959
  min_op: 1,
537
960
  checksum: 1,
538
961
  count: 1,
962
+ size: 1,
539
963
  target_op: 1,
540
964
  ops: 1,
541
965
  storage_ref: 1
@@ -547,15 +971,22 @@ export class MongoCompactorV3 extends MongoCompactor {
547
971
  let clearedOpCount = 0;
548
972
  let maxTargetOp = null;
549
973
  const boundarySurvivors = [];
974
+ const inputStats = emptyBucketStats();
550
975
  for await (const doc of query.stream()) {
551
976
  docsRead++;
552
977
  if (docsRead > 2) {
553
978
  throw new ReplicationAssertionError(`Unexpected extra document before CLEAR boundary in bucket ${bucket}`);
554
979
  }
980
+ const documentStats = statsForDocument(doc);
981
+ inputStats.count += documentStats.count;
982
+ inputStats.bytes += documentStats.bytes;
983
+ inputStats.chunks += documentStats.chunks;
984
+ inputStats.checksum = addChecksums(inputStats.checksum, documentStats.checksum);
555
985
  const isBoundaryDoc = doc._id.o == boundaryDocId.o;
556
986
  if (doc.storage_ref) {
557
987
  oldStoragePaths.push(doc.storage_ref.path);
558
988
  }
989
+ oldStorageBytes += ObjectStorageUsage.bytes(doc);
559
990
  await hydrateBucketDataDocuments([doc], this.storage.objectStorage, { signal: this.signal });
560
991
  maxTargetOp = maxOpId(maxTargetOp, doc.target_op);
561
992
  for (const op of loadBucketDataDocument(context, doc)) {
@@ -605,12 +1036,15 @@ export class MongoCompactorV3 extends MongoCompactor {
605
1036
  });
606
1037
  await collection.insertMany(persisted.documents, { session });
607
1038
  await this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, session);
1039
+ await this.recordObjectStorageReplacement(oldStorageBytes, persisted.documents, context.definitionId, session);
608
1040
  opCountDiff = -clearedOpCount + 1;
1041
+ before = inputStats;
1042
+ after = statsForDocuments(persisted.documents);
609
1043
  }, {
610
1044
  writeConcern: { w: 'majority' },
611
1045
  readConcern: { level: 'snapshot' }
612
1046
  });
613
- return opCountDiff;
1047
+ return { opCountDiff, before, after };
614
1048
  }
615
1049
  /**
616
1050
  * Reserve stable object paths before starting a retryable MongoDB transaction.
@@ -637,6 +1071,16 @@ export class MongoCompactorV3 extends MongoCompactor {
637
1071
  await this.objectStorageLifecycle.publishUploads(uploads, session);
638
1072
  await this.objectStorageLifecycle.retire(Array.from(oldStoragePaths).filter((path) => !newStoragePaths.has(path)), session);
639
1073
  }
1074
+ async recordObjectStorageReplacement(oldBytes, newDocuments, definitionId, session) {
1075
+ if (!this.storage.objectStorage) {
1076
+ return;
1077
+ }
1078
+ let newBytes = 0n;
1079
+ for (const document of newDocuments) {
1080
+ newBytes += ObjectStorageUsage.bytes(document);
1081
+ }
1082
+ await this.objectStorageUsage.applyDelta(definitionId, newBytes - oldBytes, session);
1083
+ }
640
1084
  async persistBucketData(bucket, chunks, context, preparedUploads, options) {
641
1085
  const serializedChunks = chunks.map((chunk) => serializeBucketData(bucket, chunk, options));
642
1086
  if (!this.storage.objectStorage) {
@@ -679,7 +1123,7 @@ export class MongoCompactorV3 extends MongoCompactor {
679
1123
  return serialized;
680
1124
  }
681
1125
  const { ops, ...metadata } = serialized;
682
- const { fileSize } = await store.store(upload.path, ops);
1126
+ const { fileSize } = await store.store(upload.path, ops, { signal: this.signal });
683
1127
  storagePaths.add(upload.path);
684
1128
  return {
685
1129
  ...metadata,