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