@powersync/service-module-mongodb-storage 0.21.0 → 0.23.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 (130) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +10 -1
  3. package/dist/storage/MongoBucketStorage.js +37 -7
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +24 -10
  6. package/dist/storage/implementation/MongoBucketBatch.js +134 -89
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoCompactor.d.ts +2 -0
  9. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  10. package/dist/storage/implementation/MongoIdSequence.d.ts +10 -1
  11. package/dist/storage/implementation/MongoIdSequence.js +14 -1
  12. package/dist/storage/implementation/MongoIdSequence.js.map +1 -1
  13. package/dist/storage/implementation/MongoOpIdAllocator.d.ts +27 -0
  14. package/dist/storage/implementation/MongoOpIdAllocator.js +97 -0
  15. package/dist/storage/implementation/MongoOpIdAllocator.js.map +1 -0
  16. package/dist/storage/implementation/MongoStorageProvider.js +4 -2
  17. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  18. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +5 -0
  19. package/dist/storage/implementation/MongoSyncBucketStorage.js +17 -0
  20. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  21. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +26 -2
  22. package/dist/storage/implementation/MongoSyncRulesLock.js +38 -1
  23. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  24. package/dist/storage/implementation/MongoWriteBatch.d.ts +49 -0
  25. package/dist/storage/implementation/MongoWriteBatch.js +149 -0
  26. package/dist/storage/implementation/MongoWriteBatch.js.map +1 -0
  27. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +2 -2
  28. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  29. package/dist/storage/implementation/common/PersistedBatch.d.ts +5 -4
  30. package/dist/storage/implementation/common/PersistedBatch.js +6 -4
  31. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  32. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.d.ts +3 -0
  33. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +3 -0
  34. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  35. package/dist/storage/implementation/db.d.ts +5 -0
  36. package/dist/storage/implementation/db.js +14 -0
  37. package/dist/storage/implementation/db.js.map +1 -1
  38. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +5 -0
  39. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +149 -139
  40. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  41. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +2 -1
  42. package/dist/storage/implementation/v1/MongoCompactorV1.js +23 -17
  43. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  44. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +1 -1
  45. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  46. package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +7 -5
  47. package/dist/storage/implementation/v1/PersistedBatchV1.js +22 -28
  48. package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
  49. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +6 -1
  50. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +148 -162
  51. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  52. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +10 -4
  53. package/dist/storage/implementation/v3/MongoCompactorV3.js +175 -76
  54. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  55. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +12 -8
  56. package/dist/storage/implementation/v3/PersistedBatchV3.js +25 -32
  57. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  58. package/dist/storage/implementation/v3/compact-utils.d.ts +3 -2
  59. package/dist/storage/implementation/v3/compact-utils.js +12 -5
  60. package/dist/storage/implementation/v3/compact-utils.js.map +1 -1
  61. package/dist/storage/implementation/v3/models.d.ts +1 -1
  62. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +8 -1
  63. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  64. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +3 -3
  65. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +7 -9
  66. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  67. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +3 -2
  68. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +4 -7
  69. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -1
  70. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +21 -3
  71. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
  72. package/dist/types/types.d.ts +5 -0
  73. package/dist/types/types.js +12 -0
  74. package/dist/types/types.js.map +1 -1
  75. package/dist/utils/test-utils.d.ts +2 -1
  76. package/dist/utils/test-utils.js +5 -3
  77. package/dist/utils/test-utils.js.map +1 -1
  78. package/package.json +7 -7
  79. package/src/storage/MongoBucketStorage.ts +47 -8
  80. package/src/storage/implementation/MongoBucketBatch.ts +205 -111
  81. package/src/storage/implementation/MongoCompactor.ts +2 -0
  82. package/src/storage/implementation/MongoIdSequence.ts +21 -1
  83. package/src/storage/implementation/MongoOpIdAllocator.ts +105 -0
  84. package/src/storage/implementation/MongoStorageProvider.ts +12 -2
  85. package/src/storage/implementation/MongoSyncBucketStorage.ts +19 -0
  86. package/src/storage/implementation/MongoSyncRulesLock.ts +56 -3
  87. package/src/storage/implementation/MongoWriteBatch.ts +172 -0
  88. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +4 -2
  89. package/src/storage/implementation/common/PersistedBatch.ts +12 -8
  90. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +4 -0
  91. package/src/storage/implementation/db.ts +15 -0
  92. package/src/storage/implementation/models.ts +1 -0
  93. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +167 -160
  94. package/src/storage/implementation/v1/MongoCompactorV1.ts +29 -26
  95. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +1 -1
  96. package/src/storage/implementation/v1/PersistedBatchV1.ts +25 -30
  97. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +217 -242
  98. package/src/storage/implementation/v3/MongoCompactorV3.ts +284 -108
  99. package/src/storage/implementation/v3/PersistedBatchV3.ts +36 -36
  100. package/src/storage/implementation/v3/compact-utils.ts +16 -7
  101. package/src/storage/implementation/v3/models.ts +1 -1
  102. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +8 -1
  103. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +11 -10
  104. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +8 -9
  105. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +23 -3
  106. package/src/types/types.ts +18 -0
  107. package/src/utils/test-utils.ts +6 -3
  108. package/test/src/__snapshots__/storage.test.ts.snap +102 -2
  109. package/test/src/__snapshots__/storage_compacting.test.ts.snap +2 -2
  110. package/test/src/__snapshots__/storage_sync.test.ts.snap +12 -12
  111. package/test/src/chunk_compaction_config.test.ts +38 -0
  112. package/test/src/cleanup-stopped-sync-configs.test.ts +19 -13
  113. package/test/src/compact-utils.test.ts +33 -8
  114. package/test/src/concurrent_writers.test.ts +599 -0
  115. package/test/src/helpers/concurrentWriter.mjs +29 -0
  116. package/test/src/helpers/s3TestFactory.ts +12 -12
  117. package/test/src/object_storage_usage.test.ts +18 -5
  118. package/test/src/parameter_compacting_v1.test.ts +2 -2
  119. package/test/src/parameter_compacting_v3.test.ts +2 -2
  120. package/test/src/parameter_compaction_fence.test.ts +3 -3
  121. package/test/src/storage.test.ts +32 -33
  122. package/test/src/storage_compacting.test.ts +402 -24
  123. package/test/src/storage_object_storage_inline_threshold.test.ts +3 -3
  124. package/test/src/storage_s3_checksums.test.ts +4 -4
  125. package/test/src/storage_s3_compaction_lifecycle.test.ts +8 -8
  126. package/test/src/storage_s3_reading.test.ts +51 -5
  127. package/test/src/storage_s3_writing.test.ts +3 -3
  128. package/test/src/storage_sync.test.ts +52 -46
  129. package/test/src/storeCurrentData.test.ts +5 -5
  130. package/tsconfig.tsbuildinfo +1 -1
