@powersync/service-module-mongodb-storage 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +5 -0
  3. package/dist/storage/MongoBucketStorage.js +7 -1
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +2 -1
  6. package/dist/storage/implementation/MongoBucketBatch.js +9 -8
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoStorageProvider.js +4 -2
  9. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  10. package/dist/storage/implementation/MongoWriteBatch.d.ts +49 -0
  11. package/dist/storage/implementation/MongoWriteBatch.js +149 -0
  12. package/dist/storage/implementation/MongoWriteBatch.js.map +1 -0
  13. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +2 -2
  14. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  15. package/dist/storage/implementation/common/PersistedBatch.d.ts +5 -4
  16. package/dist/storage/implementation/common/PersistedBatch.js +6 -4
  17. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  18. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.d.ts +3 -0
  19. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +3 -0
  20. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  21. package/dist/storage/implementation/db.d.ts +5 -0
  22. package/dist/storage/implementation/db.js +13 -0
  23. package/dist/storage/implementation/db.js.map +1 -1
  24. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +13 -6
  25. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  26. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +2 -1
  27. package/dist/storage/implementation/v1/MongoCompactorV1.js +23 -17
  28. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  29. package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +7 -5
  30. package/dist/storage/implementation/v1/PersistedBatchV1.js +22 -28
  31. package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
  32. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +2 -1
  33. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +17 -14
  34. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  35. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +6 -1
  36. package/dist/storage/implementation/v3/MongoCompactorV3.js +164 -69
  37. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  38. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +12 -8
  39. package/dist/storage/implementation/v3/PersistedBatchV3.js +25 -32
  40. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  41. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +8 -1
  42. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  43. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +3 -3
  44. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +7 -9
  45. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  46. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +3 -2
  47. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +4 -7
  48. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -1
  49. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +21 -3
  50. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
  51. package/dist/types/types.d.ts +5 -0
  52. package/dist/types/types.js +12 -0
  53. package/dist/types/types.js.map +1 -1
  54. package/dist/utils/test-utils.js +1 -0
  55. package/dist/utils/test-utils.js.map +1 -1
  56. package/package.json +6 -6
  57. package/src/storage/MongoBucketStorage.ts +12 -1
  58. package/src/storage/implementation/MongoBucketBatch.ts +13 -10
  59. package/src/storage/implementation/MongoStorageProvider.ts +12 -2
  60. package/src/storage/implementation/MongoWriteBatch.ts +172 -0
  61. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +4 -2
  62. package/src/storage/implementation/common/PersistedBatch.ts +12 -8
  63. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +4 -0
  64. package/src/storage/implementation/db.ts +14 -0
  65. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +17 -8
  66. package/src/storage/implementation/v1/MongoCompactorV1.ts +29 -26
  67. package/src/storage/implementation/v1/PersistedBatchV1.ts +25 -30
  68. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +51 -49
  69. package/src/storage/implementation/v3/MongoCompactorV3.ts +276 -97
  70. package/src/storage/implementation/v3/PersistedBatchV3.ts +36 -36
  71. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +8 -1
  72. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +11 -10
  73. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +8 -9
  74. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +23 -3
  75. package/src/types/types.ts +18 -0
  76. package/src/utils/test-utils.ts +1 -0
  77. package/test/src/chunk_compaction_config.test.ts +38 -0
  78. package/test/src/object_storage_usage.test.ts +16 -4
  79. package/test/src/storage_compacting.test.ts +257 -5
  80. package/test/src/storage_s3_reading.test.ts +47 -1
  81. package/tsconfig.tsbuildinfo +1 -1
