@powersync/service-module-mongodb-storage 0.0.0-dev-20260602073133 → 0.0.0-dev-20260909133214
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 +177 -5
- package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.d.ts +3 -0
- package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js +41 -0
- package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js.map +1 -0
- package/dist/storage/MongoBucketStorage.d.ts +71 -18
- package/dist/storage/MongoBucketStorage.js +516 -202
- package/dist/storage/MongoBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoBucketBatch.d.ts +71 -29
- package/dist/storage/implementation/MongoBucketBatch.js +140 -66
- package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
- package/dist/storage/implementation/MongoChecksums.d.ts +41 -13
- package/dist/storage/implementation/MongoChecksums.js +36 -121
- package/dist/storage/implementation/MongoChecksums.js.map +1 -1
- package/dist/storage/implementation/MongoCompactor.d.ts +28 -90
- package/dist/storage/implementation/MongoCompactor.js +87 -481
- package/dist/storage/implementation/MongoCompactor.js.map +1 -1
- package/dist/storage/implementation/MongoHydrationState.d.ts +11 -0
- package/dist/storage/implementation/MongoHydrationState.js +49 -0
- package/dist/storage/implementation/MongoHydrationState.js.map +1 -0
- package/dist/storage/implementation/MongoParameterCompactor.d.ts +82 -8
- package/dist/storage/implementation/MongoParameterCompactor.js +312 -76
- package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
- package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +21 -0
- package/dist/storage/implementation/MongoParsedSyncConfigSet.js +93 -0
- package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -0
- package/dist/storage/implementation/MongoPersistedReplicationStream.d.ts +36 -0
- package/dist/storage/implementation/MongoPersistedReplicationStream.js +90 -0
- package/dist/storage/implementation/MongoPersistedReplicationStream.js.map +1 -0
- package/dist/storage/implementation/MongoPersistedSyncConfigContent.d.ts +25 -0
- package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +125 -0
- package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -0
- package/dist/storage/implementation/MongoStorageProvider.js +41 -4
- package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
- package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +81 -37
- package/dist/storage/implementation/MongoSyncBucketStorage.js +159 -86
- package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoSyncRulesLock.d.ts +1 -1
- package/dist/storage/implementation/MongoSyncRulesLock.js +23 -11
- package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
- package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +11 -8
- package/dist/storage/implementation/MongoWriteCheckpointAPI.js +159 -33
- 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 +28 -0
- package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js +2 -0
- package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js.map +1 -0
- package/dist/storage/implementation/common/PersistedBatch.d.ts +34 -2
- package/dist/storage/implementation/common/PersistedBatch.js +63 -10
- package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
- package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +1 -1
- package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
- package/dist/storage/implementation/createMongoSyncBucketStorage.d.ts +2 -2
- package/dist/storage/implementation/createMongoSyncBucketStorage.js +4 -4
- package/dist/storage/implementation/createMongoSyncBucketStorage.js.map +1 -1
- package/dist/storage/implementation/db.d.ts +4 -1
- package/dist/storage/implementation/db.js +31 -16
- package/dist/storage/implementation/db.js.map +1 -1
- package/dist/storage/implementation/models.d.ts +55 -13
- package/dist/storage/implementation/models.js.map +1 -1
- package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +15 -1
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js +135 -107
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoChecksumsV1.d.ts +4 -4
- package/dist/storage/implementation/v1/MongoChecksumsV1.js +119 -7
- package/dist/storage/implementation/v1/MongoChecksumsV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +47 -7
- package/dist/storage/implementation/v1/MongoCompactorV1.js +478 -8
- 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 +33 -27
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +146 -73
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
- package/dist/storage/implementation/v1/SingleBucketStoreV1.d.ts +9 -10
- package/dist/storage/implementation/v1/SingleBucketStoreV1.js +2 -8
- package/dist/storage/implementation/v1/SingleBucketStoreV1.js.map +1 -1
- package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.d.ts +2 -1
- package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js +3 -0
- package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js.map +1 -1
- package/dist/storage/implementation/v1/models.d.ts +10 -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/BucketDataContextV3.d.ts +13 -0
- package/dist/storage/implementation/v3/BucketDataContextV3.js +23 -0
- package/dist/storage/implementation/v3/BucketDataContextV3.js.map +1 -0
- 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 +16 -5
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js +393 -262
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +30 -9
- package/dist/storage/implementation/v3/MongoChecksumsV3.js +161 -20
- package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +108 -18
- package/dist/storage/implementation/v3/MongoCompactorV3.js +1115 -47
- package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +12 -3
- package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +13 -8
- package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +54 -0
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +319 -0
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -0
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +61 -36
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +353 -161
- 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 +8 -3
- package/dist/storage/implementation/v3/PersistedBatchV3.js +131 -29
- package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/SourceRecordStoreV3.d.ts +3 -3
- package/dist/storage/implementation/v3/SourceRecordStoreV3.js +13 -11
- package/dist/storage/implementation/v3/SourceRecordStoreV3.js.map +1 -1
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +26 -14
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +73 -29
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
- package/dist/storage/implementation/v3/bucket-format.d.ts +13 -0
- package/dist/storage/implementation/v3/bucket-format.js +79 -0
- package/dist/storage/implementation/v3/bucket-format.js.map +1 -0
- package/dist/storage/implementation/v3/chunking.d.ts +10 -0
- package/dist/storage/implementation/v3/chunking.js +30 -0
- package/dist/storage/implementation/v3/chunking.js.map +1 -0
- 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 +187 -23
- package/dist/storage/implementation/v3/models.js +11 -28
- package/dist/storage/implementation/v3/models.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +20 -0
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +48 -0
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +33 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorage.js +12 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorage.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +68 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +154 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +37 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +167 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +67 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +439 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -0
- package/dist/storage/implementation/v3/source-table-utils.d.ts +63 -0
- package/dist/storage/implementation/v3/source-table-utils.js +206 -0
- package/dist/storage/implementation/v3/source-table-utils.js.map +1 -0
- package/dist/storage/storage-index.d.ts +5 -4
- package/dist/storage/storage-index.js +5 -4
- package/dist/storage/storage-index.js.map +1 -1
- package/dist/types/types.d.ts +71 -3
- package/dist/types/types.js +58 -2
- package/dist/types/types.js.map +1 -1
- package/dist/utils/test-utils.d.ts +1 -2
- package/dist/utils/test-utils.js +7 -1
- package/dist/utils/test-utils.js.map +1 -1
- package/dist/utils/util.d.ts +9 -2
- package/dist/utils/util.js +131 -5
- package/dist/utils/util.js.map +1 -1
- package/package.json +13 -19
- package/src/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.ts +55 -0
- package/src/storage/MongoBucketStorage.ts +728 -242
- package/src/storage/implementation/MongoBucketBatch.ts +196 -91
- package/src/storage/implementation/MongoChecksums.ts +93 -153
- package/src/storage/implementation/MongoCompactor.ts +106 -631
- package/src/storage/implementation/MongoHydrationState.ts +61 -0
- package/src/storage/implementation/MongoParameterCompactor.ts +392 -87
- package/src/storage/implementation/MongoParsedSyncConfigSet.ts +122 -0
- package/src/storage/implementation/MongoPersistedReplicationStream.ts +118 -0
- package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +156 -0
- package/src/storage/implementation/MongoStorageProvider.ts +48 -4
- package/src/storage/implementation/MongoSyncBucketStorage.ts +226 -118
- package/src/storage/implementation/MongoSyncRulesLock.ts +28 -12
- package/src/storage/implementation/MongoWriteCheckpointAPI.ts +194 -52
- package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
- package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +31 -0
- package/src/storage/implementation/common/PersistedBatch.ts +83 -15
- package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +1 -1
- package/src/storage/implementation/createMongoSyncBucketStorage.ts +21 -7
- package/src/storage/implementation/db.ts +40 -22
- package/src/storage/implementation/models.ts +58 -14
- package/src/storage/implementation/v1/MongoBucketBatchV1.ts +174 -125
- package/src/storage/implementation/v1/MongoChecksumsV1.ts +154 -12
- package/src/storage/implementation/v1/MongoCompactorV1.ts +594 -25
- package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
- package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +226 -129
- package/src/storage/implementation/v1/SingleBucketStoreV1.ts +18 -27
- package/src/storage/implementation/v1/VersionedPowerSyncMongoV1.ts +11 -1
- package/src/storage/implementation/v1/models.ts +12 -1
- package/src/storage/implementation/v3/BucketDataContextV3.ts +32 -0
- package/src/storage/implementation/v3/CompactionLease.ts +152 -0
- package/src/storage/implementation/v3/MongoBucketBatchV3.ts +502 -324
- package/src/storage/implementation/v3/MongoChecksumsV3.ts +250 -32
- package/src/storage/implementation/v3/MongoCompactorV3.ts +1439 -70
- package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +19 -9
- package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +473 -0
- package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +453 -211
- package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
- package/src/storage/implementation/v3/PersistedBatchV3.ts +169 -45
- package/src/storage/implementation/v3/SourceRecordStoreV3.ts +12 -12
- package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +99 -38
- package/src/storage/implementation/v3/bucket-format.ts +99 -0
- package/src/storage/implementation/v3/chunking.ts +40 -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 +228 -58
- package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +62 -0
- package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +44 -0
- package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +182 -0
- package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +230 -0
- package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +573 -0
- package/src/storage/implementation/v3/source-table-utils.ts +398 -0
- package/src/storage/storage-index.ts +14 -4
- package/src/types/types.ts +76 -2
- package/src/utils/test-utils.ts +8 -3
- package/src/utils/util.ts +188 -7
- package/test/src/__snapshots__/storage.test.ts.snap +4 -54
- package/test/src/__snapshots__/storage_sync.test.ts.snap +652 -645
- package/test/src/cleanup-stopped-sync-configs.test.ts +698 -0
- package/test/src/compact-utils.test.ts +226 -0
- package/test/src/helpers/MemoryObjectStorage.ts +61 -0
- package/test/src/helpers/s3TestFactory.ts +69 -0
- package/test/src/object_storage_lifecycle.test.ts +59 -0
- package/test/src/object_storage_usage.test.ts +255 -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/setup.ts +6 -1
- package/test/src/storage.test.ts +478 -13
- package/test/src/storage_compacting.test.ts +2516 -107
- package/test/src/storage_object_storage_inline_threshold.test.ts +116 -0
- package/test/src/storage_s3_checksums.test.ts +190 -0
- package/test/src/storage_s3_compaction_lifecycle.test.ts +543 -0
- package/test/src/storage_s3_reading.test.ts +768 -0
- package/test/src/storage_s3_writing.test.ts +136 -0
- package/test/src/storage_sync.test.ts +1507 -76
- package/test/src/storeCurrentData.test.ts +227 -0
- package/test/src/util.ts +3 -2
- package/tsconfig.json +1 -4
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/storage/implementation/BucketDefinitionMapping.d.ts +0 -15
- package/dist/storage/implementation/BucketDefinitionMapping.js +0 -58
- package/dist/storage/implementation/BucketDefinitionMapping.js.map +0 -1
- package/dist/storage/implementation/MongoPersistedSyncRules.d.ts +0 -14
- package/dist/storage/implementation/MongoPersistedSyncRules.js +0 -67
- package/dist/storage/implementation/MongoPersistedSyncRules.js.map +0 -1
- package/dist/storage/implementation/MongoPersistedSyncRulesContent.d.ts +0 -29
- package/dist/storage/implementation/MongoPersistedSyncRulesContent.js +0 -76
- package/dist/storage/implementation/MongoPersistedSyncRulesContent.js.map +0 -1
- package/dist/storage/implementation/common/MongoSyncBucketStorageContext.d.ts +0 -13
- package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js +0 -2
- package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js.map +0 -1
- package/dist/storage/implementation/common/SingleBucketStore.d.ts +0 -54
- package/dist/storage/implementation/common/SingleBucketStore.js +0 -3
- package/dist/storage/implementation/common/SingleBucketStore.js.map +0 -1
- package/dist/storage/implementation/v3/MongoParameterLookupV3.d.ts +0 -4
- package/dist/storage/implementation/v3/MongoParameterLookupV3.js +0 -9
- package/dist/storage/implementation/v3/MongoParameterLookupV3.js.map +0 -1
- package/dist/storage/implementation/v3/SingleBucketStoreV3.d.ts +0 -18
- package/dist/storage/implementation/v3/SingleBucketStoreV3.js +0 -48
- package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +0 -1
- package/src/storage/implementation/BucketDefinitionMapping.ts +0 -75
- package/src/storage/implementation/MongoPersistedSyncRules.ts +0 -82
- package/src/storage/implementation/MongoPersistedSyncRulesContent.ts +0 -101
- package/src/storage/implementation/common/MongoSyncBucketStorageContext.ts +0 -15
- package/src/storage/implementation/common/SingleBucketStore.ts +0 -63
- package/src/storage/implementation/v3/MongoParameterLookupV3.ts +0 -11
- package/src/storage/implementation/v3/SingleBucketStoreV3.ts +0 -68
|
@@ -1,68 +1,1136 @@
|
|
|
1
|
-
import { mongo } from '@powersync/lib-service-mongodb';
|
|
2
1
|
import { ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { addChecksums, formatBytes, utils } from '@powersync/service-core';
|
|
3
|
+
import { ConcurrentCompactionError, MongoCompactor } from '../MongoCompactor.js';
|
|
4
|
+
import { cacheKey } from '../OperationBatch.js';
|
|
5
|
+
import { loadBucketDataDocument, maxOpId, serializeBucketData } from './bucket-format.js';
|
|
6
|
+
import { BucketDataContextV3 } from './BucketDataContextV3.js';
|
|
7
|
+
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 { DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS } from './compaction-constants.js';
|
|
10
|
+
import { AVAILABLE_LEASE_EXPR, CompactionLease } from './CompactionLease.js';
|
|
11
|
+
import { BucketDataObjectStorage, hydrateBucketDataDocuments } from './object-storage/BucketDataObjectStorage.js';
|
|
12
|
+
import { ObjectStorageLifecycle } from './object-storage/ObjectStorageLifecycle.js';
|
|
13
|
+
import { createObjectStorageUsageWriterId, ObjectStorageUsage } from './object-storage/ObjectStorageUsage.js';
|
|
14
|
+
const DEFAULT_MIN_COMPACT_FULL_INTERVAL_MS = 2 * 60 * 60 * 1000;
|
|
15
|
+
const DEFAULT_MAX_COMPACT_FULL_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
16
|
+
const DEFAULT_COMPACT_LEASE_DURATION_MS = 10 * 60 * 1000;
|
|
17
|
+
const SCHEDULED_COMPACTION_BATCH_SIZE = 100;
|
|
5
18
|
export class MongoCompactorV3 extends MongoCompactor {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
19
|
+
minCompactChunkIntervalMs;
|
|
20
|
+
minCompactFullIntervalMs;
|
|
21
|
+
maxCompactFullIntervalMs;
|
|
22
|
+
compactLeaseDurationMs;
|
|
23
|
+
maxOpIdCap;
|
|
24
|
+
objectStorageUsage;
|
|
25
|
+
constructor(bucketStorage, db, options) {
|
|
26
|
+
super(bucketStorage, db, options);
|
|
27
|
+
this.minCompactChunkIntervalMs = options.minCompactChunkIntervalMs ?? DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS;
|
|
28
|
+
this.minCompactFullIntervalMs = options.minCompactFullIntervalMs ?? DEFAULT_MIN_COMPACT_FULL_INTERVAL_MS;
|
|
29
|
+
this.maxCompactFullIntervalMs = options.maxCompactFullIntervalMs ?? DEFAULT_MAX_COMPACT_FULL_INTERVAL_MS;
|
|
30
|
+
this.compactLeaseDurationMs = options.compactLeaseDurationMs ?? DEFAULT_COMPACT_LEASE_DURATION_MS;
|
|
31
|
+
this.maxOpIdCap = options.maxOpId;
|
|
32
|
+
this.objectStorageUsage = new ObjectStorageUsage(this.db, this.group_id, createObjectStorageUsageWriterId());
|
|
33
|
+
}
|
|
34
|
+
async compact() {
|
|
35
|
+
if (this.storage.objectStorage) {
|
|
36
|
+
// Clean these before compacting - should be quick in most cases.
|
|
37
|
+
try {
|
|
38
|
+
await this.objectStorageLifecycle.cleanup(this.logger, { signal: this.signal });
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
if (this.signal?.aborted) {
|
|
42
|
+
throw e;
|
|
43
|
+
}
|
|
44
|
+
// In this case, still continue normal compact process
|
|
45
|
+
this.logger.error(`Failed to clean up object storage deletion markers before compaction`, e);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
await this.deleteOldCheckpointRequests();
|
|
49
|
+
if (this.buckets != null) {
|
|
50
|
+
await this.compactExplicitBuckets(this.buckets);
|
|
51
|
+
}
|
|
52
|
+
else if (this.compactChunksOnly) {
|
|
53
|
+
// Writers defer their first chunk-compaction check by this fixed default.
|
|
54
|
+
// Include that interval so this synchronous initial-replication pass
|
|
55
|
+
// processes the work that existed when it started.
|
|
56
|
+
await this.compactScheduledBuckets({
|
|
57
|
+
dueAheadMs: DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS,
|
|
58
|
+
forceKind: CompactionKind.Chunks
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
await this.compactScheduledBuckets();
|
|
9
63
|
}
|
|
10
|
-
|
|
11
|
-
|
|
64
|
+
if (this.storage.objectStorage) {
|
|
65
|
+
// Cleanup for any produced during compacting.
|
|
66
|
+
// Note that markers only expire after a delay, so this may skip many produced during this compact
|
|
67
|
+
// run. However, during long compact runs, this may also have many ones it can clean up.
|
|
68
|
+
await this.objectStorageLifecycle.cleanup(this.logger, { signal: this.signal });
|
|
69
|
+
await this.objectStorageUsage.foldStaleWriterDeltas();
|
|
70
|
+
}
|
|
71
|
+
return this.compactedBucketCount;
|
|
12
72
|
}
|
|
13
|
-
async
|
|
14
|
-
if (
|
|
15
|
-
|
|
73
|
+
async deleteOldCustomCheckpointRequests() {
|
|
74
|
+
if (this.deleteCheckpointRequestsBefore == null) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (const collection of await this.db.listCustomCheckpointRequestCollections(this.group_id)) {
|
|
78
|
+
this.signal?.throwIfAborted();
|
|
79
|
+
await collection.deleteMany({
|
|
80
|
+
checkpoint_requested_at: { $exists: true, $lt: this.deleteCheckpointRequestsBefore }
|
|
81
|
+
});
|
|
16
82
|
}
|
|
17
|
-
return this.dirtyBucketBatchForChecksumsForCollection(this.db.bucketStateV3(this.group_id), {
|
|
18
|
-
'estimate_since_compact.count': { $gte: options.minBucketChanges }
|
|
19
|
-
}, (bucketState) => bucketState._id.d);
|
|
20
83
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
84
|
+
/** An explicit compact request always runs a full compact for its buckets. */
|
|
85
|
+
async compactExplicitBuckets(buckets) {
|
|
86
|
+
for (const bucket of buckets) {
|
|
87
|
+
// This is not a super efficient query, but this is not a common use case.
|
|
88
|
+
// May be optimized later.
|
|
89
|
+
const states = await this.db
|
|
90
|
+
.bucketState(this.group_id)
|
|
91
|
+
.find({ '_id.b': bucket }, { projection: { _id: 1 } })
|
|
92
|
+
.toArray();
|
|
93
|
+
for (const state of states) {
|
|
94
|
+
await using lease = await this.claimBucket({ _id: state._id });
|
|
95
|
+
if (lease == null || lease.state.first_uncompacted_write == null) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (this.isCompactionTargetCovered(lease.state, CompactionKind.Full)) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const decision = chooseCompactionKind(lease.state, lease.startedAt, this);
|
|
102
|
+
await this.compactClaimedBucket(lease, CompactionKind.Full, decision);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
25
105
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Process scheduled work in bounded batches.
|
|
108
|
+
*
|
|
109
|
+
* Batching specifically help to cover cases of many buckets where no compaction is required:
|
|
110
|
+
* Instead of sequentially claiming and then rescheduling a bucket, this handles it in bulk.
|
|
111
|
+
*
|
|
112
|
+
* Buckets that do need compaction are still claimed and processed sequentially.
|
|
113
|
+
*
|
|
114
|
+
* Any concurrent workers may read the same batch. Rescheduling filters out buckets handled
|
|
115
|
+
* by a concurrent worker or replication write, while buckets that do need compaction are
|
|
116
|
+
* filtered out when claiming a compaction lease.
|
|
117
|
+
*
|
|
118
|
+
* We filter scheduled jobs by the job start date, so that the same bucket is not compacted
|
|
119
|
+
* multiple times in one run. Reschedules fall beyond the fixed boundary. For the run after
|
|
120
|
+
* initial replication, dueAheadMs extends that boundary to include the first deferred interval.
|
|
121
|
+
*/
|
|
122
|
+
async compactScheduledBuckets(options = {}) {
|
|
123
|
+
// Writers derive next_compact_check from MongoDB's $$NOW. Use the same
|
|
124
|
+
// clock for the fixed job boundary so clock skew cannot exclude work at
|
|
125
|
+
// the exact initial-replication interval.
|
|
126
|
+
const [{ now: jobStartedAt }] = await this.db.db
|
|
127
|
+
.aggregate([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
|
|
128
|
+
.toArray();
|
|
129
|
+
const dueBefore = new Date(jobStartedAt.getTime() + (options.dueAheadMs ?? 0));
|
|
130
|
+
const forceKind = options.forceKind;
|
|
131
|
+
const rescheduleNotBefore = new Date(dueBefore.getTime() + 1);
|
|
132
|
+
while (true) {
|
|
133
|
+
this.signal?.throwIfAborted();
|
|
134
|
+
const states = await this.findScheduledBucketBatch(dueBefore);
|
|
135
|
+
if (states.length == 0) {
|
|
136
|
+
break;
|
|
30
137
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
138
|
+
const scheduled = [];
|
|
139
|
+
for (const state of states) {
|
|
140
|
+
try {
|
|
141
|
+
scheduled.push({
|
|
142
|
+
state,
|
|
143
|
+
decision: chooseCompactionKind(state, jobStartedAt, this),
|
|
144
|
+
forcedKind: forcedCompactionKind(state, forceKind, this)
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const noOpStates = scheduled.filter(({ state, decision, forcedKind }) => state.compact_lease == null && (forceKind == null ? decision.kind : forcedKind) == null);
|
|
152
|
+
await this.rescheduleUnclaimedBuckets(noOpStates, rescheduleNotBefore);
|
|
153
|
+
for (const { state, decision, forcedKind } of scheduled) {
|
|
154
|
+
const kind = forceKind == null ? decision.kind : forcedKind;
|
|
155
|
+
if (state.compact_lease == null && kind == null) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
await using lease = await this.claimBucket({ _id: state._id, next_compact_check: { $lte: dueBefore } });
|
|
160
|
+
if (lease == null) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const claimedDecision = chooseCompactionKind(lease.state, lease.startedAt, this);
|
|
164
|
+
const claimedKind = forceKind == null ? claimedDecision.kind : forcedCompactionKind(lease.state, forceKind, this);
|
|
165
|
+
if (claimedKind == null) {
|
|
166
|
+
await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
|
|
167
|
+
}
|
|
168
|
+
else if (this.isCompactionTargetCovered(lease.state, claimedKind)) {
|
|
169
|
+
// The run cannot advance this kind's watermark without regressing
|
|
170
|
+
// already-published progress. Keep any newer work scheduled.
|
|
171
|
+
await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
await this.compactClaimedBucket(lease, claimedKind, claimedDecision, rescheduleNotBefore);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (this.signal?.aborted) {
|
|
179
|
+
// When aborted, stop completely, rather than logging and re-scheduling individual buckets.
|
|
180
|
+
// The lease on the current bucket is still released automatically.
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Read a bounded, priority-ordered snapshot of currently claimable scheduled work. */
|
|
189
|
+
async findScheduledBucketBatch(dueBefore) {
|
|
190
|
+
return this.db
|
|
191
|
+
.bucketState(this.group_id)
|
|
192
|
+
.find({
|
|
193
|
+
next_compact_check: { $lte: dueBefore },
|
|
194
|
+
...AVAILABLE_LEASE_EXPR
|
|
195
|
+
})
|
|
196
|
+
.sort({ next_compact_check: 1 })
|
|
197
|
+
.limit(SCHEDULED_COMPACTION_BATCH_SIZE)
|
|
198
|
+
.toArray();
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Reschedule snapshots that were already known to be no-ops without first
|
|
202
|
+
* taking a lease. Every decision input is compared so a concurrent writer
|
|
203
|
+
* or compactor simply makes the update a no-op instead of losing work. A
|
|
204
|
+
* successful reschedule moves beyond this run's fixed selection boundary.
|
|
205
|
+
*/
|
|
206
|
+
async rescheduleUnclaimedBuckets(states, notBefore) {
|
|
207
|
+
if (states.length == 0) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
await this.db.bucketState(this.group_id).bulkWrite(states.map(({ state, decision }) => ({
|
|
211
|
+
updateOne: {
|
|
212
|
+
filter: unclaimedSnapshotFilter(state),
|
|
213
|
+
update: [{ $set: { next_compact_check: this.rescheduleAtOrAfter(decision.nextCompactCheck, notBefore) } }]
|
|
214
|
+
}
|
|
215
|
+
})), { ordered: false });
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Isolate a malformed bucket so it cannot prevent other scheduled buckets
|
|
219
|
+
* from compacting. The snapshot filter preserves any concurrent write or
|
|
220
|
+
* compactor result instead of overwriting its next check.
|
|
221
|
+
*/
|
|
222
|
+
async rescheduleFailedBucket(state, notBefore, error) {
|
|
223
|
+
this.logger.error(`Failed to compact scheduled bucket ${state._id.b}; rescheduling it`, error);
|
|
224
|
+
try {
|
|
225
|
+
await this.db
|
|
226
|
+
.bucketState(this.group_id)
|
|
227
|
+
.updateOne(unclaimedSnapshotFilter(state), [{ $set: { next_compact_check: notBefore } }]);
|
|
228
|
+
}
|
|
229
|
+
catch (rescheduleError) {
|
|
230
|
+
this.logger.error(`Failed to reschedule bucket ${state._id.b} after a compaction error`, rescheduleError);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Given a bucket filter, claim a lease on the bucket. The filter should include a filter on _id.
|
|
235
|
+
*
|
|
236
|
+
* Resolves to null if the bucket is already claimed, not found, or filtered out.
|
|
237
|
+
*/
|
|
238
|
+
async claimBucket(filter, sort) {
|
|
239
|
+
return CompactionLease.claim(this.db.bucketState(this.group_id), filter, sort, this.compactLeaseDurationMs);
|
|
240
|
+
}
|
|
241
|
+
async compactClaimedBucket(lease, kind, decision, rescheduleNotBefore) {
|
|
242
|
+
const context = new CompactionContext(lease, kind, decision, rescheduleNotBefore, this.compactionTarget(lease.state));
|
|
243
|
+
lease.startRenewal();
|
|
244
|
+
await this.compactSingleBucket(context);
|
|
245
|
+
}
|
|
246
|
+
async rescheduleClaimedBucket(lease, decision, notBefore) {
|
|
247
|
+
await lease.reschedule(this.rescheduleAtOrAfter(decision.nextCompactCheck, notBefore));
|
|
37
248
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
249
|
+
rescheduleAtOrAfter(nextCompactCheck, notBefore) {
|
|
250
|
+
return notBefore == null ? nextCompactCheck : { $max: [nextCompactCheck, notBefore] };
|
|
251
|
+
}
|
|
252
|
+
compactionTarget(state) {
|
|
253
|
+
return this.maxOpIdCap == null || state.last_op < this.maxOpIdCap ? state.last_op : this.maxOpIdCap;
|
|
254
|
+
}
|
|
255
|
+
isCompactionTargetCovered(state, kind) {
|
|
256
|
+
const target = this.compactionTarget(state);
|
|
257
|
+
if (kind == CompactionKind.Chunks) {
|
|
258
|
+
return state.compacted_state != null && state.compacted_state.op_id >= target;
|
|
259
|
+
}
|
|
260
|
+
if (state.last_full_compact != null && state.last_full_compact.op_id >= target) {
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
// A full compact may change counts before the checksum-cache boundary.
|
|
264
|
+
// Wait for the safe target to catch up instead of publishing an older or
|
|
265
|
+
// stale cache. At the same boundary, full coverage can still advance.
|
|
266
|
+
return state.compacted_state != null && state.compacted_state.op_id > target;
|
|
267
|
+
}
|
|
268
|
+
get objectStorageLifecycle() {
|
|
269
|
+
if (!this.storage.objectStorage) {
|
|
270
|
+
throw new Error('Object storage is not configured');
|
|
271
|
+
}
|
|
272
|
+
return new ObjectStorageLifecycle(this.db, this.group_id, this.storage.objectStorage);
|
|
273
|
+
}
|
|
274
|
+
async compactSingleBucket(context) {
|
|
275
|
+
if (context.kind == CompactionKind.Chunks) {
|
|
276
|
+
return this.compactSingleBucketChunks(context);
|
|
277
|
+
}
|
|
278
|
+
return this.compactSingleBucketFully(context);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Merge adjacent bucket-data chunks without inspecting their operations
|
|
282
|
+
* unless a merge is possible. The metadata contains enough information to
|
|
283
|
+
* update the persisted checksum state and to decide whether a group can fit
|
|
284
|
+
* in one chunk.
|
|
285
|
+
*/
|
|
286
|
+
async compactSingleBucketChunks(context) {
|
|
287
|
+
const bucket = context.state._id.b;
|
|
288
|
+
const resolvedDefinitionId = context.state._id.d;
|
|
289
|
+
const bucketContext = new BucketDataContextV3(this.db, {
|
|
290
|
+
bucket,
|
|
291
|
+
definitionId: resolvedDefinitionId,
|
|
292
|
+
replicationStreamId: this.group_id
|
|
293
|
+
});
|
|
294
|
+
const collection = this.db.bucketData(this.group_id, resolvedDefinitionId);
|
|
295
|
+
const dataContext = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
|
|
296
|
+
let previousCompactedState = context.state.compacted_state;
|
|
297
|
+
// A zero boundary represents an empty prefix, so there is no stored chunk
|
|
298
|
+
// whose statistics need to be carried into this pass.
|
|
299
|
+
if (previousCompactedState?.op_id === 0n) {
|
|
300
|
+
previousCompactedState = undefined;
|
|
301
|
+
}
|
|
302
|
+
// Include the last previously compacted chunk as well as new chunks. It
|
|
303
|
+
// is the only old chunk which can become mergeable with the new tail.
|
|
304
|
+
let lowerBound = previousCompactedState != null ? bucketContext.docId(previousCompactedState.op_id - 1n) : bucketContext.minId;
|
|
305
|
+
const upperBound = bucketContext.docId(context.targetOp + 1n);
|
|
306
|
+
let cachedBoundaryToVerify = previousCompactedState?.op_id;
|
|
307
|
+
let compactedOpId = null;
|
|
308
|
+
let overlappingCompactedChunk;
|
|
309
|
+
let compactedTail = emptyBucketStats();
|
|
310
|
+
let pendingChunks = [];
|
|
311
|
+
let pendingSize = 0;
|
|
312
|
+
while (true) {
|
|
313
|
+
this.signal?.throwIfAborted();
|
|
314
|
+
await context.lease.throwIfLost();
|
|
315
|
+
const batch = await readCompactionBatch(collection.aggregate([
|
|
316
|
+
{
|
|
317
|
+
$match: {
|
|
318
|
+
_id: {
|
|
319
|
+
$gt: lowerBound,
|
|
320
|
+
$lt: upperBound
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
{ $sort: { _id: 1 } },
|
|
325
|
+
{ $limit: this.moveBatchQueryLimit },
|
|
326
|
+
{
|
|
327
|
+
$project: {
|
|
328
|
+
_id: 1,
|
|
329
|
+
min_op: 1,
|
|
330
|
+
checksum: 1,
|
|
331
|
+
count: 1,
|
|
332
|
+
size: 1,
|
|
333
|
+
target_op: 1,
|
|
334
|
+
storage_ref: 1
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
], { batchSize: this.moveBatchQueryLimit + 1 }), {
|
|
338
|
+
byteLimit: this.moveBatchByteLimit,
|
|
339
|
+
documentLimit: this.moveBatchQueryLimit
|
|
340
|
+
});
|
|
341
|
+
if (cachedBoundaryToVerify != null) {
|
|
342
|
+
const cachedBoundary = cachedBoundaryToVerify;
|
|
343
|
+
cachedBoundaryToVerify = undefined;
|
|
344
|
+
if (batch.documents[0]?._id.o !== cachedBoundary) {
|
|
345
|
+
// A previous attempt may have replaced the cached boundary before
|
|
346
|
+
// finalizing bucket state. Keep the persisted cache available to
|
|
347
|
+
// readers, but ignore it in this attempt and calculate its
|
|
348
|
+
// replacement through the normal scan from the bucket beginning.
|
|
349
|
+
previousCompactedState = undefined;
|
|
350
|
+
lowerBound = bucketContext.minId;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (batch.documents.length == 0) {
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
for (const doc of batch.documents) {
|
|
358
|
+
compactedOpId = maxOpId(compactedOpId, doc._id.o);
|
|
359
|
+
const documentStats = statsForDocument(doc);
|
|
360
|
+
if (previousCompactedState?.op_id === doc._id.o) {
|
|
361
|
+
overlappingCompactedChunk = documentStats;
|
|
362
|
+
}
|
|
363
|
+
const nextSize = pendingSize + doc.size;
|
|
364
|
+
if (pendingChunks.length > 0 && nextSize > DEFAULT_MAX_DOC_SIZE_BYTES) {
|
|
365
|
+
const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext);
|
|
366
|
+
compactedTail = combineAdjacentStats(compactedTail, groupStats);
|
|
367
|
+
pendingChunks = [];
|
|
368
|
+
pendingSize = 0;
|
|
369
|
+
}
|
|
370
|
+
pendingChunks.push(doc);
|
|
371
|
+
pendingSize += doc.size;
|
|
372
|
+
}
|
|
373
|
+
lowerBound = batch.documents[batch.documents.length - 1]._id;
|
|
374
|
+
if (!batch.hasMore) {
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (pendingChunks.length > 0) {
|
|
379
|
+
const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext);
|
|
380
|
+
compactedTail = combineAdjacentStats(compactedTail, groupStats);
|
|
381
|
+
}
|
|
382
|
+
if (compactedOpId == null) {
|
|
383
|
+
await this.finalizeSkippedBucket(context);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const compactedState = previousCompactedState == null
|
|
387
|
+
? compactedTail
|
|
388
|
+
: combineChunkStats(previousCompactedState, compactedTail, overlappingCompactedChunk);
|
|
389
|
+
const tailStats = compactedOpId == context.lastOp
|
|
390
|
+
? undefined
|
|
391
|
+
: await this.readBucketStats(bucket, resolvedDefinitionId, context.lastOp, bucketContext.docId(compactedOpId));
|
|
392
|
+
const result = {
|
|
393
|
+
compactedState,
|
|
394
|
+
bucketStats: tailStats == null ? compactedState : combineAdjacentStats(compactedState, tailStats)
|
|
395
|
+
};
|
|
396
|
+
await this.finalizeCompactedBucket({ context, compactedOpId, compactionResult: result, puts: 0 });
|
|
397
|
+
this.compactedBucketCount++;
|
|
398
|
+
this.logger.info(`Compacted bucket chunks ${bucket}: ${result.bucketStats.count} ops, ${result.bucketStats.chunks} chunks, ${formatBytes(result.bucketStats.bytes)}`);
|
|
399
|
+
}
|
|
400
|
+
async flushChunkMerge(bucket, inputs, collection, context, bucketContext) {
|
|
401
|
+
if (inputs.length == 1) {
|
|
402
|
+
return statsForDocument(inputs[0]);
|
|
403
|
+
}
|
|
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());
|
|
415
|
+
}
|
|
416
|
+
}
|
|
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
|
+
}
|
|
428
|
+
async finalizeCompactedBucket({ context, compactedOpId, compactionResult, puts }) {
|
|
429
|
+
await context.lease.throwIfLost();
|
|
430
|
+
const startedStats = bucketStats(context.state);
|
|
431
|
+
const delta = {
|
|
432
|
+
count: compactionResult.bucketStats.count - startedStats.count,
|
|
433
|
+
bytes: compactionResult.bucketStats.bytes - startedStats.bytes,
|
|
434
|
+
chunks: compactionResult.bucketStats.chunks - startedStats.chunks
|
|
435
|
+
};
|
|
436
|
+
const coveredClaimedHead = compactedOpId >= context.lastOp;
|
|
437
|
+
const concurrentWriteCheck = { $gt: ['$last_op', context.lastOp] };
|
|
438
|
+
const remainingFullWorkCheck = coveredClaimedHead ? concurrentWriteCheck : true;
|
|
439
|
+
const nextAfterPartialFullCompact = this.rescheduleAtOrAfter({ $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } }, context.rescheduleNotBefore);
|
|
440
|
+
const nextCheckForUncompactedWork = this.rescheduleAtOrAfter({
|
|
441
|
+
$min: [
|
|
442
|
+
new Date(firstUncompactedWrite(context.state).getTime() + this.maxCompactFullIntervalMs),
|
|
443
|
+
{ $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } }
|
|
444
|
+
]
|
|
445
|
+
}, context.rescheduleNotBefore);
|
|
446
|
+
const update = {
|
|
447
|
+
compacted_state: {
|
|
448
|
+
op_id: compactedOpId,
|
|
449
|
+
checksum: BigInt(compactionResult.compactedState.checksum),
|
|
450
|
+
count: compactionResult.compactedState.count,
|
|
451
|
+
bytes: compactionResult.compactedState.bytes,
|
|
452
|
+
chunks: compactionResult.compactedState.chunks,
|
|
453
|
+
at: '$$NOW'
|
|
454
|
+
},
|
|
455
|
+
bucket_stats: {
|
|
456
|
+
count: { $add: ['$bucket_stats.count', delta.count] },
|
|
457
|
+
bytes: { $add: ['$bucket_stats.bytes', delta.bytes] },
|
|
458
|
+
chunks: { $add: ['$bucket_stats.chunks', delta.chunks] }
|
|
459
|
+
},
|
|
460
|
+
first_uncompacted_write: context.kind == CompactionKind.Full
|
|
461
|
+
? { $cond: [remainingFullWorkCheck, '$$NOW', '$$REMOVE'] }
|
|
462
|
+
: '$first_uncompacted_write',
|
|
463
|
+
next_compact_check: context.kind == CompactionKind.Full
|
|
464
|
+
? { $cond: [remainingFullWorkCheck, nextAfterPartialFullCompact, '$$REMOVE'] }
|
|
465
|
+
: nextCheckForUncompactedWork
|
|
466
|
+
};
|
|
467
|
+
if (context.kind == CompactionKind.Full) {
|
|
468
|
+
update.last_full_compact = {
|
|
469
|
+
op_id: compactedOpId,
|
|
470
|
+
count: compactionResult.compactedState.count,
|
|
471
|
+
puts,
|
|
472
|
+
at: '$$NOW'
|
|
473
|
+
};
|
|
41
474
|
}
|
|
475
|
+
await context.lease.finalize(update);
|
|
476
|
+
}
|
|
477
|
+
async finalizeSkippedBucket(context) {
|
|
478
|
+
// A maxOpId cap can exclude the first remaining document entirely. Avoid
|
|
479
|
+
// immediately claiming the same no-progress bucket again in this run.
|
|
480
|
+
await this.rescheduleClaimedBucket(context.lease, {
|
|
481
|
+
...context.decision,
|
|
482
|
+
nextCompactCheck: {
|
|
483
|
+
$max: [
|
|
484
|
+
context.decision.nextCompactCheck,
|
|
485
|
+
{ $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } }
|
|
486
|
+
]
|
|
487
|
+
}
|
|
488
|
+
}, context.rescheduleNotBefore);
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Read bucket stats directly from bucket_data documents.
|
|
492
|
+
*/
|
|
493
|
+
async readBucketStats(bucket, definitionId, maxOp, lowerBound) {
|
|
494
|
+
const context = new BucketDataContextV3(this.db, {
|
|
495
|
+
bucket,
|
|
496
|
+
definitionId,
|
|
497
|
+
replicationStreamId: this.group_id
|
|
498
|
+
});
|
|
499
|
+
const [stats] = await this.db
|
|
500
|
+
.bucketData(this.group_id, definitionId)
|
|
501
|
+
.aggregate([
|
|
502
|
+
{
|
|
503
|
+
$match: {
|
|
504
|
+
_id: lowerBound == null
|
|
505
|
+
? { $gte: context.minId, $lte: context.docId(maxOp) }
|
|
506
|
+
: { $gt: lowerBound, $lte: context.docId(maxOp) }
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
$group: {
|
|
511
|
+
_id: null,
|
|
512
|
+
count: { $sum: '$count' },
|
|
513
|
+
bytes: { $sum: '$size' },
|
|
514
|
+
chunks: { $sum: 1 },
|
|
515
|
+
checksum: { $sum: '$checksum' }
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
])
|
|
519
|
+
.toArray();
|
|
42
520
|
return {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
521
|
+
count: Number(stats?.count ?? 0),
|
|
522
|
+
bytes: BigInt(stats?.bytes ?? 0),
|
|
523
|
+
chunks: Number(stats?.chunks ?? 0),
|
|
524
|
+
checksum: typeof stats?.checksum == 'bigint'
|
|
525
|
+
? Number(BigInt.asIntN(32, stats.checksum))
|
|
526
|
+
: addChecksums(0, Number(stats?.checksum ?? 0))
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
async compactSingleBucketFully(context) {
|
|
530
|
+
const bucket = context.state._id.b;
|
|
531
|
+
const resolvedDefinitionId = context.state._id.d;
|
|
532
|
+
const bucketContext = new BucketDataContextV3(this.db, {
|
|
533
|
+
bucket,
|
|
534
|
+
definitionId: resolvedDefinitionId,
|
|
535
|
+
replicationStreamId: this.group_id
|
|
536
|
+
});
|
|
537
|
+
const collection = this.db.bucketData(this.group_id, resolvedDefinitionId);
|
|
538
|
+
const dataContext = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
|
|
539
|
+
const lowerBound = bucketContext.minId;
|
|
540
|
+
let upperBound = bucketContext.docId(context.targetOp + 1n);
|
|
541
|
+
let totalOpCount = 0;
|
|
542
|
+
let lastNotPut = null;
|
|
543
|
+
let opsSincePut = 0;
|
|
544
|
+
let compactedOpId = null;
|
|
545
|
+
let clearBoundary = null;
|
|
546
|
+
let compactedStats = emptyBucketStats();
|
|
547
|
+
const seen = new Map();
|
|
548
|
+
let trackingSize = 0;
|
|
549
|
+
let putCount = 0;
|
|
550
|
+
let pendingGroup = null;
|
|
551
|
+
// --- Read batch from MongoDB ---
|
|
552
|
+
while (true) {
|
|
553
|
+
this.signal?.throwIfAborted();
|
|
554
|
+
await context.lease.throwIfLost();
|
|
555
|
+
const pipeline = [
|
|
556
|
+
{
|
|
557
|
+
$match: {
|
|
558
|
+
_id: {
|
|
559
|
+
$gte: lowerBound,
|
|
560
|
+
$lt: upperBound
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
{ $sort: { _id: -1 } },
|
|
565
|
+
{ $limit: this.moveBatchQueryLimit },
|
|
566
|
+
{
|
|
567
|
+
$project: {
|
|
568
|
+
_id: 1,
|
|
569
|
+
min_op: 1,
|
|
570
|
+
checksum: 1,
|
|
571
|
+
count: 1,
|
|
572
|
+
size: 1,
|
|
573
|
+
target_op: 1,
|
|
574
|
+
ops: 1,
|
|
575
|
+
storage_ref: 1
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
];
|
|
579
|
+
const batch = await readCompactionBatch(collection.aggregate(pipeline, {
|
|
580
|
+
batchSize: this.moveBatchQueryLimit + 1
|
|
581
|
+
}), {
|
|
582
|
+
byteLimit: this.moveBatchByteLimit,
|
|
583
|
+
documentLimit: this.moveBatchQueryLimit
|
|
584
|
+
});
|
|
585
|
+
const batchDocs = batch.documents;
|
|
586
|
+
if (batchDocs.length == 0) {
|
|
587
|
+
// No more documents in this bucket — compaction complete.
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
await hydrateBucketDataDocuments(batchDocs, this.storage.objectStorage, { signal: this.signal });
|
|
591
|
+
// Compact each document independently, then greedily merge adjacent
|
|
592
|
+
// post-compaction results. This preserves existing boundaries unless
|
|
593
|
+
// merging is useful, and writes each final object at most once.
|
|
594
|
+
for (const doc of batchDocs) {
|
|
595
|
+
compactedOpId ??= doc._id.o;
|
|
596
|
+
const originalOps = Array.from(loadBucketDataDocument(dataContext, doc));
|
|
597
|
+
let changed = false;
|
|
598
|
+
const compactedOps = [];
|
|
599
|
+
let maxTargetOp = doc.target_op ?? null;
|
|
600
|
+
for (let index = originalOps.length - 1; index >= 0; index--) {
|
|
601
|
+
const op = originalOps[index];
|
|
602
|
+
if (op.op == 'PUT' || op.op == 'REMOVE') {
|
|
603
|
+
const key = `${op.table}/${op.row_id}/${cacheKey(op.source_table, op.source_key)}`;
|
|
604
|
+
const targetOp = seen.get(key);
|
|
605
|
+
if (targetOp != null) {
|
|
606
|
+
maxTargetOp = maxOpId(maxTargetOp, targetOp);
|
|
607
|
+
compactedOps.push({
|
|
608
|
+
...op,
|
|
609
|
+
op: 'MOVE',
|
|
610
|
+
table: undefined,
|
|
611
|
+
row_id: undefined,
|
|
612
|
+
source_table: undefined,
|
|
613
|
+
source_key: undefined,
|
|
614
|
+
subkey: undefined,
|
|
615
|
+
data: null
|
|
616
|
+
});
|
|
617
|
+
changed = true;
|
|
618
|
+
if (lastNotPut == null) {
|
|
619
|
+
lastNotPut = op.o;
|
|
620
|
+
}
|
|
621
|
+
opsSincePut += 1;
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
if (trackingSize < this.idLimitBytes) {
|
|
625
|
+
seen.set(utils.flatstr(key), op.o);
|
|
626
|
+
trackingSize += key.length + 140;
|
|
627
|
+
}
|
|
628
|
+
compactedOps.push(op);
|
|
629
|
+
if (op.op == 'PUT') {
|
|
630
|
+
putCount++;
|
|
631
|
+
lastNotPut = null;
|
|
632
|
+
opsSincePut = 0;
|
|
633
|
+
}
|
|
634
|
+
else {
|
|
635
|
+
if (lastNotPut == null) {
|
|
636
|
+
lastNotPut = op.o;
|
|
637
|
+
}
|
|
638
|
+
opsSincePut += 1;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
compactedOps.push(op);
|
|
644
|
+
if (op.op != 'CLEAR') {
|
|
645
|
+
if (lastNotPut == null) {
|
|
646
|
+
lastNotPut = op.o;
|
|
647
|
+
}
|
|
648
|
+
opsSincePut += 1;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
compactedOps.reverse();
|
|
653
|
+
totalOpCount += compactedOps.length;
|
|
654
|
+
const candidate = {
|
|
655
|
+
inputs: [doc],
|
|
656
|
+
ops: compactedOps,
|
|
657
|
+
changed,
|
|
658
|
+
targetOp: maxTargetOp
|
|
659
|
+
};
|
|
660
|
+
if (pendingGroup == null) {
|
|
661
|
+
pendingGroup = candidate;
|
|
662
|
+
}
|
|
663
|
+
else {
|
|
664
|
+
const mergedOps = [...candidate.ops, ...pendingGroup.ops];
|
|
665
|
+
const mergedSize = serializeBucketData(bucket, mergedOps, { targetOp: maxTargetOp }).size;
|
|
666
|
+
if (mergedSize <= DEFAULT_MAX_DOC_SIZE_BYTES) {
|
|
667
|
+
pendingGroup = {
|
|
668
|
+
inputs: [...candidate.inputs, ...pendingGroup.inputs],
|
|
669
|
+
ops: mergedOps,
|
|
670
|
+
changed: candidate.changed || pendingGroup.changed,
|
|
671
|
+
targetOp: maxOpId(maxTargetOp, pendingGroup.targetOp)
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
const flushedGroup = pendingGroup;
|
|
676
|
+
const result = await this.flushCompactionGroup(bucket, flushedGroup, bucketContext, dataContext);
|
|
677
|
+
compactedStats = combineAdjacentStats(compactedStats, result.stats);
|
|
678
|
+
if (lastNotPut != null &&
|
|
679
|
+
flushedGroup.ops[0].o <= lastNotPut &&
|
|
680
|
+
flushedGroup.ops[flushedGroup.ops.length - 1].o >= lastNotPut) {
|
|
681
|
+
clearBoundary = { opId: lastNotPut, documentId: result.documentId };
|
|
682
|
+
}
|
|
683
|
+
pendingGroup = candidate;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
// --- Advance to next batch ---
|
|
688
|
+
upperBound = batchDocs[batchDocs.length - 1]._id;
|
|
689
|
+
if (!batch.hasMore) {
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
this.logger.info(`Compacted batch of ${batchDocs.length} documents for bucket ${bucket}`);
|
|
693
|
+
}
|
|
694
|
+
if (pendingGroup != null) {
|
|
695
|
+
const result = await this.flushCompactionGroup(bucket, pendingGroup, bucketContext, dataContext);
|
|
696
|
+
compactedStats = combineAdjacentStats(compactedStats, result.stats);
|
|
697
|
+
if (lastNotPut != null &&
|
|
698
|
+
pendingGroup.ops[0].o <= lastNotPut &&
|
|
699
|
+
pendingGroup.ops[pendingGroup.ops.length - 1].o >= lastNotPut) {
|
|
700
|
+
clearBoundary = { opId: lastNotPut, documentId: result.documentId };
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (compactedOpId == null) {
|
|
704
|
+
await this.finalizeSkippedBucket(context);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
// --- Clear: collapse leading MOVE/REMOVE/CLEAR sequence ---
|
|
708
|
+
if (lastNotPut != null && opsSincePut >= 2) {
|
|
709
|
+
if (clearBoundary == null || clearBoundary.opId != lastNotPut) {
|
|
710
|
+
throw new ReplicationAssertionError(`Missing CLEAR boundary document for bucket ${bucket}`);
|
|
46
711
|
}
|
|
712
|
+
const clearResult = await this.clearBucketLeading(lastNotPut, clearBoundary.documentId, bucketContext, collection, dataContext);
|
|
713
|
+
totalOpCount += clearResult.opCountDiff;
|
|
714
|
+
compactedStats = applyStatsReplacement(compactedStats, clearResult.before, clearResult.after);
|
|
715
|
+
}
|
|
716
|
+
const tailStats = compactedOpId == context.lastOp
|
|
717
|
+
? undefined
|
|
718
|
+
: await this.readBucketStats(bucket, resolvedDefinitionId, context.lastOp, bucketContext.docId(compactedOpId));
|
|
719
|
+
const result = {
|
|
720
|
+
compactedState: compactedStats,
|
|
721
|
+
bucketStats: tailStats == null ? compactedStats : combineAdjacentStats(compactedStats, tailStats)
|
|
722
|
+
};
|
|
723
|
+
// --- Finalize: update bucket checksums and state ---
|
|
724
|
+
await this.finalizeCompactedBucket({ context, compactedOpId, compactionResult: result, puts: putCount });
|
|
725
|
+
this.compactedBucketCount++;
|
|
726
|
+
this.logger.info(`Compacted bucket ${bucket}: ${totalOpCount} ops, ${result.bucketStats.chunks} chunks, ${formatBytes(result.bucketStats.bytes)}`);
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Persist replacement objects before starting the transaction, then atomically
|
|
730
|
+
* publish their lifecycle markers alongside the MongoDB document replacement.
|
|
731
|
+
* If verification or the transaction fails, the prepared markers retain enough
|
|
732
|
+
* information for the uploaded objects to be cleaned up later.
|
|
733
|
+
*/
|
|
734
|
+
async flushCompactionGroup(bucket, group, bucketContext, context) {
|
|
735
|
+
if (group.inputs.length == 1 && !group.changed) {
|
|
736
|
+
return {
|
|
737
|
+
documentId: group.inputs[0]._id,
|
|
738
|
+
stats: statsForDocument(group.inputs[0])
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
const inputs = group.inputs;
|
|
742
|
+
const idsToDelete = inputs.map((doc) => doc._id);
|
|
743
|
+
const expectedDocCount = inputs.length;
|
|
744
|
+
const expectedChecksum = inputs.reduce((sum, doc) => sum + doc.checksum, 0n);
|
|
745
|
+
const expectedOpCount = inputs.reduce((sum, doc) => sum + doc.count, 0);
|
|
746
|
+
const oldStoragePaths = inputs.flatMap((doc) => (doc.storage_ref ? [doc.storage_ref.path] : []));
|
|
747
|
+
const oldStorageBytes = inputs.reduce((sum, document) => sum + ObjectStorageUsage.bytes(document), 0n);
|
|
748
|
+
const { documents, storagePaths: newStoragePaths, uploads } = await this.persistBucketData(bucket, [group.ops], context, undefined, { targetOp: group.targetOp });
|
|
749
|
+
const session = this.db.client.startSession();
|
|
750
|
+
try {
|
|
751
|
+
await session.withTransaction(async () => {
|
|
752
|
+
const verification = await bucketContext.collection
|
|
753
|
+
.aggregate([
|
|
754
|
+
{ $match: { _id: { $in: idsToDelete } } },
|
|
755
|
+
{
|
|
756
|
+
$group: {
|
|
757
|
+
_id: null,
|
|
758
|
+
docCount: { $sum: 1 },
|
|
759
|
+
checksumSum: { $sum: '$checksum' },
|
|
760
|
+
opCountSum: { $sum: '$count' }
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
], { session })
|
|
764
|
+
.next();
|
|
765
|
+
if (verification == null ||
|
|
766
|
+
verification.docCount !== expectedDocCount ||
|
|
767
|
+
verification.checksumSum !== expectedChecksum ||
|
|
768
|
+
verification.opCountSum !== expectedOpCount) {
|
|
769
|
+
throw new ConcurrentCompactionError(`Inputs changed while compacting bucket ${bucket}; restarting from the latest bucket state`);
|
|
770
|
+
}
|
|
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);
|
|
775
|
+
}, {
|
|
776
|
+
writeConcern: { w: 'majority' },
|
|
777
|
+
readConcern: { level: 'snapshot' }
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
finally {
|
|
781
|
+
await session.endSession();
|
|
782
|
+
}
|
|
783
|
+
return {
|
|
784
|
+
documentId: documents[0]._id,
|
|
785
|
+
stats: statsForDocuments(documents)
|
|
47
786
|
};
|
|
48
787
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
788
|
+
/**
|
|
789
|
+
* Collapse the leading sequence of MOVE/REMOVE/CLEAR ops at the start
|
|
790
|
+
* of the bucket into a single CLEAR op. Reads whole clearable documents
|
|
791
|
+
* before the known boundary document, then splits that boundary document
|
|
792
|
+
* if it contains ops on both sides of lastNotPut.
|
|
793
|
+
*
|
|
794
|
+
* Returns the op count and stored-stat changes after replacing cleared ops
|
|
795
|
+
* with CLEAR ops.
|
|
796
|
+
*/
|
|
797
|
+
async clearBucketLeading(lastNotPut, boundaryDocId, bucketContext, collection, context) {
|
|
798
|
+
let opCountDiff = 0;
|
|
799
|
+
let before = emptyBucketStats();
|
|
800
|
+
let after = emptyBucketStats();
|
|
801
|
+
const session = this.db.client.startSession();
|
|
802
|
+
try {
|
|
803
|
+
let done = false;
|
|
804
|
+
// First step is to clear full chunks that contain only CLEAR/MOVE/REMOVE operations.
|
|
805
|
+
// There can be many of them, so we do one batch at a time.
|
|
806
|
+
while (!done) {
|
|
807
|
+
const batch = await this.clearLeadingFullDocuments(session, lastNotPut, boundaryDocId, bucketContext, collection, context);
|
|
808
|
+
done = batch.done;
|
|
809
|
+
opCountDiff += batch.opCountDiff;
|
|
810
|
+
before = combineAdjacentStats(before, batch.before);
|
|
811
|
+
after = combineAdjacentStats(after, batch.after);
|
|
55
812
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
813
|
+
// The final step is to process the "boundary" document: It may contain some CLEAR/MOVE/REMOVE operations,
|
|
814
|
+
// 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);
|
|
816
|
+
opCountDiff += boundaryResult.opCountDiff;
|
|
817
|
+
before = combineAdjacentStats(before, boundaryResult.before);
|
|
818
|
+
after = combineAdjacentStats(after, boundaryResult.after);
|
|
819
|
+
}
|
|
820
|
+
finally {
|
|
821
|
+
await session.endSession();
|
|
822
|
+
}
|
|
823
|
+
return { opCountDiff, before, after };
|
|
824
|
+
}
|
|
825
|
+
async clearLeadingFullDocuments(session, lastNotPut, boundaryDocId, bucketContext, collection, context) {
|
|
826
|
+
const bucket = bucketContext.key.bucket;
|
|
827
|
+
this.signal?.throwIfAborted();
|
|
828
|
+
let prepared;
|
|
829
|
+
let done = false;
|
|
830
|
+
let opCountDiff = 0;
|
|
831
|
+
let before = emptyBucketStats();
|
|
832
|
+
let after = emptyBucketStats();
|
|
833
|
+
await session.withTransaction(async () => {
|
|
834
|
+
done = false;
|
|
835
|
+
opCountDiff = 0;
|
|
836
|
+
before = emptyBucketStats();
|
|
837
|
+
after = emptyBucketStats();
|
|
838
|
+
const oldStoragePaths = [];
|
|
839
|
+
let oldStorageBytes = 0n;
|
|
840
|
+
const query = collection.find({
|
|
841
|
+
_id: {
|
|
842
|
+
$gte: bucketContext.minId,
|
|
843
|
+
$lt: boundaryDocId
|
|
844
|
+
}
|
|
845
|
+
}, {
|
|
846
|
+
session,
|
|
847
|
+
sort: { _id: 1 },
|
|
848
|
+
projection: {
|
|
849
|
+
_id: 1,
|
|
850
|
+
min_op: 1,
|
|
851
|
+
checksum: 1,
|
|
852
|
+
count: 1,
|
|
853
|
+
size: 1,
|
|
854
|
+
target_op: 1,
|
|
855
|
+
has_clear_op: 1,
|
|
856
|
+
storage_ref: 1
|
|
857
|
+
},
|
|
858
|
+
limit: this.clearBatchLimit
|
|
59
859
|
});
|
|
60
|
-
|
|
61
|
-
|
|
860
|
+
let combinedChecksum = 0;
|
|
861
|
+
let clearedOpCount = 0;
|
|
862
|
+
let maxTargetOp = null;
|
|
863
|
+
let lastDocId = null;
|
|
864
|
+
let clearOpCount = 0;
|
|
865
|
+
let gotNonClearOp = false;
|
|
866
|
+
const inputStats = emptyBucketStats();
|
|
867
|
+
for await (const doc of query.stream()) {
|
|
868
|
+
if (doc.min_op > lastNotPut) {
|
|
869
|
+
throw new ReplicationAssertionError(`Unexpected document before CLEAR boundary with min_op ${doc.min_op} > ${lastNotPut} in bucket ${bucket}`);
|
|
870
|
+
}
|
|
871
|
+
lastDocId = doc._id;
|
|
872
|
+
const documentStats = statsForDocument(doc);
|
|
873
|
+
inputStats.count += documentStats.count;
|
|
874
|
+
inputStats.bytes += documentStats.bytes;
|
|
875
|
+
inputStats.chunks += documentStats.chunks;
|
|
876
|
+
inputStats.checksum = addChecksums(inputStats.checksum, documentStats.checksum);
|
|
877
|
+
if (doc.storage_ref) {
|
|
878
|
+
oldStoragePaths.push(doc.storage_ref.path);
|
|
879
|
+
}
|
|
880
|
+
oldStorageBytes += ObjectStorageUsage.bytes(doc);
|
|
881
|
+
// The compaction scan established that every operation before the
|
|
882
|
+
// boundary is MOVE/REMOVE/CLEAR. Root metadata is sufficient to fold
|
|
883
|
+
// whole documents into one CLEAR, so avoid downloading their payloads.
|
|
884
|
+
if (doc.has_clear_op) {
|
|
885
|
+
clearOpCount++;
|
|
886
|
+
if (clearOpCount > 1) {
|
|
887
|
+
throw new ReplicationAssertionError(`Unexpected multiple CLEAR operations in bucket ${bucket}`);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
if (!doc.has_clear_op || doc.count > 1) {
|
|
891
|
+
gotNonClearOp = true;
|
|
892
|
+
}
|
|
893
|
+
combinedChecksum = addChecksums(combinedChecksum, Number(doc.checksum));
|
|
894
|
+
clearedOpCount += doc.count;
|
|
895
|
+
if (doc.target_op != null && (maxTargetOp == null || doc.target_op > maxTargetOp)) {
|
|
896
|
+
maxTargetOp = doc.target_op;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (!gotNonClearOp) {
|
|
900
|
+
done = true;
|
|
901
|
+
return;
|
|
62
902
|
}
|
|
63
|
-
|
|
903
|
+
prepared ??= await this.prepareCompactionUploads(bucket, context, [lastNotPut]);
|
|
904
|
+
this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastDocId?.o}`);
|
|
905
|
+
await collection.deleteMany({
|
|
906
|
+
_id: {
|
|
907
|
+
$gte: bucketContext.minId,
|
|
908
|
+
$lte: lastDocId
|
|
909
|
+
}
|
|
910
|
+
}, { session });
|
|
911
|
+
const clearOp = {
|
|
912
|
+
bucketKey: { ...context, bucket },
|
|
913
|
+
o: lastDocId.o,
|
|
914
|
+
op: 'CLEAR',
|
|
915
|
+
checksum: BigInt(combinedChecksum),
|
|
916
|
+
data: null
|
|
917
|
+
};
|
|
918
|
+
const persisted = await this.persistBucketData(bucket, [[clearOp]], context, prepared, {
|
|
919
|
+
targetOp: maxTargetOp
|
|
920
|
+
});
|
|
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);
|
|
924
|
+
opCountDiff = -clearedOpCount + 1;
|
|
925
|
+
before = inputStats;
|
|
926
|
+
after = statsForDocuments(persisted.documents);
|
|
927
|
+
}, {
|
|
928
|
+
writeConcern: { w: 'majority' },
|
|
929
|
+
readConcern: { level: 'snapshot' }
|
|
930
|
+
});
|
|
931
|
+
return { done, opCountDiff, before, after };
|
|
932
|
+
}
|
|
933
|
+
async clearBoundaryDocument(session, lastNotPut, boundaryDocId, bucketContext, collection, context) {
|
|
934
|
+
const bucket = bucketContext.key.bucket;
|
|
935
|
+
this.signal?.throwIfAborted();
|
|
936
|
+
const prepared = await this.prepareCompactionUploads(bucket, context, [lastNotPut, boundaryDocId.o]);
|
|
937
|
+
let opCountDiff = 0;
|
|
938
|
+
let before = emptyBucketStats();
|
|
939
|
+
let after = emptyBucketStats();
|
|
940
|
+
await session.withTransaction(async () => {
|
|
941
|
+
opCountDiff = 0;
|
|
942
|
+
before = emptyBucketStats();
|
|
943
|
+
after = emptyBucketStats();
|
|
944
|
+
const oldStoragePaths = [];
|
|
945
|
+
let oldStorageBytes = 0n;
|
|
946
|
+
const query = collection.find({
|
|
947
|
+
// This is a range query, but should only ever return two documents:
|
|
948
|
+
// 1. The CLEAR op from the previous clearLeadingFullDocuments.
|
|
949
|
+
// 2. The boundary document.
|
|
950
|
+
_id: {
|
|
951
|
+
$gte: bucketContext.minId,
|
|
952
|
+
$lte: boundaryDocId
|
|
953
|
+
}
|
|
954
|
+
}, {
|
|
955
|
+
session,
|
|
956
|
+
sort: { _id: 1 },
|
|
957
|
+
projection: {
|
|
958
|
+
_id: 1,
|
|
959
|
+
min_op: 1,
|
|
960
|
+
checksum: 1,
|
|
961
|
+
count: 1,
|
|
962
|
+
size: 1,
|
|
963
|
+
target_op: 1,
|
|
964
|
+
ops: 1,
|
|
965
|
+
storage_ref: 1
|
|
966
|
+
},
|
|
967
|
+
limit: 3
|
|
968
|
+
});
|
|
969
|
+
let docsRead = 0;
|
|
970
|
+
let combinedChecksum = 0;
|
|
971
|
+
let clearedOpCount = 0;
|
|
972
|
+
let maxTargetOp = null;
|
|
973
|
+
const boundarySurvivors = [];
|
|
974
|
+
const inputStats = emptyBucketStats();
|
|
975
|
+
for await (const doc of query.stream()) {
|
|
976
|
+
docsRead++;
|
|
977
|
+
if (docsRead > 2) {
|
|
978
|
+
throw new ReplicationAssertionError(`Unexpected extra document before CLEAR boundary in bucket ${bucket}`);
|
|
979
|
+
}
|
|
980
|
+
const documentStats = statsForDocument(doc);
|
|
981
|
+
inputStats.count += documentStats.count;
|
|
982
|
+
inputStats.bytes += documentStats.bytes;
|
|
983
|
+
inputStats.chunks += documentStats.chunks;
|
|
984
|
+
inputStats.checksum = addChecksums(inputStats.checksum, documentStats.checksum);
|
|
985
|
+
const isBoundaryDoc = doc._id.o == boundaryDocId.o;
|
|
986
|
+
if (doc.storage_ref) {
|
|
987
|
+
oldStoragePaths.push(doc.storage_ref.path);
|
|
988
|
+
}
|
|
989
|
+
oldStorageBytes += ObjectStorageUsage.bytes(doc);
|
|
990
|
+
await hydrateBucketDataDocuments([doc], this.storage.objectStorage, { signal: this.signal });
|
|
991
|
+
maxTargetOp = maxOpId(maxTargetOp, doc.target_op);
|
|
992
|
+
for (const op of loadBucketDataDocument(context, doc)) {
|
|
993
|
+
if (!isBoundaryDoc && op.op != 'CLEAR') {
|
|
994
|
+
throw new ReplicationAssertionError(`Unexpected ${op.op} operation before CLEAR boundary in bucket ${bucket}`);
|
|
995
|
+
}
|
|
996
|
+
if (op.o <= lastNotPut) {
|
|
997
|
+
if (op.op == 'PUT') {
|
|
998
|
+
throw new ReplicationAssertionError(`Unexpected PUT at op ${op.o} in CLEAR region for bucket ${bucket}`);
|
|
999
|
+
}
|
|
1000
|
+
combinedChecksum = addChecksums(combinedChecksum, Number(op.checksum));
|
|
1001
|
+
clearedOpCount++;
|
|
1002
|
+
}
|
|
1003
|
+
else if (isBoundaryDoc) {
|
|
1004
|
+
boundarySurvivors.push(op);
|
|
1005
|
+
}
|
|
1006
|
+
else {
|
|
1007
|
+
throw new ReplicationAssertionError(`Unexpected op ${op.o} after CLEAR boundary ${lastNotPut} in bucket ${bucket}`);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
if (clearedOpCount == 0) {
|
|
1012
|
+
throw new Error(`CLEAR boundary document not found for bucket ${bucket}`);
|
|
1013
|
+
}
|
|
1014
|
+
this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastNotPut}`);
|
|
1015
|
+
await collection.deleteMany({
|
|
1016
|
+
_id: {
|
|
1017
|
+
$gte: bucketContext.minId,
|
|
1018
|
+
$lte: boundaryDocId
|
|
1019
|
+
}
|
|
1020
|
+
}, { session });
|
|
1021
|
+
const clearOp = {
|
|
1022
|
+
bucketKey: { ...context, bucket },
|
|
1023
|
+
o: lastNotPut,
|
|
1024
|
+
op: 'CLEAR',
|
|
1025
|
+
checksum: BigInt(combinedChecksum),
|
|
1026
|
+
data: null
|
|
1027
|
+
};
|
|
1028
|
+
const chunks = [[clearOp]];
|
|
1029
|
+
if (boundarySurvivors.length > 0) {
|
|
1030
|
+
// These operations are a subset of one existing document, so keeping
|
|
1031
|
+
// them together cannot increase its stored ops payload.
|
|
1032
|
+
chunks.push(boundarySurvivors);
|
|
1033
|
+
}
|
|
1034
|
+
const persisted = await this.persistBucketData(bucket, chunks, context, prepared, {
|
|
1035
|
+
targetOp: maxTargetOp ?? undefined
|
|
1036
|
+
});
|
|
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);
|
|
1040
|
+
opCountDiff = -clearedOpCount + 1;
|
|
1041
|
+
before = inputStats;
|
|
1042
|
+
after = statsForDocuments(persisted.documents);
|
|
1043
|
+
}, {
|
|
1044
|
+
writeConcern: { w: 'majority' },
|
|
1045
|
+
readConcern: { level: 'snapshot' }
|
|
1046
|
+
});
|
|
1047
|
+
return { opCountDiff, before, after };
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Reserve stable object paths before starting a retryable MongoDB transaction.
|
|
1051
|
+
* Each retry can safely overwrite the same paths, while the pre-existing
|
|
1052
|
+
* deletion markers remain visible to the transaction that publishes them.
|
|
1053
|
+
*
|
|
1054
|
+
* CLEAR compaction reserves its maximum output count. Unused markers remain
|
|
1055
|
+
* pending so they can clean up a path that an earlier transaction attempt may
|
|
1056
|
+
* have uploaded before retrying with fewer output documents.
|
|
1057
|
+
*/
|
|
1058
|
+
async prepareCompactionUploads(bucket, context, opIdHints) {
|
|
1059
|
+
if (!this.storage.objectStorage) {
|
|
1060
|
+
return [];
|
|
1061
|
+
}
|
|
1062
|
+
const lifecycle = this.objectStorageLifecycle;
|
|
1063
|
+
const paths = opIdHints.map((opIdHint) => lifecycle.allocatePath(context.definitionId, bucket, opIdHint, opIdHint));
|
|
1064
|
+
return lifecycle.prepareUploads(paths);
|
|
1065
|
+
}
|
|
1066
|
+
/** Publish replacement uploads and retire superseded objects in the same transaction. */
|
|
1067
|
+
async finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, session) {
|
|
1068
|
+
if (!this.storage.objectStorage) {
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
await this.objectStorageLifecycle.publishUploads(uploads, session);
|
|
1072
|
+
await this.objectStorageLifecycle.retire(Array.from(oldStoragePaths).filter((path) => !newStoragePaths.has(path)), session);
|
|
1073
|
+
}
|
|
1074
|
+
async recordObjectStorageReplacement(oldBytes, newDocuments, definitionId, session) {
|
|
1075
|
+
if (!this.storage.objectStorage) {
|
|
1076
|
+
return;
|
|
64
1077
|
}
|
|
65
|
-
|
|
1078
|
+
let newBytes = 0n;
|
|
1079
|
+
for (const document of newDocuments) {
|
|
1080
|
+
newBytes += ObjectStorageUsage.bytes(document);
|
|
1081
|
+
}
|
|
1082
|
+
await this.objectStorageUsage.applyDelta(definitionId, newBytes - oldBytes, session);
|
|
1083
|
+
}
|
|
1084
|
+
async persistBucketData(bucket, chunks, context, preparedUploads, options) {
|
|
1085
|
+
const serializedChunks = chunks.map((chunk) => serializeBucketData(bucket, chunk, options));
|
|
1086
|
+
if (!this.storage.objectStorage) {
|
|
1087
|
+
return {
|
|
1088
|
+
documents: serializedChunks,
|
|
1089
|
+
storagePaths: new Set(),
|
|
1090
|
+
uploads: []
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
const store = new BucketDataObjectStorage(this.storage.objectStorage);
|
|
1094
|
+
const storagePaths = new Set();
|
|
1095
|
+
const lifecycle = this.objectStorageLifecycle;
|
|
1096
|
+
// Base placement on the final compacted size. Unchanged documents are not
|
|
1097
|
+
// rewritten, while small MOVE/merge results and CLEAR ops stay inline.
|
|
1098
|
+
const storedIndexes = serializedChunks.flatMap((document, index) => document.size > this.storage.inlineThresholdBytes ? [index] : []);
|
|
1099
|
+
const uploadsByIndex = new Map();
|
|
1100
|
+
if (preparedUploads) {
|
|
1101
|
+
for (const index of storedIndexes) {
|
|
1102
|
+
const upload = preparedUploads[index];
|
|
1103
|
+
if (!upload) {
|
|
1104
|
+
throw new ServiceAssertionError(`Missing prepared object storage path for compacted document at index ${index}`);
|
|
1105
|
+
}
|
|
1106
|
+
uploadsByIndex.set(index, upload);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
else {
|
|
1110
|
+
const paths = storedIndexes.map((index) => {
|
|
1111
|
+
const chunk = chunks[index];
|
|
1112
|
+
return lifecycle.allocatePath(context.definitionId, bucket, chunk[0].o, chunk[chunk.length - 1].o);
|
|
1113
|
+
});
|
|
1114
|
+
const prepared = await lifecycle.prepareUploads(paths);
|
|
1115
|
+
storedIndexes.forEach((index, preparedIndex) => uploadsByIndex.set(index, prepared[preparedIndex]));
|
|
1116
|
+
}
|
|
1117
|
+
// S3ObjectStorage applies one shared concurrency limit across all callers,
|
|
1118
|
+
// so compaction can schedule its uploads together without creating a
|
|
1119
|
+
// separate limiter here.
|
|
1120
|
+
const documents = await Promise.all(serializedChunks.map(async (serialized, index) => {
|
|
1121
|
+
const upload = uploadsByIndex.get(index);
|
|
1122
|
+
if (!upload) {
|
|
1123
|
+
return serialized;
|
|
1124
|
+
}
|
|
1125
|
+
const { ops, ...metadata } = serialized;
|
|
1126
|
+
const { fileSize } = await store.store(upload.path, ops, { signal: this.signal });
|
|
1127
|
+
storagePaths.add(upload.path);
|
|
1128
|
+
return {
|
|
1129
|
+
...metadata,
|
|
1130
|
+
storage_ref: { path: upload.path, file_size: fileSize }
|
|
1131
|
+
};
|
|
1132
|
+
}));
|
|
1133
|
+
return { documents, storagePaths, uploads: Array.from(uploadsByIndex.values()) };
|
|
66
1134
|
}
|
|
67
1135
|
}
|
|
68
1136
|
//# sourceMappingURL=MongoCompactorV3.js.map
|