@powersync/service-module-mongodb-storage 0.18.3 → 0.20.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 (145) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.d.ts +3 -0
  3. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js +41 -0
  4. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js.map +1 -0
  5. package/dist/storage/MongoBucketStorage.d.ts +5 -1
  6. package/dist/storage/MongoBucketStorage.js +5 -1
  7. package/dist/storage/MongoBucketStorage.js.map +1 -1
  8. package/dist/storage/implementation/MongoBucketBatch.d.ts +3 -0
  9. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  10. package/dist/storage/implementation/MongoCompactor.d.ts +15 -19
  11. package/dist/storage/implementation/MongoCompactor.js +68 -260
  12. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  13. package/dist/storage/implementation/MongoStorageProvider.js +26 -3
  14. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  15. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +10 -4
  16. package/dist/storage/implementation/MongoSyncBucketStorage.js +13 -12
  17. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  18. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +2 -1
  19. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +146 -34
  20. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  21. package/dist/storage/implementation/common/PersistedBatch.d.ts +6 -0
  22. package/dist/storage/implementation/common/PersistedBatch.js +15 -8
  23. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  24. package/dist/storage/implementation/models.d.ts +20 -0
  25. package/dist/storage/implementation/models.js.map +1 -1
  26. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +4 -0
  27. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +58 -84
  28. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  29. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +11 -2
  30. package/dist/storage/implementation/v1/MongoCompactorV1.js +250 -1
  31. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  32. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +19 -11
  33. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  34. package/dist/storage/implementation/v1/SingleBucketStoreV1.d.ts +9 -10
  35. package/dist/storage/implementation/v1/SingleBucketStoreV1.js +2 -8
  36. package/dist/storage/implementation/v1/SingleBucketStoreV1.js.map +1 -1
  37. package/dist/storage/implementation/v3/BucketDataContextV3.d.ts +13 -0
  38. package/dist/storage/implementation/v3/BucketDataContextV3.js +23 -0
  39. package/dist/storage/implementation/v3/BucketDataContextV3.js.map +1 -0
  40. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +35 -23
  41. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  42. package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +1 -0
  43. package/dist/storage/implementation/v3/MongoChecksumsV3.js +59 -103
  44. package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
  45. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +23 -2
  46. package/dist/storage/implementation/v3/MongoCompactorV3.js +354 -219
  47. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  48. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +5 -0
  49. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +12 -11
  50. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  51. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +5 -3
  52. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +108 -106
  53. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  54. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +5 -2
  55. package/dist/storage/implementation/v3/PersistedBatchV3.js +53 -15
  56. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  57. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +2 -1
  58. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +3 -0
  59. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  60. package/dist/storage/implementation/v3/bucket-format.d.ts +10 -1
  61. package/dist/storage/implementation/v3/bucket-format.js +28 -9
  62. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  63. package/dist/storage/implementation/v3/chunking.js +3 -1
  64. package/dist/storage/implementation/v3/chunking.js.map +1 -1
  65. package/dist/storage/implementation/v3/models.d.ts +43 -2
  66. package/dist/storage/implementation/v3/models.js.map +1 -1
  67. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +21 -0
  68. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +44 -0
  69. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -0
  70. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +30 -0
  71. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js +12 -0
  72. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js.map +1 -0
  73. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +70 -0
  74. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +146 -0
  75. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -0
  76. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +42 -0
  77. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +285 -0
  78. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -0
  79. package/dist/storage/implementation/v3/source-table-utils.d.ts +9 -1
  80. package/dist/storage/implementation/v3/source-table-utils.js +20 -21
  81. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
  82. package/dist/types/types.d.ts +35 -0
  83. package/dist/types/types.js +35 -1
  84. package/dist/types/types.js.map +1 -1
  85. package/dist/utils/test-utils.js +3 -1
  86. package/dist/utils/test-utils.js.map +1 -1
  87. package/dist/utils/util.d.ts +8 -1
  88. package/dist/utils/util.js +125 -2
  89. package/dist/utils/util.js.map +1 -1
  90. package/package.json +10 -7
  91. package/src/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.ts +55 -0
  92. package/src/storage/MongoBucketStorage.ts +10 -2
  93. package/src/storage/implementation/MongoBucketBatch.ts +4 -0
  94. package/src/storage/implementation/MongoCompactor.ts +90 -314
  95. package/src/storage/implementation/MongoStorageProvider.ts +32 -3
  96. package/src/storage/implementation/MongoSyncBucketStorage.ts +23 -22
  97. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +164 -37
  98. package/src/storage/implementation/common/PersistedBatch.ts +22 -10
  99. package/src/storage/implementation/models.ts +20 -0
  100. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +76 -96
  101. package/src/storage/implementation/v1/MongoCompactorV1.ts +292 -8
  102. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +53 -40
  103. package/src/storage/implementation/v1/SingleBucketStoreV1.ts +18 -27
  104. package/src/storage/implementation/v3/BucketDataContextV3.ts +32 -0
  105. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +40 -23
  106. package/src/storage/implementation/v3/MongoChecksumsV3.ts +75 -105
  107. package/src/storage/implementation/v3/MongoCompactorV3.ts +456 -258
  108. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +21 -19
  109. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +122 -126
  110. package/src/storage/implementation/v3/PersistedBatchV3.ts +69 -16
  111. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +5 -0
  112. package/src/storage/implementation/v3/bucket-format.ts +36 -9
  113. package/src/storage/implementation/v3/chunking.ts +3 -1
  114. package/src/storage/implementation/v3/models.ts +45 -1
  115. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +54 -0
  116. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +29 -0
  117. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +171 -0
  118. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +343 -0
  119. package/src/storage/implementation/v3/source-table-utils.ts +43 -35
  120. package/src/types/types.ts +42 -1
  121. package/src/utils/test-utils.ts +3 -1
  122. package/src/utils/util.ts +182 -4
  123. package/test/src/__snapshots__/storage_sync.test.ts.snap +1 -583
  124. package/test/src/cleanup-stopped-sync-configs.test.ts +24 -1
  125. package/test/src/helpers/MemoryObjectStorage.ts +53 -0
  126. package/test/src/helpers/s3TestFactory.ts +69 -0
  127. package/test/src/object_storage_lifecycle.test.ts +59 -0
  128. package/test/src/setup.ts +6 -1
  129. package/test/src/storage.test.ts +216 -2
  130. package/test/src/storage_compacting.test.ts +176 -187
  131. package/test/src/storage_object_storage_inline_threshold.test.ts +116 -0
  132. package/test/src/storage_s3_checksums.test.ts +191 -0
  133. package/test/src/storage_s3_compaction_lifecycle.test.ts +417 -0
  134. package/test/src/storage_s3_reading.test.ts +526 -0
  135. package/test/src/storage_s3_writing.test.ts +135 -0
  136. package/test/src/storage_sync.test.ts +124 -5
  137. package/tsconfig.tsbuildinfo +1 -1
  138. package/dist/storage/implementation/common/SingleBucketStore.d.ts +0 -54
  139. package/dist/storage/implementation/common/SingleBucketStore.js +0 -3
  140. package/dist/storage/implementation/common/SingleBucketStore.js.map +0 -1
  141. package/dist/storage/implementation/v3/SingleBucketStoreV3.d.ts +0 -18
  142. package/dist/storage/implementation/v3/SingleBucketStoreV3.js +0 -84
  143. package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +0 -1
  144. package/src/storage/implementation/common/SingleBucketStore.ts +0 -63
  145. package/src/storage/implementation/v3/SingleBucketStoreV3.ts +0 -108
