@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,10 +1,19 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
2
  import { ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
3
- import { addChecksums, formatBytes, InternalOpId, storage, utils } from '@powersync/service-core';
3
+ import {
4
+ acquireSemaphoreAbortable,
5
+ addChecksums,
6
+ formatBytes,
7
+ InternalOpId,
8
+ storage,
9
+ utils
10
+ } from '@powersync/service-core';
4
11
  import { BucketDefinitionId } from '@powersync/service-sync-rules';
12
+ import { setImmediate } from 'node:timers/promises';
5
13
  import { BucketDataDoc } from '../common/BucketDataDoc.js';
6
14
  import { BucketDataKey } from '../models.js';
7
15
  import { ConcurrentCompactionError, MongoCompactor } from '../MongoCompactor.js';
16
+ import { MongoWriteBatch } from '../MongoWriteBatch.js';
8
17
  import { cacheKey } from '../OperationBatch.js';
9
18
  import { loadBucketDataDocument, maxOpId, serializeBucketData } from './bucket-format.js';
10
19
  import { BucketDataContextV3 } from './BucketDataContextV3.js';
@@ -161,7 +170,8 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
161
170
  * Batching specifically help to cover cases of many buckets where no compaction is required:
162
171
  * Instead of sequentially claiming and then rescheduling a bucket, this handles it in bulk.
163
172
  *
164
- * Buckets that do need compaction are still claimed and processed sequentially.
173
+ * Chunk merges overlap a bounded number of buckets. Full compaction stays
174
+ * sequential because its working set includes operation deduplication state.
165
175
  *
166
176
  * Any concurrent workers may read the same batch. Rescheduling filters out buckets handled
167
177
  * by a concurrent worker or replication write, while buckets that do need compaction are
@@ -175,18 +185,24 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
175
185
  // Writers derive next_compact_check from MongoDB's $$NOW. Use the same
176
186
  // clock for the fixed job boundary so clock skew cannot exclude work at
177
187
  // the exact initial-replication interval.
178
- const [{ now: jobStartedAt }] = await this.db.db
179
- .aggregate<{ now: Date }>([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
180
- .toArray();
188
+ const jobStartedAt = await this.readCompactionTime();
181
189
  const dueBefore = new Date(jobStartedAt.getTime() + (options.dueAheadMs ?? 0));
182
190
  const forceKind = options.forceKind;
183
191
  const rescheduleNotBefore = new Date(dueBefore.getTime() + 1);
192
+ // Keep accounting documents bounded by workers, not buckets or scan batches.
193
+ const workerUsage = Array.from(
194
+ { length: this.storage.factory.chunkCompactionConcurrency },
195
+ () => new ObjectStorageUsage(this.db, this.group_id, createObjectStorageUsageWriterId())
196
+ );
184
197
  while (true) {
185
198
  this.signal?.throwIfAborted();
186
199
  const states = await this.findScheduledBucketBatch(dueBefore);
187
200
  if (states.length == 0) {
188
201
  break;
189
202
  }
203
+ // Keep eligibility bounded by dueBefore, but classify with the current
204
+ // server time so buckets that age into full compaction can advance.
205
+ const batchStartedAt = await this.readCompactionTime();
190
206
 
191
207
  const scheduled: {
192
208
  state: BucketStateDocumentV3;
@@ -197,7 +213,7 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
197
213
  try {
198
214
  scheduled.push({
199
215
  state,
200
- decision: chooseCompactionKind(state, jobStartedAt, this),
216
+ decision: chooseCompactionKind(state, batchStartedAt, this),
201
217
  forcedKind: forcedCompactionKind(state, forceKind, this)
202
218
  });
203
219
  } catch (error) {
@@ -210,20 +226,29 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
210
226
  );
211
227
  await this.rescheduleUnclaimedBuckets(noOpStates, rescheduleNotBefore);
212
228
 
213
- for (const { state, decision, forcedKind } of scheduled) {
229
+ const processBucket = async (
230
+ { state, decision, forcedKind }: (typeof scheduled)[number],
231
+ objectStorageUsage: ObjectStorageUsage,
232
+ chunksOnly = false
233
+ ) => {
214
234
  const kind = forceKind == null ? decision.kind : forcedKind;
215
235
  if (state.compact_lease == null && kind == null) {
216
- continue;
236
+ return;
217
237
  }
218
238
 
219
239
  try {
220
240
  await using lease = await this.claimBucket({ _id: state._id, next_compact_check: { $lte: dueBefore } });
221
241
  if (lease == null) {
222
- continue;
242
+ return;
223
243
  }
224
244
  const claimedDecision = chooseCompactionKind(lease.state, lease.startedAt, this);
225
245
  const claimedKind =
226
246
  forceKind == null ? claimedDecision.kind : forcedCompactionKind(lease.state, forceKind, this);
247
+ if (chunksOnly && claimedKind === CompactionKind.Full) {
248
+ // The decision changed after scanning. Release the lease without
249
+ // rescheduling; the next batch will classify it with a fresh timestamp.
250
+ return;
251
+ }
227
252
  if (claimedKind == null) {
228
253
  await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
229
254
  } else if (this.isCompactionTargetCovered(lease.state, claimedKind)) {
@@ -231,7 +256,13 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
231
256
  // already-published progress. Keep any newer work scheduled.
232
257
  await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
233
258
  } else {
234
- await this.compactClaimedBucket(lease, claimedKind, claimedDecision, rescheduleNotBefore);
259
+ await this.compactClaimedBucket(
260
+ lease,
261
+ claimedKind,
262
+ claimedDecision,
263
+ rescheduleNotBefore,
264
+ objectStorageUsage
265
+ );
235
266
  }
236
267
  } catch (error) {
237
268
  if (this.signal?.aborted) {
@@ -241,6 +272,87 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
241
272
  }
242
273
  await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
243
274
  }
275
+ };
276
+
277
+ const chunkBuckets = scheduled.filter(
278
+ ({ decision, forcedKind }) => (forceKind == null ? decision.kind : forcedKind) === CompactionKind.Chunks
279
+ );
280
+ const sequentialBuckets = scheduled.filter(
281
+ ({ decision, forcedKind }) => (forceKind == null ? decision.kind : forcedKind) !== CompactionKind.Chunks
282
+ );
283
+ await this.runChunkCompactionWorkers(chunkBuckets, workerUsage, (entry, usage) =>
284
+ processBucket(entry, usage, true)
285
+ );
286
+ // Full compaction cannot overlap chunk workers from this job, and only
287
+ // one full bucket is processed at a time.
288
+ for (const entry of sequentialBuckets) {
289
+ await processBucket(entry, this.objectStorageUsage);
290
+ }
291
+ }
292
+ }
293
+
294
+ /** Use MongoDB's clock, matching scheduling and lease timestamps. */
295
+ private async readCompactionTime(): Promise<Date> {
296
+ const [{ now }] = await this.db.db
297
+ .aggregate<{ now: Date }>([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
298
+ .toArray();
299
+ return now;
300
+ }
301
+
302
+ /** Process one scheduled batch with a fixed pool of workers. */
303
+ private async runChunkCompactionWorkers<T>(
304
+ buckets: readonly T[],
305
+ workerUsage: readonly ObjectStorageUsage[],
306
+ processBucket: (bucket: T, usage: ObjectStorageUsage) => Promise<void>
307
+ ): Promise<void> {
308
+ const signal = this.signal;
309
+ let nextBucket = 0;
310
+ let failed = false;
311
+
312
+ const runWorker = async (usage: ObjectStorageUsage) => {
313
+ try {
314
+ while (!failed && nextBucket < buckets.length) {
315
+ // Taking an entry has no await, so each worker gets a different bucket.
316
+ // A worker takes another only after finishing its current bucket.
317
+ const bucket = buckets[nextBucket++];
318
+
319
+ // This pool bounds one job; the factory semaphore bounds all jobs together.
320
+ // Acquire before claiming the bucket lease, and hold until it is released.
321
+ const acquired = await acquireSemaphoreAbortable(this.storage.factory.chunkCompactionSlots, signal);
322
+ if (acquired === 'aborted') {
323
+ signal?.throwIfAborted();
324
+ return;
325
+ }
326
+ const [, releaseSlot] = acquired;
327
+ try {
328
+ // A sibling may have failed while this worker waited for a slot.
329
+ if (failed) return;
330
+ signal?.throwIfAborted();
331
+ await processBucket(bucket, usage);
332
+ } finally {
333
+ releaseSlot();
334
+ }
335
+ // Let replication and other event-loop work run between buckets.
336
+ await setImmediate();
337
+ }
338
+ } catch (error) {
339
+ // Drain work already started, but do not let siblings start new buckets.
340
+ failed = true;
341
+ throw error;
342
+ }
343
+ };
344
+
345
+ // Concurrent transactions must not all increment the same usage document.
346
+ // Reuse one writer for each worker instead of creating one per bucket.
347
+ const workers = workerUsage.map(runWorker);
348
+
349
+ // Do not release the caller's replication lock or run cleanup while a
350
+ // sibling worker still owns a bucket lease or is finishing a replacement.
351
+ // Wait for every worker even on failure, then propagate the first error.
352
+ const results = await Promise.allSettled(workers);
353
+ for (const result of results) {
354
+ if (result.status === 'rejected') {
355
+ throw result.reason;
244
356
  }
245
357
  }
246
358
  }
@@ -314,7 +426,8 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
314
426
  lease: CompactionLease,
315
427
  kind: CompactionKind,
316
428
  decision: CompactionDecision,
317
- rescheduleNotBefore?: Date
429
+ rescheduleNotBefore?: Date,
430
+ objectStorageUsage = this.objectStorageUsage
318
431
  ) {
319
432
  const context = new CompactionContext(
320
433
  lease,
@@ -324,7 +437,7 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
324
437
  this.compactionTarget(lease.state)
325
438
  );
326
439
  lease.startRenewal();
327
- await this.compactSingleBucket(context);
440
+ await this.compactSingleBucket(context, objectStorageUsage);
328
441
  }
329
442
 
330
443
  private async rescheduleClaimedBucket(lease: CompactionLease, decision: CompactionDecision, notBefore?: Date) {
@@ -360,12 +473,12 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
360
473
  return new ObjectStorageLifecycle(this.db, this.group_id, this.storage.objectStorage);
361
474
  }
362
475
 
363
- private async compactSingleBucket(context: CompactionContext) {
476
+ private async compactSingleBucket(context: CompactionContext, objectStorageUsage: ObjectStorageUsage) {
364
477
  if (context.kind == CompactionKind.Chunks) {
365
- return this.compactSingleBucketChunks(context);
478
+ return this.compactSingleBucketChunks(context, objectStorageUsage);
366
479
  }
367
480
 
368
- return this.compactSingleBucketFully(context);
481
+ return this.compactSingleBucketFully(context, objectStorageUsage);
369
482
  }
370
483
 
371
484
  /**
@@ -374,7 +487,7 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
374
487
  * update the persisted checksum state and to decide whether a group can fit
375
488
  * in one chunk.
376
489
  */
377
- private async compactSingleBucketChunks(context: CompactionContext) {
490
+ private async compactSingleBucketChunks(context: CompactionContext, objectStorageUsage: ObjectStorageUsage) {
378
491
  const bucket = context.state._id.b;
379
492
  const resolvedDefinitionId = context.state._id.d;
380
493
  const bucketContext = new BucketDataContextV3(this.db, {
@@ -467,7 +580,14 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
467
580
 
468
581
  const nextSize = pendingSize + doc.size;
469
582
  if (pendingChunks.length > 0 && nextSize > DEFAULT_MAX_DOC_SIZE_BYTES) {
470
- const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext);
583
+ const groupStats = await this.flushChunkMerge(
584
+ bucket,
585
+ pendingChunks,
586
+ collection,
587
+ dataContext,
588
+ bucketContext,
589
+ objectStorageUsage
590
+ );
471
591
  compactedTail = combineAdjacentStats(compactedTail, groupStats);
472
592
  pendingChunks = [];
473
593
  pendingSize = 0;
@@ -484,7 +604,14 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
484
604
  }
485
605
 
486
606
  if (pendingChunks.length > 0) {
487
- const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext);
607
+ const groupStats = await this.flushChunkMerge(
608
+ bucket,
609
+ pendingChunks,
610
+ collection,
611
+ dataContext,
612
+ bucketContext,
613
+ objectStorageUsage
614
+ );
488
615
  compactedTail = combineAdjacentStats(compactedTail, groupStats);
489
616
  }
490
617
 
@@ -518,44 +645,57 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
518
645
  inputs: BucketDataDocumentV3[],
519
646
  collection: mongo.Collection<BucketDataDocumentV3>,
520
647
  context: { replicationStreamId: number; definitionId: string },
521
- bucketContext: BucketDataContextV3
648
+ bucketContext: BucketDataContextV3,
649
+ objectStorageUsage: ObjectStorageUsage
522
650
  ): Promise<BucketStatsWithChecksum> {
523
651
  if (inputs.length == 1) {
524
652
  return statsForDocument(inputs[0]);
525
653
  }
526
654
 
527
- // The metadata scan deliberately excluded ops. Read inline payloads only
528
- // for this merge group; object-storage payloads are fetched below using
529
- // the same rule.
530
- const inlineInputs = inputs.filter((input) => input.storage_ref == null);
531
- if (inlineInputs.length > 0) {
532
- const inlineDocuments = await collection
533
- .find({ _id: { $in: inlineInputs.map((input) => input._id) } }, { projection: { _id: 1, ops: 1 } })
534
- .toArray();
535
- const opsById = new Map(inlineDocuments.map((document) => [document._id.o.toString(), document.ops]));
536
- for (const input of inlineInputs) {
537
- input.ops = opsById.get(input._id.o.toString());
655
+ try {
656
+ this.signal?.throwIfAborted();
657
+
658
+ // The metadata scan deliberately excluded ops. Read inline payloads only
659
+ // for this merge group; object-storage payloads are fetched below using
660
+ // the same rule.
661
+ const inlineInputs = inputs.filter((input) => input.storage_ref == null);
662
+ if (inlineInputs.length > 0) {
663
+ const inlineDocuments = await collection
664
+ .find({ _id: { $in: inlineInputs.map((input) => input._id) } }, { projection: { _id: 1, ops: 1 } })
665
+ .toArray();
666
+ const opsById = new Map(inlineDocuments.map((document) => [document._id.o.toString(), document.ops]));
667
+ for (const input of inlineInputs) {
668
+ input.ops = opsById.get(input._id.o.toString());
669
+ }
538
670
  }
539
- }
540
- await hydrateBucketDataDocuments(inputs, this.storage.objectStorage, { signal: this.signal });
671
+ await hydrateBucketDataDocuments(inputs, this.storage.objectStorage, { signal: this.signal });
541
672
 
542
- const operations = inputs.flatMap((input) => Array.from(loadBucketDataDocument(context, input)));
543
- const targetOp = inputs.reduce<InternalOpId | null>(
544
- (maxTarget, input) => maxOpId(maxTarget, input.target_op),
545
- null
546
- );
547
- const result = await this.flushCompactionGroup(
548
- bucket,
549
- {
550
- inputs,
551
- ops: operations,
552
- changed: true,
553
- targetOp
554
- },
555
- bucketContext,
556
- context
557
- );
558
- return result.stats;
673
+ const operations = inputs.flatMap((input) => Array.from(loadBucketDataDocument(context, input)));
674
+ const targetOp = inputs.reduce<InternalOpId | null>(
675
+ (maxTarget, input) => maxOpId(maxTarget, input.target_op),
676
+ null
677
+ );
678
+ const result = await this.flushCompactionGroup(
679
+ bucket,
680
+ {
681
+ inputs,
682
+ ops: operations,
683
+ changed: true,
684
+ targetOp
685
+ },
686
+ bucketContext,
687
+ context,
688
+ objectStorageUsage
689
+ );
690
+ return result.stats;
691
+ } finally {
692
+ // The scan batch also references these documents. Do not retain hydrated
693
+ // operations after finishing this merge group.
694
+ for (const input of inputs) {
695
+ delete input.ops;
696
+ }
697
+ await setImmediate();
698
+ }
559
699
  }
560
700
 
561
701
  private async finalizeCompactedBucket({
@@ -692,7 +832,7 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
692
832
  };
693
833
  }
694
834
 
695
- private async compactSingleBucketFully(context: CompactionContext) {
835
+ private async compactSingleBucketFully(context: CompactionContext, objectStorageUsage: ObjectStorageUsage) {
696
836
  const bucket = context.state._id.b;
697
837
  const resolvedDefinitionId = context.state._id.d;
698
838
  const bucketContext = new BucketDataContextV3(this.db, {
@@ -849,7 +989,13 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
849
989
  };
850
990
  } else {
851
991
  const flushedGroup = pendingGroup;
852
- const result = await this.flushCompactionGroup(bucket, flushedGroup, bucketContext, dataContext);
992
+ const result = await this.flushCompactionGroup(
993
+ bucket,
994
+ flushedGroup,
995
+ bucketContext,
996
+ dataContext,
997
+ objectStorageUsage
998
+ );
853
999
  compactedStats = combineAdjacentStats(compactedStats, result.stats);
854
1000
  if (
855
1001
  lastNotPut != null &&
@@ -874,7 +1020,13 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
874
1020
  }
875
1021
 
876
1022
  if (pendingGroup != null) {
877
- const result = await this.flushCompactionGroup(bucket, pendingGroup, bucketContext, dataContext);
1023
+ const result = await this.flushCompactionGroup(
1024
+ bucket,
1025
+ pendingGroup,
1026
+ bucketContext,
1027
+ dataContext,
1028
+ objectStorageUsage
1029
+ );
878
1030
  compactedStats = combineAdjacentStats(compactedStats, result.stats);
879
1031
  if (
880
1032
  lastNotPut != null &&
@@ -900,7 +1052,8 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
900
1052
  clearBoundary.documentId,
901
1053
  bucketContext,
902
1054
  collection,
903
- dataContext
1055
+ dataContext,
1056
+ objectStorageUsage
904
1057
  );
905
1058
  totalOpCount += clearResult.opCountDiff;
906
1059
  compactedStats = applyStatsReplacement(compactedStats, clearResult.before, clearResult.after);
@@ -934,7 +1087,8 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
934
1087
  bucket: string,
935
1088
  group: PendingCompactionGroup,
936
1089
  bucketContext: BucketDataContextV3,
937
- context: { replicationStreamId: number; definitionId: string }
1090
+ context: { replicationStreamId: number; definitionId: string },
1091
+ objectStorageUsage: ObjectStorageUsage
938
1092
  ): Promise<CompactionGroupResult> {
939
1093
  if (group.inputs.length == 1 && !group.changed) {
940
1094
  return {
@@ -987,10 +1141,19 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
987
1141
  );
988
1142
  }
989
1143
 
990
- await bucketContext.collection.deleteMany({ _id: { $in: idsToDelete } }, { session });
991
- await bucketContext.collection.insertMany(documents, { session });
992
- await this.finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, session);
993
- await this.recordObjectStorageReplacement(oldStorageBytes, documents, context.definitionId, session);
1144
+ // Replacement documents can reuse deleted IDs, so retain delete-before-insert ordering.
1145
+ const writes = this.db.createWriteBatch(session, { ordered: true });
1146
+ writes.deleteMany(bucketContext.collection, { _id: { $in: idsToDelete } });
1147
+ writes.insertMany(bucketContext.collection, documents);
1148
+ this.finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, writes);
1149
+ this.recordObjectStorageReplacement(
1150
+ oldStorageBytes,
1151
+ documents,
1152
+ context.definitionId,
1153
+ writes,
1154
+ objectStorageUsage
1155
+ );
1156
+ await writes.execute();
994
1157
  },
995
1158
  {
996
1159
  writeConcern: { w: 'majority' },
@@ -1020,7 +1183,8 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1020
1183
  boundaryDocId: BucketDataKey,
1021
1184
  bucketContext: BucketDataContextV3,
1022
1185
  collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
1023
- context: { replicationStreamId: number; definitionId: string }
1186
+ context: { replicationStreamId: number; definitionId: string },
1187
+ objectStorageUsage: ObjectStorageUsage
1024
1188
  ): Promise<ClearCompactionResult> {
1025
1189
  let opCountDiff = 0;
1026
1190
  let before = emptyBucketStats();
@@ -1037,7 +1201,8 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1037
1201
  boundaryDocId,
1038
1202
  bucketContext,
1039
1203
  collection,
1040
- context
1204
+ context,
1205
+ objectStorageUsage
1041
1206
  );
1042
1207
  done = batch.done;
1043
1208
  opCountDiff += batch.opCountDiff;
@@ -1053,7 +1218,8 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1053
1218
  boundaryDocId,
1054
1219
  bucketContext,
1055
1220
  collection,
1056
- context
1221
+ context,
1222
+ objectStorageUsage
1057
1223
  );
1058
1224
  opCountDiff += boundaryResult.opCountDiff;
1059
1225
  before = combineAdjacentStats(before, boundaryResult.before);
@@ -1071,7 +1237,8 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1071
1237
  boundaryDocId: BucketDataKey,
1072
1238
  bucketContext: BucketDataContextV3,
1073
1239
  collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
1074
- context: { replicationStreamId: number; definitionId: string }
1240
+ context: { replicationStreamId: number; definitionId: string },
1241
+ objectStorageUsage: ObjectStorageUsage
1075
1242
  ): Promise<{ done: boolean; opCountDiff: number } & CompactionStatsReplacement> {
1076
1243
  const bucket = bucketContext.key.bucket;
1077
1244
  this.signal?.throwIfAborted();
@@ -1165,15 +1332,13 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1165
1332
 
1166
1333
  prepared ??= await this.prepareCompactionUploads(bucket, context, [lastNotPut]);
1167
1334
  this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastDocId?.o}`);
1168
- await collection.deleteMany(
1169
- {
1170
- _id: {
1171
- $gte: bucketContext.minId,
1172
- $lte: lastDocId!
1173
- }
1174
- },
1175
- { session }
1176
- );
1335
+ const writes = this.db.createWriteBatch(session, { ordered: true });
1336
+ writes.deleteMany(collection, {
1337
+ _id: {
1338
+ $gte: bucketContext.minId,
1339
+ $lte: lastDocId!
1340
+ }
1341
+ });
1177
1342
 
1178
1343
  const clearOp = {
1179
1344
  bucketKey: { ...context, bucket },
@@ -1185,9 +1350,16 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1185
1350
  const persisted = await this.persistBucketData(bucket, [[clearOp]], context, prepared, {
1186
1351
  targetOp: maxTargetOp
1187
1352
  });
1188
- await collection.insertOne(persisted.documents[0], { session });
1189
- await this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, session);
1190
- await this.recordObjectStorageReplacement(oldStorageBytes, persisted.documents, context.definitionId, session);
1353
+ writes.insertOne(collection, persisted.documents[0]);
1354
+ this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, writes);
1355
+ this.recordObjectStorageReplacement(
1356
+ oldStorageBytes,
1357
+ persisted.documents,
1358
+ context.definitionId,
1359
+ writes,
1360
+ objectStorageUsage
1361
+ );
1362
+ await writes.execute();
1191
1363
 
1192
1364
  opCountDiff = -clearedOpCount + 1;
1193
1365
  before = inputStats;
@@ -1208,7 +1380,8 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1208
1380
  boundaryDocId: BucketDataKey,
1209
1381
  bucketContext: BucketDataContextV3,
1210
1382
  collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
1211
- context: { replicationStreamId: number; definitionId: string }
1383
+ context: { replicationStreamId: number; definitionId: string },
1384
+ objectStorageUsage: ObjectStorageUsage
1212
1385
  ): Promise<ClearCompactionResult> {
1213
1386
  const bucket = bucketContext.key.bucket;
1214
1387
  this.signal?.throwIfAborted();
@@ -1307,15 +1480,13 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1307
1480
  }
1308
1481
 
1309
1482
  this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastNotPut}`);
1310
- await collection.deleteMany(
1311
- {
1312
- _id: {
1313
- $gte: bucketContext.minId,
1314
- $lte: boundaryDocId
1315
- }
1316
- },
1317
- { session }
1318
- );
1483
+ const writes = this.db.createWriteBatch(session, { ordered: true });
1484
+ writes.deleteMany(collection, {
1485
+ _id: {
1486
+ $gte: bucketContext.minId,
1487
+ $lte: boundaryDocId
1488
+ }
1489
+ });
1319
1490
 
1320
1491
  const clearOp = {
1321
1492
  bucketKey: { ...context, bucket },
@@ -1333,9 +1504,16 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1333
1504
  const persisted = await this.persistBucketData(bucket, chunks, context, prepared, {
1334
1505
  targetOp: maxTargetOp ?? undefined
1335
1506
  });
1336
- await collection.insertMany(persisted.documents, { session });
1337
- await this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, session);
1338
- await this.recordObjectStorageReplacement(oldStorageBytes, persisted.documents, context.definitionId, session);
1507
+ writes.insertMany(collection, persisted.documents);
1508
+ this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, writes);
1509
+ this.recordObjectStorageReplacement(
1510
+ oldStorageBytes,
1511
+ persisted.documents,
1512
+ context.definitionId,
1513
+ writes,
1514
+ objectStorageUsage
1515
+ );
1516
+ await writes.execute();
1339
1517
 
1340
1518
  opCountDiff = -clearedOpCount + 1;
1341
1519
  before = inputStats;
@@ -1374,28 +1552,29 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1374
1552
  }
1375
1553
 
1376
1554
  /** Publish replacement uploads and retire superseded objects in the same transaction. */
1377
- private async finishObjectStorageReplacement(
1555
+ private finishObjectStorageReplacement(
1378
1556
  oldStoragePaths: Iterable<string>,
1379
1557
  newStoragePaths: Set<string>,
1380
1558
  uploads: PreparedObjectStorageUpload[],
1381
- session: mongo.ClientSession
1382
- ): Promise<void> {
1559
+ writes: MongoWriteBatch
1560
+ ): void {
1383
1561
  if (!this.storage.objectStorage) {
1384
1562
  return;
1385
1563
  }
1386
- await this.objectStorageLifecycle.publishUploads(uploads, session);
1387
- await this.objectStorageLifecycle.retire(
1564
+ this.objectStorageLifecycle.publishUploads(uploads, writes);
1565
+ this.objectStorageLifecycle.retire(
1388
1566
  Array.from(oldStoragePaths).filter((path) => !newStoragePaths.has(path)),
1389
- session
1567
+ writes
1390
1568
  );
1391
1569
  }
1392
1570
 
1393
- private async recordObjectStorageReplacement(
1571
+ private recordObjectStorageReplacement(
1394
1572
  oldBytes: bigint,
1395
1573
  newDocuments: Iterable<Pick<BucketDataDocumentV3, 'storage_ref'>>,
1396
1574
  definitionId: BucketDefinitionId,
1397
- session: mongo.ClientSession
1398
- ): Promise<void> {
1575
+ writes: MongoWriteBatch,
1576
+ objectStorageUsage: ObjectStorageUsage
1577
+ ): void {
1399
1578
  if (!this.storage.objectStorage) {
1400
1579
  return;
1401
1580
  }
@@ -1403,7 +1582,7 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1403
1582
  for (const document of newDocuments) {
1404
1583
  newBytes += ObjectStorageUsage.bytes(document);
1405
1584
  }
1406
- await this.objectStorageUsage.applyDelta(definitionId, newBytes - oldBytes, session);
1585
+ objectStorageUsage.applyDelta(definitionId, newBytes - oldBytes, writes);
1407
1586
  }
1408
1587
 
1409
1588
  private async persistBucketData(