@@ -1,5 +1,6 @@
1
1
  import { ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
2
- import { addChecksums, formatBytes, utils } from '@powersync/service-core';
2
+ import { acquireSemaphoreAbortable, addChecksums, formatBytes, utils } from '@powersync/service-core';
3
+ import { setImmediate } from 'node:timers/promises';
3
4
  import { ConcurrentCompactionError, MongoCompactor } from '../MongoCompactor.js';
4
5
  import { cacheKey } from '../OperationBatch.js';
5
6
  import { loadBucketDataDocument, maxOpId, serializeBucketData } from './bucket-format.js';
@@ -109,7 +110,8 @@ export class MongoCompactorV3 extends MongoCompactor {
109
110
  * Batching specifically help to cover cases of many buckets where no compaction is required:
110
111
  * Instead of sequentially claiming and then rescheduling a bucket, this handles it in bulk.
111
112
  *
112
- * Buckets that do need compaction are still claimed and processed sequentially.
113
+ * Chunk merges overlap a bounded number of buckets. Full compaction stays
114
+ * sequential because its working set includes operation deduplication state.
113
115
  *
114
116
  * Any concurrent workers may read the same batch. Rescheduling filters out buckets handled
115
117
  * by a concurrent worker or replication write, while buckets that do need compaction are
@@ -123,24 +125,27 @@ export class MongoCompactorV3 extends MongoCompactor {
123
125
  // Writers derive next_compact_check from MongoDB's $$NOW. Use the same
124
126
  // clock for the fixed job boundary so clock skew cannot exclude work at
125
127
  // the exact initial-replication interval.
126
- const [{ now: jobStartedAt }] = await this.db.db
127
- .aggregate([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
128
- .toArray();
128
+ const jobStartedAt = await this.readCompactionTime();
129
129
  const dueBefore = new Date(jobStartedAt.getTime() + (options.dueAheadMs ?? 0));
130
130
  const forceKind = options.forceKind;
131
131
  const rescheduleNotBefore = new Date(dueBefore.getTime() + 1);
132
+ // Keep accounting documents bounded by workers, not buckets or scan batches.
133
+ const workerUsage = Array.from({ length: this.storage.factory.chunkCompactionConcurrency }, () => new ObjectStorageUsage(this.db, this.group_id, createObjectStorageUsageWriterId()));
132
134
  while (true) {
133
135
  this.signal?.throwIfAborted();
134
136
  const states = await this.findScheduledBucketBatch(dueBefore);
135
137
  if (states.length == 0) {
136
138
  break;
137
139
  }
140
+ // Keep eligibility bounded by dueBefore, but classify with the current
141
+ // server time so buckets that age into full compaction can advance.
142
+ const batchStartedAt = await this.readCompactionTime();
138
143
  const scheduled = [];
139
144
  for (const state of states) {
140
145
  try {
141
146
  scheduled.push({
142
147
  state,
143
- decision: chooseCompactionKind(state, jobStartedAt, this),
148
+ decision: chooseCompactionKind(state, batchStartedAt, this),
144
149
  forcedKind: forcedCompactionKind(state, forceKind, this)
145
150
  });
146
151
  }
@@ -150,18 +155,23 @@ export class MongoCompactorV3 extends MongoCompactor {
150
155
  }
151
156
  const noOpStates = scheduled.filter(({ state, decision, forcedKind }) => state.compact_lease == null && (forceKind == null ? decision.kind : forcedKind) == null);
152
157
  await this.rescheduleUnclaimedBuckets(noOpStates, rescheduleNotBefore);
153
- for (const { state, decision, forcedKind } of scheduled) {
158
+ const processBucket = async ({ state, decision, forcedKind }, objectStorageUsage, chunksOnly = false) => {
154
159
  const kind = forceKind == null ? decision.kind : forcedKind;
155
160
  if (state.compact_lease == null && kind == null) {
156
- continue;
161
+ return;
157
162
  }
158
163
  try {
159
164
  await using lease = await this.claimBucket({ _id: state._id, next_compact_check: { $lte: dueBefore } });
160
165
  if (lease == null) {
161
- continue;
166
+ return;
162
167
  }
163
168
  const claimedDecision = chooseCompactionKind(lease.state, lease.startedAt, this);
164
169
  const claimedKind = forceKind == null ? claimedDecision.kind : forcedCompactionKind(lease.state, forceKind, this);
170
+ if (chunksOnly && claimedKind === CompactionKind.Full) {
171
+ // The decision changed after scanning. Release the lease without
172
+ // rescheduling; the next batch will classify it with a fresh timestamp.
173
+ return;
174
+ }
165
175
  if (claimedKind == null) {
166
176
  await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
167
177
  }
@@ -171,7 +181,7 @@ export class MongoCompactorV3 extends MongoCompactor {
171
181
  await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
172
182
  }
173
183
  else {
174
- await this.compactClaimedBucket(lease, claimedKind, claimedDecision, rescheduleNotBefore);
184
+ await this.compactClaimedBucket(lease, claimedKind, claimedDecision, rescheduleNotBefore, objectStorageUsage);
175
185
  }
176
186
  }
177
187
  catch (error) {
@@ -182,6 +192,73 @@ export class MongoCompactorV3 extends MongoCompactor {
182
192
  }
183
193
  await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
184
194
  }
195
+ };
196
+ const chunkBuckets = scheduled.filter(({ decision, forcedKind }) => (forceKind == null ? decision.kind : forcedKind) === CompactionKind.Chunks);
197
+ const sequentialBuckets = scheduled.filter(({ decision, forcedKind }) => (forceKind == null ? decision.kind : forcedKind) !== CompactionKind.Chunks);
198
+ await this.runChunkCompactionWorkers(chunkBuckets, workerUsage, (entry, usage) => processBucket(entry, usage, true));
199
+ // Full compaction cannot overlap chunk workers from this job, and only
200
+ // one full bucket is processed at a time.
201
+ for (const entry of sequentialBuckets) {
202
+ await processBucket(entry, this.objectStorageUsage);
203
+ }
204
+ }
205
+ }
206
+ /** Use MongoDB's clock, matching scheduling and lease timestamps. */
207
+ async readCompactionTime() {
208
+ const [{ now }] = await this.db.db
209
+ .aggregate([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
210
+ .toArray();
211
+ return now;
212
+ }
213
+ /** Process one scheduled batch with a fixed pool of workers. */
214
+ async runChunkCompactionWorkers(buckets, workerUsage, processBucket) {
215
+ const signal = this.signal;
216
+ let nextBucket = 0;
217
+ let failed = false;
218
+ const runWorker = async (usage) => {
219
+ try {
220
+ while (!failed && nextBucket < buckets.length) {
221
+ // Taking an entry has no await, so each worker gets a different bucket.
222
+ // A worker takes another only after finishing its current bucket.
223
+ const bucket = buckets[nextBucket++];
224
+ // This pool bounds one job; the factory semaphore bounds all jobs together.
225
+ // Acquire before claiming the bucket lease, and hold until it is released.
226
+ const acquired = await acquireSemaphoreAbortable(this.storage.factory.chunkCompactionSlots, signal);
227
+ if (acquired === 'aborted') {
228
+ signal?.throwIfAborted();
229
+ return;
230
+ }
231
+ const [, releaseSlot] = acquired;
232
+ try {
233
+ // A sibling may have failed while this worker waited for a slot.
234
+ if (failed)
235
+ return;
236
+ signal?.throwIfAborted();
237
+ await processBucket(bucket, usage);
238
+ }
239
+ finally {
240
+ releaseSlot();
241
+ }
242
+ // Let replication and other event-loop work run between buckets.
243
+ await setImmediate();
244
+ }
245
+ }
246
+ catch (error) {
247
+ // Drain work already started, but do not let siblings start new buckets.
248
+ failed = true;
249
+ throw error;
250
+ }
251
+ };
252
+ // Concurrent transactions must not all increment the same usage document.
253
+ // Reuse one writer for each worker instead of creating one per bucket.
254
+ const workers = workerUsage.map(runWorker);
255
+ // Do not release the caller's replication lock or run cleanup while a
256
+ // sibling worker still owns a bucket lease or is finishing a replacement.
257
+ // Wait for every worker even on failure, then propagate the first error.
258
+ const results = await Promise.allSettled(workers);
259
+ for (const result of results) {
260
+ if (result.status === 'rejected') {
261
+ throw result.reason;
185
262
  }
186
263
  }
187
264
  }
@@ -238,10 +315,10 @@ export class MongoCompactorV3 extends MongoCompactor {
238
315
  async claimBucket(filter, sort) {
239
316
  return CompactionLease.claim(this.db.bucketState(this.group_id), filter, sort, this.compactLeaseDurationMs);
240
317
  }
241
- async compactClaimedBucket(lease, kind, decision, rescheduleNotBefore) {
318
+ async compactClaimedBucket(lease, kind, decision, rescheduleNotBefore, objectStorageUsage = this.objectStorageUsage) {
242
319
  const context = new CompactionContext(lease, kind, decision, rescheduleNotBefore, this.compactionTarget(lease.state));
243
320
  lease.startRenewal();
244
- await this.compactSingleBucket(context);
321
+ await this.compactSingleBucket(context, objectStorageUsage);
245
322
  }
246
323
  async rescheduleClaimedBucket(lease, decision, notBefore) {
247
324
  await lease.reschedule(this.rescheduleAtOrAfter(decision.nextCompactCheck, notBefore));
@@ -271,11 +348,11 @@ export class MongoCompactorV3 extends MongoCompactor {
271
348
  }
272
349
  return new ObjectStorageLifecycle(this.db, this.group_id, this.storage.objectStorage);
273
350
  }
274
- async compactSingleBucket(context) {
351
+ async compactSingleBucket(context, objectStorageUsage) {
275
352
  if (context.kind == CompactionKind.Chunks) {
276
- return this.compactSingleBucketChunks(context);
353
+ return this.compactSingleBucketChunks(context, objectStorageUsage);
277
354
  }
278
- return this.compactSingleBucketFully(context);
355
+ return this.compactSingleBucketFully(context, objectStorageUsage);
279
356
  }
280
357
  /**
281
358
  * Merge adjacent bucket-data chunks without inspecting their operations
@@ -283,7 +360,7 @@ export class MongoCompactorV3 extends MongoCompactor {
283
360
  * update the persisted checksum state and to decide whether a group can fit
284
361
  * in one chunk.
285
362
  */
286
- async compactSingleBucketChunks(context) {
363
+ async compactSingleBucketChunks(context, objectStorageUsage) {
287
364
  const bucket = context.state._id.b;
288
365
  const resolvedDefinitionId = context.state._id.d;
289
366
  const bucketContext = new BucketDataContextV3(this.db, {
@@ -362,7 +439,7 @@ export class MongoCompactorV3 extends MongoCompactor {
362
439
  }
363
440
  const nextSize = pendingSize + doc.size;
364
441
  if (pendingChunks.length > 0 && nextSize > DEFAULT_MAX_DOC_SIZE_BYTES) {
365
- const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext);
442
+ const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext, objectStorageUsage);
366
443
  compactedTail = combineAdjacentStats(compactedTail, groupStats);
367
444
  pendingChunks = [];
368
445
  pendingSize = 0;
@@ -376,7 +453,7 @@ export class MongoCompactorV3 extends MongoCompactor {
376
453
  }
377
454
  }
378
455
  if (pendingChunks.length > 0) {
379
- const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext);
456
+ const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext, objectStorageUsage);
380
457
  compactedTail = combineAdjacentStats(compactedTail, groupStats);
381
458
  }
382
459
  if (compactedOpId == null) {
@@ -397,33 +474,44 @@ export class MongoCompactorV3 extends MongoCompactor {
397
474
  this.compactedBucketCount++;
398
475
  this.logger.info(`Compacted bucket chunks ${bucket}: ${result.bucketStats.count} ops, ${result.bucketStats.chunks} chunks, ${formatBytes(result.bucketStats.bytes)}`);
399
476
  }
400
- async flushChunkMerge(bucket, inputs, collection, context, bucketContext) {
477
+ async flushChunkMerge(bucket, inputs, collection, context, bucketContext, objectStorageUsage) {
401
478
  if (inputs.length == 1) {
402
479
  return statsForDocument(inputs[0]);
403
480
  }
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());
481
+ try {
482
+ this.signal?.throwIfAborted();
483
+ // The metadata scan deliberately excluded ops. Read inline payloads only
484
+ // for this merge group; object-storage payloads are fetched below using
485
+ // the same rule.
486
+ const inlineInputs = inputs.filter((input) => input.storage_ref == null);
487
+ if (inlineInputs.length > 0) {
488
+ const inlineDocuments = await collection
489
+ .find({ _id: { $in: inlineInputs.map((input) => input._id) } }, { projection: { _id: 1, ops: 1 } })
490
+ .toArray();
491
+ const opsById = new Map(inlineDocuments.map((document) => [document._id.o.toString(), document.ops]));
492
+ for (const input of inlineInputs) {
493
+ input.ops = opsById.get(input._id.o.toString());
494
+ }
415
495
  }
496
+ await hydrateBucketDataDocuments(inputs, this.storage.objectStorage, { signal: this.signal });
497
+ const operations = inputs.flatMap((input) => Array.from(loadBucketDataDocument(context, input)));
498
+ const targetOp = inputs.reduce((maxTarget, input) => maxOpId(maxTarget, input.target_op), null);
499
+ const result = await this.flushCompactionGroup(bucket, {
500
+ inputs,
501
+ ops: operations,
502
+ changed: true,
503
+ targetOp
504
+ }, bucketContext, context, objectStorageUsage);
505
+ return result.stats;
506
+ }
507
+ finally {
508
+ // The scan batch also references these documents. Do not retain hydrated
509
+ // operations after finishing this merge group.
510
+ for (const input of inputs) {
511
+ delete input.ops;
512
+ }
513
+ await setImmediate();
416
514
  }
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
515
  }
428
516
  async finalizeCompactedBucket({ context, compactedOpId, compactionResult, puts }) {
429
517
  await context.lease.throwIfLost();
@@ -526,7 +614,7 @@ export class MongoCompactorV3 extends MongoCompactor {
526
614
  : addChecksums(0, Number(stats?.checksum ?? 0))
527
615
  };
528
616
  }
529
- async compactSingleBucketFully(context) {
617
+ async compactSingleBucketFully(context, objectStorageUsage) {
530
618
  const bucket = context.state._id.b;
531
619
  const resolvedDefinitionId = context.state._id.d;
532
620
  const bucketContext = new BucketDataContextV3(this.db, {
@@ -673,7 +761,7 @@ export class MongoCompactorV3 extends MongoCompactor {
673
761
  }
674
762
  else {
675
763
  const flushedGroup = pendingGroup;
676
- const result = await this.flushCompactionGroup(bucket, flushedGroup, bucketContext, dataContext);
764
+ const result = await this.flushCompactionGroup(bucket, flushedGroup, bucketContext, dataContext, objectStorageUsage);
677
765
  compactedStats = combineAdjacentStats(compactedStats, result.stats);
678
766
  if (lastNotPut != null &&
679
767
  flushedGroup.ops[0].o <= lastNotPut &&
@@ -692,7 +780,7 @@ export class MongoCompactorV3 extends MongoCompactor {
692
780
  this.logger.info(`Compacted batch of ${batchDocs.length} documents for bucket ${bucket}`);
693
781
  }
694
782
  if (pendingGroup != null) {
695
- const result = await this.flushCompactionGroup(bucket, pendingGroup, bucketContext, dataContext);
783
+ const result = await this.flushCompactionGroup(bucket, pendingGroup, bucketContext, dataContext, objectStorageUsage);
696
784
  compactedStats = combineAdjacentStats(compactedStats, result.stats);
697
785
  if (lastNotPut != null &&
698
786
  pendingGroup.ops[0].o <= lastNotPut &&
@@ -709,7 +797,7 @@ export class MongoCompactorV3 extends MongoCompactor {
709
797
  if (clearBoundary == null || clearBoundary.opId != lastNotPut) {
710
798
  throw new ReplicationAssertionError(`Missing CLEAR boundary document for bucket ${bucket}`);
711
799
  }
712
- const clearResult = await this.clearBucketLeading(lastNotPut, clearBoundary.documentId, bucketContext, collection, dataContext);
800
+ const clearResult = await this.clearBucketLeading(lastNotPut, clearBoundary.documentId, bucketContext, collection, dataContext, objectStorageUsage);
713
801
  totalOpCount += clearResult.opCountDiff;
714
802
  compactedStats = applyStatsReplacement(compactedStats, clearResult.before, clearResult.after);
715
803
  }
@@ -731,7 +819,7 @@ export class MongoCompactorV3 extends MongoCompactor {
731
819
  * If verification or the transaction fails, the prepared markers retain enough
732
820
  * information for the uploaded objects to be cleaned up later.
733
821
  */
734
- async flushCompactionGroup(bucket, group, bucketContext, context) {
822
+ async flushCompactionGroup(bucket, group, bucketContext, context, objectStorageUsage) {
735
823
  if (group.inputs.length == 1 && !group.changed) {
736
824
  return {
737
825
  documentId: group.inputs[0]._id,
@@ -768,10 +856,13 @@ export class MongoCompactorV3 extends MongoCompactor {
768
856
  verification.opCountSum !== expectedOpCount) {
769
857
  throw new ConcurrentCompactionError(`Inputs changed while compacting bucket ${bucket}; restarting from the latest bucket state`);
770
858
  }
771
- await bucketContext.collection.deleteMany({ _id: { $in: idsToDelete } }, { session });
772
- await bucketContext.collection.insertMany(documents, { session });
773
- await this.finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, session);
774
- await this.recordObjectStorageReplacement(oldStorageBytes, documents, context.definitionId, session);
859
+ // Replacement documents can reuse deleted IDs, so retain delete-before-insert ordering.
860
+ const writes = this.db.createWriteBatch(session, { ordered: true });
861
+ writes.deleteMany(bucketContext.collection, { _id: { $in: idsToDelete } });
862
+ writes.insertMany(bucketContext.collection, documents);
863
+ this.finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, writes);
864
+ this.recordObjectStorageReplacement(oldStorageBytes, documents, context.definitionId, writes, objectStorageUsage);
865
+ await writes.execute();
775
866
  }, {
776
867
  writeConcern: { w: 'majority' },
777
868
  readConcern: { level: 'snapshot' }
@@ -794,7 +885,7 @@ export class MongoCompactorV3 extends MongoCompactor {
794
885
  * Returns the op count and stored-stat changes after replacing cleared ops
795
886
  * with CLEAR ops.
796
887
  */
797
- async clearBucketLeading(lastNotPut, boundaryDocId, bucketContext, collection, context) {
888
+ async clearBucketLeading(lastNotPut, boundaryDocId, bucketContext, collection, context, objectStorageUsage) {
798
889
  let opCountDiff = 0;
799
890
  let before = emptyBucketStats();
800
891
  let after = emptyBucketStats();
@@ -804,7 +895,7 @@ export class MongoCompactorV3 extends MongoCompactor {
804
895
  // First step is to clear full chunks that contain only CLEAR/MOVE/REMOVE operations.
805
896
  // There can be many of them, so we do one batch at a time.
806
897
  while (!done) {
807
- const batch = await this.clearLeadingFullDocuments(session, lastNotPut, boundaryDocId, bucketContext, collection, context);
898
+ const batch = await this.clearLeadingFullDocuments(session, lastNotPut, boundaryDocId, bucketContext, collection, context, objectStorageUsage);
808
899
  done = batch.done;
809
900
  opCountDiff += batch.opCountDiff;
810
901
  before = combineAdjacentStats(before, batch.before);
@@ -812,7 +903,7 @@ export class MongoCompactorV3 extends MongoCompactor {
812
903
  }
813
904
  // The final step is to process the "boundary" document: It may contain some CLEAR/MOVE/REMOVE operations,
814
905
  // potentially followed by PUT operations. This is only a single document, so no need for batching.
815
- const boundaryResult = await this.clearBoundaryDocument(session, lastNotPut, boundaryDocId, bucketContext, collection, context);
906
+ const boundaryResult = await this.clearBoundaryDocument(session, lastNotPut, boundaryDocId, bucketContext, collection, context, objectStorageUsage);
816
907
  opCountDiff += boundaryResult.opCountDiff;
817
908
  before = combineAdjacentStats(before, boundaryResult.before);
818
909
  after = combineAdjacentStats(after, boundaryResult.after);
@@ -822,7 +913,7 @@ export class MongoCompactorV3 extends MongoCompactor {
822
913
  }
823
914
  return { opCountDiff, before, after };
824
915
  }
825
- async clearLeadingFullDocuments(session, lastNotPut, boundaryDocId, bucketContext, collection, context) {
916
+ async clearLeadingFullDocuments(session, lastNotPut, boundaryDocId, bucketContext, collection, context, objectStorageUsage) {
826
917
  const bucket = bucketContext.key.bucket;
827
918
  this.signal?.throwIfAborted();
828
919
  let prepared;
@@ -902,12 +993,13 @@ export class MongoCompactorV3 extends MongoCompactor {
902
993
  }
903
994
  prepared ??= await this.prepareCompactionUploads(bucket, context, [lastNotPut]);
904
995
  this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastDocId?.o}`);
905
- await collection.deleteMany({
996
+ const writes = this.db.createWriteBatch(session, { ordered: true });
997
+ writes.deleteMany(collection, {
906
998
  _id: {
907
999
  $gte: bucketContext.minId,
908
1000
  $lte: lastDocId
909
1001
  }
910
- }, { session });
1002
+ });
911
1003
  const clearOp = {
912
1004
  bucketKey: { ...context, bucket },
913
1005
  o: lastDocId.o,
@@ -918,9 +1010,10 @@ export class MongoCompactorV3 extends MongoCompactor {
918
1010
  const persisted = await this.persistBucketData(bucket, [[clearOp]], context, prepared, {
919
1011
  targetOp: maxTargetOp
920
1012
  });
921
- await collection.insertOne(persisted.documents[0], { session });
922
- await this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, session);
923
- await this.recordObjectStorageReplacement(oldStorageBytes, persisted.documents, context.definitionId, session);
1013
+ writes.insertOne(collection, persisted.documents[0]);
1014
+ this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, writes);
1015
+ this.recordObjectStorageReplacement(oldStorageBytes, persisted.documents, context.definitionId, writes, objectStorageUsage);
1016
+ await writes.execute();
924
1017
  opCountDiff = -clearedOpCount + 1;
925
1018
  before = inputStats;
926
1019
  after = statsForDocuments(persisted.documents);
@@ -930,7 +1023,7 @@ export class MongoCompactorV3 extends MongoCompactor {
930
1023
  });
931
1024
  return { done, opCountDiff, before, after };
932
1025
  }
933
- async clearBoundaryDocument(session, lastNotPut, boundaryDocId, bucketContext, collection, context) {
1026
+ async clearBoundaryDocument(session, lastNotPut, boundaryDocId, bucketContext, collection, context, objectStorageUsage) {
934
1027
  const bucket = bucketContext.key.bucket;
935
1028
  this.signal?.throwIfAborted();
936
1029
  const prepared = await this.prepareCompactionUploads(bucket, context, [lastNotPut, boundaryDocId.o]);
@@ -1012,12 +1105,13 @@ export class MongoCompactorV3 extends MongoCompactor {
1012
1105
  throw new Error(`CLEAR boundary document not found for bucket ${bucket}`);
1013
1106
  }
1014
1107
  this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastNotPut}`);
1015
- await collection.deleteMany({
1108
+ const writes = this.db.createWriteBatch(session, { ordered: true });
1109
+ writes.deleteMany(collection, {
1016
1110
  _id: {
1017
1111
  $gte: bucketContext.minId,
1018
1112
  $lte: boundaryDocId
1019
1113
  }
1020
- }, { session });
1114
+ });
1021
1115
  const clearOp = {
1022
1116
  bucketKey: { ...context, bucket },
1023
1117
  o: lastNotPut,
@@ -1034,9 +1128,10 @@ export class MongoCompactorV3 extends MongoCompactor {
1034
1128
  const persisted = await this.persistBucketData(bucket, chunks, context, prepared, {
1035
1129
  targetOp: maxTargetOp ?? undefined
1036
1130
  });
1037
- await collection.insertMany(persisted.documents, { session });
1038
- await this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, session);
1039
- await this.recordObjectStorageReplacement(oldStorageBytes, persisted.documents, context.definitionId, session);
1131
+ writes.insertMany(collection, persisted.documents);
1132
+ this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, writes);
1133
+ this.recordObjectStorageReplacement(oldStorageBytes, persisted.documents, context.definitionId, writes, objectStorageUsage);
1134
+ await writes.execute();
1040
1135
  opCountDiff = -clearedOpCount + 1;
1041
1136
  before = inputStats;
1042
1137
  after = statsForDocuments(persisted.documents);
@@ -1064,14 +1159,14 @@ export class MongoCompactorV3 extends MongoCompactor {
1064
1159
  return lifecycle.prepareUploads(paths);
1065
1160
  }
1066
1161
  /** Publish replacement uploads and retire superseded objects in the same transaction. */
1067
- async finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, session) {
1162
+ finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, writes) {
1068
1163
  if (!this.storage.objectStorage) {
1069
1164
  return;
1070
1165
  }
1071
- await this.objectStorageLifecycle.publishUploads(uploads, session);
1072
- await this.objectStorageLifecycle.retire(Array.from(oldStoragePaths).filter((path) => !newStoragePaths.has(path)), session);
1166
+ this.objectStorageLifecycle.publishUploads(uploads, writes);
1167
+ this.objectStorageLifecycle.retire(Array.from(oldStoragePaths).filter((path) => !newStoragePaths.has(path)), writes);
1073
1168
  }
1074
- async recordObjectStorageReplacement(oldBytes, newDocuments, definitionId, session) {
1169
+ recordObjectStorageReplacement(oldBytes, newDocuments, definitionId, writes, objectStorageUsage) {
1075
1170
  if (!this.storage.objectStorage) {
1076
1171
  return;
1077
1172
  }
@@ -1079,7 +1174,7 @@ export class MongoCompactorV3 extends MongoCompactor {
1079
1174
  for (const document of newDocuments) {
1080
1175
  newBytes += ObjectStorageUsage.bytes(document);
1081
1176
  }
1082
- await this.objectStorageUsage.applyDelta(definitionId, newBytes - oldBytes, session);
1177
+ objectStorageUsage.applyDelta(definitionId, newBytes - oldBytes, writes);
1083
1178
  }
1084
1179
  async persistBucketData(bucket, chunks, context, preparedUploads, options) {
1085
1180
  const serializedChunks = chunks.map((chunk) => serializeBucketData(bucket, chunk, options));