@@ -1,24 +1,100 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
2
  import { logger, ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
3
- import { addChecksums, storage, utils } from '@powersync/service-core';
3
+ import { addChecksums, InternalOpId, storage, utils } from '@powersync/service-core';
4
4
  import { BucketDefinitionId } from '@powersync/service-sync-rules';
5
5
  import { BucketDataDoc } from '../common/BucketDataDoc.js';
6
- import { BucketDataDocumentGeneric } from '../common/SingleBucketStore.js';
7
6
  import { BucketDataKey, BucketStateDocumentBase } from '../models.js';
8
- import { DirtyBucket, MongoCompactor } from '../MongoCompactor.js';
7
+ import { ConcurrentCompactionError, DirtyBucket, MongoCompactor } from '../MongoCompactor.js';
9
8
  import { cacheKey } from '../OperationBatch.js';
10
- import { loadBucketDataDocument, serializeBucketData } from './bucket-format.js';
11
- import { chunkBucketData } from './chunking.js';
9
+ import { loadBucketDataDocument, maxOpId, serializeBucketData } from './bucket-format.js';
10
+ import { BucketDataContextV3 } from './BucketDataContextV3.js';
11
+ import { DEFAULT_MAX_DOC_SIZE_BYTES } from './chunking.js';
12
12
  import { BucketDataDocumentV3, BucketStateDocumentV3 } from './models.js';
13
13
  import { DefinitionChecksumOperations, MongoChecksumsV3 } from './MongoChecksumsV3.js';
14
14
  import type { MongoSyncBucketStorageV3 } from './MongoSyncBucketStorageV3.js';
15
- import { SingleBucketStoreV3 } from './SingleBucketStoreV3.js';
15
+ import { BucketDataObjectStorage, hydrateBucketDataDocuments } from './object-storage/BucketDataObjectStorage.js';
16
+ import { ObjectStorageLifecycle, PreparedObjectStorageUpload } from './object-storage/ObjectStorageLifecycle.js';
16
17
  import { VersionedPowerSyncMongoV3 } from './VersionedPowerSyncMongoV3.js';
17
18
 
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
+ }
29
+
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
+ }
54
+
55
+ documents.push(document);
56
+ cumulativeBytes += document.size;
57
+
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
+ }
66
+ }
67
+
18
68
  export class MongoCompactorV3 extends MongoCompactor {
19
69
  declare protected readonly db: VersionedPowerSyncMongoV3;
20
70
  declare protected readonly storage: MongoSyncBucketStorageV3;
21
71
 
72
+ override async compact(): Promise<void> {
73
+ if (this.storage.objectStorage) {
74
+ // Clean these before compacting - should be quick in most cases.
75
+ try {
76
+ await this.objectStorageLifecycle.cleanup(this.logger);
77
+ } catch (e) {
78
+ // In this case, still continue normal compact process
79
+ this.logger.error(`Failed to clean up object storage deletion markers before compaction`, e);
80
+ }
81
+ }
82
+ await super.compact();
83
+ if (this.storage.objectStorage) {
84
+ // Cleanup for any produced during compacting.
85
+ // Note that markers only expire after a delay, so this may skip many produced during this compact
86
+ // run. However, during long compact runs, this may also have many ones it can clean up.
87
+ await this.objectStorageLifecycle.cleanup(this.logger);
88
+ }
89
+ }
90
+
91
+ private get objectStorageLifecycle(): ObjectStorageLifecycle {
92
+ if (!this.storage.objectStorage) {
93
+ throw new Error('Object storage is not configured');
94
+ }
95
+ return new ObjectStorageLifecycle(this.db, this.group_id, this.storage.objectStorage);
96
+ }
97
+
22
98
  public async *dirtyBucketBatches(options: {
23
99
  minBucketChanges: number;
24
100
  minChangeRatio: number;
@@ -97,10 +173,10 @@ export class MongoCompactorV3 extends MongoCompactor {
97
173
  };
98
174
  }
99
175
 
100
- protected async getBucketDataContext(
176
+ private async getBucketDataContext(
101
177
  bucket: string,
102
178
  definitionId: BucketDefinitionId | null
103
- ): Promise<SingleBucketStoreV3 | null> {
179
+ ): Promise<BucketDataContextV3 | null> {
104
180
  let resolvedDefinitionId = definitionId;
105
181
 
106
182
  if (resolvedDefinitionId == null) {
@@ -120,7 +196,7 @@ export class MongoCompactorV3 extends MongoCompactor {
120
196
  return null;
121
197
  }
122
198
 
123
- return new SingleBucketStoreV3(this.db, {
199
+ return new BucketDataContextV3(this.db, {
124
200
  bucket,
125
201
  definitionId: resolvedDefinitionId,
126
202
  replicationStreamId: this.group_id
@@ -138,7 +214,7 @@ export class MongoCompactorV3 extends MongoCompactor {
138
214
  const context = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
139
215
 
140
216
  const lowerBound = bucketContext.minId;
141
- let upperBound = bucketContext.maxId;
217
+ let upperBound = bucketContext.docId(this.maxOpId + 1n);
142
218
 
143
219
  let totalChecksum = 0;
144
220
  let totalOpCount = 0;
@@ -146,10 +222,11 @@ export class MongoCompactorV3 extends MongoCompactor {
146
222
 
147
223
  let lastNotPut: bigint | null = null;
148
224
  let opsSincePut = 0;
149
- let clearBoundaryDocId: BucketDataKey | null = null;
150
-
225
+ let compactedOpId: bigint | null = null;
226
+ let clearBoundary: { opId: bigint; documentId: BucketDataKey } | null = null;
151
227
  const seen = new Map<string, bigint>();
152
228
  let trackingSize = 0;
229
+ let pendingGroup: PendingCompactionGroup | null = null;
153
230
 
154
231
  // --- Read batch from MongoDB ---
155
232
  while (true) {
@@ -158,7 +235,6 @@ export class MongoCompactorV3 extends MongoCompactor {
158
235
  const pipeline: mongo.Document[] = [
159
236
  {
160
237
  $match: {
161
- '_id.b': bucket,
162
238
  _id: {
163
239
  $gte: lowerBound,
164
240
  $lt: upperBound
@@ -176,258 +252,182 @@ export class MongoCompactorV3 extends MongoCompactor {
176
252
  size: 1,
177
253
  target_op: 1,
178
254
  ops: 1,
179
- bsonSize: { $bsonSize: '$$ROOT' }
255
+ storage_ref: 1
180
256
  }
181
257
  }
182
258
  ];
183
259
 
184
- const rawBatch = await collection
185
- .aggregate<BucketDataDocumentV3 & { bsonSize: number | bigint }>(pipeline, {
260
+ const batch = await readCompactionBatch(
261
+ collection.aggregate<BucketDataDocumentV3>(pipeline, {
186
262
  batchSize: this.moveBatchQueryLimit + 1
187
- })
188
- .toArray();
263
+ }),
264
+ {
265
+ byteLimit: this.moveBatchByteLimit,
266
+ documentLimit: this.moveBatchQueryLimit
267
+ }
268
+ );
269
+ const batchDocs = batch.documents;
189
270
 
190
- if (rawBatch.length == 0) {
271
+ if (batchDocs.length == 0) {
191
272
  // No more documents in this bucket — compaction complete.
192
273
  break;
193
274
  }
194
275
 
195
- // --- Cut batch to byte limit ---
196
- let cumulativeBytes = 0;
197
- let batchCutIndex = rawBatch.length;
198
-
199
- for (let i = 0; i < rawBatch.length; i++) {
200
- cumulativeBytes += Number(rawBatch[i].bsonSize);
201
- if (cumulativeBytes > this.moveBatchByteLimit && i > 0) {
202
- // Byte limit exceeded; cut batch at current index. Always include
203
- // at least one document (i > 0 guard) to guarantee forward progress.
204
- batchCutIndex = i;
205
- break;
206
- }
207
- }
208
-
209
- const batchDocs = rawBatch.slice(0, batchCutIndex);
210
-
211
- // --- Decode documents into individual ops ---
212
- // Processable: document has at least one op <= maxOpId.
213
- // Only processable docs are deleted and recreated; the rest survive untouched.
214
- const batchOps: BucketDataDoc[] = [];
215
- const processableDocs: (BucketDataDocumentV3 & { bsonSize: number | bigint })[] = [];
276
+ await hydrateBucketDataDocuments(batchDocs, this.storage.objectStorage, { signal: this.signal });
216
277
 
278
+ // Compact each document independently, then greedily merge adjacent
279
+ // post-compaction results. This preserves existing boundaries unless
280
+ // merging is useful, and writes each final object at most once.
217
281
  for (const doc of batchDocs) {
218
- let hasRelevantOp = false;
219
- const candidateOps: BucketDataDoc[] = [];
220
- for (const op of loadBucketDataDocument(context, doc as unknown as BucketDataDocumentV3)) {
221
- candidateOps.push(op);
222
- if (op.o <= this.maxOpId) {
223
- hasRelevantOp = true;
224
- }
225
- }
226
- if (hasRelevantOp) {
227
- processableDocs.push(doc);
228
- batchOps.push(...candidateOps);
229
- } // else: candidateOps discarded — document has no ops <= maxOpId
230
- }
231
-
232
- if (processableDocs.length == 0) {
233
- // No documents with relevant ops in this batch; paginate to next batch
234
- // without performing any writes. This handles batches where all documents
235
- // contain only ops above maxOpId.
236
- upperBound = batchDocs[batchDocs.length - 1]._id as typeof upperBound;
237
- if (batchCutIndex >= rawBatch.length && rawBatch.length < this.moveBatchQueryLimit) {
238
- // Entire remaining bucket is non-processable — compaction complete.
239
- break;
240
- }
241
- // Skip dedup, rechunking, and transaction for this batch.
242
- continue;
243
- }
244
-
245
- // Scoped replace in a bounded transaction.
246
- // Delete by individual _id values instead of a continuous range.
247
- // A continuous range could catch non-processable documents (all ops > maxOpId)
248
- // that happen to fall between processable documents in _id.o sort order.
249
- const idsToDelete = processableDocs.map((d) => d._id);
250
- const expectedDocCount = processableDocs.length;
251
- const expectedChecksum = processableDocs.reduce((sum, doc) => sum + doc.checksum, 0n);
252
- const expectedOpCount = processableDocs.reduce((sum, doc) => sum + doc.count, 0);
253
-
254
- // Sort ops by o descending for newest-first dedup
255
- batchOps.sort((a, b) => (b.o > a.o ? 1 : b.o < a.o ? -1 : 0));
256
-
257
- // --- Dedup: newest-first, superseded → MOVE ---
258
- const surviving: BucketDataDoc[] = [];
259
-
260
- for (const op of batchOps) {
261
- if (op.op == 'PUT' || op.op == 'REMOVE') {
262
- if (op.o > this.maxOpId) {
263
- surviving.push(op);
264
- continue; // Do not dedup ops above compaction horizon
265
- }
266
- const key = `${op.table}/${op.row_id}/${cacheKey(op.source_table!, op.source_key!)}`;
267
- const targetOp = seen.get(key);
268
- if (targetOp != null) {
269
- surviving.push({
270
- ...op,
271
- op: 'MOVE',
272
- target_op: targetOp,
273
- table: undefined,
274
- row_id: undefined,
275
- source_table: undefined,
276
- source_key: undefined,
277
- data: null
278
- });
279
- if (lastNotPut == null) {
280
- lastNotPut = op.o;
282
+ compactedOpId ??= doc._id.o;
283
+ const originalOps = Array.from(loadBucketDataDocument(context, doc));
284
+
285
+ let changed = false;
286
+ const compactedOps: BucketDataDoc[] = [];
287
+ let maxTargetOp: InternalOpId | null = doc.target_op ?? null;
288
+ for (let index = originalOps.length - 1; index >= 0; index--) {
289
+ const op = originalOps[index];
290
+ if (op.op == 'PUT' || op.op == 'REMOVE') {
291
+ const key = `${op.table}/${op.row_id}/${cacheKey(op.source_table!, op.source_key!)}`;
292
+ const targetOp = seen.get(key);
293
+ if (targetOp != null) {
294
+ maxTargetOp = maxOpId(maxTargetOp, targetOp);
295
+ compactedOps.push({
296
+ ...op,
297
+ op: 'MOVE',
298
+ table: undefined,
299
+ row_id: undefined,
300
+ source_table: undefined,
301
+ source_key: undefined,
302
+ data: null
303
+ });
304
+ changed = true;
305
+ if (lastNotPut == null) {
306
+ lastNotPut = op.o;
307
+ }
308
+ opsSincePut += 1;
309
+ } else {
310
+ if (trackingSize < this.idLimitBytes) {
311
+ seen.set(utils.flatstr(key), op.o);
312
+ trackingSize += key.length + 140;
313
+ }
314
+ compactedOps.push(op);
315
+ if (op.op == 'PUT') {
316
+ lastNotPut = null;
317
+ opsSincePut = 0;
318
+ } else {
319
+ if (lastNotPut == null) {
320
+ lastNotPut = op.o;
321
+ }
322
+ opsSincePut += 1;
323
+ }
281
324
  }
282
- opsSincePut += 1;
283
325
  } else {
284
- if (trackingSize < this.idLimitBytes) {
285
- seen.set(utils.flatstr(key), op.o);
286
- trackingSize += key.length + 140;
287
- }
288
- surviving.push(op);
289
- if (op.op == 'PUT') {
290
- lastNotPut = null;
291
- opsSincePut = 0;
292
- } else {
326
+ compactedOps.push(op);
327
+ if (op.op != 'CLEAR') {
293
328
  if (lastNotPut == null) {
294
329
  lastNotPut = op.o;
295
330
  }
296
331
  opsSincePut += 1;
297
332
  }
298
333
  }
299
- } else {
300
- surviving.push(op);
301
- if (op.op != 'CLEAR') {
302
- if (lastNotPut == null) {
303
- lastNotPut = op.o;
304
- }
305
- opsSincePut += 1;
306
- }
307
334
  }
308
- }
335
+ compactedOps.reverse();
309
336
 
310
- // Reverse back to ascending order for rechunking
311
- surviving.reverse();
312
-
313
- // --- Rechunk survivors into new V3 documents ---
314
- const chunks = chunkBucketData(surviving);
315
- const newDocs = chunks.map((chunk) => serializeBucketData(bucket, chunk));
316
-
317
- if (lastNotPut == null) {
318
- clearBoundaryDocId = null;
319
- } else {
320
- const boundaryOp = lastNotPut;
321
- const boundaryDoc = newDocs.find((doc) => doc.min_op <= boundaryOp && doc._id.o >= boundaryOp);
322
- if (boundaryDoc != null) {
323
- clearBoundaryDocId = boundaryDoc._id;
337
+ for (const op of compactedOps) {
338
+ totalChecksum = addChecksums(totalChecksum, Number(op.checksum));
339
+ totalOpBytes += op.data?.length ?? 0;
324
340
  }
325
- }
341
+ totalOpCount += compactedOps.length;
326
342
 
327
- // --- Commit: scoped delete + insert in transaction ---
328
- const session = this.db.client.startSession();
329
- try {
330
- await session.withTransaction(
331
- async () => {
332
- // Verify documents haven't been modified since we read them.
333
- // This aggregate anchors the transaction snapshot and catches
334
- // concurrent compaction jobs that modified the same documents.
335
- const verification = await bucketContext.collection
336
- .aggregate<{ docCount: number; checksumSum: bigint | null; opCountSum: number | null }>(
337
- [
338
- { $match: { _id: { $in: idsToDelete } } },
339
- {
340
- $group: {
341
- _id: null,
342
- docCount: { $sum: 1 },
343
- checksumSum: { $sum: '$checksum' },
344
- opCountSum: { $sum: '$count' }
345
- }
346
- }
347
- ],
348
- { session }
349
- )
350
- .next();
343
+ const candidate: PendingCompactionGroup = {
344
+ inputs: [doc],
345
+ ops: compactedOps,
346
+ changed,
347
+ targetOp: maxTargetOp
348
+ };
351
349
 
350
+ if (pendingGroup == null) {
351
+ pendingGroup = candidate;
352
+ } else {
353
+ const mergedOps: BucketDataDoc[] = [...candidate.ops, ...pendingGroup.ops];
354
+ const mergedSize = serializeBucketData(bucket, mergedOps, { targetOp: maxTargetOp }).size;
355
+ if (mergedSize <= DEFAULT_MAX_DOC_SIZE_BYTES) {
356
+ pendingGroup = {
357
+ inputs: [...candidate.inputs, ...pendingGroup.inputs],
358
+ ops: mergedOps,
359
+ changed: candidate.changed || pendingGroup.changed,
360
+ targetOp: maxOpId(maxTargetOp, pendingGroup.targetOp)
361
+ };
362
+ } else {
363
+ const flushedGroup = pendingGroup;
364
+ const documentId = await this.flushCompactionGroup(bucket, flushedGroup, bucketContext, context);
352
365
  if (
353
- verification == null || // all docs deleted
354
- verification.docCount !== expectedDocCount || // some docs deleted
355
- verification.checksumSum !== expectedChecksum || // docs modified in-place
356
- verification.opCountSum !== expectedOpCount // ops added/removed within docs
366
+ lastNotPut != null &&
367
+ flushedGroup.ops[0].o <= lastNotPut &&
368
+ flushedGroup.ops[flushedGroup.ops.length - 1].o >= lastNotPut
357
369
  ) {
358
- throw new Error(
359
- `Concurrent modification detected in bucket ${bucket}. Aborting compaction for this batch.`
360
- );
361
- }
362
-
363
- await bucketContext.collection.deleteMany(
364
- {
365
- _id: { $in: idsToDelete }
366
- } as any,
367
- { session }
368
- );
369
- if (newDocs.length > 0) {
370
- await bucketContext.collection.insertMany(newDocs as unknown as BucketDataDocumentGeneric[], { session });
370
+ clearBoundary = { opId: lastNotPut, documentId };
371
371
  }
372
- },
373
- {
374
- writeConcern: { w: 'majority' },
375
- readConcern: { level: 'snapshot' }
376
- }
377
- );
378
- } finally {
379
- await session.endSession();
380
- }
381
-
382
- // --- Accumulate bucket state ---
383
- for (const chunk of chunks) {
384
- for (const op of chunk) {
385
- if (op.o <= this.maxOpId) {
386
- totalChecksum = addChecksums(totalChecksum, Number(op.checksum));
387
- totalOpBytes += op.data?.length ?? 0;
372
+ pendingGroup = candidate;
388
373
  }
389
374
  }
390
375
  }
391
- totalOpCount += surviving.filter((op) => op.o <= this.maxOpId).length;
392
376
 
393
377
  // --- Advance to next batch ---
394
- upperBound = (newDocs.length > 0 ? newDocs[0]._id : rawBatch[batchCutIndex - 1]._id) as typeof upperBound;
395
-
396
- if (batchCutIndex < rawBatch.length) {
397
- // We cut the batch short due to byte limit — don't advance past cut point
398
- // The upperBound is already set to the last doc we processed
399
- } else {
400
- // Processed all docs in the raw batch. If we got fewer than the query
401
- // limit, there are no more documents in this bucket — compaction complete.
402
- if (rawBatch.length < this.moveBatchQueryLimit) {
403
- break;
404
- }
378
+ upperBound = batchDocs[batchDocs.length - 1]._id as typeof upperBound;
379
+
380
+ if (!batch.hasMore) {
381
+ break;
405
382
  }
406
383
 
407
384
  this.logger.info(`Compacted batch of ${batchDocs.length} documents for bucket ${bucket}`);
408
385
  }
409
386
 
387
+ if (pendingGroup != null) {
388
+ const documentId = await this.flushCompactionGroup(bucket, pendingGroup, bucketContext, context);
389
+ if (
390
+ lastNotPut != null &&
391
+ pendingGroup.ops[0].o <= lastNotPut &&
392
+ pendingGroup.ops[pendingGroup.ops.length - 1].o >= lastNotPut
393
+ ) {
394
+ clearBoundary = { opId: lastNotPut, documentId };
395
+ }
396
+ }
397
+ if (compactedOpId == null) {
398
+ return;
399
+ }
400
+
410
401
  // --- Clear: collapse leading MOVE/REMOVE/CLEAR sequence ---
411
402
  if (lastNotPut != null && opsSincePut >= 2) {
412
- if (clearBoundaryDocId == null) {
403
+ if (clearBoundary == null || clearBoundary.opId != lastNotPut) {
413
404
  throw new ReplicationAssertionError(`Missing CLEAR boundary document for bucket ${bucket}`);
414
405
  }
415
406
 
416
- totalOpCount += await this.clearBucketLeading(lastNotPut, clearBoundaryDocId, bucketContext, collection, context);
407
+ totalOpCount += await this.clearBucketLeading(
408
+ lastNotPut,
409
+ clearBoundary.documentId,
410
+ bucketContext,
411
+ collection,
412
+ context
413
+ );
417
414
  }
418
415
 
419
416
  // --- Finalize: update bucket checksums and state ---
420
- this.updateBucketChecksums({
421
- bucket,
422
- definitionId: resolvedDefinitionId,
423
- seen: new Map(),
424
- trackingSize: 0,
425
- lastNotPut: lastNotPut,
426
- opsSincePut: opsSincePut,
427
- checksum: totalChecksum,
428
- opCount: totalOpCount,
429
- opBytes: totalOpBytes
430
- });
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
431
  if (this.bucketStateUpdates.length > 0) {
432
432
  await this.writeBucketStateUpdates();
433
433
  this.bucketStateUpdates = [];
@@ -436,6 +436,80 @@ export class MongoCompactorV3 extends MongoCompactor {
436
436
  logger.info(`Compacted bucket ${bucket}: ${totalOpCount} surviving ops`);
437
437
  }
438
438
 
439
+ /**
440
+ * Persist replacement objects before starting the transaction, then atomically
441
+ * publish their lifecycle markers alongside the MongoDB document replacement.
442
+ * If verification or the transaction fails, the prepared markers retain enough
443
+ * information for the uploaded objects to be cleaned up later.
444
+ */
445
+ private async flushCompactionGroup(
446
+ bucket: string,
447
+ group: PendingCompactionGroup,
448
+ bucketContext: BucketDataContextV3,
449
+ context: { replicationStreamId: number; definitionId: string }
450
+ ): Promise<BucketDataKey> {
451
+ if (group.inputs.length == 1 && !group.changed) {
452
+ return group.inputs[0]._id;
453
+ }
454
+
455
+ const inputs = group.inputs;
456
+ const idsToDelete = inputs.map((doc) => doc._id);
457
+ const expectedDocCount = inputs.length;
458
+ const expectedChecksum = inputs.reduce((sum, doc) => sum + doc.checksum, 0n);
459
+ const expectedOpCount = inputs.reduce((sum, doc) => sum + doc.count, 0);
460
+ const oldStoragePaths = inputs.flatMap((doc) => (doc.storage_ref ? [doc.storage_ref.path] : []));
461
+ const {
462
+ documents,
463
+ storagePaths: newStoragePaths,
464
+ uploads
465
+ } = await this.persistBucketData(bucket, [group.ops], context, undefined, { targetOp: group.targetOp });
466
+ const session = this.db.client.startSession();
467
+ try {
468
+ await session.withTransaction(
469
+ async () => {
470
+ const verification = await bucketContext.collection
471
+ .aggregate<{ docCount: number; checksumSum: bigint | null; opCountSum: number | null }>(
472
+ [
473
+ { $match: { _id: { $in: idsToDelete } } },
474
+ {
475
+ $group: {
476
+ _id: null,
477
+ docCount: { $sum: 1 },
478
+ checksumSum: { $sum: '$checksum' },
479
+ opCountSum: { $sum: '$count' }
480
+ }
481
+ }
482
+ ],
483
+ { session }
484
+ )
485
+ .next();
486
+
487
+ if (
488
+ verification == null ||
489
+ verification.docCount !== expectedDocCount ||
490
+ verification.checksumSum !== expectedChecksum ||
491
+ verification.opCountSum !== expectedOpCount
492
+ ) {
493
+ throw new ConcurrentCompactionError(
494
+ `Inputs changed while compacting bucket ${bucket}; restarting from the latest bucket state`
495
+ );
496
+ }
497
+
498
+ await bucketContext.collection.deleteMany({ _id: { $in: idsToDelete } }, { session });
499
+ await bucketContext.collection.insertMany(documents, { session });
500
+ await this.finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, session);
501
+ },
502
+ {
503
+ writeConcern: { w: 'majority' },
504
+ readConcern: { level: 'snapshot' }
505
+ }
506
+ );
507
+ } finally {
508
+ await session.endSession();
509
+ }
510
+ return documents[0]._id;
511
+ }
512
+
439
513
  /**
440
514
  * Collapse the leading sequence of MOVE/REMOVE/CLEAR ops at the start
441
515
  * of the bucket into a single CLEAR op. Reads whole clearable documents
@@ -447,7 +521,7 @@ export class MongoCompactorV3 extends MongoCompactor {
447
521
  private async clearBucketLeading(
448
522
  lastNotPut: bigint,
449
523
  boundaryDocId: BucketDataKey,
450
- bucketContext: SingleBucketStoreV3,
524
+ bucketContext: BucketDataContextV3,
451
525
  collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
452
526
  context: { replicationStreamId: number; definitionId: string }
453
527
  ): Promise<number> {
@@ -491,17 +565,21 @@ export class MongoCompactorV3 extends MongoCompactor {
491
565
  session: mongo.ClientSession,
492
566
  lastNotPut: bigint,
493
567
  boundaryDocId: BucketDataKey,
494
- bucketContext: SingleBucketStoreV3,
568
+ bucketContext: BucketDataContextV3,
495
569
  collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
496
570
  context: { replicationStreamId: number; definitionId: string }
497
571
  ): Promise<{ done: boolean; opCountDiff: number }> {
498
572
  const bucket = bucketContext.key.bucket;
573
+ this.signal?.throwIfAborted();
574
+ const prepared = await this.prepareCompactionUploads(bucket, context, [lastNotPut]);
499
575
  let done = false;
500
576
  let opCountDiff = 0;
501
577
 
502
- this.signal?.throwIfAborted();
503
578
  await session.withTransaction(
504
579
  async () => {
580
+ done = false;
581
+ opCountDiff = 0;
582
+ const oldStoragePaths: string[] = [];
505
583
  const query = collection.find(
506
584
  {
507
585
  _id: {
@@ -518,7 +596,8 @@ export class MongoCompactorV3 extends MongoCompactor {
518
596
  checksum: 1,
519
597
  count: 1,
520
598
  target_op: 1,
521
- ops: 1
599
+ has_clear_op: 1,
600
+ storage_ref: 1
522
601
  },
523
602
  limit: this.clearBatchLimit
524
603
  }
@@ -539,30 +618,27 @@ export class MongoCompactorV3 extends MongoCompactor {
539
618
  }
540
619
 
541
620
  lastDocId = doc._id;
542
- for (const op of loadBucketDataDocument(context, doc)) {
543
- if (op.o > lastNotPut) {
544
- throw new ReplicationAssertionError(
545
- `Unexpected op ${op.o} after CLEAR boundary ${lastNotPut} in bucket ${bucket}`
546
- );
547
- }
548
- if (op.op == 'PUT') {
549
- throw new ReplicationAssertionError(`Unexpected PUT at op ${op.o} in CLEAR region for bucket ${bucket}`);
550
- }
621
+ if (doc.storage_ref) {
622
+ oldStoragePaths.push(doc.storage_ref.path);
623
+ }
551
624
 
552
- if (op.op == 'CLEAR') {
553
- clearOpCount++;
554
- if (clearOpCount > 1) {
555
- throw new ReplicationAssertionError(`Unexpected multiple CLEAR operations in bucket ${bucket}`);
556
- }
557
- } else {
558
- gotNonClearOp = true;
559
- }
560
- combinedChecksum = addChecksums(combinedChecksum, Number(op.checksum));
561
- clearedOpCount++;
562
- if (op.target_op != null && (maxTargetOp == null || op.target_op > maxTargetOp)) {
563
- maxTargetOp = op.target_op;
625
+ // The compaction scan established that every operation before the
626
+ // boundary is MOVE/REMOVE/CLEAR. Root metadata is sufficient to fold
627
+ // whole documents into one CLEAR, so avoid downloading their payloads.
628
+ if (doc.has_clear_op) {
629
+ clearOpCount++;
630
+ if (clearOpCount > 1) {
631
+ throw new ReplicationAssertionError(`Unexpected multiple CLEAR operations in bucket ${bucket}`);
564
632
  }
565
633
  }
634
+ if (!doc.has_clear_op || doc.count > 1) {
635
+ gotNonClearOp = true;
636
+ }
637
+ combinedChecksum = addChecksums(combinedChecksum, Number(doc.checksum));
638
+ clearedOpCount += doc.count;
639
+ if (doc.target_op != null && (maxTargetOp == null || doc.target_op > maxTargetOp)) {
640
+ maxTargetOp = doc.target_op;
641
+ }
566
642
  }
567
643
 
568
644
  if (!gotNonClearOp) {
@@ -586,10 +662,13 @@ export class MongoCompactorV3 extends MongoCompactor {
586
662
  o: lastDocId!.o,
587
663
  op: 'CLEAR' as const,
588
664
  checksum: BigInt(combinedChecksum),
589
- data: null,
590
- target_op: maxTargetOp
665
+ data: null
591
666
  } satisfies BucketDataDoc;
592
- await collection.insertOne(serializeBucketData(bucket, [clearOp]), { session });
667
+ const persisted = await this.persistBucketData(bucket, [[clearOp]], context, prepared, {
668
+ targetOp: maxTargetOp
669
+ });
670
+ await collection.insertOne(persisted.documents[0], { session });
671
+ await this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, session);
593
672
 
594
673
  opCountDiff = -clearedOpCount + 1;
595
674
  },
@@ -606,15 +685,19 @@ export class MongoCompactorV3 extends MongoCompactor {
606
685
  session: mongo.ClientSession,
607
686
  lastNotPut: bigint,
608
687
  boundaryDocId: BucketDataKey,
609
- bucketContext: SingleBucketStoreV3,
688
+ bucketContext: BucketDataContextV3,
610
689
  collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
611
690
  context: { replicationStreamId: number; definitionId: string }
612
691
  ): Promise<number> {
613
692
  const bucket = bucketContext.key.bucket;
693
+ this.signal?.throwIfAborted();
694
+ const prepared = await this.prepareCompactionUploads(bucket, context, [lastNotPut, boundaryDocId.o]);
614
695
  let opCountDiff = 0;
615
696
 
616
697
  await session.withTransaction(
617
698
  async () => {
699
+ opCountDiff = 0;
700
+ const oldStoragePaths: string[] = [];
618
701
  const query = collection.find(
619
702
  {
620
703
  // This is a range query, but should only ever return two documents:
@@ -634,7 +717,8 @@ export class MongoCompactorV3 extends MongoCompactor {
634
717
  checksum: 1,
635
718
  count: 1,
636
719
  target_op: 1,
637
- ops: 1
720
+ ops: 1,
721
+ storage_ref: 1
638
722
  },
639
723
  limit: 3
640
724
  }
@@ -653,6 +737,11 @@ export class MongoCompactorV3 extends MongoCompactor {
653
737
  }
654
738
 
655
739
  const isBoundaryDoc = doc._id.o == boundaryDocId.o;
740
+ if (doc.storage_ref) {
741
+ oldStoragePaths.push(doc.storage_ref.path);
742
+ }
743
+ await hydrateBucketDataDocuments([doc], this.storage.objectStorage, { signal: this.signal });
744
+ maxTargetOp = maxOpId(maxTargetOp, doc.target_op);
656
745
  for (const op of loadBucketDataDocument(context, doc)) {
657
746
  if (!isBoundaryDoc && op.op != 'CLEAR') {
658
747
  throw new ReplicationAssertionError(
@@ -668,9 +757,6 @@ export class MongoCompactorV3 extends MongoCompactor {
668
757
  }
669
758
  combinedChecksum = addChecksums(combinedChecksum, Number(op.checksum));
670
759
  clearedOpCount++;
671
- if (op.target_op != null && (maxTargetOp == null || op.target_op > maxTargetOp)) {
672
- maxTargetOp = op.target_op;
673
- }
674
760
  } else if (isBoundaryDoc) {
675
761
  boundarySurvivors.push(op);
676
762
  } else {
@@ -701,15 +787,19 @@ export class MongoCompactorV3 extends MongoCompactor {
701
787
  o: lastNotPut,
702
788
  op: 'CLEAR' as const,
703
789
  checksum: BigInt(combinedChecksum),
704
- data: null,
705
- target_op: maxTargetOp
790
+ data: null
706
791
  } satisfies BucketDataDoc;
707
- await collection.insertOne(serializeBucketData(bucket, [clearOp]), { session });
708
-
792
+ const chunks: BucketDataDoc[][] = [[clearOp]];
709
793
  if (boundarySurvivors.length > 0) {
710
- const survivingDocs = chunkBucketData(boundarySurvivors).map((chunk) => serializeBucketData(bucket, chunk));
711
- await collection.insertMany(survivingDocs, { session });
794
+ // These operations are a subset of one existing document, so keeping
795
+ // them together cannot increase its stored ops payload.
796
+ chunks.push(boundarySurvivors);
712
797
  }
798
+ const persisted = await this.persistBucketData(bucket, chunks, context, prepared, {
799
+ targetOp: maxTargetOp ?? undefined
800
+ });
801
+ await collection.insertMany(persisted.documents, { session });
802
+ await this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, session);
713
803
 
714
804
  opCountDiff = -clearedOpCount + 1;
715
805
  },
@@ -721,4 +811,112 @@ export class MongoCompactorV3 extends MongoCompactor {
721
811
 
722
812
  return opCountDiff;
723
813
  }
814
+
815
+ /**
816
+ * Reserve stable object paths before starting a retryable MongoDB transaction.
817
+ * Each retry can safely overwrite the same paths, while the pre-existing
818
+ * deletion markers remain visible to the transaction that publishes them.
819
+ *
820
+ * CLEAR compaction reserves its maximum output count. Unused markers remain
821
+ * pending so they can clean up a path that an earlier transaction attempt may
822
+ * have uploaded before retrying with fewer output documents.
823
+ */
824
+ private async prepareCompactionUploads(
825
+ bucket: string,
826
+ context: { replicationStreamId: number; definitionId: string },
827
+ opIdHints: bigint[]
828
+ ): Promise<PreparedObjectStorageUpload[]> {
829
+ if (!this.storage.objectStorage) {
830
+ return [];
831
+ }
832
+
833
+ const lifecycle = this.objectStorageLifecycle;
834
+ const paths = opIdHints.map((opIdHint) => lifecycle.allocatePath(context.definitionId, bucket, opIdHint, opIdHint));
835
+ return lifecycle.prepareUploads(paths);
836
+ }
837
+
838
+ /** Publish replacement uploads and retire superseded objects in the same transaction. */
839
+ private async finishObjectStorageReplacement(
840
+ oldStoragePaths: Iterable<string>,
841
+ newStoragePaths: Set<string>,
842
+ uploads: PreparedObjectStorageUpload[],
843
+ session: mongo.ClientSession
844
+ ): Promise<void> {
845
+ if (!this.storage.objectStorage) {
846
+ return;
847
+ }
848
+ await this.objectStorageLifecycle.publishUploads(uploads, session);
849
+ await this.objectStorageLifecycle.retire(
850
+ Array.from(oldStoragePaths).filter((path) => !newStoragePaths.has(path)),
851
+ session
852
+ );
853
+ }
854
+
855
+ private async persistBucketData(
856
+ bucket: string,
857
+ chunks: BucketDataDoc[][],
858
+ context: { replicationStreamId: number; definitionId: string },
859
+ preparedUploads?: PreparedObjectStorageUpload[],
860
+ options?: { targetOp?: InternalOpId | null }
861
+ ): Promise<{ documents: BucketDataDocumentV3[]; storagePaths: Set<string>; uploads: PreparedObjectStorageUpload[] }> {
862
+ const serializedChunks = chunks.map((chunk) => serializeBucketData(bucket, chunk, options));
863
+ if (!this.storage.objectStorage) {
864
+ return {
865
+ documents: serializedChunks,
866
+ storagePaths: new Set(),
867
+ uploads: []
868
+ };
869
+ }
870
+
871
+ const store = new BucketDataObjectStorage(this.storage.objectStorage);
872
+ const storagePaths = new Set<string>();
873
+ const lifecycle = this.objectStorageLifecycle;
874
+ // Base placement on the final compacted size. Unchanged documents are not
875
+ // rewritten, while small MOVE/merge results and CLEAR ops stay inline.
876
+ const storedIndexes = serializedChunks.flatMap((document, index) =>
877
+ document.size > this.storage.inlineThresholdBytes ? [index] : []
878
+ );
879
+ const uploadsByIndex = new Map<number, PreparedObjectStorageUpload>();
880
+
881
+ if (preparedUploads) {
882
+ for (const index of storedIndexes) {
883
+ const upload = preparedUploads[index];
884
+ if (!upload) {
885
+ throw new ServiceAssertionError(
886
+ `Missing prepared object storage path for compacted document at index ${index}`
887
+ );
888
+ }
889
+ uploadsByIndex.set(index, upload);
890
+ }
891
+ } else {
892
+ const paths = storedIndexes.map((index) => {
893
+ const chunk = chunks[index];
894
+ return lifecycle.allocatePath(context.definitionId, bucket, chunk[0].o, chunk[chunk.length - 1].o);
895
+ });
896
+ const prepared = await lifecycle.prepareUploads(paths);
897
+ storedIndexes.forEach((index, preparedIndex) => uploadsByIndex.set(index, prepared[preparedIndex]));
898
+ }
899
+
900
+ // S3ObjectStorage applies one shared concurrency limit across all callers,
901
+ // so compaction can schedule its uploads together without creating a
902
+ // separate limiter here.
903
+ const documents = await Promise.all(
904
+ serializedChunks.map(async (serialized, index) => {
905
+ const upload = uploadsByIndex.get(index);
906
+ if (!upload) {
907
+ return serialized;
908
+ }
909
+
910
+ const { ops, ...metadata } = serialized;
911
+ const { fileSize } = await store.store(upload.path, ops!);
912
+ storagePaths.add(upload.path);
913
+ return {
914
+ ...metadata,
915
+ storage_ref: { path: upload.path, file_size: fileSize }
916
+ };
917
+ })
918
+ );
919
+
920
+ return { documents, storagePaths, uploads: Array.from(uploadsByIndex.values()) };
921
+ }
724
922
  }