@powersync/service-module-mongodb-storage 0.20.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.
- package/CHANGELOG.md +62 -0
- package/dist/storage/MongoBucketStorage.d.ts +36 -3
- package/dist/storage/MongoBucketStorage.js +372 -207
- package/dist/storage/MongoBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoBucketBatch.d.ts +17 -5
- package/dist/storage/implementation/MongoBucketBatch.js +44 -17
- package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
- package/dist/storage/implementation/MongoCompactor.d.ts +19 -79
- package/dist/storage/implementation/MongoCompactor.js +27 -238
- package/dist/storage/implementation/MongoCompactor.js.map +1 -1
- package/dist/storage/implementation/MongoParameterCompactor.d.ts +83 -10
- package/dist/storage/implementation/MongoParameterCompactor.js +309 -96
- package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
- package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +9 -1
- package/dist/storage/implementation/MongoParsedSyncConfigSet.js +29 -0
- package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
- package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +4 -2
- package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -1
- package/dist/storage/implementation/MongoStorageProvider.js +7 -3
- package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
- package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +27 -11
- package/dist/storage/implementation/MongoSyncBucketStorage.js +78 -31
- package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoSyncRulesLock.js +21 -9
- package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
- package/dist/storage/implementation/MongoWriteBatch.d.ts +49 -0
- package/dist/storage/implementation/MongoWriteBatch.js +149 -0
- package/dist/storage/implementation/MongoWriteBatch.js.map +1 -0
- package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +9 -7
- package/dist/storage/implementation/MongoWriteCheckpointAPI.js +9 -30
- package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
- package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +11 -5
- package/dist/storage/implementation/SyncRuleStateUpdate.js +14 -6
- package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -1
- package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +22 -2
- package/dist/storage/implementation/common/PersistedBatch.d.ts +32 -4
- package/dist/storage/implementation/common/PersistedBatch.js +49 -6
- package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
- package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.d.ts +3 -0
- package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +3 -0
- package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
- package/dist/storage/implementation/db.d.ts +9 -1
- package/dist/storage/implementation/db.js +27 -1
- package/dist/storage/implementation/db.js.map +1 -1
- package/dist/storage/implementation/models.d.ts +31 -0
- package/dist/storage/implementation/models.js.map +1 -1
- package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +2 -0
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js +33 -4
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +38 -6
- package/dist/storage/implementation/v1/MongoCompactorV1.js +247 -20
- package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
- package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
- package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +18 -11
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +65 -16
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
- package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +7 -5
- package/dist/storage/implementation/v1/PersistedBatchV1.js +22 -28
- package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
- package/dist/storage/implementation/v1/models.js +1 -0
- package/dist/storage/implementation/v1/models.js.map +1 -1
- package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
- package/dist/storage/implementation/v3/CompactionLease.js +131 -0
- package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
- package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +10 -2
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js +100 -26
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +82 -25
- package/dist/storage/implementation/v3/MongoCompactorV3.js +698 -159
- package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +18 -0
- package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +23 -0
- package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -0
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +2 -15
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +60 -92
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +19 -9
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +91 -31
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
- package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
- package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
- package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +14 -8
- package/dist/storage/implementation/v3/PersistedBatchV3.js +76 -42
- package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +12 -2
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +42 -3
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
- package/dist/storage/implementation/v3/bucket-format.js +2 -0
- package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
- package/dist/storage/implementation/v3/compact-utils.d.ts +107 -0
- package/dist/storage/implementation/v3/compact-utils.js +216 -0
- package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
- package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
- package/dist/storage/implementation/v3/compaction-constants.js +6 -0
- package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
- package/dist/storage/implementation/v3/models.d.ts +84 -4
- package/dist/storage/implementation/v3/models.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +7 -8
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +16 -5
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +14 -11
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +6 -8
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +17 -11
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +38 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +164 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +37 -12
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +197 -25
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
- package/dist/storage/implementation/v3/source-table-utils.d.ts +6 -6
- package/dist/storage/implementation/v3/source-table-utils.js +30 -37
- package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
- package/dist/storage/storage-index.d.ts +2 -1
- package/dist/storage/storage-index.js +1 -0
- package/dist/storage/storage-index.js.map +1 -1
- package/dist/types/types.d.ts +28 -1
- package/dist/types/types.js +28 -7
- package/dist/types/types.js.map +1 -1
- package/dist/utils/test-utils.js +1 -0
- package/dist/utils/test-utils.js.map +1 -1
- package/dist/utils/util.js +3 -1
- package/dist/utils/util.js.map +1 -1
- package/package.json +10 -9
- package/src/storage/MongoBucketStorage.ts +507 -269
- package/src/storage/implementation/MongoBucketBatch.ts +67 -24
- package/src/storage/implementation/MongoCompactor.ts +35 -346
- package/src/storage/implementation/MongoParameterCompactor.ts +388 -102
- package/src/storage/implementation/MongoParsedSyncConfigSet.ts +34 -0
- package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +4 -2
- package/src/storage/implementation/MongoStorageProvider.ts +15 -3
- package/src/storage/implementation/MongoSyncBucketStorage.ts +114 -40
- package/src/storage/implementation/MongoSyncRulesLock.ts +22 -9
- package/src/storage/implementation/MongoWriteBatch.ts +172 -0
- package/src/storage/implementation/MongoWriteCheckpointAPI.ts +16 -46
- package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
- package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +24 -2
- package/src/storage/implementation/common/PersistedBatch.ts +66 -10
- package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +4 -0
- package/src/storage/implementation/db.ts +35 -2
- package/src/storage/implementation/models.ts +34 -0
- package/src/storage/implementation/v1/MongoBucketBatchV1.ts +46 -6
- package/src/storage/implementation/v1/MongoCompactorV1.ts +327 -40
- package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
- package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +87 -27
- package/src/storage/implementation/v1/PersistedBatchV1.ts +25 -30
- package/src/storage/implementation/v1/models.ts +1 -0
- package/src/storage/implementation/v3/CompactionLease.ts +152 -0
- package/src/storage/implementation/v3/MongoBucketBatchV3.ts +138 -34
- package/src/storage/implementation/v3/MongoCompactorV3.ts +958 -225
- package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +34 -0
- package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +100 -109
- package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +111 -41
- package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
- package/src/storage/implementation/v3/PersistedBatchV3.ts +93 -48
- package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +55 -4
- package/src/storage/implementation/v3/bucket-format.ts +2 -0
- package/src/storage/implementation/v3/compact-utils.ts +320 -0
- package/src/storage/implementation/v3/compaction-constants.ts +5 -0
- package/src/storage/implementation/v3/models.ts +91 -3
- package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +24 -9
- package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +20 -5
- package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +26 -14
- package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +229 -0
- package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +279 -29
- package/src/storage/implementation/v3/source-table-utils.ts +39 -41
- package/src/storage/storage-index.ts +2 -0
- package/src/types/types.ts +42 -7
- package/src/utils/test-utils.ts +1 -0
- package/src/utils/util.ts +3 -1
- package/test/src/__snapshots__/storage.test.ts.snap +25 -0
- package/test/src/__snapshots__/storage_sync.test.ts.snap +605 -16
- package/test/src/chunk_compaction_config.test.ts +38 -0
- package/test/src/cleanup-stopped-sync-configs.test.ts +40 -13
- package/test/src/compact-utils.test.ts +226 -0
- package/test/src/helpers/MemoryObjectStorage.ts +14 -6
- package/test/src/object_storage_usage.test.ts +267 -0
- package/test/src/parameter_compacting_v1.test.ts +120 -0
- package/test/src/parameter_compacting_v3.test.ts +192 -0
- package/test/src/parameter_compaction_fence.test.ts +276 -0
- package/test/src/storage.test.ts +273 -22
- package/test/src/storage_compacting.test.ts +968 -246
- package/test/src/storage_s3_checksums.test.ts +3 -4
- package/test/src/storage_s3_compaction_lifecycle.test.ts +138 -12
- package/test/src/storage_s3_reading.test.ts +290 -2
- package/test/src/storage_s3_writing.test.ts +1 -0
- package/test/src/storage_sync.test.ts +336 -41
- package/test/src/util.ts +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,169 +1,645 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
|
|
2
|
+
import { acquireSemaphoreAbortable, addChecksums, formatBytes, utils } from '@powersync/service-core';
|
|
3
|
+
import { setImmediate } from 'node:timers/promises';
|
|
4
4
|
import { ConcurrentCompactionError, MongoCompactor } from '../MongoCompactor.js';
|
|
5
5
|
import { cacheKey } from '../OperationBatch.js';
|
|
6
6
|
import { loadBucketDataDocument, maxOpId, serializeBucketData } from './bucket-format.js';
|
|
7
7
|
import { BucketDataContextV3 } from './BucketDataContextV3.js';
|
|
8
8
|
import { DEFAULT_MAX_DOC_SIZE_BYTES } from './chunking.js';
|
|
9
|
+
import { applyStatsReplacement, bucketStats, chooseCompactionKind, combineAdjacentStats, combineChunkStats, CompactionContext, CompactionKind, emptyBucketStats, firstUncompactedWrite, forcedCompactionKind, readCompactionBatch, statsForDocument, statsForDocuments, unclaimedSnapshotFilter } from './compact-utils.js';
|
|
10
|
+
import { DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS } from './compaction-constants.js';
|
|
11
|
+
import { AVAILABLE_LEASE_EXPR, CompactionLease } from './CompactionLease.js';
|
|
9
12
|
import { BucketDataObjectStorage, hydrateBucketDataDocuments } from './object-storage/BucketDataObjectStorage.js';
|
|
10
13
|
import { ObjectStorageLifecycle } from './object-storage/ObjectStorageLifecycle.js';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* eligible for the next query. The first document is always accepted to ensure
|
|
17
|
-
* progress when a single document exceeds the configured byte limit.
|
|
18
|
-
*
|
|
19
|
-
* `hasMore` is conservative when the document limit is reached. An extra empty
|
|
20
|
-
* query is preferable to exhausting the cursor just to determine whether the
|
|
21
|
-
* limited MongoDB query contained another document.
|
|
22
|
-
*/
|
|
23
|
-
async function readCompactionBatch(cursor, options) {
|
|
24
|
-
const documents = [];
|
|
25
|
-
let cumulativeBytes = 0;
|
|
26
|
-
try {
|
|
27
|
-
for await (const document of cursor) {
|
|
28
|
-
if (documents.length > 0 && cumulativeBytes + document.size > options.byteLimit) {
|
|
29
|
-
return { documents, hasMore: true };
|
|
30
|
-
}
|
|
31
|
-
documents.push(document);
|
|
32
|
-
cumulativeBytes += document.size;
|
|
33
|
-
if (documents.length >= options.documentLimit) {
|
|
34
|
-
return { documents, hasMore: true };
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return { documents, hasMore: false };
|
|
38
|
-
}
|
|
39
|
-
finally {
|
|
40
|
-
await cursor.close();
|
|
41
|
-
}
|
|
42
|
-
}
|
|
14
|
+
import { createObjectStorageUsageWriterId, ObjectStorageUsage } from './object-storage/ObjectStorageUsage.js';
|
|
15
|
+
const DEFAULT_MIN_COMPACT_FULL_INTERVAL_MS = 2 * 60 * 60 * 1000;
|
|
16
|
+
const DEFAULT_MAX_COMPACT_FULL_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
17
|
+
const DEFAULT_COMPACT_LEASE_DURATION_MS = 10 * 60 * 1000;
|
|
18
|
+
const SCHEDULED_COMPACTION_BATCH_SIZE = 100;
|
|
43
19
|
export class MongoCompactorV3 extends MongoCompactor {
|
|
20
|
+
minCompactChunkIntervalMs;
|
|
21
|
+
minCompactFullIntervalMs;
|
|
22
|
+
maxCompactFullIntervalMs;
|
|
23
|
+
compactLeaseDurationMs;
|
|
24
|
+
maxOpIdCap;
|
|
25
|
+
objectStorageUsage;
|
|
26
|
+
constructor(bucketStorage, db, options) {
|
|
27
|
+
super(bucketStorage, db, options);
|
|
28
|
+
this.minCompactChunkIntervalMs = options.minCompactChunkIntervalMs ?? DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS;
|
|
29
|
+
this.minCompactFullIntervalMs = options.minCompactFullIntervalMs ?? DEFAULT_MIN_COMPACT_FULL_INTERVAL_MS;
|
|
30
|
+
this.maxCompactFullIntervalMs = options.maxCompactFullIntervalMs ?? DEFAULT_MAX_COMPACT_FULL_INTERVAL_MS;
|
|
31
|
+
this.compactLeaseDurationMs = options.compactLeaseDurationMs ?? DEFAULT_COMPACT_LEASE_DURATION_MS;
|
|
32
|
+
this.maxOpIdCap = options.maxOpId;
|
|
33
|
+
this.objectStorageUsage = new ObjectStorageUsage(this.db, this.group_id, createObjectStorageUsageWriterId());
|
|
34
|
+
}
|
|
44
35
|
async compact() {
|
|
45
36
|
if (this.storage.objectStorage) {
|
|
46
37
|
// Clean these before compacting - should be quick in most cases.
|
|
47
38
|
try {
|
|
48
|
-
await this.objectStorageLifecycle.cleanup(this.logger);
|
|
39
|
+
await this.objectStorageLifecycle.cleanup(this.logger, { signal: this.signal });
|
|
49
40
|
}
|
|
50
41
|
catch (e) {
|
|
42
|
+
if (this.signal?.aborted) {
|
|
43
|
+
throw e;
|
|
44
|
+
}
|
|
51
45
|
// In this case, still continue normal compact process
|
|
52
46
|
this.logger.error(`Failed to clean up object storage deletion markers before compaction`, e);
|
|
53
47
|
}
|
|
54
48
|
}
|
|
55
|
-
await
|
|
49
|
+
await this.deleteOldCheckpointRequests();
|
|
50
|
+
if (this.buckets != null) {
|
|
51
|
+
await this.compactExplicitBuckets(this.buckets);
|
|
52
|
+
}
|
|
53
|
+
else if (this.compactChunksOnly) {
|
|
54
|
+
// Writers defer their first chunk-compaction check by this fixed default.
|
|
55
|
+
// Include that interval so this synchronous initial-replication pass
|
|
56
|
+
// processes the work that existed when it started.
|
|
57
|
+
await this.compactScheduledBuckets({
|
|
58
|
+
dueAheadMs: DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS,
|
|
59
|
+
forceKind: CompactionKind.Chunks
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
await this.compactScheduledBuckets();
|
|
64
|
+
}
|
|
56
65
|
if (this.storage.objectStorage) {
|
|
57
66
|
// Cleanup for any produced during compacting.
|
|
58
67
|
// Note that markers only expire after a delay, so this may skip many produced during this compact
|
|
59
68
|
// run. However, during long compact runs, this may also have many ones it can clean up.
|
|
60
|
-
await this.objectStorageLifecycle.cleanup(this.logger);
|
|
69
|
+
await this.objectStorageLifecycle.cleanup(this.logger, { signal: this.signal });
|
|
70
|
+
await this.objectStorageUsage.foldStaleWriterDeltas();
|
|
61
71
|
}
|
|
72
|
+
return this.compactedBucketCount;
|
|
62
73
|
}
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
74
|
+
async deleteOldCustomCheckpointRequests() {
|
|
75
|
+
if (this.deleteCheckpointRequestsBefore == null) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const collection of await this.db.listCustomCheckpointRequestCollections(this.group_id)) {
|
|
79
|
+
this.signal?.throwIfAborted();
|
|
80
|
+
await collection.deleteMany({
|
|
81
|
+
checkpoint_requested_at: { $exists: true, $lt: this.deleteCheckpointRequestsBefore }
|
|
82
|
+
});
|
|
66
83
|
}
|
|
67
|
-
return new ObjectStorageLifecycle(this.db, this.group_id, this.storage.objectStorage);
|
|
68
84
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
85
|
+
/** An explicit compact request always runs a full compact for its buckets. */
|
|
86
|
+
async compactExplicitBuckets(buckets) {
|
|
87
|
+
for (const bucket of buckets) {
|
|
88
|
+
// This is not a super efficient query, but this is not a common use case.
|
|
89
|
+
// May be optimized later.
|
|
90
|
+
const states = await this.db
|
|
91
|
+
.bucketState(this.group_id)
|
|
92
|
+
.find({ '_id.b': bucket }, { projection: { _id: 1 } })
|
|
93
|
+
.toArray();
|
|
94
|
+
for (const state of states) {
|
|
95
|
+
await using lease = await this.claimBucket({ _id: state._id });
|
|
96
|
+
if (lease == null || lease.state.first_uncompacted_write == null) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (this.isCompactionTargetCovered(lease.state, CompactionKind.Full)) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const decision = chooseCompactionKind(lease.state, lease.startedAt, this);
|
|
103
|
+
await this.compactClaimedBucket(lease, CompactionKind.Full, decision);
|
|
104
|
+
}
|
|
72
105
|
}
|
|
73
|
-
const collection = this.db.bucketState(this.group_id);
|
|
74
|
-
yield* this.dirtyBucketBatchesForCollection(collection, { d: new mongo.MinKey(), b: new mongo.MinKey() }, { d: new mongo.MaxKey(), b: new mongo.MaxKey() }, options, (bucketState) => bucketState._id.d);
|
|
75
106
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Process scheduled work in bounded batches.
|
|
109
|
+
*
|
|
110
|
+
* Batching specifically help to cover cases of many buckets where no compaction is required:
|
|
111
|
+
* Instead of sequentially claiming and then rescheduling a bucket, this handles it in bulk.
|
|
112
|
+
*
|
|
113
|
+
* Chunk merges overlap a bounded number of buckets. Full compaction stays
|
|
114
|
+
* sequential because its working set includes operation deduplication state.
|
|
115
|
+
*
|
|
116
|
+
* Any concurrent workers may read the same batch. Rescheduling filters out buckets handled
|
|
117
|
+
* by a concurrent worker or replication write, while buckets that do need compaction are
|
|
118
|
+
* filtered out when claiming a compaction lease.
|
|
119
|
+
*
|
|
120
|
+
* We filter scheduled jobs by the job start date, so that the same bucket is not compacted
|
|
121
|
+
* multiple times in one run. Reschedules fall beyond the fixed boundary. For the run after
|
|
122
|
+
* initial replication, dueAheadMs extends that boundary to include the first deferred interval.
|
|
123
|
+
*/
|
|
124
|
+
async compactScheduledBuckets(options = {}) {
|
|
125
|
+
// Writers derive next_compact_check from MongoDB's $$NOW. Use the same
|
|
126
|
+
// clock for the fixed job boundary so clock skew cannot exclude work at
|
|
127
|
+
// the exact initial-replication interval.
|
|
128
|
+
const jobStartedAt = await this.readCompactionTime();
|
|
129
|
+
const dueBefore = new Date(jobStartedAt.getTime() + (options.dueAheadMs ?? 0));
|
|
130
|
+
const forceKind = options.forceKind;
|
|
131
|
+
const rescheduleNotBefore = new Date(dueBefore.getTime() + 1);
|
|
132
|
+
// Keep accounting documents bounded by workers, not buckets or scan batches.
|
|
133
|
+
const workerUsage = Array.from({ length: this.storage.factory.chunkCompactionConcurrency }, () => new ObjectStorageUsage(this.db, this.group_id, createObjectStorageUsageWriterId()));
|
|
134
|
+
while (true) {
|
|
135
|
+
this.signal?.throwIfAborted();
|
|
136
|
+
const states = await this.findScheduledBucketBatch(dueBefore);
|
|
137
|
+
if (states.length == 0) {
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
// Keep eligibility bounded by dueBefore, but classify with the current
|
|
141
|
+
// server time so buckets that age into full compaction can advance.
|
|
142
|
+
const batchStartedAt = await this.readCompactionTime();
|
|
143
|
+
const scheduled = [];
|
|
144
|
+
for (const state of states) {
|
|
145
|
+
try {
|
|
146
|
+
scheduled.push({
|
|
147
|
+
state,
|
|
148
|
+
decision: chooseCompactionKind(state, batchStartedAt, this),
|
|
149
|
+
forcedKind: forcedCompactionKind(state, forceKind, this)
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const noOpStates = scheduled.filter(({ state, decision, forcedKind }) => state.compact_lease == null && (forceKind == null ? decision.kind : forcedKind) == null);
|
|
157
|
+
await this.rescheduleUnclaimedBuckets(noOpStates, rescheduleNotBefore);
|
|
158
|
+
const processBucket = async ({ state, decision, forcedKind }, objectStorageUsage, chunksOnly = false) => {
|
|
159
|
+
const kind = forceKind == null ? decision.kind : forcedKind;
|
|
160
|
+
if (state.compact_lease == null && kind == null) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
await using lease = await this.claimBucket({ _id: state._id, next_compact_check: { $lte: dueBefore } });
|
|
165
|
+
if (lease == null) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const claimedDecision = chooseCompactionKind(lease.state, lease.startedAt, this);
|
|
169
|
+
const claimedKind = forceKind == null ? claimedDecision.kind : forcedCompactionKind(lease.state, forceKind, this);
|
|
170
|
+
if (chunksOnly && claimedKind === CompactionKind.Full) {
|
|
171
|
+
// The decision changed after scanning. Release the lease without
|
|
172
|
+
// rescheduling; the next batch will classify it with a fresh timestamp.
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (claimedKind == null) {
|
|
176
|
+
await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
|
|
177
|
+
}
|
|
178
|
+
else if (this.isCompactionTargetCovered(lease.state, claimedKind)) {
|
|
179
|
+
// The run cannot advance this kind's watermark without regressing
|
|
180
|
+
// already-published progress. Keep any newer work scheduled.
|
|
181
|
+
await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
await this.compactClaimedBucket(lease, claimedKind, claimedDecision, rescheduleNotBefore, objectStorageUsage);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (this.signal?.aborted) {
|
|
189
|
+
// When aborted, stop completely, rather than logging and re-scheduling individual buckets.
|
|
190
|
+
// The lease on the current bucket is still released automatically.
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
const chunkBuckets = scheduled.filter(({ decision, forcedKind }) => (forceKind == null ? decision.kind : forcedKind) === CompactionKind.Chunks);
|
|
197
|
+
const sequentialBuckets = scheduled.filter(({ decision, forcedKind }) => (forceKind == null ? decision.kind : forcedKind) !== CompactionKind.Chunks);
|
|
198
|
+
await this.runChunkCompactionWorkers(chunkBuckets, workerUsage, (entry, usage) => processBucket(entry, usage, true));
|
|
199
|
+
// Full compaction cannot overlap chunk workers from this job, and only
|
|
200
|
+
// one full bucket is processed at a time.
|
|
201
|
+
for (const entry of sequentialBuckets) {
|
|
202
|
+
await processBucket(entry, this.objectStorageUsage);
|
|
203
|
+
}
|
|
79
204
|
}
|
|
80
|
-
return this.dirtyBucketBatchForChecksumsForCollection(this.db.bucketState(this.group_id), {
|
|
81
|
-
'estimate_since_compact.count': { $gte: options.minBucketChanges }
|
|
82
|
-
}, (bucketState) => bucketState._id.d);
|
|
83
205
|
}
|
|
84
|
-
|
|
85
|
-
|
|
206
|
+
/** Use MongoDB's clock, matching scheduling and lease timestamps. */
|
|
207
|
+
async readCompactionTime() {
|
|
208
|
+
const [{ now }] = await this.db.db
|
|
209
|
+
.aggregate([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
|
|
210
|
+
.toArray();
|
|
211
|
+
return now;
|
|
212
|
+
}
|
|
213
|
+
/** Process one scheduled batch with a fixed pool of workers. */
|
|
214
|
+
async runChunkCompactionWorkers(buckets, workerUsage, processBucket) {
|
|
215
|
+
const signal = this.signal;
|
|
216
|
+
let nextBucket = 0;
|
|
217
|
+
let failed = false;
|
|
218
|
+
const runWorker = async (usage) => {
|
|
219
|
+
try {
|
|
220
|
+
while (!failed && nextBucket < buckets.length) {
|
|
221
|
+
// Taking an entry has no await, so each worker gets a different bucket.
|
|
222
|
+
// A worker takes another only after finishing its current bucket.
|
|
223
|
+
const bucket = buckets[nextBucket++];
|
|
224
|
+
// This pool bounds one job; the factory semaphore bounds all jobs together.
|
|
225
|
+
// Acquire before claiming the bucket lease, and hold until it is released.
|
|
226
|
+
const acquired = await acquireSemaphoreAbortable(this.storage.factory.chunkCompactionSlots, signal);
|
|
227
|
+
if (acquired === 'aborted') {
|
|
228
|
+
signal?.throwIfAborted();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const [, releaseSlot] = acquired;
|
|
232
|
+
try {
|
|
233
|
+
// A sibling may have failed while this worker waited for a slot.
|
|
234
|
+
if (failed)
|
|
235
|
+
return;
|
|
236
|
+
signal?.throwIfAborted();
|
|
237
|
+
await processBucket(bucket, usage);
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
releaseSlot();
|
|
241
|
+
}
|
|
242
|
+
// Let replication and other event-loop work run between buckets.
|
|
243
|
+
await setImmediate();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
// Drain work already started, but do not let siblings start new buckets.
|
|
248
|
+
failed = true;
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
// Concurrent transactions must not all increment the same usage document.
|
|
253
|
+
// Reuse one writer for each worker instead of creating one per bucket.
|
|
254
|
+
const workers = workerUsage.map(runWorker);
|
|
255
|
+
// Do not release the caller's replication lock or run cleanup while a
|
|
256
|
+
// sibling worker still owns a bucket lease or is finishing a replacement.
|
|
257
|
+
// Wait for every worker even on failure, then propagate the first error.
|
|
258
|
+
const results = await Promise.allSettled(workers);
|
|
259
|
+
for (const result of results) {
|
|
260
|
+
if (result.status === 'rejected') {
|
|
261
|
+
throw result.reason;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/** Read a bounded, priority-ordered snapshot of currently claimable scheduled work. */
|
|
266
|
+
async findScheduledBucketBatch(dueBefore) {
|
|
267
|
+
return this.db
|
|
86
268
|
.bucketState(this.group_id)
|
|
87
|
-
.
|
|
88
|
-
|
|
89
|
-
|
|
269
|
+
.find({
|
|
270
|
+
next_compact_check: { $lte: dueBefore },
|
|
271
|
+
...AVAILABLE_LEASE_EXPR
|
|
272
|
+
})
|
|
273
|
+
.sort({ next_compact_check: 1 })
|
|
274
|
+
.limit(SCHEDULED_COMPACTION_BATCH_SIZE)
|
|
275
|
+
.toArray();
|
|
90
276
|
}
|
|
91
277
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
278
|
+
* Reschedule snapshots that were already known to be no-ops without first
|
|
279
|
+
* taking a lease. Every decision input is compared so a concurrent writer
|
|
280
|
+
* or compactor simply makes the update a no-op instead of losing work. A
|
|
281
|
+
* successful reschedule moves beyond this run's fixed selection boundary.
|
|
94
282
|
*/
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
283
|
+
async rescheduleUnclaimedBuckets(states, notBefore) {
|
|
284
|
+
if (states.length == 0) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
await this.db.bucketState(this.group_id).bulkWrite(states.map(({ state, decision }) => ({
|
|
288
|
+
updateOne: {
|
|
289
|
+
filter: unclaimedSnapshotFilter(state),
|
|
290
|
+
update: [{ $set: { next_compact_check: this.rescheduleAtOrAfter(decision.nextCompactCheck, notBefore) } }]
|
|
102
291
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
292
|
+
})), { ordered: false });
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Isolate a malformed bucket so it cannot prevent other scheduled buckets
|
|
296
|
+
* from compacting. The snapshot filter preserves any concurrent write or
|
|
297
|
+
* compactor result instead of overwriting its next check.
|
|
298
|
+
*/
|
|
299
|
+
async rescheduleFailedBucket(state, notBefore, error) {
|
|
300
|
+
this.logger.error(`Failed to compact scheduled bucket ${state._id.b}; rescheduling it`, error);
|
|
301
|
+
try {
|
|
302
|
+
await this.db
|
|
303
|
+
.bucketState(this.group_id)
|
|
304
|
+
.updateOne(unclaimedSnapshotFilter(state), [{ $set: { next_compact_check: notBefore } }]);
|
|
305
|
+
}
|
|
306
|
+
catch (rescheduleError) {
|
|
307
|
+
this.logger.error(`Failed to reschedule bucket ${state._id.b} after a compaction error`, rescheduleError);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Given a bucket filter, claim a lease on the bucket. The filter should include a filter on _id.
|
|
312
|
+
*
|
|
313
|
+
* Resolves to null if the bucket is already claimed, not found, or filtered out.
|
|
314
|
+
*/
|
|
315
|
+
async claimBucket(filter, sort) {
|
|
316
|
+
return CompactionLease.claim(this.db.bucketState(this.group_id), filter, sort, this.compactLeaseDurationMs);
|
|
317
|
+
}
|
|
318
|
+
async compactClaimedBucket(lease, kind, decision, rescheduleNotBefore, objectStorageUsage = this.objectStorageUsage) {
|
|
319
|
+
const context = new CompactionContext(lease, kind, decision, rescheduleNotBefore, this.compactionTarget(lease.state));
|
|
320
|
+
lease.startRenewal();
|
|
321
|
+
await this.compactSingleBucket(context, objectStorageUsage);
|
|
322
|
+
}
|
|
323
|
+
async rescheduleClaimedBucket(lease, decision, notBefore) {
|
|
324
|
+
await lease.reschedule(this.rescheduleAtOrAfter(decision.nextCompactCheck, notBefore));
|
|
325
|
+
}
|
|
326
|
+
rescheduleAtOrAfter(nextCompactCheck, notBefore) {
|
|
327
|
+
return notBefore == null ? nextCompactCheck : { $max: [nextCompactCheck, notBefore] };
|
|
109
328
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
329
|
+
compactionTarget(state) {
|
|
330
|
+
return this.maxOpIdCap == null || state.last_op < this.maxOpIdCap ? state.last_op : this.maxOpIdCap;
|
|
331
|
+
}
|
|
332
|
+
isCompactionTargetCovered(state, kind) {
|
|
333
|
+
const target = this.compactionTarget(state);
|
|
334
|
+
if (kind == CompactionKind.Chunks) {
|
|
335
|
+
return state.compacted_state != null && state.compacted_state.op_id >= target;
|
|
113
336
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
337
|
+
if (state.last_full_compact != null && state.last_full_compact.op_id >= target) {
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
// A full compact may change counts before the checksum-cache boundary.
|
|
341
|
+
// Wait for the safe target to catch up instead of publishing an older or
|
|
342
|
+
// stale cache. At the same boundary, full coverage can still advance.
|
|
343
|
+
return state.compacted_state != null && state.compacted_state.op_id > target;
|
|
344
|
+
}
|
|
345
|
+
get objectStorageLifecycle() {
|
|
346
|
+
if (!this.storage.objectStorage) {
|
|
347
|
+
throw new Error('Object storage is not configured');
|
|
348
|
+
}
|
|
349
|
+
return new ObjectStorageLifecycle(this.db, this.group_id, this.storage.objectStorage);
|
|
350
|
+
}
|
|
351
|
+
async compactSingleBucket(context, objectStorageUsage) {
|
|
352
|
+
if (context.kind == CompactionKind.Chunks) {
|
|
353
|
+
return this.compactSingleBucketChunks(context, objectStorageUsage);
|
|
354
|
+
}
|
|
355
|
+
return this.compactSingleBucketFully(context, objectStorageUsage);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Merge adjacent bucket-data chunks without inspecting their operations
|
|
359
|
+
* unless a merge is possible. The metadata contains enough information to
|
|
360
|
+
* update the persisted checksum state and to decide whether a group can fit
|
|
361
|
+
* in one chunk.
|
|
362
|
+
*/
|
|
363
|
+
async compactSingleBucketChunks(context, objectStorageUsage) {
|
|
364
|
+
const bucket = context.state._id.b;
|
|
365
|
+
const resolvedDefinitionId = context.state._id.d;
|
|
366
|
+
const bucketContext = new BucketDataContextV3(this.db, {
|
|
367
|
+
bucket,
|
|
368
|
+
definitionId: resolvedDefinitionId,
|
|
369
|
+
replicationStreamId: this.group_id
|
|
370
|
+
});
|
|
371
|
+
const collection = this.db.bucketData(this.group_id, resolvedDefinitionId);
|
|
372
|
+
const dataContext = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
|
|
373
|
+
let previousCompactedState = context.state.compacted_state;
|
|
374
|
+
// A zero boundary represents an empty prefix, so there is no stored chunk
|
|
375
|
+
// whose statistics need to be carried into this pass.
|
|
376
|
+
if (previousCompactedState?.op_id === 0n) {
|
|
377
|
+
previousCompactedState = undefined;
|
|
378
|
+
}
|
|
379
|
+
// Include the last previously compacted chunk as well as new chunks. It
|
|
380
|
+
// is the only old chunk which can become mergeable with the new tail.
|
|
381
|
+
let lowerBound = previousCompactedState != null ? bucketContext.docId(previousCompactedState.op_id - 1n) : bucketContext.minId;
|
|
382
|
+
const upperBound = bucketContext.docId(context.targetOp + 1n);
|
|
383
|
+
let cachedBoundaryToVerify = previousCompactedState?.op_id;
|
|
384
|
+
let compactedOpId = null;
|
|
385
|
+
let overlappingCompactedChunk;
|
|
386
|
+
let compactedTail = emptyBucketStats();
|
|
387
|
+
let pendingChunks = [];
|
|
388
|
+
let pendingSize = 0;
|
|
389
|
+
while (true) {
|
|
390
|
+
this.signal?.throwIfAborted();
|
|
391
|
+
await context.lease.throwIfLost();
|
|
392
|
+
const batch = await readCompactionBatch(collection.aggregate([
|
|
393
|
+
{
|
|
394
|
+
$match: {
|
|
395
|
+
_id: {
|
|
396
|
+
$gt: lowerBound,
|
|
397
|
+
$lt: upperBound
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
{ $sort: { _id: 1 } },
|
|
402
|
+
{ $limit: this.moveBatchQueryLimit },
|
|
403
|
+
{
|
|
404
|
+
$project: {
|
|
405
|
+
_id: 1,
|
|
406
|
+
min_op: 1,
|
|
407
|
+
checksum: 1,
|
|
408
|
+
count: 1,
|
|
409
|
+
size: 1,
|
|
410
|
+
target_op: 1,
|
|
411
|
+
storage_ref: 1
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
], { batchSize: this.moveBatchQueryLimit + 1 }), {
|
|
415
|
+
byteLimit: this.moveBatchByteLimit,
|
|
416
|
+
documentLimit: this.moveBatchQueryLimit
|
|
417
|
+
});
|
|
418
|
+
if (cachedBoundaryToVerify != null) {
|
|
419
|
+
const cachedBoundary = cachedBoundaryToVerify;
|
|
420
|
+
cachedBoundaryToVerify = undefined;
|
|
421
|
+
if (batch.documents[0]?._id.o !== cachedBoundary) {
|
|
422
|
+
// A previous attempt may have replaced the cached boundary before
|
|
423
|
+
// finalizing bucket state. Keep the persisted cache available to
|
|
424
|
+
// readers, but ignore it in this attempt and calculate its
|
|
425
|
+
// replacement through the normal scan from the bucket beginning.
|
|
426
|
+
previousCompactedState = undefined;
|
|
427
|
+
lowerBound = bucketContext.minId;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
118
430
|
}
|
|
431
|
+
if (batch.documents.length == 0) {
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
for (const doc of batch.documents) {
|
|
435
|
+
compactedOpId = maxOpId(compactedOpId, doc._id.o);
|
|
436
|
+
const documentStats = statsForDocument(doc);
|
|
437
|
+
if (previousCompactedState?.op_id === doc._id.o) {
|
|
438
|
+
overlappingCompactedChunk = documentStats;
|
|
439
|
+
}
|
|
440
|
+
const nextSize = pendingSize + doc.size;
|
|
441
|
+
if (pendingChunks.length > 0 && nextSize > DEFAULT_MAX_DOC_SIZE_BYTES) {
|
|
442
|
+
const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext, objectStorageUsage);
|
|
443
|
+
compactedTail = combineAdjacentStats(compactedTail, groupStats);
|
|
444
|
+
pendingChunks = [];
|
|
445
|
+
pendingSize = 0;
|
|
446
|
+
}
|
|
447
|
+
pendingChunks.push(doc);
|
|
448
|
+
pendingSize += doc.size;
|
|
449
|
+
}
|
|
450
|
+
lowerBound = batch.documents[batch.documents.length - 1]._id;
|
|
451
|
+
if (!batch.hasMore) {
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (pendingChunks.length > 0) {
|
|
456
|
+
const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext, objectStorageUsage);
|
|
457
|
+
compactedTail = combineAdjacentStats(compactedTail, groupStats);
|
|
458
|
+
}
|
|
459
|
+
if (compactedOpId == null) {
|
|
460
|
+
await this.finalizeSkippedBucket(context);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const compactedState = previousCompactedState == null
|
|
464
|
+
? compactedTail
|
|
465
|
+
: combineChunkStats(previousCompactedState, compactedTail, overlappingCompactedChunk);
|
|
466
|
+
const tailStats = compactedOpId == context.lastOp
|
|
467
|
+
? undefined
|
|
468
|
+
: await this.readBucketStats(bucket, resolvedDefinitionId, context.lastOp, bucketContext.docId(compactedOpId));
|
|
469
|
+
const result = {
|
|
470
|
+
compactedState,
|
|
471
|
+
bucketStats: tailStats == null ? compactedState : combineAdjacentStats(compactedState, tailStats)
|
|
119
472
|
};
|
|
473
|
+
await this.finalizeCompactedBucket({ context, compactedOpId, compactionResult: result, puts: 0 });
|
|
474
|
+
this.compactedBucketCount++;
|
|
475
|
+
this.logger.info(`Compacted bucket chunks ${bucket}: ${result.bucketStats.count} ops, ${result.bucketStats.chunks} chunks, ${formatBytes(result.bucketStats.bytes)}`);
|
|
120
476
|
}
|
|
121
|
-
async
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
477
|
+
async flushChunkMerge(bucket, inputs, collection, context, bucketContext, objectStorageUsage) {
|
|
478
|
+
if (inputs.length == 1) {
|
|
479
|
+
return statsForDocument(inputs[0]);
|
|
480
|
+
}
|
|
481
|
+
try {
|
|
482
|
+
this.signal?.throwIfAborted();
|
|
483
|
+
// The metadata scan deliberately excluded ops. Read inline payloads only
|
|
484
|
+
// for this merge group; object-storage payloads are fetched below using
|
|
485
|
+
// the same rule.
|
|
486
|
+
const inlineInputs = inputs.filter((input) => input.storage_ref == null);
|
|
487
|
+
if (inlineInputs.length > 0) {
|
|
488
|
+
const inlineDocuments = await collection
|
|
489
|
+
.find({ _id: { $in: inlineInputs.map((input) => input._id) } }, { projection: { _id: 1, ops: 1 } })
|
|
490
|
+
.toArray();
|
|
491
|
+
const opsById = new Map(inlineDocuments.map((document) => [document._id.o.toString(), document.ops]));
|
|
492
|
+
for (const input of inlineInputs) {
|
|
493
|
+
input.ops = opsById.get(input._id.o.toString());
|
|
132
494
|
}
|
|
133
495
|
}
|
|
496
|
+
await hydrateBucketDataDocuments(inputs, this.storage.objectStorage, { signal: this.signal });
|
|
497
|
+
const operations = inputs.flatMap((input) => Array.from(loadBucketDataDocument(context, input)));
|
|
498
|
+
const targetOp = inputs.reduce((maxTarget, input) => maxOpId(maxTarget, input.target_op), null);
|
|
499
|
+
const result = await this.flushCompactionGroup(bucket, {
|
|
500
|
+
inputs,
|
|
501
|
+
ops: operations,
|
|
502
|
+
changed: true,
|
|
503
|
+
targetOp
|
|
504
|
+
}, bucketContext, context, objectStorageUsage);
|
|
505
|
+
return result.stats;
|
|
506
|
+
}
|
|
507
|
+
finally {
|
|
508
|
+
// The scan batch also references these documents. Do not retain hydrated
|
|
509
|
+
// operations after finishing this merge group.
|
|
510
|
+
for (const input of inputs) {
|
|
511
|
+
delete input.ops;
|
|
512
|
+
}
|
|
513
|
+
await setImmediate();
|
|
134
514
|
}
|
|
135
|
-
|
|
136
|
-
|
|
515
|
+
}
|
|
516
|
+
async finalizeCompactedBucket({ context, compactedOpId, compactionResult, puts }) {
|
|
517
|
+
await context.lease.throwIfLost();
|
|
518
|
+
const startedStats = bucketStats(context.state);
|
|
519
|
+
const delta = {
|
|
520
|
+
count: compactionResult.bucketStats.count - startedStats.count,
|
|
521
|
+
bytes: compactionResult.bucketStats.bytes - startedStats.bytes,
|
|
522
|
+
chunks: compactionResult.bucketStats.chunks - startedStats.chunks
|
|
523
|
+
};
|
|
524
|
+
const coveredClaimedHead = compactedOpId >= context.lastOp;
|
|
525
|
+
const concurrentWriteCheck = { $gt: ['$last_op', context.lastOp] };
|
|
526
|
+
const remainingFullWorkCheck = coveredClaimedHead ? concurrentWriteCheck : true;
|
|
527
|
+
const nextAfterPartialFullCompact = this.rescheduleAtOrAfter({ $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } }, context.rescheduleNotBefore);
|
|
528
|
+
const nextCheckForUncompactedWork = this.rescheduleAtOrAfter({
|
|
529
|
+
$min: [
|
|
530
|
+
new Date(firstUncompactedWrite(context.state).getTime() + this.maxCompactFullIntervalMs),
|
|
531
|
+
{ $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } }
|
|
532
|
+
]
|
|
533
|
+
}, context.rescheduleNotBefore);
|
|
534
|
+
const update = {
|
|
535
|
+
compacted_state: {
|
|
536
|
+
op_id: compactedOpId,
|
|
537
|
+
checksum: BigInt(compactionResult.compactedState.checksum),
|
|
538
|
+
count: compactionResult.compactedState.count,
|
|
539
|
+
bytes: compactionResult.compactedState.bytes,
|
|
540
|
+
chunks: compactionResult.compactedState.chunks,
|
|
541
|
+
at: '$$NOW'
|
|
542
|
+
},
|
|
543
|
+
bucket_stats: {
|
|
544
|
+
count: { $add: ['$bucket_stats.count', delta.count] },
|
|
545
|
+
bytes: { $add: ['$bucket_stats.bytes', delta.bytes] },
|
|
546
|
+
chunks: { $add: ['$bucket_stats.chunks', delta.chunks] }
|
|
547
|
+
},
|
|
548
|
+
first_uncompacted_write: context.kind == CompactionKind.Full
|
|
549
|
+
? { $cond: [remainingFullWorkCheck, '$$NOW', '$$REMOVE'] }
|
|
550
|
+
: '$first_uncompacted_write',
|
|
551
|
+
next_compact_check: context.kind == CompactionKind.Full
|
|
552
|
+
? { $cond: [remainingFullWorkCheck, nextAfterPartialFullCompact, '$$REMOVE'] }
|
|
553
|
+
: nextCheckForUncompactedWork
|
|
554
|
+
};
|
|
555
|
+
if (context.kind == CompactionKind.Full) {
|
|
556
|
+
update.last_full_compact = {
|
|
557
|
+
op_id: compactedOpId,
|
|
558
|
+
count: compactionResult.compactedState.count,
|
|
559
|
+
puts,
|
|
560
|
+
at: '$$NOW'
|
|
561
|
+
};
|
|
137
562
|
}
|
|
138
|
-
|
|
563
|
+
await context.lease.finalize(update);
|
|
564
|
+
}
|
|
565
|
+
async finalizeSkippedBucket(context) {
|
|
566
|
+
// A maxOpId cap can exclude the first remaining document entirely. Avoid
|
|
567
|
+
// immediately claiming the same no-progress bucket again in this run.
|
|
568
|
+
await this.rescheduleClaimedBucket(context.lease, {
|
|
569
|
+
...context.decision,
|
|
570
|
+
nextCompactCheck: {
|
|
571
|
+
$max: [
|
|
572
|
+
context.decision.nextCompactCheck,
|
|
573
|
+
{ $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } }
|
|
574
|
+
]
|
|
575
|
+
}
|
|
576
|
+
}, context.rescheduleNotBefore);
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Read bucket stats directly from bucket_data documents.
|
|
580
|
+
*/
|
|
581
|
+
async readBucketStats(bucket, definitionId, maxOp, lowerBound) {
|
|
582
|
+
const context = new BucketDataContextV3(this.db, {
|
|
139
583
|
bucket,
|
|
140
|
-
definitionId
|
|
584
|
+
definitionId,
|
|
141
585
|
replicationStreamId: this.group_id
|
|
142
586
|
});
|
|
587
|
+
const [stats] = await this.db
|
|
588
|
+
.bucketData(this.group_id, definitionId)
|
|
589
|
+
.aggregate([
|
|
590
|
+
{
|
|
591
|
+
$match: {
|
|
592
|
+
_id: lowerBound == null
|
|
593
|
+
? { $gte: context.minId, $lte: context.docId(maxOp) }
|
|
594
|
+
: { $gt: lowerBound, $lte: context.docId(maxOp) }
|
|
595
|
+
}
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
$group: {
|
|
599
|
+
_id: null,
|
|
600
|
+
count: { $sum: '$count' },
|
|
601
|
+
bytes: { $sum: '$size' },
|
|
602
|
+
chunks: { $sum: 1 },
|
|
603
|
+
checksum: { $sum: '$checksum' }
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
])
|
|
607
|
+
.toArray();
|
|
608
|
+
return {
|
|
609
|
+
count: Number(stats?.count ?? 0),
|
|
610
|
+
bytes: BigInt(stats?.bytes ?? 0),
|
|
611
|
+
chunks: Number(stats?.chunks ?? 0),
|
|
612
|
+
checksum: typeof stats?.checksum == 'bigint'
|
|
613
|
+
? Number(BigInt.asIntN(32, stats.checksum))
|
|
614
|
+
: addChecksums(0, Number(stats?.checksum ?? 0))
|
|
615
|
+
};
|
|
143
616
|
}
|
|
144
|
-
async
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
617
|
+
async compactSingleBucketFully(context, objectStorageUsage) {
|
|
618
|
+
const bucket = context.state._id.b;
|
|
619
|
+
const resolvedDefinitionId = context.state._id.d;
|
|
620
|
+
const bucketContext = new BucketDataContextV3(this.db, {
|
|
621
|
+
bucket,
|
|
622
|
+
definitionId: resolvedDefinitionId,
|
|
623
|
+
replicationStreamId: this.group_id
|
|
624
|
+
});
|
|
150
625
|
const collection = this.db.bucketData(this.group_id, resolvedDefinitionId);
|
|
151
|
-
const
|
|
626
|
+
const dataContext = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
|
|
152
627
|
const lowerBound = bucketContext.minId;
|
|
153
|
-
let upperBound = bucketContext.docId(
|
|
154
|
-
let totalChecksum = 0;
|
|
628
|
+
let upperBound = bucketContext.docId(context.targetOp + 1n);
|
|
155
629
|
let totalOpCount = 0;
|
|
156
|
-
let totalOpBytes = 0;
|
|
157
630
|
let lastNotPut = null;
|
|
158
631
|
let opsSincePut = 0;
|
|
159
632
|
let compactedOpId = null;
|
|
160
633
|
let clearBoundary = null;
|
|
634
|
+
let compactedStats = emptyBucketStats();
|
|
161
635
|
const seen = new Map();
|
|
162
636
|
let trackingSize = 0;
|
|
637
|
+
let putCount = 0;
|
|
163
638
|
let pendingGroup = null;
|
|
164
639
|
// --- Read batch from MongoDB ---
|
|
165
640
|
while (true) {
|
|
166
641
|
this.signal?.throwIfAborted();
|
|
642
|
+
await context.lease.throwIfLost();
|
|
167
643
|
const pipeline = [
|
|
168
644
|
{
|
|
169
645
|
$match: {
|
|
@@ -205,7 +681,7 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
205
681
|
// merging is useful, and writes each final object at most once.
|
|
206
682
|
for (const doc of batchDocs) {
|
|
207
683
|
compactedOpId ??= doc._id.o;
|
|
208
|
-
const originalOps = Array.from(loadBucketDataDocument(
|
|
684
|
+
const originalOps = Array.from(loadBucketDataDocument(dataContext, doc));
|
|
209
685
|
let changed = false;
|
|
210
686
|
const compactedOps = [];
|
|
211
687
|
let maxTargetOp = doc.target_op ?? null;
|
|
@@ -223,6 +699,7 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
223
699
|
row_id: undefined,
|
|
224
700
|
source_table: undefined,
|
|
225
701
|
source_key: undefined,
|
|
702
|
+
subkey: undefined,
|
|
226
703
|
data: null
|
|
227
704
|
});
|
|
228
705
|
changed = true;
|
|
@@ -238,6 +715,7 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
238
715
|
}
|
|
239
716
|
compactedOps.push(op);
|
|
240
717
|
if (op.op == 'PUT') {
|
|
718
|
+
putCount++;
|
|
241
719
|
lastNotPut = null;
|
|
242
720
|
opsSincePut = 0;
|
|
243
721
|
}
|
|
@@ -260,10 +738,6 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
260
738
|
}
|
|
261
739
|
}
|
|
262
740
|
compactedOps.reverse();
|
|
263
|
-
for (const op of compactedOps) {
|
|
264
|
-
totalChecksum = addChecksums(totalChecksum, Number(op.checksum));
|
|
265
|
-
totalOpBytes += op.data?.length ?? 0;
|
|
266
|
-
}
|
|
267
741
|
totalOpCount += compactedOps.length;
|
|
268
742
|
const candidate = {
|
|
269
743
|
inputs: [doc],
|
|
@@ -287,11 +761,12 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
287
761
|
}
|
|
288
762
|
else {
|
|
289
763
|
const flushedGroup = pendingGroup;
|
|
290
|
-
const
|
|
764
|
+
const result = await this.flushCompactionGroup(bucket, flushedGroup, bucketContext, dataContext, objectStorageUsage);
|
|
765
|
+
compactedStats = combineAdjacentStats(compactedStats, result.stats);
|
|
291
766
|
if (lastNotPut != null &&
|
|
292
767
|
flushedGroup.ops[0].o <= lastNotPut &&
|
|
293
768
|
flushedGroup.ops[flushedGroup.ops.length - 1].o >= lastNotPut) {
|
|
294
|
-
clearBoundary = { opId: lastNotPut, documentId };
|
|
769
|
+
clearBoundary = { opId: lastNotPut, documentId: result.documentId };
|
|
295
770
|
}
|
|
296
771
|
pendingGroup = candidate;
|
|
297
772
|
}
|
|
@@ -305,14 +780,16 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
305
780
|
this.logger.info(`Compacted batch of ${batchDocs.length} documents for bucket ${bucket}`);
|
|
306
781
|
}
|
|
307
782
|
if (pendingGroup != null) {
|
|
308
|
-
const
|
|
783
|
+
const result = await this.flushCompactionGroup(bucket, pendingGroup, bucketContext, dataContext, objectStorageUsage);
|
|
784
|
+
compactedStats = combineAdjacentStats(compactedStats, result.stats);
|
|
309
785
|
if (lastNotPut != null &&
|
|
310
786
|
pendingGroup.ops[0].o <= lastNotPut &&
|
|
311
787
|
pendingGroup.ops[pendingGroup.ops.length - 1].o >= lastNotPut) {
|
|
312
|
-
clearBoundary = { opId: lastNotPut, documentId };
|
|
788
|
+
clearBoundary = { opId: lastNotPut, documentId: result.documentId };
|
|
313
789
|
}
|
|
314
790
|
}
|
|
315
791
|
if (compactedOpId == null) {
|
|
792
|
+
await this.finalizeSkippedBucket(context);
|
|
316
793
|
return;
|
|
317
794
|
}
|
|
318
795
|
// --- Clear: collapse leading MOVE/REMOVE/CLEAR sequence ---
|
|
@@ -320,25 +797,21 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
320
797
|
if (clearBoundary == null || clearBoundary.opId != lastNotPut) {
|
|
321
798
|
throw new ReplicationAssertionError(`Missing CLEAR boundary document for bucket ${bucket}`);
|
|
322
799
|
}
|
|
323
|
-
|
|
800
|
+
const clearResult = await this.clearBucketLeading(lastNotPut, clearBoundary.documentId, bucketContext, collection, dataContext, objectStorageUsage);
|
|
801
|
+
totalOpCount += clearResult.opCountDiff;
|
|
802
|
+
compactedStats = applyStatsReplacement(compactedStats, clearResult.before, clearResult.after);
|
|
324
803
|
}
|
|
804
|
+
const tailStats = compactedOpId == context.lastOp
|
|
805
|
+
? undefined
|
|
806
|
+
: await this.readBucketStats(bucket, resolvedDefinitionId, context.lastOp, bucketContext.docId(compactedOpId));
|
|
807
|
+
const result = {
|
|
808
|
+
compactedState: compactedStats,
|
|
809
|
+
bucketStats: tailStats == null ? compactedStats : combineAdjacentStats(compactedStats, tailStats)
|
|
810
|
+
};
|
|
325
811
|
// --- Finalize: update bucket checksums and state ---
|
|
326
|
-
this.
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
seen: new Map(),
|
|
330
|
-
trackingSize: 0,
|
|
331
|
-
lastNotPut: lastNotPut,
|
|
332
|
-
opsSincePut: opsSincePut,
|
|
333
|
-
checksum: totalChecksum,
|
|
334
|
-
opCount: totalOpCount,
|
|
335
|
-
opBytes: totalOpBytes
|
|
336
|
-
}, compactedOpId);
|
|
337
|
-
if (this.bucketStateUpdates.length > 0) {
|
|
338
|
-
await this.writeBucketStateUpdates();
|
|
339
|
-
this.bucketStateUpdates = [];
|
|
340
|
-
}
|
|
341
|
-
logger.info(`Compacted bucket ${bucket}: ${totalOpCount} surviving ops`);
|
|
812
|
+
await this.finalizeCompactedBucket({ context, compactedOpId, compactionResult: result, puts: putCount });
|
|
813
|
+
this.compactedBucketCount++;
|
|
814
|
+
this.logger.info(`Compacted bucket ${bucket}: ${totalOpCount} ops, ${result.bucketStats.chunks} chunks, ${formatBytes(result.bucketStats.bytes)}`);
|
|
342
815
|
}
|
|
343
816
|
/**
|
|
344
817
|
* Persist replacement objects before starting the transaction, then atomically
|
|
@@ -346,9 +819,12 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
346
819
|
* If verification or the transaction fails, the prepared markers retain enough
|
|
347
820
|
* information for the uploaded objects to be cleaned up later.
|
|
348
821
|
*/
|
|
349
|
-
async flushCompactionGroup(bucket, group, bucketContext, context) {
|
|
822
|
+
async flushCompactionGroup(bucket, group, bucketContext, context, objectStorageUsage) {
|
|
350
823
|
if (group.inputs.length == 1 && !group.changed) {
|
|
351
|
-
return
|
|
824
|
+
return {
|
|
825
|
+
documentId: group.inputs[0]._id,
|
|
826
|
+
stats: statsForDocument(group.inputs[0])
|
|
827
|
+
};
|
|
352
828
|
}
|
|
353
829
|
const inputs = group.inputs;
|
|
354
830
|
const idsToDelete = inputs.map((doc) => doc._id);
|
|
@@ -356,6 +832,7 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
356
832
|
const expectedChecksum = inputs.reduce((sum, doc) => sum + doc.checksum, 0n);
|
|
357
833
|
const expectedOpCount = inputs.reduce((sum, doc) => sum + doc.count, 0);
|
|
358
834
|
const oldStoragePaths = inputs.flatMap((doc) => (doc.storage_ref ? [doc.storage_ref.path] : []));
|
|
835
|
+
const oldStorageBytes = inputs.reduce((sum, document) => sum + ObjectStorageUsage.bytes(document), 0n);
|
|
359
836
|
const { documents, storagePaths: newStoragePaths, uploads } = await this.persistBucketData(bucket, [group.ops], context, undefined, { targetOp: group.targetOp });
|
|
360
837
|
const session = this.db.client.startSession();
|
|
361
838
|
try {
|
|
@@ -379,9 +856,13 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
379
856
|
verification.opCountSum !== expectedOpCount) {
|
|
380
857
|
throw new ConcurrentCompactionError(`Inputs changed while compacting bucket ${bucket}; restarting from the latest bucket state`);
|
|
381
858
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
859
|
+
// Replacement documents can reuse deleted IDs, so retain delete-before-insert ordering.
|
|
860
|
+
const writes = this.db.createWriteBatch(session, { ordered: true });
|
|
861
|
+
writes.deleteMany(bucketContext.collection, { _id: { $in: idsToDelete } });
|
|
862
|
+
writes.insertMany(bucketContext.collection, documents);
|
|
863
|
+
this.finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, writes);
|
|
864
|
+
this.recordObjectStorageReplacement(oldStorageBytes, documents, context.definitionId, writes, objectStorageUsage);
|
|
865
|
+
await writes.execute();
|
|
385
866
|
}, {
|
|
386
867
|
writeConcern: { w: 'majority' },
|
|
387
868
|
readConcern: { level: 'snapshot' }
|
|
@@ -390,7 +871,10 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
390
871
|
finally {
|
|
391
872
|
await session.endSession();
|
|
392
873
|
}
|
|
393
|
-
return
|
|
874
|
+
return {
|
|
875
|
+
documentId: documents[0]._id,
|
|
876
|
+
stats: statsForDocuments(documents)
|
|
877
|
+
};
|
|
394
878
|
}
|
|
395
879
|
/**
|
|
396
880
|
* Collapse the leading sequence of MOVE/REMOVE/CLEAR ops at the start
|
|
@@ -398,39 +882,52 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
398
882
|
* before the known boundary document, then splits that boundary document
|
|
399
883
|
* if it contains ops on both sides of lastNotPut.
|
|
400
884
|
*
|
|
401
|
-
* Returns the op count
|
|
885
|
+
* Returns the op count and stored-stat changes after replacing cleared ops
|
|
886
|
+
* with CLEAR ops.
|
|
402
887
|
*/
|
|
403
|
-
async clearBucketLeading(lastNotPut, boundaryDocId, bucketContext, collection, context) {
|
|
888
|
+
async clearBucketLeading(lastNotPut, boundaryDocId, bucketContext, collection, context, objectStorageUsage) {
|
|
404
889
|
let opCountDiff = 0;
|
|
890
|
+
let before = emptyBucketStats();
|
|
891
|
+
let after = emptyBucketStats();
|
|
405
892
|
const session = this.db.client.startSession();
|
|
406
893
|
try {
|
|
407
894
|
let done = false;
|
|
408
895
|
// First step is to clear full chunks that contain only CLEAR/MOVE/REMOVE operations.
|
|
409
896
|
// There can be many of them, so we do one batch at a time.
|
|
410
897
|
while (!done) {
|
|
411
|
-
const batch = await this.clearLeadingFullDocuments(session, lastNotPut, boundaryDocId, bucketContext, collection, context);
|
|
898
|
+
const batch = await this.clearLeadingFullDocuments(session, lastNotPut, boundaryDocId, bucketContext, collection, context, objectStorageUsage);
|
|
412
899
|
done = batch.done;
|
|
413
900
|
opCountDiff += batch.opCountDiff;
|
|
901
|
+
before = combineAdjacentStats(before, batch.before);
|
|
902
|
+
after = combineAdjacentStats(after, batch.after);
|
|
414
903
|
}
|
|
415
904
|
// The final step is to process the "boundary" document: It may contain some CLEAR/MOVE/REMOVE operations,
|
|
416
905
|
// potentially followed by PUT operations. This is only a single document, so no need for batching.
|
|
417
|
-
|
|
906
|
+
const boundaryResult = await this.clearBoundaryDocument(session, lastNotPut, boundaryDocId, bucketContext, collection, context, objectStorageUsage);
|
|
907
|
+
opCountDiff += boundaryResult.opCountDiff;
|
|
908
|
+
before = combineAdjacentStats(before, boundaryResult.before);
|
|
909
|
+
after = combineAdjacentStats(after, boundaryResult.after);
|
|
418
910
|
}
|
|
419
911
|
finally {
|
|
420
912
|
await session.endSession();
|
|
421
913
|
}
|
|
422
|
-
return opCountDiff;
|
|
914
|
+
return { opCountDiff, before, after };
|
|
423
915
|
}
|
|
424
|
-
async clearLeadingFullDocuments(session, lastNotPut, boundaryDocId, bucketContext, collection, context) {
|
|
916
|
+
async clearLeadingFullDocuments(session, lastNotPut, boundaryDocId, bucketContext, collection, context, objectStorageUsage) {
|
|
425
917
|
const bucket = bucketContext.key.bucket;
|
|
426
918
|
this.signal?.throwIfAborted();
|
|
427
|
-
|
|
919
|
+
let prepared;
|
|
428
920
|
let done = false;
|
|
429
921
|
let opCountDiff = 0;
|
|
922
|
+
let before = emptyBucketStats();
|
|
923
|
+
let after = emptyBucketStats();
|
|
430
924
|
await session.withTransaction(async () => {
|
|
431
925
|
done = false;
|
|
432
926
|
opCountDiff = 0;
|
|
927
|
+
before = emptyBucketStats();
|
|
928
|
+
after = emptyBucketStats();
|
|
433
929
|
const oldStoragePaths = [];
|
|
930
|
+
let oldStorageBytes = 0n;
|
|
434
931
|
const query = collection.find({
|
|
435
932
|
_id: {
|
|
436
933
|
$gte: bucketContext.minId,
|
|
@@ -444,6 +941,7 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
444
941
|
min_op: 1,
|
|
445
942
|
checksum: 1,
|
|
446
943
|
count: 1,
|
|
944
|
+
size: 1,
|
|
447
945
|
target_op: 1,
|
|
448
946
|
has_clear_op: 1,
|
|
449
947
|
storage_ref: 1
|
|
@@ -456,14 +954,21 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
456
954
|
let lastDocId = null;
|
|
457
955
|
let clearOpCount = 0;
|
|
458
956
|
let gotNonClearOp = false;
|
|
957
|
+
const inputStats = emptyBucketStats();
|
|
459
958
|
for await (const doc of query.stream()) {
|
|
460
959
|
if (doc.min_op > lastNotPut) {
|
|
461
960
|
throw new ReplicationAssertionError(`Unexpected document before CLEAR boundary with min_op ${doc.min_op} > ${lastNotPut} in bucket ${bucket}`);
|
|
462
961
|
}
|
|
463
962
|
lastDocId = doc._id;
|
|
963
|
+
const documentStats = statsForDocument(doc);
|
|
964
|
+
inputStats.count += documentStats.count;
|
|
965
|
+
inputStats.bytes += documentStats.bytes;
|
|
966
|
+
inputStats.chunks += documentStats.chunks;
|
|
967
|
+
inputStats.checksum = addChecksums(inputStats.checksum, documentStats.checksum);
|
|
464
968
|
if (doc.storage_ref) {
|
|
465
969
|
oldStoragePaths.push(doc.storage_ref.path);
|
|
466
970
|
}
|
|
971
|
+
oldStorageBytes += ObjectStorageUsage.bytes(doc);
|
|
467
972
|
// The compaction scan established that every operation before the
|
|
468
973
|
// boundary is MOVE/REMOVE/CLEAR. Root metadata is sufficient to fold
|
|
469
974
|
// whole documents into one CLEAR, so avoid downloading their payloads.
|
|
@@ -486,13 +991,15 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
486
991
|
done = true;
|
|
487
992
|
return;
|
|
488
993
|
}
|
|
994
|
+
prepared ??= await this.prepareCompactionUploads(bucket, context, [lastNotPut]);
|
|
489
995
|
this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastDocId?.o}`);
|
|
490
|
-
|
|
996
|
+
const writes = this.db.createWriteBatch(session, { ordered: true });
|
|
997
|
+
writes.deleteMany(collection, {
|
|
491
998
|
_id: {
|
|
492
999
|
$gte: bucketContext.minId,
|
|
493
1000
|
$lte: lastDocId
|
|
494
1001
|
}
|
|
495
|
-
}
|
|
1002
|
+
});
|
|
496
1003
|
const clearOp = {
|
|
497
1004
|
bucketKey: { ...context, bucket },
|
|
498
1005
|
o: lastDocId.o,
|
|
@@ -503,23 +1010,32 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
503
1010
|
const persisted = await this.persistBucketData(bucket, [[clearOp]], context, prepared, {
|
|
504
1011
|
targetOp: maxTargetOp
|
|
505
1012
|
});
|
|
506
|
-
|
|
507
|
-
|
|
1013
|
+
writes.insertOne(collection, persisted.documents[0]);
|
|
1014
|
+
this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, writes);
|
|
1015
|
+
this.recordObjectStorageReplacement(oldStorageBytes, persisted.documents, context.definitionId, writes, objectStorageUsage);
|
|
1016
|
+
await writes.execute();
|
|
508
1017
|
opCountDiff = -clearedOpCount + 1;
|
|
1018
|
+
before = inputStats;
|
|
1019
|
+
after = statsForDocuments(persisted.documents);
|
|
509
1020
|
}, {
|
|
510
1021
|
writeConcern: { w: 'majority' },
|
|
511
1022
|
readConcern: { level: 'snapshot' }
|
|
512
1023
|
});
|
|
513
|
-
return { done, opCountDiff };
|
|
1024
|
+
return { done, opCountDiff, before, after };
|
|
514
1025
|
}
|
|
515
|
-
async clearBoundaryDocument(session, lastNotPut, boundaryDocId, bucketContext, collection, context) {
|
|
1026
|
+
async clearBoundaryDocument(session, lastNotPut, boundaryDocId, bucketContext, collection, context, objectStorageUsage) {
|
|
516
1027
|
const bucket = bucketContext.key.bucket;
|
|
517
1028
|
this.signal?.throwIfAborted();
|
|
518
1029
|
const prepared = await this.prepareCompactionUploads(bucket, context, [lastNotPut, boundaryDocId.o]);
|
|
519
1030
|
let opCountDiff = 0;
|
|
1031
|
+
let before = emptyBucketStats();
|
|
1032
|
+
let after = emptyBucketStats();
|
|
520
1033
|
await session.withTransaction(async () => {
|
|
521
1034
|
opCountDiff = 0;
|
|
1035
|
+
before = emptyBucketStats();
|
|
1036
|
+
after = emptyBucketStats();
|
|
522
1037
|
const oldStoragePaths = [];
|
|
1038
|
+
let oldStorageBytes = 0n;
|
|
523
1039
|
const query = collection.find({
|
|
524
1040
|
// This is a range query, but should only ever return two documents:
|
|
525
1041
|
// 1. The CLEAR op from the previous clearLeadingFullDocuments.
|
|
@@ -536,6 +1052,7 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
536
1052
|
min_op: 1,
|
|
537
1053
|
checksum: 1,
|
|
538
1054
|
count: 1,
|
|
1055
|
+
size: 1,
|
|
539
1056
|
target_op: 1,
|
|
540
1057
|
ops: 1,
|
|
541
1058
|
storage_ref: 1
|
|
@@ -547,15 +1064,22 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
547
1064
|
let clearedOpCount = 0;
|
|
548
1065
|
let maxTargetOp = null;
|
|
549
1066
|
const boundarySurvivors = [];
|
|
1067
|
+
const inputStats = emptyBucketStats();
|
|
550
1068
|
for await (const doc of query.stream()) {
|
|
551
1069
|
docsRead++;
|
|
552
1070
|
if (docsRead > 2) {
|
|
553
1071
|
throw new ReplicationAssertionError(`Unexpected extra document before CLEAR boundary in bucket ${bucket}`);
|
|
554
1072
|
}
|
|
1073
|
+
const documentStats = statsForDocument(doc);
|
|
1074
|
+
inputStats.count += documentStats.count;
|
|
1075
|
+
inputStats.bytes += documentStats.bytes;
|
|
1076
|
+
inputStats.chunks += documentStats.chunks;
|
|
1077
|
+
inputStats.checksum = addChecksums(inputStats.checksum, documentStats.checksum);
|
|
555
1078
|
const isBoundaryDoc = doc._id.o == boundaryDocId.o;
|
|
556
1079
|
if (doc.storage_ref) {
|
|
557
1080
|
oldStoragePaths.push(doc.storage_ref.path);
|
|
558
1081
|
}
|
|
1082
|
+
oldStorageBytes += ObjectStorageUsage.bytes(doc);
|
|
559
1083
|
await hydrateBucketDataDocuments([doc], this.storage.objectStorage, { signal: this.signal });
|
|
560
1084
|
maxTargetOp = maxOpId(maxTargetOp, doc.target_op);
|
|
561
1085
|
for (const op of loadBucketDataDocument(context, doc)) {
|
|
@@ -581,12 +1105,13 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
581
1105
|
throw new Error(`CLEAR boundary document not found for bucket ${bucket}`);
|
|
582
1106
|
}
|
|
583
1107
|
this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastNotPut}`);
|
|
584
|
-
|
|
1108
|
+
const writes = this.db.createWriteBatch(session, { ordered: true });
|
|
1109
|
+
writes.deleteMany(collection, {
|
|
585
1110
|
_id: {
|
|
586
1111
|
$gte: bucketContext.minId,
|
|
587
1112
|
$lte: boundaryDocId
|
|
588
1113
|
}
|
|
589
|
-
}
|
|
1114
|
+
});
|
|
590
1115
|
const clearOp = {
|
|
591
1116
|
bucketKey: { ...context, bucket },
|
|
592
1117
|
o: lastNotPut,
|
|
@@ -603,14 +1128,18 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
603
1128
|
const persisted = await this.persistBucketData(bucket, chunks, context, prepared, {
|
|
604
1129
|
targetOp: maxTargetOp ?? undefined
|
|
605
1130
|
});
|
|
606
|
-
|
|
607
|
-
|
|
1131
|
+
writes.insertMany(collection, persisted.documents);
|
|
1132
|
+
this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, writes);
|
|
1133
|
+
this.recordObjectStorageReplacement(oldStorageBytes, persisted.documents, context.definitionId, writes, objectStorageUsage);
|
|
1134
|
+
await writes.execute();
|
|
608
1135
|
opCountDiff = -clearedOpCount + 1;
|
|
1136
|
+
before = inputStats;
|
|
1137
|
+
after = statsForDocuments(persisted.documents);
|
|
609
1138
|
}, {
|
|
610
1139
|
writeConcern: { w: 'majority' },
|
|
611
1140
|
readConcern: { level: 'snapshot' }
|
|
612
1141
|
});
|
|
613
|
-
return opCountDiff;
|
|
1142
|
+
return { opCountDiff, before, after };
|
|
614
1143
|
}
|
|
615
1144
|
/**
|
|
616
1145
|
* Reserve stable object paths before starting a retryable MongoDB transaction.
|
|
@@ -630,12 +1159,22 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
630
1159
|
return lifecycle.prepareUploads(paths);
|
|
631
1160
|
}
|
|
632
1161
|
/** Publish replacement uploads and retire superseded objects in the same transaction. */
|
|
633
|
-
|
|
1162
|
+
finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, writes) {
|
|
634
1163
|
if (!this.storage.objectStorage) {
|
|
635
1164
|
return;
|
|
636
1165
|
}
|
|
637
|
-
|
|
638
|
-
|
|
1166
|
+
this.objectStorageLifecycle.publishUploads(uploads, writes);
|
|
1167
|
+
this.objectStorageLifecycle.retire(Array.from(oldStoragePaths).filter((path) => !newStoragePaths.has(path)), writes);
|
|
1168
|
+
}
|
|
1169
|
+
recordObjectStorageReplacement(oldBytes, newDocuments, definitionId, writes, objectStorageUsage) {
|
|
1170
|
+
if (!this.storage.objectStorage) {
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
let newBytes = 0n;
|
|
1174
|
+
for (const document of newDocuments) {
|
|
1175
|
+
newBytes += ObjectStorageUsage.bytes(document);
|
|
1176
|
+
}
|
|
1177
|
+
objectStorageUsage.applyDelta(definitionId, newBytes - oldBytes, writes);
|
|
639
1178
|
}
|
|
640
1179
|
async persistBucketData(bucket, chunks, context, preparedUploads, options) {
|
|
641
1180
|
const serializedChunks = chunks.map((chunk) => serializeBucketData(bucket, chunk, options));
|
|
@@ -679,7 +1218,7 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
679
1218
|
return serialized;
|
|
680
1219
|
}
|
|
681
1220
|
const { ops, ...metadata } = serialized;
|
|
682
|
-
const { fileSize } = await store.store(upload.path, ops);
|
|
1221
|
+
const { fileSize } = await store.store(upload.path, ops, { signal: this.signal });
|
|
683
1222
|
storagePaths.add(upload.path);
|
|
684
1223
|
return {
|
|
685
1224
|
...metadata,
|