@@ -1,10 +1,12 @@
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 { acquireSemaphoreAbortable, addChecksums, formatBytes, InternalOpId, utils } from '@powersync/service-core';
4
4
  import { BucketDefinitionId } from '@powersync/service-sync-rules';
5
+ import { setImmediate } from 'node:timers/promises';
5
6
  import { BucketDataDoc } from '../common/BucketDataDoc.js';
6
7
  import { BucketDataKey } from '../models.js';
7
- import { ConcurrentCompactionError, MongoCompactor } from '../MongoCompactor.js';
8
+ import { ConcurrentCompactionError, MongoCompactOptions, MongoCompactor } from '../MongoCompactor.js';
9
+ import { MongoWriteBatch } from '../MongoWriteBatch.js';
8
10
  import { cacheKey } from '../OperationBatch.js';
9
11
  import { loadBucketDataDocument, maxOpId, serializeBucketData } from './bucket-format.js';
10
12
  import { BucketDataContextV3 } from './BucketDataContextV3.js';
@@ -14,6 +16,7 @@ import {
14
16
  bucketStats,
15
17
  BucketStatsWithChecksum,
16
18
  chooseCompactionKind,
19
+ chooseRequestedCompactionKind,
17
20
  combineAdjacentStats,
18
21
  combineChunkStats,
19
22
  CompactIntervalConfig,
@@ -24,7 +27,6 @@ import {
24
27
  CompactTargetConfig,
25
28
  emptyBucketStats,
26
29
  firstUncompactedWrite,
27
- forcedCompactionKind,
28
30
  PendingCompactionGroup,
29
31
  readCompactionBatch,
30
32
  ScheduledCompactionOptions,
@@ -69,15 +71,17 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
69
71
  readonly maxCompactFullIntervalMs: number;
70
72
  readonly compactLeaseDurationMs: number;
71
73
  readonly maxOpIdCap: InternalOpId | undefined;
74
+ readonly forceChunkCompaction: boolean;
72
75
  private readonly objectStorageUsage: ObjectStorageUsage;
73
76
 
74
- constructor(bucketStorage: MongoSyncBucketStorageV3, db: VersionedPowerSyncMongoV3, options: storage.CompactOptions) {
77
+ constructor(bucketStorage: MongoSyncBucketStorageV3, db: VersionedPowerSyncMongoV3, options: MongoCompactOptions) {
75
78
  super(bucketStorage, db, options);
76
79
  this.minCompactChunkIntervalMs = options.minCompactChunkIntervalMs ?? DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS;
77
80
  this.minCompactFullIntervalMs = options.minCompactFullIntervalMs ?? DEFAULT_MIN_COMPACT_FULL_INTERVAL_MS;
78
81
  this.maxCompactFullIntervalMs = options.maxCompactFullIntervalMs ?? DEFAULT_MAX_COMPACT_FULL_INTERVAL_MS;
79
82
  this.compactLeaseDurationMs = options.compactLeaseDurationMs ?? DEFAULT_COMPACT_LEASE_DURATION_MS;
80
83
  this.maxOpIdCap = options.maxOpId;
84
+ this.forceChunkCompaction = options.forceChunkCompaction ?? false;
81
85
  this.objectStorageUsage = new ObjectStorageUsage(this.db, this.group_id, createObjectStorageUsageWriterId());
82
86
  }
83
87
 
@@ -104,7 +108,7 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
104
108
  // processes the work that existed when it started.
105
109
  await this.compactScheduledBuckets({
106
110
  dueAheadMs: DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS,
107
- forceKind: CompactionKind.Chunks
111
+ requestedKind: CompactionKind.Chunks
108
112
  });
109
113
  } else {
110
114
  await this.compactScheduledBuckets();
@@ -161,7 +165,8 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
161
165
  * Batching specifically help to cover cases of many buckets where no compaction is required:
162
166
  * Instead of sequentially claiming and then rescheduling a bucket, this handles it in bulk.
163
167
  *
164
- * Buckets that do need compaction are still claimed and processed sequentially.
168
+ * Chunk merges overlap a bounded number of buckets. Full compaction stays
169
+ * sequential because its working set includes operation deduplication state.
165
170
  *
166
171
  * Any concurrent workers may read the same batch. Rescheduling filters out buckets handled
167
172
  * by a concurrent worker or replication write, while buckets that do need compaction are
@@ -175,55 +180,72 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
175
180
  // Writers derive next_compact_check from MongoDB's $$NOW. Use the same
176
181
  // clock for the fixed job boundary so clock skew cannot exclude work at
177
182
  // 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();
183
+ const jobStartedAt = await this.readCompactionTime();
181
184
  const dueBefore = new Date(jobStartedAt.getTime() + (options.dueAheadMs ?? 0));
182
- const forceKind = options.forceKind;
185
+ const requestedKind = options.requestedKind;
183
186
  const rescheduleNotBefore = new Date(dueBefore.getTime() + 1);
187
+ // Keep accounting documents bounded by workers, not buckets or scan batches.
188
+ const workerUsage = Array.from(
189
+ { length: this.storage.factory.chunkCompactionConcurrency },
190
+ () => new ObjectStorageUsage(this.db, this.group_id, createObjectStorageUsageWriterId())
191
+ );
184
192
  while (true) {
185
193
  this.signal?.throwIfAborted();
186
194
  const states = await this.findScheduledBucketBatch(dueBefore);
187
195
  if (states.length == 0) {
188
196
  break;
189
197
  }
198
+ // Keep eligibility bounded by dueBefore, but classify with the current
199
+ // server time so buckets that age into full compaction can advance.
200
+ const batchStartedAt = await this.readCompactionTime();
190
201
 
191
202
  const scheduled: {
192
203
  state: BucketStateDocumentV3;
193
204
  decision: CompactionDecision;
194
- forcedKind: CompactionKind | null;
205
+ selectedKind: CompactionKind | null;
195
206
  }[] = [];
196
207
  for (const state of states) {
197
208
  try {
198
209
  scheduled.push({
199
210
  state,
200
- decision: chooseCompactionKind(state, jobStartedAt, this),
201
- forcedKind: forcedCompactionKind(state, forceKind, this)
211
+ decision: chooseCompactionKind(state, batchStartedAt, this),
212
+ selectedKind: chooseRequestedCompactionKind(state, requestedKind, this)
202
213
  });
203
214
  } catch (error) {
204
215
  await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
205
216
  }
206
217
  }
207
218
  const noOpStates = scheduled.filter(
208
- ({ state, decision, forcedKind }) =>
209
- state.compact_lease == null && (forceKind == null ? decision.kind : forcedKind) == null
219
+ ({ state, decision, selectedKind }) =>
220
+ state.compact_lease == null && (requestedKind == null ? decision.kind : selectedKind) == null
210
221
  );
211
222
  await this.rescheduleUnclaimedBuckets(noOpStates, rescheduleNotBefore);
212
223
 
213
- for (const { state, decision, forcedKind } of scheduled) {
214
- const kind = forceKind == null ? decision.kind : forcedKind;
224
+ const processBucket = async (
225
+ { state, decision, selectedKind }: (typeof scheduled)[number],
226
+ objectStorageUsage: ObjectStorageUsage,
227
+ chunksOnly = false
228
+ ) => {
229
+ const kind = requestedKind == null ? decision.kind : selectedKind;
215
230
  if (state.compact_lease == null && kind == null) {
216
- continue;
231
+ return;
217
232
  }
218
233
 
219
234
  try {
220
235
  await using lease = await this.claimBucket({ _id: state._id, next_compact_check: { $lte: dueBefore } });
221
236
  if (lease == null) {
222
- continue;
237
+ return;
223
238
  }
224
239
  const claimedDecision = chooseCompactionKind(lease.state, lease.startedAt, this);
225
240
  const claimedKind =
226
- forceKind == null ? claimedDecision.kind : forcedCompactionKind(lease.state, forceKind, this);
241
+ requestedKind == null
242
+ ? claimedDecision.kind
243
+ : chooseRequestedCompactionKind(lease.state, requestedKind, this);
244
+ if (chunksOnly && claimedKind === CompactionKind.Full) {
245
+ // The decision changed after scanning. Release the lease without
246
+ // rescheduling; the next batch will classify it with a fresh timestamp.
247
+ return;
248
+ }
227
249
  if (claimedKind == null) {
228
250
  await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
229
251
  } else if (this.isCompactionTargetCovered(lease.state, claimedKind)) {
@@ -231,7 +253,13 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
231
253
  // already-published progress. Keep any newer work scheduled.
232
254
  await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
233
255
  } else {
234
- await this.compactClaimedBucket(lease, claimedKind, claimedDecision, rescheduleNotBefore);
256
+ await this.compactClaimedBucket(
257
+ lease,
258
+ claimedKind,
259
+ claimedDecision,
260
+ rescheduleNotBefore,
261
+ objectStorageUsage
262
+ );
235
263
  }
236
264
  } catch (error) {
237
265
  if (this.signal?.aborted) {
@@ -241,6 +269,87 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
241
269
  }
242
270
  await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
243
271
  }
272
+ };
273
+
274
+ const chunkBuckets = scheduled.filter(
275
+ ({ decision, selectedKind }) => (requestedKind == null ? decision.kind : selectedKind) === CompactionKind.Chunks
276
+ );
277
+ const sequentialBuckets = scheduled.filter(
278
+ ({ decision, selectedKind }) => (requestedKind == null ? decision.kind : selectedKind) !== CompactionKind.Chunks
279
+ );
280
+ await this.runChunkCompactionWorkers(chunkBuckets, workerUsage, (entry, usage) =>
281
+ processBucket(entry, usage, true)
282
+ );
283
+ // Full compaction cannot overlap chunk workers from this job, and only
284
+ // one full bucket is processed at a time.
285
+ for (const entry of sequentialBuckets) {
286
+ await processBucket(entry, this.objectStorageUsage);
287
+ }
288
+ }
289
+ }
290
+
291
+ /** Use MongoDB's clock, matching scheduling and lease timestamps. */
292
+ private async readCompactionTime(): Promise<Date> {
293
+ const [{ now }] = await this.db.db
294
+ .aggregate<{ now: Date }>([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
295
+ .toArray();
296
+ return now;
297
+ }
298
+
299
+ /** Process one scheduled batch with a fixed pool of workers. */
300
+ private async runChunkCompactionWorkers<T>(
301
+ buckets: readonly T[],
302
+ workerUsage: readonly ObjectStorageUsage[],
303
+ processBucket: (bucket: T, usage: ObjectStorageUsage) => Promise<void>
304
+ ): Promise<void> {
305
+ const signal = this.signal;
306
+ let nextBucket = 0;
307
+ let failed = false;
308
+
309
+ const runWorker = async (usage: ObjectStorageUsage) => {
310
+ try {
311
+ while (!failed && nextBucket < buckets.length) {
312
+ // Taking an entry has no await, so each worker gets a different bucket.
313
+ // A worker takes another only after finishing its current bucket.
314
+ const bucket = buckets[nextBucket++];
315
+
316
+ // This pool bounds one job; the factory semaphore bounds all jobs together.
317
+ // Acquire before claiming the bucket lease, and hold until it is released.
318
+ const acquired = await acquireSemaphoreAbortable(this.storage.factory.chunkCompactionSlots, signal);
319
+ if (acquired === 'aborted') {
320
+ signal?.throwIfAborted();
321
+ return;
322
+ }
323
+ const [, releaseSlot] = acquired;
324
+ try {
325
+ // A sibling may have failed while this worker waited for a slot.
326
+ if (failed) return;
327
+ signal?.throwIfAborted();
328
+ await processBucket(bucket, usage);
329
+ } finally {
330
+ releaseSlot();
331
+ }
332
+ // Let replication and other event-loop work run between buckets.
333
+ await setImmediate();
334
+ }
335
+ } catch (error) {
336
+ // Drain work already started, but do not let siblings start new buckets.
337
+ failed = true;
338
+ throw error;
339
+ }
340
+ };
341
+
342
+ // Concurrent transactions must not all increment the same usage document.
343
+ // Reuse one writer for each worker instead of creating one per bucket.
344
+ const workers = workerUsage.map(runWorker);
345
+
346
+ // Do not release the caller's replication lock or run cleanup while a
347
+ // sibling worker still owns a bucket lease or is finishing a replacement.
348
+ // Wait for every worker even on failure, then propagate the first error.
349
+ const results = await Promise.allSettled(workers);
350
+ for (const result of results) {
351
+ if (result.status === 'rejected') {
352
+ throw result.reason;
244
353
  }
245
354
  }
246
355
  }
@@ -314,7 +423,8 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
314
423
  lease: CompactionLease,
315
424
  kind: CompactionKind,
316
425
  decision: CompactionDecision,
317
- rescheduleNotBefore?: Date
426
+ rescheduleNotBefore?: Date,
427
+ objectStorageUsage = this.objectStorageUsage
318
428
  ) {
319
429
  const context = new CompactionContext(
320
430
  lease,
@@ -324,7 +434,7 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
324
434
  this.compactionTarget(lease.state)
325
435
  );
326
436
  lease.startRenewal();
327
- await this.compactSingleBucket(context);
437
+ await this.compactSingleBucket(context, objectStorageUsage);
328
438
  }
329
439
 
330
440
  private async rescheduleClaimedBucket(lease: CompactionLease, decision: CompactionDecision, notBefore?: Date) {
@@ -360,12 +470,12 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
360
470
  return new ObjectStorageLifecycle(this.db, this.group_id, this.storage.objectStorage);
361
471
  }
362
472
 
363
- private async compactSingleBucket(context: CompactionContext) {
473
+ private async compactSingleBucket(context: CompactionContext, objectStorageUsage: ObjectStorageUsage) {
364
474
  if (context.kind == CompactionKind.Chunks) {
365
- return this.compactSingleBucketChunks(context);
475
+ return this.compactSingleBucketChunks(context, objectStorageUsage);
366
476
  }
367
477
 
368
- return this.compactSingleBucketFully(context);
478
+ return this.compactSingleBucketFully(context, objectStorageUsage);
369
479
  }
370
480
 
371
481
  /**
@@ -374,7 +484,7 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
374
484
  * update the persisted checksum state and to decide whether a group can fit
375
485
  * in one chunk.
376
486
  */
377
- private async compactSingleBucketChunks(context: CompactionContext) {
487
+ private async compactSingleBucketChunks(context: CompactionContext, objectStorageUsage: ObjectStorageUsage) {
378
488
  const bucket = context.state._id.b;
379
489
  const resolvedDefinitionId = context.state._id.d;
380
490
  const bucketContext = new BucketDataContextV3(this.db, {
@@ -467,7 +577,14 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
467
577
 
468
578
  const nextSize = pendingSize + doc.size;
469
579
  if (pendingChunks.length > 0 && nextSize > DEFAULT_MAX_DOC_SIZE_BYTES) {
470
- const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext);
580
+ const groupStats = await this.flushChunkMerge(
581
+ bucket,
582
+ pendingChunks,
583
+ collection,
584
+ dataContext,
585
+ bucketContext,
586
+ objectStorageUsage
587
+ );
471
588
  compactedTail = combineAdjacentStats(compactedTail, groupStats);
472
589
  pendingChunks = [];
473
590
  pendingSize = 0;
@@ -484,7 +601,14 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
484
601
  }
485
602
 
486
603
  if (pendingChunks.length > 0) {
487
- const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext);
604
+ const groupStats = await this.flushChunkMerge(
605
+ bucket,
606
+ pendingChunks,
607
+ collection,
608
+ dataContext,
609
+ bucketContext,
610
+ objectStorageUsage
611
+ );
488
612
  compactedTail = combineAdjacentStats(compactedTail, groupStats);
489
613
  }
490
614
 
@@ -518,44 +642,57 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
518
642
  inputs: BucketDataDocumentV3[],
519
643
  collection: mongo.Collection<BucketDataDocumentV3>,
520
644
  context: { replicationStreamId: number; definitionId: string },
521
- bucketContext: BucketDataContextV3
645
+ bucketContext: BucketDataContextV3,
646
+ objectStorageUsage: ObjectStorageUsage
522
647
  ): Promise<BucketStatsWithChecksum> {
523
648
  if (inputs.length == 1) {
524
649
  return statsForDocument(inputs[0]);
525
650
  }
526
651
 
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());
652
+ try {
653
+ this.signal?.throwIfAborted();
654
+
655
+ // The metadata scan deliberately excluded ops. Read inline payloads only
656
+ // for this merge group; object-storage payloads are fetched below using
657
+ // the same rule.
658
+ const inlineInputs = inputs.filter((input) => input.storage_ref == null);
659
+ if (inlineInputs.length > 0) {
660
+ const inlineDocuments = await collection
661
+ .find({ _id: { $in: inlineInputs.map((input) => input._id) } }, { projection: { _id: 1, ops: 1 } })
662
+ .toArray();
663
+ const opsById = new Map(inlineDocuments.map((document) => [document._id.o.toString(), document.ops]));
664
+ for (const input of inlineInputs) {
665
+ input.ops = opsById.get(input._id.o.toString());
666
+ }
538
667
  }
539
- }
540
- await hydrateBucketDataDocuments(inputs, this.storage.objectStorage, { signal: this.signal });
668
+ await hydrateBucketDataDocuments(inputs, this.storage.objectStorage, { signal: this.signal });
541
669
 
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;
670
+ const operations = inputs.flatMap((input) => Array.from(loadBucketDataDocument(context, input)));
671
+ const targetOp = inputs.reduce<InternalOpId | null>(
672
+ (maxTarget, input) => maxOpId(maxTarget, input.target_op),
673
+ null
674
+ );
675
+ const result = await this.flushCompactionGroup(
676
+ bucket,
677
+ {
678
+ inputs,
679
+ ops: operations,
680
+ changed: true,
681
+ targetOp
682
+ },
683
+ bucketContext,
684
+ context,
685
+ objectStorageUsage
686
+ );
687
+ return result.stats;
688
+ } finally {
689
+ // The scan batch also references these documents. Do not retain hydrated
690
+ // operations after finishing this merge group.
691
+ for (const input of inputs) {
692
+ delete input.ops;
693
+ }
694
+ await setImmediate();
695
+ }
559
696
  }
560
697
 
561
698
  private async finalizeCompactedBucket({
@@ -692,7 +829,7 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
692
829
  };
693
830
  }
694
831
 
695
- private async compactSingleBucketFully(context: CompactionContext) {
832
+ private async compactSingleBucketFully(context: CompactionContext, objectStorageUsage: ObjectStorageUsage) {
696
833
  const bucket = context.state._id.b;
697
834
  const resolvedDefinitionId = context.state._id.d;
698
835
  const bucketContext = new BucketDataContextV3(this.db, {
@@ -849,7 +986,13 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
849
986
  };
850
987
  } else {
851
988
  const flushedGroup = pendingGroup;
852
- const result = await this.flushCompactionGroup(bucket, flushedGroup, bucketContext, dataContext);
989
+ const result = await this.flushCompactionGroup(
990
+ bucket,
991
+ flushedGroup,
992
+ bucketContext,
993
+ dataContext,
994
+ objectStorageUsage
995
+ );
853
996
  compactedStats = combineAdjacentStats(compactedStats, result.stats);
854
997
  if (
855
998
  lastNotPut != null &&
@@ -874,7 +1017,13 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
874
1017
  }
875
1018
 
876
1019
  if (pendingGroup != null) {
877
- const result = await this.flushCompactionGroup(bucket, pendingGroup, bucketContext, dataContext);
1020
+ const result = await this.flushCompactionGroup(
1021
+ bucket,
1022
+ pendingGroup,
1023
+ bucketContext,
1024
+ dataContext,
1025
+ objectStorageUsage
1026
+ );
878
1027
  compactedStats = combineAdjacentStats(compactedStats, result.stats);
879
1028
  if (
880
1029
  lastNotPut != null &&
@@ -900,7 +1049,8 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
900
1049
  clearBoundary.documentId,
901
1050
  bucketContext,
902
1051
  collection,
903
- dataContext
1052
+ dataContext,
1053
+ objectStorageUsage
904
1054
  );
905
1055
  totalOpCount += clearResult.opCountDiff;
906
1056
  compactedStats = applyStatsReplacement(compactedStats, clearResult.before, clearResult.after);
@@ -934,7 +1084,8 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
934
1084
  bucket: string,
935
1085
  group: PendingCompactionGroup,
936
1086
  bucketContext: BucketDataContextV3,
937
- context: { replicationStreamId: number; definitionId: string }
1087
+ context: { replicationStreamId: number; definitionId: string },
1088
+ objectStorageUsage: ObjectStorageUsage
938
1089
  ): Promise<CompactionGroupResult> {
939
1090
  if (group.inputs.length == 1 && !group.changed) {
940
1091
  return {
@@ -987,10 +1138,19 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
987
1138
  );
988
1139
  }
989
1140
 
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);
1141
+ // Replacement documents can reuse deleted IDs, so retain delete-before-insert ordering.
1142
+ const writes = this.db.createWriteBatch(session, { ordered: true });
1143
+ writes.deleteMany(bucketContext.collection, { _id: { $in: idsToDelete } });
1144
+ writes.insertMany(bucketContext.collection, documents);
1145
+ this.finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, writes);
1146
+ this.recordObjectStorageReplacement(
1147
+ oldStorageBytes,
1148
+ documents,
1149
+ context.definitionId,
1150
+ writes,
1151
+ objectStorageUsage
1152
+ );
1153
+ await writes.execute();
994
1154
  },
995
1155
  {
996
1156
  writeConcern: { w: 'majority' },
@@ -1020,7 +1180,8 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1020
1180
  boundaryDocId: BucketDataKey,
1021
1181
  bucketContext: BucketDataContextV3,
1022
1182
  collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
1023
- context: { replicationStreamId: number; definitionId: string }
1183
+ context: { replicationStreamId: number; definitionId: string },
1184
+ objectStorageUsage: ObjectStorageUsage
1024
1185
  ): Promise<ClearCompactionResult> {
1025
1186
  let opCountDiff = 0;
1026
1187
  let before = emptyBucketStats();
@@ -1037,7 +1198,8 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1037
1198
  boundaryDocId,
1038
1199
  bucketContext,
1039
1200
  collection,
1040
- context
1201
+ context,
1202
+ objectStorageUsage
1041
1203
  );
1042
1204
  done = batch.done;
1043
1205
  opCountDiff += batch.opCountDiff;
@@ -1053,7 +1215,8 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1053
1215
  boundaryDocId,
1054
1216
  bucketContext,
1055
1217
  collection,
1056
- context
1218
+ context,
1219
+ objectStorageUsage
1057
1220
  );
1058
1221
  opCountDiff += boundaryResult.opCountDiff;
1059
1222
  before = combineAdjacentStats(before, boundaryResult.before);
@@ -1071,7 +1234,8 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1071
1234
  boundaryDocId: BucketDataKey,
1072
1235
  bucketContext: BucketDataContextV3,
1073
1236
  collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
1074
- context: { replicationStreamId: number; definitionId: string }
1237
+ context: { replicationStreamId: number; definitionId: string },
1238
+ objectStorageUsage: ObjectStorageUsage
1075
1239
  ): Promise<{ done: boolean; opCountDiff: number } & CompactionStatsReplacement> {
1076
1240
  const bucket = bucketContext.key.bucket;
1077
1241
  this.signal?.throwIfAborted();
@@ -1165,15 +1329,13 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1165
1329
 
1166
1330
  prepared ??= await this.prepareCompactionUploads(bucket, context, [lastNotPut]);
1167
1331
  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
- );
1332
+ const writes = this.db.createWriteBatch(session, { ordered: true });
1333
+ writes.deleteMany(collection, {
1334
+ _id: {
1335
+ $gte: bucketContext.minId,
1336
+ $lte: lastDocId!
1337
+ }
1338
+ });
1177
1339
 
1178
1340
  const clearOp = {
1179
1341
  bucketKey: { ...context, bucket },
@@ -1185,9 +1347,16 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1185
1347
  const persisted = await this.persistBucketData(bucket, [[clearOp]], context, prepared, {
1186
1348
  targetOp: maxTargetOp
1187
1349
  });
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);
1350
+ writes.insertOne(collection, persisted.documents[0]);
1351
+ this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, writes);
1352
+ this.recordObjectStorageReplacement(
1353
+ oldStorageBytes,
1354
+ persisted.documents,
1355
+ context.definitionId,
1356
+ writes,
1357
+ objectStorageUsage
1358
+ );
1359
+ await writes.execute();
1191
1360
 
1192
1361
  opCountDiff = -clearedOpCount + 1;
1193
1362
  before = inputStats;
@@ -1208,7 +1377,8 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1208
1377
  boundaryDocId: BucketDataKey,
1209
1378
  bucketContext: BucketDataContextV3,
1210
1379
  collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
1211
- context: { replicationStreamId: number; definitionId: string }
1380
+ context: { replicationStreamId: number; definitionId: string },
1381
+ objectStorageUsage: ObjectStorageUsage
1212
1382
  ): Promise<ClearCompactionResult> {
1213
1383
  const bucket = bucketContext.key.bucket;
1214
1384
  this.signal?.throwIfAborted();
@@ -1307,15 +1477,13 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1307
1477
  }
1308
1478
 
1309
1479
  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
- );
1480
+ const writes = this.db.createWriteBatch(session, { ordered: true });
1481
+ writes.deleteMany(collection, {
1482
+ _id: {
1483
+ $gte: bucketContext.minId,
1484
+ $lte: boundaryDocId
1485
+ }
1486
+ });
1319
1487
 
