@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,11 +1,12 @@
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';
6
7
  import { BucketDataContextV3 } from './BucketDataContextV3.js';
7
8
  import { DEFAULT_MAX_DOC_SIZE_BYTES } from './chunking.js';
8
- import { applyStatsReplacement, bucketStats, chooseCompactionKind, combineAdjacentStats, combineChunkStats, CompactionContext, CompactionKind, emptyBucketStats, firstUncompactedWrite, forcedCompactionKind, readCompactionBatch, statsForDocument, statsForDocuments, unclaimedSnapshotFilter } from './compact-utils.js';
9
+ import { applyStatsReplacement, bucketStats, chooseCompactionKind, chooseRequestedCompactionKind, combineAdjacentStats, combineChunkStats, CompactionContext, CompactionKind, emptyBucketStats, firstUncompactedWrite, readCompactionBatch, statsForDocument, statsForDocuments, unclaimedSnapshotFilter } from './compact-utils.js';
9
10
  import { DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS } from './compaction-constants.js';
10
11
  import { AVAILABLE_LEASE_EXPR, CompactionLease } from './CompactionLease.js';
11
12
  import { BucketDataObjectStorage, hydrateBucketDataDocuments } from './object-storage/BucketDataObjectStorage.js';
@@ -21,6 +22,7 @@ export class MongoCompactorV3 extends MongoCompactor {
21
22
  maxCompactFullIntervalMs;
22
23
  compactLeaseDurationMs;
23
24
  maxOpIdCap;
25
+ forceChunkCompaction;
24
26
  objectStorageUsage;
25
27
  constructor(bucketStorage, db, options) {
26
28
  super(bucketStorage, db, options);
@@ -29,6 +31,7 @@ export class MongoCompactorV3 extends MongoCompactor {
29
31
  this.maxCompactFullIntervalMs = options.maxCompactFullIntervalMs ?? DEFAULT_MAX_COMPACT_FULL_INTERVAL_MS;
30
32
  this.compactLeaseDurationMs = options.compactLeaseDurationMs ?? DEFAULT_COMPACT_LEASE_DURATION_MS;
31
33
  this.maxOpIdCap = options.maxOpId;
34
+ this.forceChunkCompaction = options.forceChunkCompaction ?? false;
32
35
  this.objectStorageUsage = new ObjectStorageUsage(this.db, this.group_id, createObjectStorageUsageWriterId());
33
36
  }
34
37
  async compact() {
@@ -55,7 +58,7 @@ export class MongoCompactorV3 extends MongoCompactor {
55
58
  // processes the work that existed when it started.
56
59
  await this.compactScheduledBuckets({
57
60
  dueAheadMs: DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS,
58
- forceKind: CompactionKind.Chunks
61
+ requestedKind: CompactionKind.Chunks
59
62
  });
60
63
  }
61
64
  else {
@@ -109,7 +112,8 @@ export class MongoCompactorV3 extends MongoCompactor {
109
112
  * Batching specifically help to cover cases of many buckets where no compaction is required:
110
113
  * Instead of sequentially claiming and then rescheduling a bucket, this handles it in bulk.
111
114
  *
112
- * Buckets that do need compaction are still claimed and processed sequentially.
115
+ * Chunk merges overlap a bounded number of buckets. Full compaction stays
116
+ * sequential because its working set includes operation deduplication state.
113
117
  *
114
118
  * Any concurrent workers may read the same batch. Rescheduling filters out buckets handled
115
119
  * by a concurrent worker or replication write, while buckets that do need compaction are
@@ -123,45 +127,55 @@ export class MongoCompactorV3 extends MongoCompactor {
123
127
  // Writers derive next_compact_check from MongoDB's $$NOW. Use the same
124
128
  // clock for the fixed job boundary so clock skew cannot exclude work at
125
129
  // the exact initial-replication interval.
126
- const [{ now: jobStartedAt }] = await this.db.db
127
- .aggregate([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
128
- .toArray();
130
+ const jobStartedAt = await this.readCompactionTime();
129
131
  const dueBefore = new Date(jobStartedAt.getTime() + (options.dueAheadMs ?? 0));
130
- const forceKind = options.forceKind;
132
+ const requestedKind = options.requestedKind;
131
133
  const rescheduleNotBefore = new Date(dueBefore.getTime() + 1);
134
+ // Keep accounting documents bounded by workers, not buckets or scan batches.
135
+ const workerUsage = Array.from({ length: this.storage.factory.chunkCompactionConcurrency }, () => new ObjectStorageUsage(this.db, this.group_id, createObjectStorageUsageWriterId()));
132
136
  while (true) {
133
137
  this.signal?.throwIfAborted();
134
138
  const states = await this.findScheduledBucketBatch(dueBefore);
135
139
  if (states.length == 0) {
136
140
  break;
137
141
  }
142
+ // Keep eligibility bounded by dueBefore, but classify with the current
143
+ // server time so buckets that age into full compaction can advance.
144
+ const batchStartedAt = await this.readCompactionTime();
138
145
  const scheduled = [];
139
146
  for (const state of states) {
140
147
  try {
141
148
  scheduled.push({
142
149
  state,
143
- decision: chooseCompactionKind(state, jobStartedAt, this),
144
- forcedKind: forcedCompactionKind(state, forceKind, this)
150
+ decision: chooseCompactionKind(state, batchStartedAt, this),
151
+ selectedKind: chooseRequestedCompactionKind(state, requestedKind, this)
145
152
  });
146
153
  }
147
154
  catch (error) {
148
155
  await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
149
156
  }
150
157
  }
151
- const noOpStates = scheduled.filter(({ state, decision, forcedKind }) => state.compact_lease == null && (forceKind == null ? decision.kind : forcedKind) == null);
158
+ const noOpStates = scheduled.filter(({ state, decision, selectedKind }) => state.compact_lease == null && (requestedKind == null ? decision.kind : selectedKind) == null);
152
159
  await this.rescheduleUnclaimedBuckets(noOpStates, rescheduleNotBefore);
153
- for (const { state, decision, forcedKind } of scheduled) {
154
- const kind = forceKind == null ? decision.kind : forcedKind;
160
+ const processBucket = async ({ state, decision, selectedKind }, objectStorageUsage, chunksOnly = false) => {
161
+ const kind = requestedKind == null ? decision.kind : selectedKind;
155
162
  if (state.compact_lease == null && kind == null) {
156
- continue;
163
+ return;
157
164
  }
158
165
  try {
159
166
  await using lease = await this.claimBucket({ _id: state._id, next_compact_check: { $lte: dueBefore } });
160
167
  if (lease == null) {
161
- continue;
168
+ return;
162
169
  }
163
170
  const claimedDecision = chooseCompactionKind(lease.state, lease.startedAt, this);
164
- const claimedKind = forceKind == null ? claimedDecision.kind : forcedCompactionKind(lease.state, forceKind, this);
171
+ const claimedKind = requestedKind == null
172
+ ? claimedDecision.kind
173
+ : chooseRequestedCompactionKind(lease.state, requestedKind, this);
174
+ if (chunksOnly && claimedKind === CompactionKind.Full) {
175
+ // The decision changed after scanning. Release the lease without
176
+ // rescheduling; the next batch will classify it with a fresh timestamp.
177
+ return;
178
+ }
165
179
  if (claimedKind == null) {
166
180
  await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
167
181
  }
@@ -171,7 +185,7 @@ export class MongoCompactorV3 extends MongoCompactor {
171
185
  await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
172
186
  }
173
187
  else {
174
- await this.compactClaimedBucket(lease, claimedKind, claimedDecision, rescheduleNotBefore);
188
+ await this.compactClaimedBucket(lease, claimedKind, claimedDecision, rescheduleNotBefore, objectStorageUsage);
175
189
  }
176
190
  }
177
191
  catch (error) {
@@ -182,6 +196,73 @@ export class MongoCompactorV3 extends MongoCompactor {
182
196
  }
183
197
  await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
184
198
  }
199
+ };
200
+ const chunkBuckets = scheduled.filter(({ decision, selectedKind }) => (requestedKind == null ? decision.kind : selectedKind) === CompactionKind.Chunks);
201
+ const sequentialBuckets = scheduled.filter(({ decision, selectedKind }) => (requestedKind == null ? decision.kind : selectedKind) !== CompactionKind.Chunks);
202
+ await this.runChunkCompactionWorkers(chunkBuckets, workerUsage, (entry, usage) => processBucket(entry, usage, true));
203
+ // Full compaction cannot overlap chunk workers from this job, and only
204
+ // one full bucket is processed at a time.
205
+ for (const entry of sequentialBuckets) {
206
+ await processBucket(entry, this.objectStorageUsage);
207
+ }
208
+ }
209
+ }
210
+ /** Use MongoDB's clock, matching scheduling and lease timestamps. */
211
+ async readCompactionTime() {
212
+ const [{ now }] = await this.db.db
213
+ .aggregate([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
214
+ .toArray();
215
+ return now;
216
+ }
217
+ /** Process one scheduled batch with a fixed pool of workers. */
218
+ async runChunkCompactionWorkers(buckets, workerUsage, processBucket) {
219
+ const signal = this.signal;
220
+ let nextBucket = 0;
221
+ let failed = false;
222
+ const runWorker = async (usage) => {
223
+ try {
224
+ while (!failed && nextBucket < buckets.length) {
225
+ // Taking an entry has no await, so each worker gets a different bucket.
226
+ // A worker takes another only after finishing its current bucket.
227
+ const bucket = buckets[nextBucket++];
228
+ // This pool bounds one job; the factory semaphore bounds all jobs together.
229
+ // Acquire before claiming the bucket lease, and hold until it is released.
230
+ const acquired = await acquireSemaphoreAbortable(this.storage.factory.chunkCompactionSlots, signal);
231
+ if (acquired === 'aborted') {
232
+ signal?.throwIfAborted();
233
+ return;
234
+ }
235
+ const [, releaseSlot] = acquired;
236
+ try {
237
+ // A sibling may have failed while this worker waited for a slot.
238
+ if (failed)
239
+ return;
240
+ signal?.throwIfAborted();
241
+ await processBucket(bucket, usage);
242
+ }
243
+ finally {
244
+ releaseSlot();
245
+ }
246
+ // Let replication and other event-loop work run between buckets.
247
+ await setImmediate();
248
+ }
249
+ }
250
+ catch (error) {
251
+ // Drain work already started, but do not let siblings start new buckets.
252
+ failed = true;
253
+ throw error;
254
+ }
255
+ };
256
+ // Concurrent transactions must not all increment the same usage document.
257
+ // Reuse one writer for each worker instead of creating one per bucket.
258
+ const workers = workerUsage.map(runWorker);
259
+ // Do not release the caller's replication lock or run cleanup while a
260
+ // sibling worker still owns a bucket lease or is finishing a replacement.
261
+ // Wait for every worker even on failure, then propagate the first error.
262
+ const results = await Promise.allSettled(workers);
263
+ for (const result of results) {
264
+ if (result.status === 'rejected') {
265
+ throw result.reason;
185
266
  }
186
267
  }
187
268
  }
@@ -238,10 +319,10 @@ export class MongoCompactorV3 extends MongoCompactor {
238
319
  async claimBucket(filter, sort) {
239
320
  return CompactionLease.claim(this.db.bucketState(this.group_id), filter, sort, this.compactLeaseDurationMs);
240
321
  }
241
- async compactClaimedBucket(lease, kind, decision, rescheduleNotBefore) {
322
+ async compactClaimedBucket(lease, kind, decision, rescheduleNotBefore, objectStorageUsage = this.objectStorageUsage) {
242
323
  const context = new CompactionContext(lease, kind, decision, rescheduleNotBefore, this.compactionTarget(lease.state));
243
324
  lease.startRenewal();
244
- await this.compactSingleBucket(context);
325
+ await this.compactSingleBucket(context, objectStorageUsage);
245
326
  }
246
327
  async rescheduleClaimedBucket(lease, decision, notBefore) {
247
328
  await lease.reschedule(this.rescheduleAtOrAfter(decision.nextCompactCheck, notBefore));
@@ -271,11 +352,11 @@ export class MongoCompactorV3 extends MongoCompactor {
271
352
  }
272
353
  return new ObjectStorageLifecycle(this.db, this.group_id, this.storage.objectStorage);
273
354
  }
274
- async compactSingleBucket(context) {
355
+ async compactSingleBucket(context, objectStorageUsage) {
275
356
  if (context.kind == CompactionKind.Chunks) {
276
- return this.compactSingleBucketChunks(context);
357
+ return this.compactSingleBucketChunks(context, objectStorageUsage);
277
358
  }
278
- return this.compactSingleBucketFully(context);
359
+ return this.compactSingleBucketFully(context, objectStorageUsage);
279
360
  }
280
361
  /**
281
362
  * Merge adjacent bucket-data chunks without inspecting their operations
@@ -283,7 +364,7 @@ export class MongoCompactorV3 extends MongoCompactor {
283
364
  * update the persisted checksum state and to decide whether a group can fit
284
365
  * in one chunk.
285
366
  */
286
- async compactSingleBucketChunks(context) {
367
+ async compactSingleBucketChunks(context, objectStorageUsage) {
287
368
  const bucket = context.state._id.b;
288
369
  const resolvedDefinitionId = context.state._id.d;
289
370
  const bucketContext = new BucketDataContextV3(this.db, {
@@ -362,7 +443,7 @@ export class MongoCompactorV3 extends MongoCompactor {
362
443
  }
363
444
  const nextSize = pendingSize + doc.size;
364
445
  if (pendingChunks.length > 0 && nextSize > DEFAULT_MAX_DOC_SIZE_BYTES) {
365
- const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext);
446
+ const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext, objectStorageUsage);
366
447
  compactedTail = combineAdjacentStats(compactedTail, groupStats);
367
448
  pendingChunks = [];
368
449
  pendingSize = 0;
@@ -376,7 +457,7 @@ export class MongoCompactorV3 extends MongoCompactor {
376
457
  }
377
458
  }
378
459
  if (pendingChunks.length > 0) {
379
- const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext);
460
+ const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext, objectStorageUsage);
380
461
  compactedTail = combineAdjacentStats(compactedTail, groupStats);
381
462
  }
382
463
  if (compactedOpId == null) {
@@ -397,33 +478,44 @@ export class MongoCompactorV3 extends MongoCompactor {
397
478
  this.compactedBucketCount++;
398
479
  this.logger.info(`Compacted bucket chunks ${bucket}: ${result.bucketStats.count} ops, ${result.bucketStats.chunks} chunks, ${formatBytes(result.bucketStats.bytes)}`);
399
480
  }
400
- async flushChunkMerge(bucket, inputs, collection, context, bucketContext) {
481
+ async flushChunkMerge(bucket, inputs, collection, context, bucketContext, objectStorageUsage) {
401
482
  if (inputs.length == 1) {
402
483
  return statsForDocument(inputs[0]);
403
484
  }
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());
485
+ try {
486
+ this.signal?.throwIfAborted();
487
+ // The metadata scan deliberately excluded ops. Read inline payloads only
488
+ // for this merge group; object-storage payloads are fetched below using
489
+ // the same rule.
490
+ const inlineInputs = inputs.filter((input) => input.storage_ref == null);
491
+ if (inlineInputs.length > 0) {
492
+ const inlineDocuments = await collection
493
+ .find({ _id: { $in: inlineInputs.map((input) => input._id) } }, { projection: { _id: 1, ops: 1 } })
494
+ .toArray();
495
+ const opsById = new Map(inlineDocuments.map((document) => [document._id.o.toString(), document.ops]));
496
+ for (const input of inlineInputs) {
497
+ input.ops = opsById.get(input._id.o.toString());
498
+ }
415
499
  }
500
+ await hydrateBucketDataDocuments(inputs, this.storage.objectStorage, { signal: this.signal });
501
+ const operations = inputs.flatMap((input) => Array.from(loadBucketDataDocument(context, input)));
502
+ const targetOp = inputs.reduce((maxTarget, input) => maxOpId(maxTarget, input.target_op), null);
503
+ const result = await this.flushCompactionGroup(bucket, {
504
+ inputs,
505
+ ops: operations,
506
+ changed: true,
507
+ targetOp
508
+ }, bucketContext, context, objectStorageUsage);
509
+ return result.stats;
510
+ }
511
+ finally {
512
+ // The scan batch also references these documents. Do not retain hydrated
513
+ // operations after finishing this merge group.
514
+ for (const input of inputs) {
515
+ delete input.ops;
516
+ }
517
+ await setImmediate();
416
518
  }
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
519
  }
428
520
  async finalizeCompactedBucket({ context, compactedOpId, compactionResult, puts }) {
429
521
  await context.lease.throwIfLost();
@@ -526,7 +618,7 @@ export class MongoCompactorV3 extends MongoCompactor {
526
618
  : addChecksums(0, Number(stats?.checksum ?? 0))
527
619
  };
528
620
  }
529
- async compactSingleBucketFully(context) {
621
+ async compactSingleBucketFully(context, objectStorageUsage) {
530
622
  const bucket = context.state._id.b;
531
623
  const resolvedDefinitionId = context.state._id.d;
532
624
  const bucketContext = new BucketDataContextV3(this.db, {
@@ -673,7 +765,7 @@ export class MongoCompactorV3 extends MongoCompactor {
673
765
  }
674
766
  else {
675
767
  const flushedGroup = pendingGroup;
676
- const result = await this.flushCompactionGroup(bucket, flushedGroup, bucketContext, dataContext);
768
+ const result = await this.flushCompactionGroup(bucket, flushedGroup, bucketContext, dataContext, objectStorageUsage);
677
769
  compactedStats = combineAdjacentStats(compactedStats, result.stats);
678
770
  if (lastNotPut != null &&
679
771
  flushedGroup.ops[0].o <= lastNotPut &&
@@ -692,7 +784,7 @@ export class MongoCompactorV3 extends MongoCompactor {
692
784
  this.logger.info(`Compacted batch of ${batchDocs.length} documents for bucket ${bucket}`);
693
785
  }
694
786
  if (pendingGroup != null) {
695
- const result = await this.flushCompactionGroup(bucket, pendingGroup, bucketContext, dataContext);
787
+ const result = await this.flushCompactionGroup(bucket, pendingGroup, bucketContext, dataContext, objectStorageUsage);
696
788
  compactedStats = combineAdjacentStats(compactedStats, result.stats);
697
789
  if (lastNotPut != null &&
698
790
  pendingGroup.ops[0].o <= lastNotPut &&
@@ -709,7 +801,7 @@ export class MongoCompactorV3 extends MongoCompactor {
709
801
  if (clearBoundary == null || clearBoundary.opId != lastNotPut) {
710
802
  throw new ReplicationAssertionError(`Missing CLEAR boundary document for bucket ${bucket}`);
711
803
  }
712
- const clearResult = await this.clearBucketLeading(lastNotPut, clearBoundary.documentId, bucketContext, collection, dataContext);
804
+ const clearResult = await this.clearBucketLeading(lastNotPut, clearBoundary.documentId, bucketContext, collection, dataContext, objectStorageUsage);
713
805
  totalOpCount += clearResult.opCountDiff;
714
806
  compactedStats = applyStatsReplacement(compactedStats, clearResult.before, clearResult.after);
715
807
  }
@@ -731,7 +823,7 @@ export class MongoCompactorV3 extends MongoCompactor {
731
823
  * If verification or the transaction fails, the prepared markers retain enough
732
824
  * information for the uploaded objects to be cleaned up later.
733
825
  */
734
- async flushCompactionGroup(bucket, group, bucketContext, context) {
826
+ async flushCompactionGroup(bucket, group, bucketContext, context, objectStorageUsage) {
735
827
  if (group.inputs.length == 1 && !group.changed) {
736
828
  return {
737
829
  documentId: group.inputs[0]._id,
@@ -768,10 +860,13 @@ export class MongoCompactorV3 extends MongoCompactor {
768
860
  verification.opCountSum !== expectedOpCount) {
769
861
  throw new ConcurrentCompactionError(`Inputs changed while compacting bucket ${bucket}; restarting from the latest bucket state`);
770
862
  }
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);
863
+ // Replacement documents can reuse deleted IDs, so retain delete-before-insert ordering.
864
+ const writes = this.db.createWriteBatch(session, { ordered: true });
865
+ writes.deleteMany(bucketContext.collection, { _id: { $in: idsToDelete } });
866
+ writes.insertMany(bucketContext.collection, documents);
867
+ this.finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, writes);
868
+ this.recordObjectStorageReplacement(oldStorageBytes, documents, context.definitionId, writes, objectStorageUsage);
869
+ await writes.execute();
775
870
  }, {
776
871
  writeConcern: { w: 'majority' },
777
872
  readConcern: { level: 'snapshot' }
@@ -794,7 +889,7 @@ export class MongoCompactorV3 extends MongoCompactor {
794
889
  * Returns the op count and stored-stat changes after replacing cleared ops
795
890
  * with CLEAR ops.
796
891
  */
797
- async clearBucketLeading(lastNotPut, boundaryDocId, bucketContext, collection, context) {
892
+ async clearBucketLeading(lastNotPut, boundaryDocId, bucketContext, collection, context, objectStorageUsage) {
798
893
  let opCountDiff = 0;
799
894
  let before = emptyBucketStats();
800
895
  let after = emptyBucketStats();
@@ -804,7 +899,7 @@ export class MongoCompactorV3 extends MongoCompactor {
804
899
  // First step is to clear full chunks that contain only CLEAR/MOVE/REMOVE operations.
805
900
  // There can be many of them, so we do one batch at a time.
806
901
  while (!done) {
807
- const batch = await this.clearLeadingFullDocuments(session, lastNotPut, boundaryDocId, bucketContext, collection, context);
902
+ const batch = await this.clearLeadingFullDocuments(session, lastNotPut, boundaryDocId, bucketContext, collection, context, objectStorageUsage);
808
903
  done = batch.done;
809
904
  opCountDiff += batch.opCountDiff;
810
905
  before = combineAdjacentStats(before, batch.before);
@@ -812,7 +907,7 @@ export class MongoCompactorV3 extends MongoCompactor {
812
907
  }
813
908
  // The final step is to process the "boundary" document: It may contain some CLEAR/MOVE/REMOVE operations,
814
909
  // 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);
910
+ const boundaryResult = await this.clearBoundaryDocument(session, lastNotPut, boundaryDocId, bucketContext, collection, context, objectStorageUsage);
816
911
  opCountDiff += boundaryResult.opCountDiff;
817
912
  before = combineAdjacentStats(before, boundaryResult.before);
818
913
  after = combineAdjacentStats(after, boundaryResult.after);
@@ -822,7 +917,7 @@ export class MongoCompactorV3 extends MongoCompactor {
822
917
  }
823
918
  return { opCountDiff, before, after };
824
919
  }
825
- async clearLeadingFullDocuments(session, lastNotPut, boundaryDocId, bucketContext, collection, context) {
920
+ async clearLeadingFullDocuments(session, lastNotPut, boundaryDocId, bucketContext, collection, context, objectStorageUsage) {
826
921
  const bucket = bucketContext.key.bucket;
827
922
  this.signal?.throwIfAborted();
828
923
  let prepared;
@@ -902,12 +997,13 @@ export class MongoCompactorV3 extends MongoCompactor {
902
997
  }
903
998
  prepared ??= await this.prepareCompactionUploads(bucket, context, [lastNotPut]);
904
999
  this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastDocId?.o}`);
905
- await collection.deleteMany({
1000
+ const writes = this.db.createWriteBatch(session, { ordered: true });
1001
+ writes.deleteMany(collection, {
906
1002
  _id: {
907
1003
  $gte: bucketContext.minId,
908
1004
  $lte: lastDocId
909
1005
  }
910
- }, { session });
1006
+ });
911
1007
  const clearOp = {
912
1008
  bucketKey: { ...context, bucket },
913
1009
  o: lastDocId.o,
@@ -918,9 +1014,10 @@ export class MongoCompactorV3 extends MongoCompactor {
918
1014
  const persisted = await this.persistBucketData(bucket, [[clearOp]], context, prepared, {
919
1015
  targetOp: maxTargetOp
920
1016
  });
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);
1017
+ writes.insertOne(collection, persisted.documents[0]);
1018
+ this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, writes);
1019
+ this.recordObjectStorageReplacement(oldStorageBytes, persisted.documents, context.definitionId, writes, objectStorageUsage);
1020
+ await writes.execute();
924
1021
  opCountDiff = -clearedOpCount + 1;
925
1022
  before = inputStats;
926
1023
  after = statsForDocuments(persisted.documents);
@@ -930,7 +1027,7 @@ export class MongoCompactorV3 extends MongoCompactor {
930
1027
  });
931
1028
  return { done, opCountDiff, before, after };
932
1029
  }
933
- async clearBoundaryDocument(session, lastNotPut, boundaryDocId, bucketContext, collection, context) {
1030
+ async clearBoundaryDocument(session, lastNotPut, boundaryDocId, bucketContext, collection, context, objectStorageUsage) {
934
1031
  const bucket = bucketContext.key.bucket;
935
1032
  this.signal?.throwIfAborted();
936
1033
  const prepared = await this.prepareCompactionUploads(bucket, context, [lastNotPut, boundaryDocId.o]);
@@ -1012,12 +1109,13 @@ export class MongoCompactorV3 extends MongoCompactor {
1012
1109
  throw new Error(`CLEAR boundary document not found for bucket ${bucket}`);
1013
1110
  }
1014
1111
  this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastNotPut}`);
1015
- await collection.deleteMany({
1112
+ const writes = this.db.createWriteBatch(session, { ordered: true });
1113
+ writes.deleteMany(collection, {
1016
1114
  _id: {
1017
1115
  $gte: bucketContext.minId,
1018
1116
  $lte: boundaryDocId
1019
1117
  }
1020
- }, { session });
1118
+ });
1021
1119
  const clearOp = {
1022
1120
  bucketKey: { ...context, bucket },
1023
1121
  o: lastNotPut,
@@ -1034,9 +1132,10 @@ export class MongoCompactorV3 extends MongoCompactor {
1034
1132
  const persisted = await this.persistBucketData(bucket, chunks, context, prepared, {
1035
1133
  targetOp: maxTargetOp ?? undefined
1036
1134
  });
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);
1135
+ writes.insertMany(collection, persisted.documents);
1136
+ this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, writes);
1137
+ this.recordObjectStorageReplacement(oldStorageBytes, persisted.documents, context.definitionId, writes, objectStorageUsage);
1138
+ await writes.execute();
1040
1139
  opCountDiff = -clearedOpCount + 1;
1041
1140
  before = inputStats;
1042
1141
  after = statsForDocuments(persisted.documents);
@@ -1064,14 +1163,14 @@ export class MongoCompactorV3 extends MongoCompactor {
1064
1163
  return lifecycle.prepareUploads(paths);
1065
1164
  }
1066
1165
  /** Publish replacement uploads and retire superseded objects in the same transaction. */
1067
- async finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, session) {
1166
+ finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, writes) {
1068
1167
  if (!this.storage.objectStorage) {
1069
1168
  return;
1070
1169
  }
1071
- await this.objectStorageLifecycle.publishUploads(uploads, session);
1072
- await this.objectStorageLifecycle.retire(Array.from(oldStoragePaths).filter((path) => !newStoragePaths.has(path)), session);
1170
+ this.objectStorageLifecycle.publishUploads(uploads, writes);
1171
+ this.objectStorageLifecycle.retire(Array.from(oldStoragePaths).filter((path) => !newStoragePaths.has(path)), writes);
1073
1172
  }
1074
- async recordObjectStorageReplacement(oldBytes, newDocuments, definitionId, session) {
1173
+ recordObjectStorageReplacement(oldBytes, newDocuments, definitionId, writes, objectStorageUsage) {
1075
1174
  if (!this.storage.objectStorage) {
1076
1175
  return;
1077
1176
  }
@@ -1079,7 +1178,7 @@ export class MongoCompactorV3 extends MongoCompactor {
1079
1178
  for (const document of newDocuments) {
1080
1179
  newBytes += ObjectStorageUsage.bytes(document);
1081
1180
  }
1082
- await this.objectStorageUsage.applyDelta(definitionId, newBytes - oldBytes, session);
1181
+ objectStorageUsage.applyDelta(definitionId, newBytes - oldBytes, writes);
1083
1182
  }
1084
1183
  async persistBucketData(bucket, chunks, context, preparedUploads, options) {
1085
1184
  const serializedChunks = chunks.map((chunk) => serializeBucketData(bucket, chunk, options));