@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,107 +1,1476 @@
|
|
|
1
1
|
import { mongo } from '@powersync/lib-service-mongodb';
|
|
2
2
|
import { ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
|
|
3
|
-
import { storage } from '@powersync/service-core';
|
|
3
|
+
import { addChecksums, formatBytes, InternalOpId, storage, utils } from '@powersync/service-core';
|
|
4
4
|
import { BucketDefinitionId } from '@powersync/service-sync-rules';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
5
|
+
import { BucketDataDoc } from '../common/BucketDataDoc.js';
|
|
6
|
+
import { BucketDataKey } from '../models.js';
|
|
7
|
+
import { ConcurrentCompactionError, MongoCompactor } from '../MongoCompactor.js';
|
|
8
|
+
import { cacheKey } from '../OperationBatch.js';
|
|
9
|
+
import { loadBucketDataDocument, maxOpId, serializeBucketData } from './bucket-format.js';
|
|
10
|
+
import { BucketDataContextV3 } from './BucketDataContextV3.js';
|
|
11
|
+
import { DEFAULT_MAX_DOC_SIZE_BYTES } from './chunking.js';
|
|
12
|
+
import {
|
|
13
|
+
applyStatsReplacement,
|
|
14
|
+
bucketStats,
|
|
15
|
+
BucketStatsWithChecksum,
|
|
16
|
+
chooseCompactionKind,
|
|
17
|
+
combineAdjacentStats,
|
|
18
|
+
combineChunkStats,
|
|
19
|
+
CompactIntervalConfig,
|
|
20
|
+
CompactionContext,
|
|
21
|
+
CompactionDecision,
|
|
22
|
+
CompactionKind,
|
|
23
|
+
CompactionResult,
|
|
24
|
+
CompactTargetConfig,
|
|
25
|
+
emptyBucketStats,
|
|
26
|
+
firstUncompactedWrite,
|
|
27
|
+
forcedCompactionKind,
|
|
28
|
+
PendingCompactionGroup,
|
|
29
|
+
readCompactionBatch,
|
|
30
|
+
ScheduledCompactionOptions,
|
|
31
|
+
statsForDocument,
|
|
32
|
+
statsForDocuments,
|
|
33
|
+
unclaimedSnapshotFilter
|
|
34
|
+
} from './compact-utils.js';
|
|
35
|
+
import { DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS } from './compaction-constants.js';
|
|
36
|
+
import { AVAILABLE_LEASE_EXPR, CompactionLease } from './CompactionLease.js';
|
|
37
|
+
import { BucketDataDocumentV3, BucketStateDocumentV3 } from './models.js';
|
|
9
38
|
import type { MongoSyncBucketStorageV3 } from './MongoSyncBucketStorageV3.js';
|
|
10
|
-
import {
|
|
39
|
+
import { BucketDataObjectStorage, hydrateBucketDataDocuments } from './object-storage/BucketDataObjectStorage.js';
|
|
40
|
+
import { ObjectStorageLifecycle, PreparedObjectStorageUpload } from './object-storage/ObjectStorageLifecycle.js';
|
|
41
|
+
import { createObjectStorageUsageWriterId, ObjectStorageUsage } from './object-storage/ObjectStorageUsage.js';
|
|
11
42
|
import { VersionedPowerSyncMongoV3 } from './VersionedPowerSyncMongoV3.js';
|
|
12
43
|
|
|
13
|
-
|
|
44
|
+
const DEFAULT_MIN_COMPACT_FULL_INTERVAL_MS = 2 * 60 * 60 * 1000;
|
|
45
|
+
const DEFAULT_MAX_COMPACT_FULL_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
46
|
+
const DEFAULT_COMPACT_LEASE_DURATION_MS = 10 * 60 * 1000;
|
|
47
|
+
const SCHEDULED_COMPACTION_BATCH_SIZE = 100;
|
|
48
|
+
|
|
49
|
+
interface CompactionGroupResult {
|
|
50
|
+
documentId: BucketDataKey;
|
|
51
|
+
stats: BucketStatsWithChecksum;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface CompactionStatsReplacement {
|
|
55
|
+
before: BucketStatsWithChecksum;
|
|
56
|
+
after: BucketStatsWithChecksum;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface ClearCompactionResult extends CompactionStatsReplacement {
|
|
60
|
+
opCountDiff: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalConfig, CompactTargetConfig {
|
|
14
64
|
declare protected readonly db: VersionedPowerSyncMongoV3;
|
|
15
65
|
declare protected readonly storage: MongoSyncBucketStorageV3;
|
|
16
66
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
67
|
+
readonly minCompactChunkIntervalMs: number;
|
|
68
|
+
readonly minCompactFullIntervalMs: number;
|
|
69
|
+
readonly maxCompactFullIntervalMs: number;
|
|
70
|
+
readonly compactLeaseDurationMs: number;
|
|
71
|
+
readonly maxOpIdCap: InternalOpId | undefined;
|
|
72
|
+
private readonly objectStorageUsage: ObjectStorageUsage;
|
|
73
|
+
|
|
74
|
+
constructor(bucketStorage: MongoSyncBucketStorageV3, db: VersionedPowerSyncMongoV3, options: storage.CompactOptions) {
|
|
75
|
+
super(bucketStorage, db, options);
|
|
76
|
+
this.minCompactChunkIntervalMs = options.minCompactChunkIntervalMs ?? DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS;
|
|
77
|
+
this.minCompactFullIntervalMs = options.minCompactFullIntervalMs ?? DEFAULT_MIN_COMPACT_FULL_INTERVAL_MS;
|
|
78
|
+
this.maxCompactFullIntervalMs = options.maxCompactFullIntervalMs ?? DEFAULT_MAX_COMPACT_FULL_INTERVAL_MS;
|
|
79
|
+
this.compactLeaseDurationMs = options.compactLeaseDurationMs ?? DEFAULT_COMPACT_LEASE_DURATION_MS;
|
|
80
|
+
this.maxOpIdCap = options.maxOpId;
|
|
81
|
+
this.objectStorageUsage = new ObjectStorageUsage(this.db, this.group_id, createObjectStorageUsageWriterId());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
override async compact(): Promise<number> {
|
|
85
|
+
if (this.storage.objectStorage) {
|
|
86
|
+
// Clean these before compacting - should be quick in most cases.
|
|
87
|
+
try {
|
|
88
|
+
await this.objectStorageLifecycle.cleanup(this.logger, { signal: this.signal });
|
|
89
|
+
} catch (e) {
|
|
90
|
+
if (this.signal?.aborted) {
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
// In this case, still continue normal compact process
|
|
94
|
+
this.logger.error(`Failed to clean up object storage deletion markers before compaction`, e);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
await this.deleteOldCheckpointRequests();
|
|
98
|
+
|
|
99
|
+
if (this.buckets != null) {
|
|
100
|
+
await this.compactExplicitBuckets(this.buckets);
|
|
101
|
+
} else if (this.compactChunksOnly) {
|
|
102
|
+
// Writers defer their first chunk-compaction check by this fixed default.
|
|
103
|
+
// Include that interval so this synchronous initial-replication pass
|
|
104
|
+
// processes the work that existed when it started.
|
|
105
|
+
await this.compactScheduledBuckets({
|
|
106
|
+
dueAheadMs: DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS,
|
|
107
|
+
forceKind: CompactionKind.Chunks
|
|
108
|
+
});
|
|
109
|
+
} else {
|
|
110
|
+
await this.compactScheduledBuckets();
|
|
111
|
+
}
|
|
112
|
+
if (this.storage.objectStorage) {
|
|
113
|
+
// Cleanup for any produced during compacting.
|
|
114
|
+
// Note that markers only expire after a delay, so this may skip many produced during this compact
|
|
115
|
+
// run. However, during long compact runs, this may also have many ones it can clean up.
|
|
116
|
+
await this.objectStorageLifecycle.cleanup(this.logger, { signal: this.signal });
|
|
117
|
+
await this.objectStorageUsage.foldStaleWriterDeltas();
|
|
118
|
+
}
|
|
119
|
+
return this.compactedBucketCount;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
protected override async deleteOldCustomCheckpointRequests() {
|
|
123
|
+
if (this.deleteCheckpointRequestsBefore == null) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const collection of await this.db.listCustomCheckpointRequestCollections(this.group_id)) {
|
|
128
|
+
this.signal?.throwIfAborted();
|
|
129
|
+
await collection.deleteMany({
|
|
130
|
+
checkpoint_requested_at: { $exists: true, $lt: this.deleteCheckpointRequestsBefore }
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** An explicit compact request always runs a full compact for its buckets. */
|
|
136
|
+
private async compactExplicitBuckets(buckets: string[]) {
|
|
137
|
+
for (const bucket of buckets) {
|
|
138
|
+
// This is not a super efficient query, but this is not a common use case.
|
|
139
|
+
// May be optimized later.
|
|
140
|
+
const states = await this.db
|
|
141
|
+
.bucketState(this.group_id)
|
|
142
|
+
.find({ '_id.b': bucket }, { projection: { _id: 1 } })
|
|
143
|
+
.toArray();
|
|
144
|
+
for (const state of states) {
|
|
145
|
+
await using lease = await this.claimBucket({ _id: state._id });
|
|
146
|
+
if (lease == null || lease.state.first_uncompacted_write == null) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (this.isCompactionTargetCovered(lease.state, CompactionKind.Full)) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const decision = chooseCompactionKind(lease.state, lease.startedAt, this);
|
|
153
|
+
await this.compactClaimedBucket(lease, CompactionKind.Full, decision);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Process scheduled work in bounded batches.
|
|
160
|
+
*
|
|
161
|
+
* Batching specifically help to cover cases of many buckets where no compaction is required:
|
|
162
|
+
* Instead of sequentially claiming and then rescheduling a bucket, this handles it in bulk.
|
|
163
|
+
*
|
|
164
|
+
* Buckets that do need compaction are still claimed and processed sequentially.
|
|
165
|
+
*
|
|
166
|
+
* Any concurrent workers may read the same batch. Rescheduling filters out buckets handled
|
|
167
|
+
* by a concurrent worker or replication write, while buckets that do need compaction are
|
|
168
|
+
* filtered out when claiming a compaction lease.
|
|
169
|
+
*
|
|
170
|
+
* We filter scheduled jobs by the job start date, so that the same bucket is not compacted
|
|
171
|
+
* multiple times in one run. Reschedules fall beyond the fixed boundary. For the run after
|
|
172
|
+
* initial replication, dueAheadMs extends that boundary to include the first deferred interval.
|
|
173
|
+
*/
|
|
174
|
+
private async compactScheduledBuckets(options: ScheduledCompactionOptions = {}) {
|
|
175
|
+
// Writers derive next_compact_check from MongoDB's $$NOW. Use the same
|
|
176
|
+
// clock for the fixed job boundary so clock skew cannot exclude work at
|
|
177
|
+
// the exact initial-replication interval.
|
|
178
|
+
const [{ now: jobStartedAt }] = await this.db.db
|
|
179
|
+
.aggregate<{ now: Date }>([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
|
|
180
|
+
.toArray();
|
|
181
|
+
const dueBefore = new Date(jobStartedAt.getTime() + (options.dueAheadMs ?? 0));
|
|
182
|
+
const forceKind = options.forceKind;
|
|
183
|
+
const rescheduleNotBefore = new Date(dueBefore.getTime() + 1);
|
|
184
|
+
while (true) {
|
|
185
|
+
this.signal?.throwIfAborted();
|
|
186
|
+
const states = await this.findScheduledBucketBatch(dueBefore);
|
|
187
|
+
if (states.length == 0) {
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const scheduled: {
|
|
192
|
+
state: BucketStateDocumentV3;
|
|
193
|
+
decision: CompactionDecision;
|
|
194
|
+
forcedKind: CompactionKind | null;
|
|
195
|
+
}[] = [];
|
|
196
|
+
for (const state of states) {
|
|
197
|
+
try {
|
|
198
|
+
scheduled.push({
|
|
199
|
+
state,
|
|
200
|
+
decision: chooseCompactionKind(state, jobStartedAt, this),
|
|
201
|
+
forcedKind: forcedCompactionKind(state, forceKind, this)
|
|
202
|
+
});
|
|
203
|
+
} catch (error) {
|
|
204
|
+
await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const noOpStates = scheduled.filter(
|
|
208
|
+
({ state, decision, forcedKind }) =>
|
|
209
|
+
state.compact_lease == null && (forceKind == null ? decision.kind : forcedKind) == null
|
|
210
|
+
);
|
|
211
|
+
await this.rescheduleUnclaimedBuckets(noOpStates, rescheduleNotBefore);
|
|
212
|
+
|
|
213
|
+
for (const { state, decision, forcedKind } of scheduled) {
|
|
214
|
+
const kind = forceKind == null ? decision.kind : forcedKind;
|
|
215
|
+
if (state.compact_lease == null && kind == null) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
await using lease = await this.claimBucket({ _id: state._id, next_compact_check: { $lte: dueBefore } });
|
|
221
|
+
if (lease == null) {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const claimedDecision = chooseCompactionKind(lease.state, lease.startedAt, this);
|
|
225
|
+
const claimedKind =
|
|
226
|
+
forceKind == null ? claimedDecision.kind : forcedCompactionKind(lease.state, forceKind, this);
|
|
227
|
+
if (claimedKind == null) {
|
|
228
|
+
await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
|
|
229
|
+
} else if (this.isCompactionTargetCovered(lease.state, claimedKind)) {
|
|
230
|
+
// The run cannot advance this kind's watermark without regressing
|
|
231
|
+
// already-published progress. Keep any newer work scheduled.
|
|
232
|
+
await this.rescheduleClaimedBucket(lease, claimedDecision, rescheduleNotBefore);
|
|
233
|
+
} else {
|
|
234
|
+
await this.compactClaimedBucket(lease, claimedKind, claimedDecision, rescheduleNotBefore);
|
|
235
|
+
}
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (this.signal?.aborted) {
|
|
238
|
+
// When aborted, stop completely, rather than logging and re-scheduling individual buckets.
|
|
239
|
+
// The lease on the current bucket is still released automatically.
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
await this.rescheduleFailedBucket(state, rescheduleNotBefore, error);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Read a bounded, priority-ordered snapshot of currently claimable scheduled work. */
|
|
249
|
+
private async findScheduledBucketBatch(dueBefore: Date): Promise<BucketStateDocumentV3[]> {
|
|
250
|
+
return this.db
|
|
251
|
+
.bucketState(this.group_id)
|
|
252
|
+
.find({
|
|
253
|
+
next_compact_check: { $lte: dueBefore },
|
|
254
|
+
...AVAILABLE_LEASE_EXPR
|
|
255
|
+
})
|
|
256
|
+
.sort({ next_compact_check: 1 })
|
|
257
|
+
.limit(SCHEDULED_COMPACTION_BATCH_SIZE)
|
|
258
|
+
.toArray();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Reschedule snapshots that were already known to be no-ops without first
|
|
263
|
+
* taking a lease. Every decision input is compared so a concurrent writer
|
|
264
|
+
* or compactor simply makes the update a no-op instead of losing work. A
|
|
265
|
+
* successful reschedule moves beyond this run's fixed selection boundary.
|
|
266
|
+
*/
|
|
267
|
+
private async rescheduleUnclaimedBuckets(
|
|
268
|
+
states: { state: BucketStateDocumentV3; decision: CompactionDecision }[],
|
|
269
|
+
notBefore: Date
|
|
270
|
+
) {
|
|
271
|
+
if (states.length == 0) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
await this.db.bucketState(this.group_id).bulkWrite(
|
|
275
|
+
states.map(({ state, decision }) => ({
|
|
276
|
+
updateOne: {
|
|
277
|
+
filter: unclaimedSnapshotFilter(state),
|
|
278
|
+
update: [{ $set: { next_compact_check: this.rescheduleAtOrAfter(decision.nextCompactCheck, notBefore) } }]
|
|
279
|
+
}
|
|
280
|
+
})),
|
|
281
|
+
{ ordered: false }
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Isolate a malformed bucket so it cannot prevent other scheduled buckets
|
|
287
|
+
* from compacting. The snapshot filter preserves any concurrent write or
|
|
288
|
+
* compactor result instead of overwriting its next check.
|
|
289
|
+
*/
|
|
290
|
+
private async rescheduleFailedBucket(state: BucketStateDocumentV3, notBefore: Date, error: unknown) {
|
|
291
|
+
this.logger.error(`Failed to compact scheduled bucket ${state._id.b}; rescheduling it`, error);
|
|
292
|
+
try {
|
|
293
|
+
await this.db
|
|
294
|
+
.bucketState(this.group_id)
|
|
295
|
+
.updateOne(unclaimedSnapshotFilter(state), [{ $set: { next_compact_check: notBefore } }]);
|
|
296
|
+
} catch (rescheduleError) {
|
|
297
|
+
this.logger.error(`Failed to reschedule bucket ${state._id.b} after a compaction error`, rescheduleError);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Given a bucket filter, claim a lease on the bucket. The filter should include a filter on _id.
|
|
303
|
+
*
|
|
304
|
+
* Resolves to null if the bucket is already claimed, not found, or filtered out.
|
|
305
|
+
*/
|
|
306
|
+
private async claimBucket(
|
|
307
|
+
filter: mongo.Filter<BucketStateDocumentV3>,
|
|
308
|
+
sort?: mongo.Sort
|
|
309
|
+
): Promise<CompactionLease | null> {
|
|
310
|
+
return CompactionLease.claim(this.db.bucketState(this.group_id), filter, sort, this.compactLeaseDurationMs);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private async compactClaimedBucket(
|
|
314
|
+
lease: CompactionLease,
|
|
315
|
+
kind: CompactionKind,
|
|
316
|
+
decision: CompactionDecision,
|
|
317
|
+
rescheduleNotBefore?: Date
|
|
318
|
+
) {
|
|
319
|
+
const context = new CompactionContext(
|
|
320
|
+
lease,
|
|
321
|
+
kind,
|
|
322
|
+
decision,
|
|
323
|
+
rescheduleNotBefore,
|
|
324
|
+
this.compactionTarget(lease.state)
|
|
325
|
+
);
|
|
326
|
+
lease.startRenewal();
|
|
327
|
+
await this.compactSingleBucket(context);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private async rescheduleClaimedBucket(lease: CompactionLease, decision: CompactionDecision, notBefore?: Date) {
|
|
331
|
+
await lease.reschedule(this.rescheduleAtOrAfter(decision.nextCompactCheck, notBefore));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private rescheduleAtOrAfter(nextCompactCheck: mongo.Document, notBefore: Date | undefined): mongo.Document {
|
|
335
|
+
return notBefore == null ? nextCompactCheck : { $max: [nextCompactCheck, notBefore] };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private compactionTarget(state: BucketStateDocumentV3): InternalOpId {
|
|
339
|
+
return this.maxOpIdCap == null || state.last_op < this.maxOpIdCap ? state.last_op : this.maxOpIdCap;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private isCompactionTargetCovered(state: BucketStateDocumentV3, kind: CompactionKind): boolean {
|
|
343
|
+
const target = this.compactionTarget(state);
|
|
344
|
+
if (kind == CompactionKind.Chunks) {
|
|
345
|
+
return state.compacted_state != null && state.compacted_state.op_id >= target;
|
|
346
|
+
}
|
|
347
|
+
if (state.last_full_compact != null && state.last_full_compact.op_id >= target) {
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
// A full compact may change counts before the checksum-cache boundary.
|
|
351
|
+
// Wait for the safe target to catch up instead of publishing an older or
|
|
352
|
+
// stale cache. At the same boundary, full coverage can still advance.
|
|
353
|
+
return state.compacted_state != null && state.compacted_state.op_id > target;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private get objectStorageLifecycle(): ObjectStorageLifecycle {
|
|
357
|
+
if (!this.storage.objectStorage) {
|
|
358
|
+
throw new Error('Object storage is not configured');
|
|
359
|
+
}
|
|
360
|
+
return new ObjectStorageLifecycle(this.db, this.group_id, this.storage.objectStorage);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private async compactSingleBucket(context: CompactionContext) {
|
|
364
|
+
if (context.kind == CompactionKind.Chunks) {
|
|
365
|
+
return this.compactSingleBucketChunks(context);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return this.compactSingleBucketFully(context);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Merge adjacent bucket-data chunks without inspecting their operations
|
|
373
|
+
* unless a merge is possible. The metadata contains enough information to
|
|
374
|
+
* update the persisted checksum state and to decide whether a group can fit
|
|
375
|
+
* in one chunk.
|
|
376
|
+
*/
|
|
377
|
+
private async compactSingleBucketChunks(context: CompactionContext) {
|
|
378
|
+
const bucket = context.state._id.b;
|
|
379
|
+
const resolvedDefinitionId = context.state._id.d;
|
|
380
|
+
const bucketContext = new BucketDataContextV3(this.db, {
|
|
381
|
+
bucket,
|
|
382
|
+
definitionId: resolvedDefinitionId,
|
|
383
|
+
replicationStreamId: this.group_id
|
|
384
|
+
});
|
|
385
|
+
const collection = this.db.bucketData(this.group_id, resolvedDefinitionId);
|
|
386
|
+
const dataContext = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
|
|
387
|
+
let previousCompactedState = context.state.compacted_state;
|
|
388
|
+
// A zero boundary represents an empty prefix, so there is no stored chunk
|
|
389
|
+
// whose statistics need to be carried into this pass.
|
|
390
|
+
if (previousCompactedState?.op_id === 0n) {
|
|
391
|
+
previousCompactedState = undefined;
|
|
392
|
+
}
|
|
393
|
+
// Include the last previously compacted chunk as well as new chunks. It
|
|
394
|
+
// is the only old chunk which can become mergeable with the new tail.
|
|
395
|
+
let lowerBound =
|
|
396
|
+
previousCompactedState != null ? bucketContext.docId(previousCompactedState.op_id - 1n) : bucketContext.minId;
|
|
397
|
+
const upperBound = bucketContext.docId(context.targetOp + 1n);
|
|
398
|
+
let cachedBoundaryToVerify = previousCompactedState?.op_id;
|
|
399
|
+
|
|
400
|
+
let compactedOpId: bigint | null = null;
|
|
401
|
+
let overlappingCompactedChunk: BucketStatsWithChecksum | undefined;
|
|
402
|
+
let compactedTail = emptyBucketStats();
|
|
403
|
+
let pendingChunks: BucketDataDocumentV3[] = [];
|
|
404
|
+
let pendingSize = 0;
|
|
405
|
+
|
|
406
|
+
while (true) {
|
|
407
|
+
this.signal?.throwIfAborted();
|
|
408
|
+
await context.lease.throwIfLost();
|
|
409
|
+
|
|
410
|
+
const batch = await readCompactionBatch(
|
|
411
|
+
collection.aggregate<BucketDataDocumentV3>(
|
|
412
|
+
[
|
|
413
|
+
{
|
|
414
|
+
$match: {
|
|
415
|
+
_id: {
|
|
416
|
+
$gt: lowerBound,
|
|
417
|
+
$lt: upperBound
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
{ $sort: { _id: 1 } },
|
|
422
|
+
{ $limit: this.moveBatchQueryLimit },
|
|
423
|
+
{
|
|
424
|
+
$project: {
|
|
425
|
+
_id: 1,
|
|
426
|
+
min_op: 1,
|
|
427
|
+
checksum: 1,
|
|
428
|
+
count: 1,
|
|
429
|
+
size: 1,
|
|
430
|
+
target_op: 1,
|
|
431
|
+
storage_ref: 1
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
],
|
|
435
|
+
{ batchSize: this.moveBatchQueryLimit + 1 }
|
|
436
|
+
),
|
|
437
|
+
{
|
|
438
|
+
byteLimit: this.moveBatchByteLimit,
|
|
439
|
+
documentLimit: this.moveBatchQueryLimit
|
|
440
|
+
}
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
if (cachedBoundaryToVerify != null) {
|
|
444
|
+
const cachedBoundary = cachedBoundaryToVerify;
|
|
445
|
+
cachedBoundaryToVerify = undefined;
|
|
446
|
+
if (batch.documents[0]?._id.o !== cachedBoundary) {
|
|
447
|
+
// A previous attempt may have replaced the cached boundary before
|
|
448
|
+
// finalizing bucket state. Keep the persisted cache available to
|
|
449
|
+
// readers, but ignore it in this attempt and calculate its
|
|
450
|
+
// replacement through the normal scan from the bucket beginning.
|
|
451
|
+
previousCompactedState = undefined;
|
|
452
|
+
lowerBound = bucketContext.minId;
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (batch.documents.length == 0) {
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
for (const doc of batch.documents) {
|
|
462
|
+
compactedOpId = maxOpId(compactedOpId, doc._id.o);
|
|
463
|
+
const documentStats = statsForDocument(doc);
|
|
464
|
+
if (previousCompactedState?.op_id === doc._id.o) {
|
|
465
|
+
overlappingCompactedChunk = documentStats;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const nextSize = pendingSize + doc.size;
|
|
469
|
+
if (pendingChunks.length > 0 && nextSize > DEFAULT_MAX_DOC_SIZE_BYTES) {
|
|
470
|
+
const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext);
|
|
471
|
+
compactedTail = combineAdjacentStats(compactedTail, groupStats);
|
|
472
|
+
pendingChunks = [];
|
|
473
|
+
pendingSize = 0;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
pendingChunks.push(doc);
|
|
477
|
+
pendingSize += doc.size;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
lowerBound = batch.documents[batch.documents.length - 1]._id;
|
|
481
|
+
if (!batch.hasMore) {
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (pendingChunks.length > 0) {
|
|
487
|
+
const groupStats = await this.flushChunkMerge(bucket, pendingChunks, collection, dataContext, bucketContext);
|
|
488
|
+
compactedTail = combineAdjacentStats(compactedTail, groupStats);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (compactedOpId == null) {
|
|
492
|
+
await this.finalizeSkippedBucket(context);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const compactedState =
|
|
497
|
+
previousCompactedState == null
|
|
498
|
+
? compactedTail
|
|
499
|
+
: combineChunkStats(previousCompactedState, compactedTail, overlappingCompactedChunk!);
|
|
500
|
+
const tailStats =
|
|
501
|
+
compactedOpId == context.lastOp
|
|
502
|
+
? undefined
|
|
503
|
+
: await this.readBucketStats(bucket, resolvedDefinitionId, context.lastOp, bucketContext.docId(compactedOpId));
|
|
504
|
+
const result: CompactionResult = {
|
|
505
|
+
compactedState,
|
|
506
|
+
bucketStats: tailStats == null ? compactedState : combineAdjacentStats(compactedState, tailStats)
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
await this.finalizeCompactedBucket({ context, compactedOpId, compactionResult: result, puts: 0 });
|
|
510
|
+
this.compactedBucketCount++;
|
|
511
|
+
this.logger.info(
|
|
512
|
+
`Compacted bucket chunks ${bucket}: ${result.bucketStats.count} ops, ${result.bucketStats.chunks} chunks, ${formatBytes(result.bucketStats.bytes)}`
|
|
31
513
|
);
|
|
32
514
|
}
|
|
33
515
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
516
|
+
private async flushChunkMerge(
|
|
517
|
+
bucket: string,
|
|
518
|
+
inputs: BucketDataDocumentV3[],
|
|
519
|
+
collection: mongo.Collection<BucketDataDocumentV3>,
|
|
520
|
+
context: { replicationStreamId: number; definitionId: string },
|
|
521
|
+
bucketContext: BucketDataContextV3
|
|
522
|
+
): Promise<BucketStatsWithChecksum> {
|
|
523
|
+
if (inputs.length == 1) {
|
|
524
|
+
return statsForDocument(inputs[0]);
|
|
37
525
|
}
|
|
38
|
-
|
|
39
|
-
|
|
526
|
+
|
|
527
|
+
// The metadata scan deliberately excluded ops. Read inline payloads only
|
|
528
|
+
// for this merge group; object-storage payloads are fetched below using
|
|
529
|
+
// the same rule.
|
|
530
|
+
const inlineInputs = inputs.filter((input) => input.storage_ref == null);
|
|
531
|
+
if (inlineInputs.length > 0) {
|
|
532
|
+
const inlineDocuments = await collection
|
|
533
|
+
.find({ _id: { $in: inlineInputs.map((input) => input._id) } }, { projection: { _id: 1, ops: 1 } })
|
|
534
|
+
.toArray();
|
|
535
|
+
const opsById = new Map(inlineDocuments.map((document) => [document._id.o.toString(), document.ops]));
|
|
536
|
+
for (const input of inlineInputs) {
|
|
537
|
+
input.ops = opsById.get(input._id.o.toString());
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
await hydrateBucketDataDocuments(inputs, this.storage.objectStorage, { signal: this.signal });
|
|
541
|
+
|
|
542
|
+
const operations = inputs.flatMap((input) => Array.from(loadBucketDataDocument(context, input)));
|
|
543
|
+
const targetOp = inputs.reduce<InternalOpId | null>(
|
|
544
|
+
(maxTarget, input) => maxOpId(maxTarget, input.target_op),
|
|
545
|
+
null
|
|
546
|
+
);
|
|
547
|
+
const result = await this.flushCompactionGroup(
|
|
548
|
+
bucket,
|
|
40
549
|
{
|
|
41
|
-
|
|
550
|
+
inputs,
|
|
551
|
+
ops: operations,
|
|
552
|
+
changed: true,
|
|
553
|
+
targetOp
|
|
42
554
|
},
|
|
43
|
-
|
|
555
|
+
bucketContext,
|
|
556
|
+
context
|
|
44
557
|
);
|
|
558
|
+
return result.stats;
|
|
45
559
|
}
|
|
46
560
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
561
|
+
private async finalizeCompactedBucket({
|
|
562
|
+
context,
|
|
563
|
+
compactedOpId,
|
|
564
|
+
compactionResult,
|
|
565
|
+
puts
|
|
566
|
+
}: {
|
|
567
|
+
context: CompactionContext;
|
|
568
|
+
compactedOpId: InternalOpId;
|
|
569
|
+
compactionResult: CompactionResult;
|
|
570
|
+
puts: number;
|
|
571
|
+
}) {
|
|
572
|
+
await context.lease.throwIfLost();
|
|
573
|
+
const startedStats = bucketStats(context.state);
|
|
574
|
+
const delta = {
|
|
575
|
+
count: compactionResult.bucketStats.count - startedStats.count,
|
|
576
|
+
bytes: compactionResult.bucketStats.bytes - startedStats.bytes,
|
|
577
|
+
chunks: compactionResult.bucketStats.chunks - startedStats.chunks
|
|
578
|
+
};
|
|
579
|
+
const coveredClaimedHead = compactedOpId >= context.lastOp;
|
|
580
|
+
const concurrentWriteCheck = { $gt: ['$last_op', context.lastOp] };
|
|
581
|
+
const remainingFullWorkCheck = coveredClaimedHead ? concurrentWriteCheck : true;
|
|
582
|
+
const nextAfterPartialFullCompact = this.rescheduleAtOrAfter(
|
|
583
|
+
{ $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } },
|
|
584
|
+
context.rescheduleNotBefore
|
|
585
|
+
);
|
|
586
|
+
const nextCheckForUncompactedWork = this.rescheduleAtOrAfter(
|
|
587
|
+
{
|
|
588
|
+
$min: [
|
|
589
|
+
new Date(firstUncompactedWrite(context.state).getTime() + this.maxCompactFullIntervalMs),
|
|
590
|
+
{ $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } }
|
|
591
|
+
]
|
|
592
|
+
},
|
|
593
|
+
context.rescheduleNotBefore
|
|
594
|
+
);
|
|
595
|
+
const update: mongo.Document = {
|
|
596
|
+
compacted_state: {
|
|
597
|
+
op_id: compactedOpId,
|
|
598
|
+
checksum: BigInt(compactionResult.compactedState.checksum),
|
|
599
|
+
count: compactionResult.compactedState.count,
|
|
600
|
+
bytes: compactionResult.compactedState.bytes,
|
|
601
|
+
chunks: compactionResult.compactedState.chunks,
|
|
602
|
+
at: '$$NOW'
|
|
603
|
+
},
|
|
604
|
+
bucket_stats: {
|
|
605
|
+
count: { $add: ['$bucket_stats.count', delta.count] },
|
|
606
|
+
bytes: { $add: ['$bucket_stats.bytes', delta.bytes] },
|
|
607
|
+
chunks: { $add: ['$bucket_stats.chunks', delta.chunks] }
|
|
608
|
+
},
|
|
609
|
+
first_uncompacted_write:
|
|
610
|
+
context.kind == CompactionKind.Full
|
|
611
|
+
? { $cond: [remainingFullWorkCheck, '$$NOW', '$$REMOVE'] }
|
|
612
|
+
: '$first_uncompacted_write',
|
|
613
|
+
next_compact_check:
|
|
614
|
+
context.kind == CompactionKind.Full
|
|
615
|
+
? { $cond: [remainingFullWorkCheck, nextAfterPartialFullCompact, '$$REMOVE'] }
|
|
616
|
+
: nextCheckForUncompactedWork
|
|
617
|
+
};
|
|
618
|
+
if (context.kind == CompactionKind.Full) {
|
|
619
|
+
update.last_full_compact = {
|
|
620
|
+
op_id: compactedOpId,
|
|
621
|
+
count: compactionResult.compactedState.count,
|
|
622
|
+
puts,
|
|
623
|
+
at: '$$NOW'
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
await context.lease.finalize(update);
|
|
51
628
|
}
|
|
52
629
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
630
|
+
private async finalizeSkippedBucket(context: CompactionContext) {
|
|
631
|
+
// A maxOpId cap can exclude the first remaining document entirely. Avoid
|
|
632
|
+
// immediately claiming the same no-progress bucket again in this run.
|
|
633
|
+
await this.rescheduleClaimedBucket(
|
|
634
|
+
context.lease,
|
|
635
|
+
{
|
|
636
|
+
...context.decision,
|
|
637
|
+
nextCompactCheck: {
|
|
638
|
+
$max: [
|
|
639
|
+
context.decision.nextCompactCheck,
|
|
640
|
+
{ $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.minCompactChunkIntervalMs } }
|
|
641
|
+
]
|
|
60
642
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
643
|
+
},
|
|
644
|
+
context.rescheduleNotBefore
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Read bucket stats directly from bucket_data documents.
|
|
650
|
+
*/
|
|
651
|
+
private async readBucketStats(
|
|
652
|
+
bucket: string,
|
|
653
|
+
definitionId: BucketDefinitionId,
|
|
654
|
+
maxOp: InternalOpId,
|
|
655
|
+
lowerBound?: BucketDataKey
|
|
656
|
+
): Promise<BucketStatsWithChecksum> {
|
|
657
|
+
const context = new BucketDataContextV3(this.db, {
|
|
658
|
+
bucket,
|
|
659
|
+
definitionId,
|
|
660
|
+
replicationStreamId: this.group_id
|
|
661
|
+
});
|
|
662
|
+
const [stats] = await this.db
|
|
663
|
+
.bucketData(this.group_id, definitionId)
|
|
664
|
+
.aggregate<{ count: number; bytes: number | bigint; chunks: number; checksum: bigint }>([
|
|
665
|
+
{
|
|
666
|
+
$match: {
|
|
667
|
+
_id:
|
|
668
|
+
lowerBound == null
|
|
669
|
+
? { $gte: context.minId, $lte: context.docId(maxOp) }
|
|
670
|
+
: { $gt: lowerBound, $lte: context.docId(maxOp) }
|
|
671
|
+
}
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
$group: {
|
|
675
|
+
_id: null,
|
|
676
|
+
count: { $sum: '$count' },
|
|
677
|
+
bytes: { $sum: '$size' },
|
|
678
|
+
chunks: { $sum: 1 },
|
|
679
|
+
checksum: { $sum: '$checksum' }
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
])
|
|
683
|
+
.toArray();
|
|
684
|
+
return {
|
|
685
|
+
count: Number(stats?.count ?? 0),
|
|
686
|
+
bytes: BigInt(stats?.bytes ?? 0),
|
|
687
|
+
chunks: Number(stats?.chunks ?? 0),
|
|
688
|
+
checksum:
|
|
689
|
+
typeof stats?.checksum == 'bigint'
|
|
690
|
+
? Number(BigInt.asIntN(32, stats.checksum))
|
|
691
|
+
: addChecksums(0, Number(stats?.checksum ?? 0))
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
private async compactSingleBucketFully(context: CompactionContext) {
|
|
696
|
+
const bucket = context.state._id.b;
|
|
697
|
+
const resolvedDefinitionId = context.state._id.d;
|
|
698
|
+
const bucketContext = new BucketDataContextV3(this.db, {
|
|
699
|
+
bucket,
|
|
700
|
+
definitionId: resolvedDefinitionId,
|
|
701
|
+
replicationStreamId: this.group_id
|
|
702
|
+
});
|
|
703
|
+
const collection = this.db.bucketData(this.group_id, resolvedDefinitionId);
|
|
704
|
+
const dataContext = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
|
|
705
|
+
const lowerBound = bucketContext.minId;
|
|
706
|
+
let upperBound = bucketContext.docId(context.targetOp + 1n);
|
|
707
|
+
|
|
708
|
+
let totalOpCount = 0;
|
|
709
|
+
|
|
710
|
+
let lastNotPut: bigint | null = null;
|
|
711
|
+
let opsSincePut = 0;
|
|
712
|
+
let compactedOpId: bigint | null = null;
|
|
713
|
+
let clearBoundary: { opId: bigint; documentId: BucketDataKey } | null = null;
|
|
714
|
+
let compactedStats = emptyBucketStats();
|
|
715
|
+
const seen = new Map<string, bigint>();
|
|
716
|
+
let trackingSize = 0;
|
|
717
|
+
let putCount = 0;
|
|
718
|
+
let pendingGroup: PendingCompactionGroup | null = null;
|
|
719
|
+
|
|
720
|
+
// --- Read batch from MongoDB ---
|
|
721
|
+
while (true) {
|
|
722
|
+
this.signal?.throwIfAborted();
|
|
723
|
+
await context.lease.throwIfLost();
|
|
724
|
+
|
|
725
|
+
const pipeline: mongo.Document[] = [
|
|
726
|
+
{
|
|
727
|
+
$match: {
|
|
728
|
+
_id: {
|
|
729
|
+
$gte: lowerBound,
|
|
730
|
+
$lt: upperBound
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
{ $sort: { _id: -1 } },
|
|
735
|
+
{ $limit: this.moveBatchQueryLimit },
|
|
736
|
+
{
|
|
737
|
+
$project: {
|
|
738
|
+
_id: 1,
|
|
739
|
+
min_op: 1,
|
|
740
|
+
checksum: 1,
|
|
741
|
+
count: 1,
|
|
742
|
+
size: 1,
|
|
743
|
+
target_op: 1,
|
|
744
|
+
ops: 1,
|
|
745
|
+
storage_ref: 1
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
];
|
|
749
|
+
|
|
750
|
+
const batch = await readCompactionBatch(
|
|
751
|
+
collection.aggregate<BucketDataDocumentV3>(pipeline, {
|
|
752
|
+
batchSize: this.moveBatchQueryLimit + 1
|
|
753
|
+
}),
|
|
754
|
+
{
|
|
755
|
+
byteLimit: this.moveBatchByteLimit,
|
|
756
|
+
documentLimit: this.moveBatchQueryLimit
|
|
757
|
+
}
|
|
758
|
+
);
|
|
759
|
+
const batchDocs = batch.documents;
|
|
760
|
+
|
|
761
|
+
if (batchDocs.length == 0) {
|
|
762
|
+
// No more documents in this bucket — compaction complete.
|
|
763
|
+
break;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
await hydrateBucketDataDocuments(batchDocs, this.storage.objectStorage, { signal: this.signal });
|
|
767
|
+
|
|
768
|
+
// Compact each document independently, then greedily merge adjacent
|
|
769
|
+
// post-compaction results. This preserves existing boundaries unless
|
|
770
|
+
// merging is useful, and writes each final object at most once.
|
|
771
|
+
for (const doc of batchDocs) {
|
|
772
|
+
compactedOpId ??= doc._id.o;
|
|
773
|
+
const originalOps = Array.from(loadBucketDataDocument(dataContext, doc));
|
|
774
|
+
|
|
775
|
+
let changed = false;
|
|
776
|
+
const compactedOps: BucketDataDoc[] = [];
|
|
777
|
+
let maxTargetOp: InternalOpId | null = doc.target_op ?? null;
|
|
778
|
+
for (let index = originalOps.length - 1; index >= 0; index--) {
|
|
779
|
+
const op = originalOps[index];
|
|
780
|
+
if (op.op == 'PUT' || op.op == 'REMOVE') {
|
|
781
|
+
const key = `${op.table}/${op.row_id}/${cacheKey(op.source_table!, op.source_key!)}`;
|
|
782
|
+
const targetOp = seen.get(key);
|
|
783
|
+
if (targetOp != null) {
|
|
784
|
+
maxTargetOp = maxOpId(maxTargetOp, targetOp);
|
|
785
|
+
compactedOps.push({
|
|
786
|
+
...op,
|
|
787
|
+
op: 'MOVE',
|
|
788
|
+
table: undefined,
|
|
789
|
+
row_id: undefined,
|
|
790
|
+
source_table: undefined,
|
|
791
|
+
source_key: undefined,
|
|
792
|
+
subkey: undefined,
|
|
793
|
+
data: null
|
|
794
|
+
});
|
|
795
|
+
changed = true;
|
|
796
|
+
if (lastNotPut == null) {
|
|
797
|
+
lastNotPut = op.o;
|
|
798
|
+
}
|
|
799
|
+
opsSincePut += 1;
|
|
800
|
+
} else {
|
|
801
|
+
if (trackingSize < this.idLimitBytes) {
|
|
802
|
+
seen.set(utils.flatstr(key), op.o);
|
|
803
|
+
trackingSize += key.length + 140;
|
|
804
|
+
}
|
|
805
|
+
compactedOps.push(op);
|
|
806
|
+
if (op.op == 'PUT') {
|
|
807
|
+
putCount++;
|
|
808
|
+
lastNotPut = null;
|
|
809
|
+
opsSincePut = 0;
|
|
810
|
+
} else {
|
|
811
|
+
if (lastNotPut == null) {
|
|
812
|
+
lastNotPut = op.o;
|
|
813
|
+
}
|
|
814
|
+
opsSincePut += 1;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
} else {
|
|
818
|
+
compactedOps.push(op);
|
|
819
|
+
if (op.op != 'CLEAR') {
|
|
820
|
+
if (lastNotPut == null) {
|
|
821
|
+
lastNotPut = op.o;
|
|
822
|
+
}
|
|
823
|
+
opsSincePut += 1;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
compactedOps.reverse();
|
|
828
|
+
|
|
829
|
+
totalOpCount += compactedOps.length;
|
|
830
|
+
|
|
831
|
+
const candidate: PendingCompactionGroup = {
|
|
832
|
+
inputs: [doc],
|
|
833
|
+
ops: compactedOps,
|
|
834
|
+
changed,
|
|
835
|
+
targetOp: maxTargetOp
|
|
65
836
|
};
|
|
66
|
-
|
|
837
|
+
|
|
838
|
+
if (pendingGroup == null) {
|
|
839
|
+
pendingGroup = candidate;
|
|
840
|
+
} else {
|
|
841
|
+
const mergedOps: BucketDataDoc[] = [...candidate.ops, ...pendingGroup.ops];
|
|
842
|
+
const mergedSize = serializeBucketData(bucket, mergedOps, { targetOp: maxTargetOp }).size;
|
|
843
|
+
if (mergedSize <= DEFAULT_MAX_DOC_SIZE_BYTES) {
|
|
844
|
+
pendingGroup = {
|
|
845
|
+
inputs: [...candidate.inputs, ...pendingGroup.inputs],
|
|
846
|
+
ops: mergedOps,
|
|
847
|
+
changed: candidate.changed || pendingGroup.changed,
|
|
848
|
+
targetOp: maxOpId(maxTargetOp, pendingGroup.targetOp)
|
|
849
|
+
};
|
|
850
|
+
} else {
|
|
851
|
+
const flushedGroup = pendingGroup;
|
|
852
|
+
const result = await this.flushCompactionGroup(bucket, flushedGroup, bucketContext, dataContext);
|
|
853
|
+
compactedStats = combineAdjacentStats(compactedStats, result.stats);
|
|
854
|
+
if (
|
|
855
|
+
lastNotPut != null &&
|
|
856
|
+
flushedGroup.ops[0].o <= lastNotPut &&
|
|
857
|
+
flushedGroup.ops[flushedGroup.ops.length - 1].o >= lastNotPut
|
|
858
|
+
) {
|
|
859
|
+
clearBoundary = { opId: lastNotPut, documentId: result.documentId };
|
|
860
|
+
}
|
|
861
|
+
pendingGroup = candidate;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// --- Advance to next batch ---
|
|
867
|
+
upperBound = batchDocs[batchDocs.length - 1]._id as typeof upperBound;
|
|
868
|
+
|
|
869
|
+
if (!batch.hasMore) {
|
|
870
|
+
break;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
this.logger.info(`Compacted batch of ${batchDocs.length} documents for bucket ${bucket}`);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (pendingGroup != null) {
|
|
877
|
+
const result = await this.flushCompactionGroup(bucket, pendingGroup, bucketContext, dataContext);
|
|
878
|
+
compactedStats = combineAdjacentStats(compactedStats, result.stats);
|
|
879
|
+
if (
|
|
880
|
+
lastNotPut != null &&
|
|
881
|
+
pendingGroup.ops[0].o <= lastNotPut &&
|
|
882
|
+
pendingGroup.ops[pendingGroup.ops.length - 1].o >= lastNotPut
|
|
883
|
+
) {
|
|
884
|
+
clearBoundary = { opId: lastNotPut, documentId: result.documentId };
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
if (compactedOpId == null) {
|
|
888
|
+
await this.finalizeSkippedBucket(context);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// --- Clear: collapse leading MOVE/REMOVE/CLEAR sequence ---
|
|
893
|
+
if (lastNotPut != null && opsSincePut >= 2) {
|
|
894
|
+
if (clearBoundary == null || clearBoundary.opId != lastNotPut) {
|
|
895
|
+
throw new ReplicationAssertionError(`Missing CLEAR boundary document for bucket ${bucket}`);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const clearResult = await this.clearBucketLeading(
|
|
899
|
+
lastNotPut,
|
|
900
|
+
clearBoundary.documentId,
|
|
901
|
+
bucketContext,
|
|
902
|
+
collection,
|
|
903
|
+
dataContext
|
|
904
|
+
);
|
|
905
|
+
totalOpCount += clearResult.opCountDiff;
|
|
906
|
+
compactedStats = applyStatsReplacement(compactedStats, clearResult.before, clearResult.after);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const tailStats =
|
|
910
|
+
compactedOpId == context.lastOp
|
|
911
|
+
? undefined
|
|
912
|
+
: await this.readBucketStats(bucket, resolvedDefinitionId, context.lastOp, bucketContext.docId(compactedOpId));
|
|
913
|
+
const result: CompactionResult = {
|
|
914
|
+
compactedState: compactedStats,
|
|
915
|
+
bucketStats: tailStats == null ? compactedStats : combineAdjacentStats(compactedStats, tailStats)
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
// --- Finalize: update bucket checksums and state ---
|
|
919
|
+
await this.finalizeCompactedBucket({ context, compactedOpId, compactionResult: result, puts: putCount });
|
|
920
|
+
|
|
921
|
+
this.compactedBucketCount++;
|
|
922
|
+
this.logger.info(
|
|
923
|
+
`Compacted bucket ${bucket}: ${totalOpCount} ops, ${result.bucketStats.chunks} chunks, ${formatBytes(result.bucketStats.bytes)}`
|
|
67
924
|
);
|
|
68
925
|
}
|
|
69
926
|
|
|
70
|
-
|
|
927
|
+
/**
|
|
928
|
+
* Persist replacement objects before starting the transaction, then atomically
|
|
929
|
+
* publish their lifecycle markers alongside the MongoDB document replacement.
|
|
930
|
+
* If verification or the transaction fails, the prepared markers retain enough
|
|
931
|
+
* information for the uploaded objects to be cleaned up later.
|
|
932
|
+
*/
|
|
933
|
+
private async flushCompactionGroup(
|
|
71
934
|
bucket: string,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
935
|
+
group: PendingCompactionGroup,
|
|
936
|
+
bucketContext: BucketDataContextV3,
|
|
937
|
+
context: { replicationStreamId: number; definitionId: string }
|
|
938
|
+
): Promise<CompactionGroupResult> {
|
|
939
|
+
if (group.inputs.length == 1 && !group.changed) {
|
|
940
|
+
return {
|
|
941
|
+
documentId: group.inputs[0]._id,
|
|
942
|
+
stats: statsForDocument(group.inputs[0])
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const inputs = group.inputs;
|
|
947
|
+
const idsToDelete = inputs.map((doc) => doc._id);
|
|
948
|
+
const expectedDocCount = inputs.length;
|
|
949
|
+
const expectedChecksum = inputs.reduce((sum, doc) => sum + doc.checksum, 0n);
|
|
950
|
+
const expectedOpCount = inputs.reduce((sum, doc) => sum + doc.count, 0);
|
|
951
|
+
const oldStoragePaths = inputs.flatMap((doc) => (doc.storage_ref ? [doc.storage_ref.path] : []));
|
|
952
|
+
const oldStorageBytes = inputs.reduce((sum, document) => sum + ObjectStorageUsage.bytes(document), 0n);
|
|
953
|
+
const {
|
|
954
|
+
documents,
|
|
955
|
+
storagePaths: newStoragePaths,
|
|
956
|
+
uploads
|
|
957
|
+
} = await this.persistBucketData(bucket, [group.ops], context, undefined, { targetOp: group.targetOp });
|
|
958
|
+
const session = this.db.client.startSession();
|
|
959
|
+
try {
|
|
960
|
+
await session.withTransaction(
|
|
961
|
+
async () => {
|
|
962
|
+
const verification = await bucketContext.collection
|
|
963
|
+
.aggregate<{ docCount: number; checksumSum: bigint | null; opCountSum: number | null }>(
|
|
964
|
+
[
|
|
965
|
+
{ $match: { _id: { $in: idsToDelete } } },
|
|
966
|
+
{
|
|
967
|
+
$group: {
|
|
968
|
+
_id: null,
|
|
969
|
+
docCount: { $sum: 1 },
|
|
970
|
+
checksumSum: { $sum: '$checksum' },
|
|
971
|
+
opCountSum: { $sum: '$count' }
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
],
|
|
975
|
+
{ session }
|
|
976
|
+
)
|
|
977
|
+
.next();
|
|
978
|
+
|
|
979
|
+
if (
|
|
980
|
+
verification == null ||
|
|
981
|
+
verification.docCount !== expectedDocCount ||
|
|
982
|
+
verification.checksumSum !== expectedChecksum ||
|
|
983
|
+
verification.opCountSum !== expectedOpCount
|
|
984
|
+
) {
|
|
985
|
+
throw new ConcurrentCompactionError(
|
|
986
|
+
`Inputs changed while compacting bucket ${bucket}; restarting from the latest bucket state`
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
await bucketContext.collection.deleteMany({ _id: { $in: idsToDelete } }, { session });
|
|
991
|
+
await bucketContext.collection.insertMany(documents, { session });
|
|
992
|
+
await this.finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, session);
|
|
993
|
+
await this.recordObjectStorageReplacement(oldStorageBytes, documents, context.definitionId, session);
|
|
994
|
+
},
|
|
995
|
+
{
|
|
996
|
+
writeConcern: { w: 'majority' },
|
|
997
|
+
readConcern: { level: 'snapshot' }
|
|
998
|
+
}
|
|
999
|
+
);
|
|
1000
|
+
} finally {
|
|
1001
|
+
await session.endSession();
|
|
76
1002
|
}
|
|
77
1003
|
return {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
b: bucket
|
|
81
|
-
}
|
|
1004
|
+
documentId: documents[0]._id,
|
|
1005
|
+
stats: statsForDocuments(documents)
|
|
82
1006
|
};
|
|
83
1007
|
}
|
|
84
1008
|
|
|
85
|
-
|
|
1009
|
+
/**
|
|
1010
|
+
* Collapse the leading sequence of MOVE/REMOVE/CLEAR ops at the start
|
|
1011
|
+
* of the bucket into a single CLEAR op. Reads whole clearable documents
|
|
1012
|
+
* before the known boundary document, then splits that boundary document
|
|
1013
|
+
* if it contains ops on both sides of lastNotPut.
|
|
1014
|
+
*
|
|
1015
|
+
* Returns the op count and stored-stat changes after replacing cleared ops
|
|
1016
|
+
* with CLEAR ops.
|
|
1017
|
+
*/
|
|
1018
|
+
private async clearBucketLeading(
|
|
1019
|
+
lastNotPut: bigint,
|
|
1020
|
+
boundaryDocId: BucketDataKey,
|
|
1021
|
+
bucketContext: BucketDataContextV3,
|
|
1022
|
+
collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
|
|
1023
|
+
context: { replicationStreamId: number; definitionId: string }
|
|
1024
|
+
): Promise<ClearCompactionResult> {
|
|
1025
|
+
let opCountDiff = 0;
|
|
1026
|
+
let before = emptyBucketStats();
|
|
1027
|
+
let after = emptyBucketStats();
|
|
1028
|
+
const session = this.db.client.startSession();
|
|
1029
|
+
try {
|
|
1030
|
+
let done = false;
|
|
1031
|
+
// First step is to clear full chunks that contain only CLEAR/MOVE/REMOVE operations.
|
|
1032
|
+
// There can be many of them, so we do one batch at a time.
|
|
1033
|
+
while (!done) {
|
|
1034
|
+
const batch = await this.clearLeadingFullDocuments(
|
|
1035
|
+
session,
|
|
1036
|
+
lastNotPut,
|
|
1037
|
+
boundaryDocId,
|
|
1038
|
+
bucketContext,
|
|
1039
|
+
collection,
|
|
1040
|
+
context
|
|
1041
|
+
);
|
|
1042
|
+
done = batch.done;
|
|
1043
|
+
opCountDiff += batch.opCountDiff;
|
|
1044
|
+
before = combineAdjacentStats(before, batch.before);
|
|
1045
|
+
after = combineAdjacentStats(after, batch.after);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// The final step is to process the "boundary" document: It may contain some CLEAR/MOVE/REMOVE operations,
|
|
1049
|
+
// potentially followed by PUT operations. This is only a single document, so no need for batching.
|
|
1050
|
+
const boundaryResult = await this.clearBoundaryDocument(
|
|
1051
|
+
session,
|
|
1052
|
+
lastNotPut,
|
|
1053
|
+
boundaryDocId,
|
|
1054
|
+
bucketContext,
|
|
1055
|
+
collection,
|
|
1056
|
+
context
|
|
1057
|
+
);
|
|
1058
|
+
opCountDiff += boundaryResult.opCountDiff;
|
|
1059
|
+
before = combineAdjacentStats(before, boundaryResult.before);
|
|
1060
|
+
after = combineAdjacentStats(after, boundaryResult.after);
|
|
1061
|
+
} finally {
|
|
1062
|
+
await session.endSession();
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
return { opCountDiff, before, after };
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
private async clearLeadingFullDocuments(
|
|
1069
|
+
session: mongo.ClientSession,
|
|
1070
|
+
lastNotPut: bigint,
|
|
1071
|
+
boundaryDocId: BucketDataKey,
|
|
1072
|
+
bucketContext: BucketDataContextV3,
|
|
1073
|
+
collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
|
|
1074
|
+
context: { replicationStreamId: number; definitionId: string }
|
|
1075
|
+
): Promise<{ done: boolean; opCountDiff: number } & CompactionStatsReplacement> {
|
|
1076
|
+
const bucket = bucketContext.key.bucket;
|
|
1077
|
+
this.signal?.throwIfAborted();
|
|
1078
|
+
let prepared: PreparedObjectStorageUpload[] | undefined;
|
|
1079
|
+
let done = false;
|
|
1080
|
+
let opCountDiff = 0;
|
|
1081
|
+
let before = emptyBucketStats();
|
|
1082
|
+
let after = emptyBucketStats();
|
|
1083
|
+
|
|
1084
|
+
await session.withTransaction(
|
|
1085
|
+
async () => {
|
|
1086
|
+
done = false;
|
|
1087
|
+
opCountDiff = 0;
|
|
1088
|
+
before = emptyBucketStats();
|
|
1089
|
+
after = emptyBucketStats();
|
|
1090
|
+
const oldStoragePaths: string[] = [];
|
|
1091
|
+
let oldStorageBytes = 0n;
|
|
1092
|
+
const query = collection.find(
|
|
1093
|
+
{
|
|
1094
|
+
_id: {
|
|
1095
|
+
$gte: bucketContext.minId,
|
|
1096
|
+
$lt: boundaryDocId
|
|
1097
|
+
}
|
|
1098
|
+
},
|
|
1099
|
+
{
|
|
1100
|
+
session,
|
|
1101
|
+
sort: { _id: 1 },
|
|
1102
|
+
projection: {
|
|
1103
|
+
_id: 1,
|
|
1104
|
+
min_op: 1,
|
|
1105
|
+
checksum: 1,
|
|
1106
|
+
count: 1,
|
|
1107
|
+
size: 1,
|
|
1108
|
+
target_op: 1,
|
|
1109
|
+
has_clear_op: 1,
|
|
1110
|
+
storage_ref: 1
|
|
1111
|
+
},
|
|
1112
|
+
limit: this.clearBatchLimit
|
|
1113
|
+
}
|
|
1114
|
+
);
|
|
1115
|
+
|
|
1116
|
+
let combinedChecksum = 0;
|
|
1117
|
+
let clearedOpCount = 0;
|
|
1118
|
+
let maxTargetOp: bigint | null = null;
|
|
1119
|
+
let lastDocId: BucketDataKey | null = null;
|
|
1120
|
+
let clearOpCount = 0;
|
|
1121
|
+
let gotNonClearOp = false;
|
|
1122
|
+
const inputStats = emptyBucketStats();
|
|
1123
|
+
|
|
1124
|
+
for await (const doc of query.stream()) {
|
|
1125
|
+
if (doc.min_op > lastNotPut) {
|
|
1126
|
+
throw new ReplicationAssertionError(
|
|
1127
|
+
`Unexpected document before CLEAR boundary with min_op ${doc.min_op} > ${lastNotPut} in bucket ${bucket}`
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
lastDocId = doc._id;
|
|
1132
|
+
const documentStats = statsForDocument(doc);
|
|
1133
|
+
inputStats.count += documentStats.count;
|
|
1134
|
+
inputStats.bytes += documentStats.bytes;
|
|
1135
|
+
inputStats.chunks += documentStats.chunks;
|
|
1136
|
+
inputStats.checksum = addChecksums(inputStats.checksum, documentStats.checksum);
|
|
1137
|
+
if (doc.storage_ref) {
|
|
1138
|
+
oldStoragePaths.push(doc.storage_ref.path);
|
|
1139
|
+
}
|
|
1140
|
+
oldStorageBytes += ObjectStorageUsage.bytes(doc);
|
|
1141
|
+
|
|
1142
|
+
// The compaction scan established that every operation before the
|
|
1143
|
+
// boundary is MOVE/REMOVE/CLEAR. Root metadata is sufficient to fold
|
|
1144
|
+
// whole documents into one CLEAR, so avoid downloading their payloads.
|
|
1145
|
+
if (doc.has_clear_op) {
|
|
1146
|
+
clearOpCount++;
|
|
1147
|
+
if (clearOpCount > 1) {
|
|
1148
|
+
throw new ReplicationAssertionError(`Unexpected multiple CLEAR operations in bucket ${bucket}`);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
if (!doc.has_clear_op || doc.count > 1) {
|
|
1152
|
+
gotNonClearOp = true;
|
|
1153
|
+
}
|
|
1154
|
+
combinedChecksum = addChecksums(combinedChecksum, Number(doc.checksum));
|
|
1155
|
+
clearedOpCount += doc.count;
|
|
1156
|
+
if (doc.target_op != null && (maxTargetOp == null || doc.target_op > maxTargetOp)) {
|
|
1157
|
+
maxTargetOp = doc.target_op;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if (!gotNonClearOp) {
|
|
1162
|
+
done = true;
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
prepared ??= await this.prepareCompactionUploads(bucket, context, [lastNotPut]);
|
|
1167
|
+
this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastDocId?.o}`);
|
|
1168
|
+
await collection.deleteMany(
|
|
1169
|
+
{
|
|
1170
|
+
_id: {
|
|
1171
|
+
$gte: bucketContext.minId,
|
|
1172
|
+
$lte: lastDocId!
|
|
1173
|
+
}
|
|
1174
|
+
},
|
|
1175
|
+
{ session }
|
|
1176
|
+
);
|
|
1177
|
+
|
|
1178
|
+
const clearOp = {
|
|
1179
|
+
bucketKey: { ...context, bucket },
|
|
1180
|
+
o: lastDocId!.o,
|
|
1181
|
+
op: 'CLEAR' as const,
|
|
1182
|
+
checksum: BigInt(combinedChecksum),
|
|
1183
|
+
data: null
|
|
1184
|
+
} satisfies BucketDataDoc;
|
|
1185
|
+
const persisted = await this.persistBucketData(bucket, [[clearOp]], context, prepared, {
|
|
1186
|
+
targetOp: maxTargetOp
|
|
1187
|
+
});
|
|
1188
|
+
await collection.insertOne(persisted.documents[0], { session });
|
|
1189
|
+
await this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, session);
|
|
1190
|
+
await this.recordObjectStorageReplacement(oldStorageBytes, persisted.documents, context.definitionId, session);
|
|
1191
|
+
|
|
1192
|
+
opCountDiff = -clearedOpCount + 1;
|
|
1193
|
+
before = inputStats;
|
|
1194
|
+
after = statsForDocuments(persisted.documents);
|
|
1195
|
+
},
|
|
1196
|
+
{
|
|
1197
|
+
writeConcern: { w: 'majority' },
|
|
1198
|
+
readConcern: { level: 'snapshot' }
|
|
1199
|
+
}
|
|
1200
|
+
);
|
|
1201
|
+
|
|
1202
|
+
return { done, opCountDiff, before, after };
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
private async clearBoundaryDocument(
|
|
1206
|
+
session: mongo.ClientSession,
|
|
1207
|
+
lastNotPut: bigint,
|
|
1208
|
+
boundaryDocId: BucketDataKey,
|
|
1209
|
+
bucketContext: BucketDataContextV3,
|
|
1210
|
+
collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
|
|
1211
|
+
context: { replicationStreamId: number; definitionId: string }
|
|
1212
|
+
): Promise<ClearCompactionResult> {
|
|
1213
|
+
const bucket = bucketContext.key.bucket;
|
|
1214
|
+
this.signal?.throwIfAborted();
|
|
1215
|
+
const prepared = await this.prepareCompactionUploads(bucket, context, [lastNotPut, boundaryDocId.o]);
|
|
1216
|
+
let opCountDiff = 0;
|
|
1217
|
+
let before = emptyBucketStats();
|
|
1218
|
+
let after = emptyBucketStats();
|
|
1219
|
+
|
|
1220
|
+
await session.withTransaction(
|
|
1221
|
+
async () => {
|
|
1222
|
+
opCountDiff = 0;
|
|
1223
|
+
before = emptyBucketStats();
|
|
1224
|
+
after = emptyBucketStats();
|
|
1225
|
+
const oldStoragePaths: string[] = [];
|
|
1226
|
+
let oldStorageBytes = 0n;
|
|
1227
|
+
const query = collection.find(
|
|
1228
|
+
{
|
|
1229
|
+
// This is a range query, but should only ever return two documents:
|
|
1230
|
+
// 1. The CLEAR op from the previous clearLeadingFullDocuments.
|
|
1231
|
+
// 2. The boundary document.
|
|
1232
|
+
_id: {
|
|
1233
|
+
$gte: bucketContext.minId,
|
|
1234
|
+
$lte: boundaryDocId
|
|
1235
|
+
}
|
|
1236
|
+
},
|
|
1237
|
+
{
|
|
1238
|
+
session,
|
|
1239
|
+
sort: { _id: 1 },
|
|
1240
|
+
projection: {
|
|
1241
|
+
_id: 1,
|
|
1242
|
+
min_op: 1,
|
|
1243
|
+
checksum: 1,
|
|
1244
|
+
count: 1,
|
|
1245
|
+
size: 1,
|
|
1246
|
+
target_op: 1,
|
|
1247
|
+
ops: 1,
|
|
1248
|
+
storage_ref: 1
|
|
1249
|
+
},
|
|
1250
|
+
limit: 3
|
|
1251
|
+
}
|
|
1252
|
+
);
|
|
1253
|
+
|
|
1254
|
+
let docsRead = 0;
|
|
1255
|
+
let combinedChecksum = 0;
|
|
1256
|
+
let clearedOpCount = 0;
|
|
1257
|
+
let maxTargetOp: bigint | null = null;
|
|
1258
|
+
const boundarySurvivors: BucketDataDoc[] = [];
|
|
1259
|
+
const inputStats = emptyBucketStats();
|
|
1260
|
+
|
|
1261
|
+
for await (const doc of query.stream()) {
|
|
1262
|
+
docsRead++;
|
|
1263
|
+
if (docsRead > 2) {
|
|
1264
|
+
throw new ReplicationAssertionError(`Unexpected extra document before CLEAR boundary in bucket ${bucket}`);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
const documentStats = statsForDocument(doc);
|
|
1268
|
+
inputStats.count += documentStats.count;
|
|
1269
|
+
inputStats.bytes += documentStats.bytes;
|
|
1270
|
+
inputStats.chunks += documentStats.chunks;
|
|
1271
|
+
inputStats.checksum = addChecksums(inputStats.checksum, documentStats.checksum);
|
|
1272
|
+
|
|
1273
|
+
const isBoundaryDoc = doc._id.o == boundaryDocId.o;
|
|
1274
|
+
if (doc.storage_ref) {
|
|
1275
|
+
oldStoragePaths.push(doc.storage_ref.path);
|
|
1276
|
+
}
|
|
1277
|
+
oldStorageBytes += ObjectStorageUsage.bytes(doc);
|
|
1278
|
+
await hydrateBucketDataDocuments([doc], this.storage.objectStorage, { signal: this.signal });
|
|
1279
|
+
maxTargetOp = maxOpId(maxTargetOp, doc.target_op);
|
|
1280
|
+
for (const op of loadBucketDataDocument(context, doc)) {
|
|
1281
|
+
if (!isBoundaryDoc && op.op != 'CLEAR') {
|
|
1282
|
+
throw new ReplicationAssertionError(
|
|
1283
|
+
`Unexpected ${op.op} operation before CLEAR boundary in bucket ${bucket}`
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
if (op.o <= lastNotPut) {
|
|
1288
|
+
if (op.op == 'PUT') {
|
|
1289
|
+
throw new ReplicationAssertionError(
|
|
1290
|
+
`Unexpected PUT at op ${op.o} in CLEAR region for bucket ${bucket}`
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
combinedChecksum = addChecksums(combinedChecksum, Number(op.checksum));
|
|
1294
|
+
clearedOpCount++;
|
|
1295
|
+
} else if (isBoundaryDoc) {
|
|
1296
|
+
boundarySurvivors.push(op);
|
|
1297
|
+
} else {
|
|
1298
|
+
throw new ReplicationAssertionError(
|
|
1299
|
+
`Unexpected op ${op.o} after CLEAR boundary ${lastNotPut} in bucket ${bucket}`
|
|
1300
|
+
);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
if (clearedOpCount == 0) {
|
|
1306
|
+
throw new Error(`CLEAR boundary document not found for bucket ${bucket}`);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastNotPut}`);
|
|
1310
|
+
await collection.deleteMany(
|
|
1311
|
+
{
|
|
1312
|
+
_id: {
|
|
1313
|
+
$gte: bucketContext.minId,
|
|
1314
|
+
$lte: boundaryDocId
|
|
1315
|
+
}
|
|
1316
|
+
},
|
|
1317
|
+
{ session }
|
|
1318
|
+
);
|
|
1319
|
+
|
|
1320
|
+
const clearOp = {
|
|
1321
|
+
bucketKey: { ...context, bucket },
|
|
1322
|
+
o: lastNotPut,
|
|
1323
|
+
op: 'CLEAR' as const,
|
|
1324
|
+
checksum: BigInt(combinedChecksum),
|
|
1325
|
+
data: null
|
|
1326
|
+
} satisfies BucketDataDoc;
|
|
1327
|
+
const chunks: BucketDataDoc[][] = [[clearOp]];
|
|
1328
|
+
if (boundarySurvivors.length > 0) {
|
|
1329
|
+
// These operations are a subset of one existing document, so keeping
|
|
1330
|
+
// them together cannot increase its stored ops payload.
|
|
1331
|
+
chunks.push(boundarySurvivors);
|
|
1332
|
+
}
|
|
1333
|
+
const persisted = await this.persistBucketData(bucket, chunks, context, prepared, {
|
|
1334
|
+
targetOp: maxTargetOp ?? undefined
|
|
1335
|
+
});
|
|
1336
|
+
await collection.insertMany(persisted.documents, { session });
|
|
1337
|
+
await this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, session);
|
|
1338
|
+
await this.recordObjectStorageReplacement(oldStorageBytes, persisted.documents, context.definitionId, session);
|
|
1339
|
+
|
|
1340
|
+
opCountDiff = -clearedOpCount + 1;
|
|
1341
|
+
before = inputStats;
|
|
1342
|
+
after = statsForDocuments(persisted.documents);
|
|
1343
|
+
},
|
|
1344
|
+
{
|
|
1345
|
+
writeConcern: { w: 'majority' },
|
|
1346
|
+
readConcern: { level: 'snapshot' }
|
|
1347
|
+
}
|
|
1348
|
+
);
|
|
1349
|
+
|
|
1350
|
+
return { opCountDiff, before, after };
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/**
|
|
1354
|
+
* Reserve stable object paths before starting a retryable MongoDB transaction.
|
|
1355
|
+
* Each retry can safely overwrite the same paths, while the pre-existing
|
|
1356
|
+
* deletion markers remain visible to the transaction that publishes them.
|
|
1357
|
+
*
|
|
1358
|
+
* CLEAR compaction reserves its maximum output count. Unused markers remain
|
|
1359
|
+
* pending so they can clean up a path that an earlier transaction attempt may
|
|
1360
|
+
* have uploaded before retrying with fewer output documents.
|
|
1361
|
+
*/
|
|
1362
|
+
private async prepareCompactionUploads(
|
|
86
1363
|
bucket: string,
|
|
87
|
-
definitionId:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
1364
|
+
context: { replicationStreamId: number; definitionId: string },
|
|
1365
|
+
opIdHints: bigint[]
|
|
1366
|
+
): Promise<PreparedObjectStorageUpload[]> {
|
|
1367
|
+
if (!this.storage.objectStorage) {
|
|
1368
|
+
return [];
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
const lifecycle = this.objectStorageLifecycle;
|
|
1372
|
+
const paths = opIdHints.map((opIdHint) => lifecycle.allocatePath(context.definitionId, bucket, opIdHint, opIdHint));
|
|
1373
|
+
return lifecycle.prepareUploads(paths);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/** Publish replacement uploads and retire superseded objects in the same transaction. */
|
|
1377
|
+
private async finishObjectStorageReplacement(
|
|
1378
|
+
oldStoragePaths: Iterable<string>,
|
|
1379
|
+
newStoragePaths: Set<string>,
|
|
1380
|
+
uploads: PreparedObjectStorageUpload[],
|
|
1381
|
+
session: mongo.ClientSession
|
|
1382
|
+
): Promise<void> {
|
|
1383
|
+
if (!this.storage.objectStorage) {
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
await this.objectStorageLifecycle.publishUploads(uploads, session);
|
|
1387
|
+
await this.objectStorageLifecycle.retire(
|
|
1388
|
+
Array.from(oldStoragePaths).filter((path) => !newStoragePaths.has(path)),
|
|
1389
|
+
session
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
private async recordObjectStorageReplacement(
|
|
1394
|
+
oldBytes: bigint,
|
|
1395
|
+
newDocuments: Iterable<Pick<BucketDataDocumentV3, 'storage_ref'>>,
|
|
1396
|
+
definitionId: BucketDefinitionId,
|
|
1397
|
+
session: mongo.ClientSession
|
|
1398
|
+
): Promise<void> {
|
|
1399
|
+
if (!this.storage.objectStorage) {
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
let newBytes = 0n;
|
|
1403
|
+
for (const document of newDocuments) {
|
|
1404
|
+
newBytes += ObjectStorageUsage.bytes(document);
|
|
1405
|
+
}
|
|
1406
|
+
await this.objectStorageUsage.applyDelta(definitionId, newBytes - oldBytes, session);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
private async persistBucketData(
|
|
1410
|
+
bucket: string,
|
|
1411
|
+
chunks: BucketDataDoc[][],
|
|
1412
|
+
context: { replicationStreamId: number; definitionId: string },
|
|
1413
|
+
preparedUploads?: PreparedObjectStorageUpload[],
|
|
1414
|
+
options?: { targetOp?: InternalOpId | null }
|
|
1415
|
+
): Promise<{ documents: BucketDataDocumentV3[]; storagePaths: Set<string>; uploads: PreparedObjectStorageUpload[] }> {
|
|
1416
|
+
const serializedChunks = chunks.map((chunk) => serializeBucketData(bucket, chunk, options));
|
|
1417
|
+
if (!this.storage.objectStorage) {
|
|
1418
|
+
return {
|
|
1419
|
+
documents: serializedChunks,
|
|
1420
|
+
storagePaths: new Set(),
|
|
1421
|
+
uploads: []
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
const store = new BucketDataObjectStorage(this.storage.objectStorage);
|
|
1426
|
+
const storagePaths = new Set<string>();
|
|
1427
|
+
const lifecycle = this.objectStorageLifecycle;
|
|
1428
|
+
// Base placement on the final compacted size. Unchanged documents are not
|
|
1429
|
+
// rewritten, while small MOVE/merge results and CLEAR ops stay inline.
|
|
1430
|
+
const storedIndexes = serializedChunks.flatMap((document, index) =>
|
|
1431
|
+
document.size > this.storage.inlineThresholdBytes ? [index] : []
|
|
1432
|
+
);
|
|
1433
|
+
const uploadsByIndex = new Map<number, PreparedObjectStorageUpload>();
|
|
1434
|
+
|
|
1435
|
+
if (preparedUploads) {
|
|
1436
|
+
for (const index of storedIndexes) {
|
|
1437
|
+
const upload = preparedUploads[index];
|
|
1438
|
+
if (!upload) {
|
|
1439
|
+
throw new ServiceAssertionError(
|
|
1440
|
+
`Missing prepared object storage path for compacted document at index ${index}`
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
uploadsByIndex.set(index, upload);
|
|
101
1444
|
}
|
|
102
|
-
|
|
1445
|
+
} else {
|
|
1446
|
+
const paths = storedIndexes.map((index) => {
|
|
1447
|
+
const chunk = chunks[index];
|
|
1448
|
+
return lifecycle.allocatePath(context.definitionId, bucket, chunk[0].o, chunk[chunk.length - 1].o);
|
|
1449
|
+
});
|
|
1450
|
+
const prepared = await lifecycle.prepareUploads(paths);
|
|
1451
|
+
storedIndexes.forEach((index, preparedIndex) => uploadsByIndex.set(index, prepared[preparedIndex]));
|
|
103
1452
|
}
|
|
104
1453
|
|
|
105
|
-
|
|
1454
|
+
// S3ObjectStorage applies one shared concurrency limit across all callers,
|
|
1455
|
+
// so compaction can schedule its uploads together without creating a
|
|
1456
|
+
// separate limiter here.
|
|
1457
|
+
const documents = await Promise.all(
|
|
1458
|
+
serializedChunks.map(async (serialized, index) => {
|
|
1459
|
+
const upload = uploadsByIndex.get(index);
|
|
1460
|
+
if (!upload) {
|
|
1461
|
+
return serialized;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
const { ops, ...metadata } = serialized;
|
|
1465
|
+
const { fileSize } = await store.store(upload.path, ops!, { signal: this.signal });
|
|
1466
|
+
storagePaths.add(upload.path);
|
|
1467
|
+
return {
|
|
1468
|
+
...metadata,
|
|
1469
|
+
storage_ref: { path: upload.path, file_size: fileSize }
|
|
1470
|
+
};
|
|
1471
|
+
})
|
|
1472
|
+
);
|
|
1473
|
+
|
|
1474
|
+
return { documents, storagePaths, uploads: Array.from(uploadsByIndex.values()) };
|
|
106
1475
|
}
|
|
107
1476
|
}
|