1320
1488
  const clearOp = {
1321
1489
  bucketKey: { ...context, bucket },
@@ -1333,9 +1501,16 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1333
1501
  const persisted = await this.persistBucketData(bucket, chunks, context, prepared, {
1334
1502
  targetOp: maxTargetOp ?? undefined
1335
1503
  });
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);
1504
+ writes.insertMany(collection, persisted.documents);
1505
+ this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, writes);
1506
+ this.recordObjectStorageReplacement(
1507
+ oldStorageBytes,
1508
+ persisted.documents,
1509
+ context.definitionId,
1510
+ writes,
1511
+ objectStorageUsage
1512
+ );
1513
+ await writes.execute();
1339
1514
 
1340
1515
  opCountDiff = -clearedOpCount + 1;
1341
1516
  before = inputStats;
@@ -1374,28 +1549,29 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1374
1549
  }
1375
1550
 
1376
1551
  /** Publish replacement uploads and retire superseded objects in the same transaction. */
1377
- private async finishObjectStorageReplacement(
1552
+ private finishObjectStorageReplacement(
1378
1553
  oldStoragePaths: Iterable<string>,
1379
1554
  newStoragePaths: Set<string>,
1380
1555
  uploads: PreparedObjectStorageUpload[],
1381
- session: mongo.ClientSession
1382
- ): Promise<void> {
1556
+ writes: MongoWriteBatch
1557
+ ): void {
1383
1558
  if (!this.storage.objectStorage) {
1384
1559
  return;
1385
1560
  }
1386
- await this.objectStorageLifecycle.publishUploads(uploads, session);
1387
- await this.objectStorageLifecycle.retire(
1561
+ this.objectStorageLifecycle.publishUploads(uploads, writes);
1562
+ this.objectStorageLifecycle.retire(
1388
1563
  Array.from(oldStoragePaths).filter((path) => !newStoragePaths.has(path)),
1389
- session
1564
+ writes
1390
1565
  );
1391
1566
  }
1392
1567
 
1393
- private async recordObjectStorageReplacement(
1568
+ private recordObjectStorageReplacement(
1394
1569
  oldBytes: bigint,
1395
1570
  newDocuments: Iterable<Pick<BucketDataDocumentV3, 'storage_ref'>>,
1396
1571
  definitionId: BucketDefinitionId,
1397
- session: mongo.ClientSession
1398
- ): Promise<void> {
1572
+ writes: MongoWriteBatch,
1573
+ objectStorageUsage: ObjectStorageUsage
1574
+ ): void {
1399
1575
  if (!this.storage.objectStorage) {
1400
1576
  return;
1401
1577
  }
@@ -1403,7 +1579,7 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1403
1579
  for (const document of newDocuments) {
1404
1580
  newBytes += ObjectStorageUsage.bytes(document);
1405
1581
  }
1406
- await this.objectStorageUsage.applyDelta(definitionId, newBytes - oldBytes, session);
1582
+ objectStorageUsage.applyDelta(definitionId, newBytes - oldBytes, writes);
1407
1583
  }
1408
1584
 
1409
1585
  private async persistBucketData(