mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-09 05:36:24 +00:00
4c2a0cdf9a
* fix(scanner): remove unused digest import Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * feat(scanner): add raw page owner index (#7375) * feat(scanner): add raw page owner index Add a serializable raw enumeration page owner index for scanner resume work. The index exposes unsupported, building, and ready states, validates committed page identity by recomputing digests, and uses generation checks for CAS-style page commits. Focused tests cover small-budget restart progress, page digest/source drift rejection, corrupt deserialized state, CAS failure, precommit crash, empty sources, and invalid entry boundaries. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * feat(scanner): persist raw page owner resume state (#7379) Wire the scanner raw enumeration partial-cache writer to the raw page owner index so interrupted bucket walks can retain validated page-builder state across scanner restarts. Keep complete owner sources terminal-only, add partial-source ingestion for in-progress raw directory reads, and validate the persisted page index through bucket checkpoint preparation. Co-authored-by: zhi22915 <qiuzgang@gmail.com> --------- Co-authored-by: zhi22915 <qiuzgang@gmail.com> * test(scanner): fence segment producer observations (#7381) Require the segment observation fixture to carry source, incarnation, key-format, baseline, process epoch, generation-window, gap, overflow, and producer-coverage proof before accepting a narrowed proposal. Keep the diagnostic path fixture-only and remove its ordinary stderr output. Co-authored-by: zhi22915 <qiuzgang@gmail.com> * fix(ecstore): isolate pool metadata read probes (#7367) Co-authored-by: zhi22915 <qiuzgang@gmail.com> * test(heal): cover MRF crash successor matrix (#7369) * test(heal): cover MRF crash successor matrix Add process-boundary MRF replay coverage for the successor snapshot window after a retained startup journal is flushed but before cleanup deletes it. Extend the mixed authoritative/legacy reader fixture with a scoped v2 journal epoch to pin the no-merge contract. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * test(heal): cover service-kill MRF replay (#7380) Add a Unix process fixture that waits after publishing the pending MRF successor snapshot, then is terminated by the parent before restart replay. Co-authored-by: zhi22915 <qiuzgang@gmail.com> --------- Co-authored-by: zhi22915 <qiuzgang@gmail.com> * test(heal): cover transport-lost start receipts (#7371) Add gRPC transport fault fixtures for heal-control start admission. The tests distinguish pre-admission transport loss from post-admission response loss, then verify exact envelope retries reuse the canonical receipt while fresh forceStart requests create distinct tasks. Co-authored-by: zhi22915 <qiuzgang@gmail.com> * test(scanner): add crash-restart heal evidence case (#7370) * test(scanner): add crash-restart heal evidence case Add a distinct W21 background target crash case to the scanner/heal evidence registry and oracle path. Keep the existing restart lane on graceful process restart, keep the crash lane on hard kill, and make the wiring checker reject evidence/oracle mismatches. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * test(scanner): support older Python wiring checks Let the scanner/heal evidence wiring checker run under Python 3.9/3.10 by falling back to tomli and chunked SHA-256 hashing when the Python 3.11 standard APIs are unavailable. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> --------- Co-authored-by: zhi22915 <qiuzgang@gmail.com> * fix(scanner): reject stale raw page source seeds (#7382) Do not prefill a resumed raw page owner with previously indexed entries when starting a new raw directory observation pass. The next pass must observe the same prefix again before the page index can advance; otherwise the index is discarded fail-closed. Co-authored-by: zhi22915 <qiuzgang@gmail.com> * fix(scanner): defer raw page revalidation until observed (#7384) A resumed raw page owner index must not prefill entries from older cache state, but it also must not discard a valid multi-entry index before the current raw directory pass has observed enough entries to prove identity. Track the persisted index floor and only run the strict owner identity check once the current pass reaches that floor. Co-authored-by: zhi22915 <qiuzgang@gmail.com> --------- Co-authored-by: zhi22915 <qiuzgang@gmail.com>
1642 lines
63 KiB
Rust
1642 lines
63 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration};
|
|
use serde::{Deserialize, Serialize, ser::SerializeMap};
|
|
use sha2::{Digest, Sha256};
|
|
use std::{
|
|
collections::{HashMap, HashSet},
|
|
future::Future,
|
|
sync::{Arc, LazyLock, Once},
|
|
time::SystemTime,
|
|
};
|
|
|
|
use http::HeaderMap;
|
|
use metrics::{counter, describe_counter, describe_histogram, histogram};
|
|
#[cfg(test)]
|
|
use rustfs_config::ENV_SCANNER_CACHE_SAVE_TIMEOUT_SECS;
|
|
pub use rustfs_data_usage::{
|
|
AllTierStats, BucketTargetUsageInfo, BucketUsageInfo, DATA_USAGE_OBJECT_NAME, DATA_USAGE_OBSERVED_OBJECT_NAME,
|
|
DataUsageEntry, DataUsageHash, DataUsageHashMap, DataUsageInfo, DataUsageSnapshotSetState, LEGACY_DATA_USAGE_OBJECT_NAME,
|
|
PrefixUsageEntry, PrefixUsageQuery, PrefixUsageSummary, ReplTargetSizeSummary, SizeReconciliationEntry,
|
|
SizeReconciliationScope, SizeSummary, TierAccountingProof, TierStats, UNKNOWN_TIER, UNKNOWN_TIER_DIAGNOSTIC_BYTE_CAP,
|
|
UNKNOWN_TIER_DIAGNOSTIC_ENTRY_CAP, UnknownTierStats, hash_path, prefix_usage_in_cache,
|
|
};
|
|
use rustfs_heal_contracts::heal_channel::HealScanMode;
|
|
use rustfs_utils::path::{SLASH_SEPARATOR, path_join_buf};
|
|
use tokio::time::{Duration, Instant, sleep, timeout};
|
|
use tracing::{debug, warn};
|
|
|
|
use crate::raw_page_index::{RawEnumerationPageIndex, RawEnumerationPageOwnerStatus};
|
|
use crate::storage_api::owner::HTTPPreconditions;
|
|
use crate::{
|
|
BUCKET_META_PREFIX, EcstoreError as Error, EcstoreResult as StorageResult, RUSTFS_META_BUCKET, ReplicationConfig,
|
|
SCANNER_PUBLICATION_EPOCH_CHANGED, ScannerObjectInfo as ObjectInfo, ScannerObjectOptions as ObjectOptions, StorageError,
|
|
TRANSITION_COMPLETE, save_config, save_config_with_preconditions, scanner_publication_admission_for_epoch, storageclass,
|
|
};
|
|
use crate::{ScannerConfigObjectDelete, ScannerObjectIO};
|
|
|
|
// Data usage constants
|
|
pub const DATA_USAGE_ROOT: &str = SLASH_SEPARATOR;
|
|
|
|
const DATA_USAGE_BLOOM_NAME: &str = ".bloomcycle.bin";
|
|
|
|
pub const DATA_USAGE_CACHE_NAME: &str = ".usage-cache.bin";
|
|
pub(crate) const DATA_USAGE_CACHE_KEY_FORMAT: u16 = 1;
|
|
|
|
const DATA_USAGE_CACHE_SAVE_RETRIES: u32 = 2;
|
|
const DATA_USAGE_CACHE_BACKUP_SAVE_TIMEOUT_SECS_MAX: u64 = 5;
|
|
const DATA_USAGE_CACHE_BACKUP_SAVE_RETRIES: u32 = 0;
|
|
const DATA_USAGE_CACHE_SAVE_RETRY_BACKOFF_MAX: Duration = Duration::from_millis(350);
|
|
const DATA_USAGE_CACHE_PERSISTENCE_MARGIN: Duration = Duration::from_secs(5);
|
|
const METRIC_CACHE_SAVE_ATTEMPT_TOTAL: &str = "rustfs_scanner_cache_save_attempt_total";
|
|
const METRIC_CACHE_SAVE_TIMEOUT_TOTAL: &str = "rustfs_scanner_cache_save_timeout_total";
|
|
const METRIC_CACHE_SAVE_RETRY_TOTAL: &str = "rustfs_scanner_cache_save_retry_total";
|
|
const METRIC_CACHE_SAVE_DURATION_SECONDS: &str = "rustfs_scanner_cache_save_duration_seconds";
|
|
const METRIC_CACHE_BACKUP_REVISION_FAILURE_TOTAL: &str = "rustfs_scanner_cache_backup_revision_failure_total";
|
|
const LOG_COMPONENT_SCANNER: &str = "scanner";
|
|
const LOG_SUBSYSTEM_CACHE: &str = "cache";
|
|
const EVENT_SCANNER_CACHE_LOAD_STATE: &str = "scanner_cache_load_state";
|
|
const EVENT_SCANNER_CACHE_SAVE_STATE: &str = "scanner_cache_save_state";
|
|
static CACHE_SAVE_METRICS_ONCE: Once = Once::new();
|
|
|
|
pub const DATA_USAGE_SCAN_CHECKPOINT_VERSION: u16 = 1;
|
|
pub const DATA_USAGE_RAW_ENUMERATION_CURSOR_VERSION: u16 = 1;
|
|
const DATA_USAGE_SCAN_CURSOR_MAX_BYTES: usize = 16 * 1024;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub(crate) enum DataUsageCacheRevision {
|
|
Missing,
|
|
Etag(String),
|
|
}
|
|
|
|
impl DataUsageCacheRevision {
|
|
pub(crate) fn preconditions(&self) -> HTTPPreconditions {
|
|
match self {
|
|
Self::Missing => HTTPPreconditions {
|
|
if_none_match: Some("*".to_string()),
|
|
..Default::default()
|
|
},
|
|
Self::Etag(etag) => HTTPPreconditions {
|
|
if_match: Some(etag.clone()),
|
|
..Default::default()
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn read_config_with_revision<S: ScannerObjectIO>(
|
|
store: Arc<S>,
|
|
path: &str,
|
|
) -> StorageResult<(Option<Vec<u8>>, DataUsageCacheRevision)> {
|
|
match store
|
|
.get_object_reader(
|
|
RUSTFS_META_BUCKET,
|
|
path,
|
|
None,
|
|
HeaderMap::new(),
|
|
&ObjectOptions {
|
|
no_lock: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
Ok(mut reader) => {
|
|
let revision = reader
|
|
.object_info
|
|
.etag
|
|
.as_ref()
|
|
.filter(|etag| !etag.is_empty())
|
|
.cloned()
|
|
.map(DataUsageCacheRevision::Etag)
|
|
.ok_or_else(|| StorageError::other(format!("scanner config object {path} has no ETag")))?;
|
|
Ok((Some(reader.read_all().await?), revision))
|
|
}
|
|
Err(
|
|
Error::ConfigNotFound
|
|
| Error::FileNotFound
|
|
| Error::VolumeNotFound
|
|
| Error::ObjectNotFound(_, _)
|
|
| Error::BucketNotFound(_),
|
|
) => Ok((None, DataUsageCacheRevision::Missing)),
|
|
Err(err) => Err(err),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn usage_floor_primary_read_error_allows_backup(err: &Error) -> bool {
|
|
match err {
|
|
Error::FileCorrupt
|
|
| Error::CorruptedFormat
|
|
| Error::CorruptedBackend
|
|
| Error::PartMissingOrCorrupt
|
|
| Error::LessData
|
|
| Error::MoreData => true,
|
|
Error::Io(io_error) => {
|
|
matches!(io_error.kind(), std::io::ErrorKind::InvalidData | std::io::ErrorKind::UnexpectedEof)
|
|
|| error_chain_has_usage_floor_corruption_signature(io_error)
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn error_chain_has_usage_floor_corruption_signature(error: &(dyn std::error::Error + 'static)) -> bool {
|
|
let mut current = Some(error);
|
|
while let Some(err) = current {
|
|
let message = err.to_string();
|
|
if message.contains("InlineData value out of range")
|
|
|| message.contains("InlineData key out of range")
|
|
|| message.contains("insufficient data for metadata")
|
|
|| message.contains("insufficient data for meta length")
|
|
|| message.contains("insufficient data for CRC")
|
|
{
|
|
return true;
|
|
}
|
|
current = err.source();
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Read only the object revision without materializing its body.
|
|
pub(crate) async fn read_config_revision<S: ScannerObjectIO>(store: Arc<S>, path: &str) -> StorageResult<DataUsageCacheRevision> {
|
|
match store
|
|
.get_object_reader(
|
|
RUSTFS_META_BUCKET,
|
|
path,
|
|
None,
|
|
HeaderMap::new(),
|
|
&ObjectOptions {
|
|
no_lock: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
Ok(reader) => reader
|
|
.object_info
|
|
.etag
|
|
.filter(|etag| !etag.is_empty())
|
|
.map(DataUsageCacheRevision::Etag)
|
|
.ok_or_else(|| StorageError::other(format!("scanner config object {path} has no ETag"))),
|
|
Err(
|
|
Error::ConfigNotFound
|
|
| Error::FileNotFound
|
|
| Error::VolumeNotFound
|
|
| Error::ObjectNotFound(_, _)
|
|
| Error::BucketNotFound(_),
|
|
) => Ok(DataUsageCacheRevision::Missing),
|
|
Err(err) => Err(err),
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub(crate) struct DataUsageCacheRevisions {
|
|
main: DataUsageCacheRevision,
|
|
backup: Option<DataUsageCacheRevision>,
|
|
}
|
|
|
|
pub static DATA_USAGE_BUCKET: LazyLock<String> =
|
|
LazyLock::new(|| format!("{RUSTFS_META_BUCKET}{SLASH_SEPARATOR}{BUCKET_META_PREFIX}"));
|
|
|
|
pub static DATA_USAGE_OBJ_NAME_PATH: LazyLock<String> =
|
|
LazyLock::new(|| format!("{BUCKET_META_PREFIX}{SLASH_SEPARATOR}{DATA_USAGE_OBJECT_NAME}"));
|
|
|
|
/// Durable evidence for recovery of the exact empty usage fence written by
|
|
/// rc.2/rc.3 bucket cleanup before the first authoritative scanner snapshot.
|
|
pub static DATA_USAGE_RECOVERY_PATH: LazyLock<String> =
|
|
LazyLock::new(|| format!("{}.recovery-pending.json", DATA_USAGE_OBJ_NAME_PATH.as_str()));
|
|
|
|
pub static DATA_USAGE_OBSERVED_OBJ_NAME_PATH: LazyLock<String> =
|
|
LazyLock::new(|| format!("{BUCKET_META_PREFIX}{SLASH_SEPARATOR}{DATA_USAGE_OBSERVED_OBJECT_NAME}"));
|
|
|
|
pub static LEGACY_DATA_USAGE_OBJ_NAME_PATH: LazyLock<String> =
|
|
LazyLock::new(|| format!("{BUCKET_META_PREFIX}{SLASH_SEPARATOR}{LEGACY_DATA_USAGE_OBJECT_NAME}"));
|
|
|
|
pub static DATA_USAGE_BLOOM_NAME_PATH: LazyLock<String> =
|
|
LazyLock::new(|| format!("{BUCKET_META_PREFIX}{SLASH_SEPARATOR}{DATA_USAGE_BLOOM_NAME}"));
|
|
|
|
/// Durable companion object for a cycle-state object which cannot be decoded.
|
|
/// The primary object is deliberately never replaced or deleted by recovery.
|
|
pub static DATA_USAGE_BLOOM_RECOVERY_PATH: LazyLock<String> =
|
|
LazyLock::new(|| format!("{}.recovery-required.json", DATA_USAGE_BLOOM_NAME_PATH.as_str()));
|
|
|
|
pub static BACKGROUND_HEAL_INFO_PATH: LazyLock<String> =
|
|
LazyLock::new(|| format!("{BUCKET_META_PREFIX}{SLASH_SEPARATOR}.background-heal.json"));
|
|
|
|
const MAX_DATA_USAGE_CACHE_DEPTH: usize = 1024;
|
|
|
|
/// Scanner-side accounting on the shared [`SizeSummary`].
|
|
///
|
|
/// The type itself lives in `rustfs-data-usage`, which sits below the storage
|
|
/// layer and cannot see `ObjectInfo`, so this stays an extension trait rather
|
|
/// than an inherent method (backlog#1828).
|
|
pub trait ScannerSizeSummaryExt {
|
|
/// Fold one object's contribution into the summary, including its tier.
|
|
fn actions_accounting(&mut self, oi: &ObjectInfo, size: i64, actual_size: i64);
|
|
/// Fold counters and physical tier usage for an object whose metadata is
|
|
/// valid but whose logical size is currently unavailable. Logical totals
|
|
/// stay unchanged.
|
|
fn actions_accounting_unknown(&mut self, oi: &ObjectInfo);
|
|
}
|
|
|
|
impl ScannerSizeSummaryExt for SizeSummary {
|
|
fn actions_accounting(&mut self, oi: &ObjectInfo, size: i64, actual_size: i64) {
|
|
if oi.delete_marker {
|
|
self.delete_markers = self.delete_markers.saturating_add(1);
|
|
return;
|
|
}
|
|
|
|
if oi.version_id.is_some_and(|v| !v.is_nil()) && size == actual_size {
|
|
self.versions = self.versions.saturating_add(1);
|
|
}
|
|
|
|
let logical_size = size.max(0);
|
|
let size = usize::try_from(logical_size).unwrap_or(usize::MAX);
|
|
self.total_size = self.total_size.saturating_add(size);
|
|
let logical_bytes = u64::try_from(logical_size).unwrap_or(u64::MAX);
|
|
let physical_bytes = u64::try_from(oi.size.max(0)).unwrap_or(0);
|
|
let mut proof = TierAccountingProof {
|
|
logical_total: logical_bytes,
|
|
logical_known: 0,
|
|
physical_total: physical_bytes,
|
|
physical_known: 0,
|
|
overflowed: false,
|
|
};
|
|
|
|
if oi.transitioned_object.free_version {
|
|
proof.logical_known = logical_bytes;
|
|
proof.physical_known = physical_bytes;
|
|
self.tier_accounting_proof.saturating_add(proof);
|
|
return;
|
|
}
|
|
|
|
let tier = if oi.transitioned_object.status == TRANSITION_COMPLETE {
|
|
oi.transitioned_object.tier.as_str()
|
|
} else {
|
|
oi.storage_class.as_deref().unwrap_or(storageclass::STANDARD)
|
|
};
|
|
|
|
let builtin_tier = tier == storageclass::STANDARD || tier == storageclass::RRS;
|
|
let tier_registry_is_empty =
|
|
self.tier_stats.is_empty() || (self.tier_stats.len() == 1 && self.tier_stats.contains_key(UNKNOWN_TIER));
|
|
let known_tier = tier != UNKNOWN_TIER && (builtin_tier || self.tier_stats.contains_key(tier));
|
|
|
|
// With no configured tier, retain the historical empty-map shape for
|
|
// ordinary STANDARD/RRS objects. A non-built-in key is still an
|
|
// observable unknown and must create only the fixed bucket.
|
|
if tier_registry_is_empty && known_tier {
|
|
proof.logical_known = logical_bytes;
|
|
proof.physical_known = physical_bytes;
|
|
self.tier_accounting_proof.saturating_add(proof);
|
|
return;
|
|
}
|
|
|
|
// Configured tiers and the fixed bucket are normally seeded, so the
|
|
// hot path can mutate them without allocating a key for every object.
|
|
// The fallback inserts only when a legacy/no-config summary sees its
|
|
// first unknown key.
|
|
let tier_stats = if known_tier {
|
|
if let Some(stats) = self.tier_stats.get_mut(tier) {
|
|
stats
|
|
} else {
|
|
self.tier_stats.entry(tier.to_owned()).or_default()
|
|
}
|
|
} else if let Some(stats) = self.tier_stats.get_mut(UNKNOWN_TIER) {
|
|
stats
|
|
} else {
|
|
self.tier_stats.entry(UNKNOWN_TIER.to_string()).or_default()
|
|
};
|
|
*tier_stats = tier_stats.add(&TierStats {
|
|
total_size: physical_bytes,
|
|
num_versions: 1,
|
|
num_objects: u64::from(oi.is_latest),
|
|
});
|
|
if known_tier {
|
|
proof.logical_known = logical_bytes;
|
|
proof.physical_known = physical_bytes;
|
|
}
|
|
if !known_tier {
|
|
self.unknown_tier_stats
|
|
.record_dimensions(tier, logical_bytes, physical_bytes, 1, u64::from(oi.is_latest));
|
|
if self.unknown_tier_stats.counter_overflowed {
|
|
proof.overflowed = true;
|
|
}
|
|
}
|
|
self.tier_accounting_proof.saturating_add(proof);
|
|
}
|
|
|
|
fn actions_accounting_unknown(&mut self, oi: &ObjectInfo) {
|
|
if oi.delete_marker {
|
|
self.delete_markers = self.delete_markers.saturating_add(1);
|
|
return;
|
|
}
|
|
|
|
if oi.version_id.is_some_and(|v| !v.is_nil()) {
|
|
self.versions = self.versions.saturating_add(1);
|
|
}
|
|
|
|
if oi.transitioned_object.free_version {
|
|
return;
|
|
}
|
|
|
|
let tier = if oi.transitioned_object.status == TRANSITION_COMPLETE {
|
|
oi.transitioned_object.tier.clone()
|
|
} else {
|
|
oi.storage_class.clone().unwrap_or_else(|| storageclass::STANDARD.to_string())
|
|
};
|
|
if let Some(tier_stats) = self.tier_stats.get_mut(&tier) {
|
|
*tier_stats = tier_stats.add(&TierStats {
|
|
total_size: u64::try_from(oi.size).unwrap_or(0),
|
|
num_versions: 1,
|
|
num_objects: u64::from(oi.is_latest),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== Cache-related data structures =====
|
|
|
|
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum DataUsageScanCheckpointReason {
|
|
Runtime,
|
|
Objects,
|
|
Directories,
|
|
Unknown,
|
|
}
|
|
|
|
impl DataUsageScanCheckpointReason {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Runtime => "runtime",
|
|
Self::Objects => "objects",
|
|
Self::Directories => "directories",
|
|
Self::Unknown => "unknown",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct DataUsageScanCheckpoint {
|
|
pub version: u16,
|
|
pub resume_after: String,
|
|
pub reason: DataUsageScanCheckpointReason,
|
|
}
|
|
|
|
impl DataUsageScanCheckpoint {
|
|
pub fn new(resume_after: String, reason: DataUsageScanCheckpointReason) -> Self {
|
|
Self {
|
|
version: DATA_USAGE_SCAN_CHECKPOINT_VERSION,
|
|
resume_after,
|
|
reason,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Durable raw directory-page cursor for a bucket scan.
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct DataUsageRawEnumerationCursor {
|
|
pub version: u16,
|
|
pub parent: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub last_entry: Option<String>,
|
|
pub entries_seen: u64,
|
|
pub page_digest: [u8; 32],
|
|
}
|
|
|
|
impl DataUsageRawEnumerationCursor {
|
|
pub fn new(parent: String, last_entry: Option<String>, entries_seen: u64, page_digest: [u8; 32]) -> Self {
|
|
Self {
|
|
version: DATA_USAGE_RAW_ENUMERATION_CURSOR_VERSION,
|
|
parent,
|
|
last_entry,
|
|
entries_seen,
|
|
page_digest,
|
|
}
|
|
}
|
|
|
|
fn is_valid_for_bucket(&self, bucket: &str) -> bool {
|
|
self.version == DATA_USAGE_RAW_ENUMERATION_CURSOR_VERSION
|
|
&& bucket != DATA_USAGE_ROOT
|
|
&& path_is_in_bucket_scope(bucket, &self.parent)
|
|
&& self.parent.len() <= DATA_USAGE_SCAN_CURSOR_MAX_BYTES
|
|
&& self.page_digest != [0; 32]
|
|
&& match &self.last_entry {
|
|
Some(last_entry) => {
|
|
!last_entry.is_empty()
|
|
&& self.entries_seen > 0
|
|
&& last_entry.len() <= DATA_USAGE_SCAN_CURSOR_MAX_BYTES
|
|
&& !last_entry.contains(SLASH_SEPARATOR)
|
|
}
|
|
None => self.entries_seen == 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn path_is_in_bucket_scope(bucket: &str, path: &str) -> bool {
|
|
path == bucket
|
|
|| path
|
|
.strip_prefix(bucket)
|
|
.is_some_and(|suffix| suffix.starts_with(SLASH_SEPARATOR))
|
|
}
|
|
|
|
/// Durable scope of a bucket checkpoint, independent of namespace mutation counters.
|
|
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct DataUsageScanIdentity {
|
|
pub version: u16,
|
|
pub bucket_incarnation: uuid::Uuid,
|
|
pub set_layout: DataUsageScanPlanDigest,
|
|
pub publication_epoch: u64,
|
|
pub tier_registry_generation: u64,
|
|
pub scan_mode: HealScanMode,
|
|
}
|
|
|
|
impl Serialize for DataUsageScanIdentity {
|
|
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
let mut map = serializer.serialize_map(Some(6))?;
|
|
map.serialize_entry("version", &self.version)?;
|
|
map.serialize_entry("bucket_incarnation", &self.bucket_incarnation)?;
|
|
map.serialize_entry("set_layout", &self.set_layout)?;
|
|
map.serialize_entry("publication_epoch", &self.publication_epoch)?;
|
|
map.serialize_entry("tier_registry_generation", &self.tier_registry_generation)?;
|
|
map.serialize_entry("scan_mode", &self.scan_mode)?;
|
|
map.end()
|
|
}
|
|
}
|
|
|
|
impl DataUsageScanIdentity {
|
|
pub(crate) fn is_valid(&self) -> bool {
|
|
self.version == 1
|
|
&& !self.bucket_incarnation.is_nil()
|
|
&& matches!(self.scan_mode, HealScanMode::Normal | HealScanMode::Deep)
|
|
}
|
|
}
|
|
|
|
/// A forward coverage sweep may span budgets, but not authorize mixed mutation generations.
|
|
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct DataUsageScanProgress {
|
|
pub started_plan: DataUsageScanPlanDigest,
|
|
pub requested_plan: DataUsageScanPlanDigest,
|
|
}
|
|
|
|
impl Serialize for DataUsageScanProgress {
|
|
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
let mut map = serializer.serialize_map(Some(2))?;
|
|
map.serialize_entry("started_plan", &self.started_plan)?;
|
|
map.serialize_entry("requested_plan", &self.requested_plan)?;
|
|
map.end()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct DataUsageScanCoverageReceipt {
|
|
pub through: String,
|
|
pub digest: [u8; 32],
|
|
}
|
|
|
|
impl Serialize for DataUsageScanCoverageReceipt {
|
|
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
let mut map = serializer.serialize_map(Some(2))?;
|
|
map.serialize_entry("through", &self.through)?;
|
|
map.serialize_entry("digest", &self.digest)?;
|
|
map.end()
|
|
}
|
|
}
|
|
|
|
struct CheckpointDigestWriter(Sha256);
|
|
|
|
impl std::io::Write for CheckpointDigestWriter {
|
|
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
|
self.0.update(bytes);
|
|
Ok(bytes.len())
|
|
}
|
|
|
|
fn flush(&mut self) -> std::io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
|
pub struct DataUsageEntryInfo {
|
|
pub name: String,
|
|
pub parent: String,
|
|
pub entry: DataUsageEntry,
|
|
/// Registry generation used to classify this root entry. Older remote
|
|
/// workers omit it; callers must reject that result when a frozen cycle
|
|
/// requires generation fencing.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub tier_registry_generation: Option<u64>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct DataUsageCacheSource {
|
|
pub pool_index: usize,
|
|
pub set_index: usize,
|
|
}
|
|
|
|
impl DataUsageCacheSource {
|
|
pub const fn new(pool_index: usize, set_index: usize) -> Self {
|
|
Self { pool_index, set_index }
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
|
#[serde(transparent)]
|
|
pub struct DataUsageScanPlanDigest(pub [u8; 32]);
|
|
|
|
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum PendingScannerHealKind {
|
|
Bucket,
|
|
Object,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct PendingScannerHeal {
|
|
pub kind: PendingScannerHealKind,
|
|
pub bucket: String,
|
|
#[serde(default)]
|
|
pub object: Option<String>,
|
|
#[serde(default)]
|
|
pub version_id: Option<String>,
|
|
pub scan_mode: HealScanMode,
|
|
pub first_seen: u64,
|
|
pub last_attempt: u64,
|
|
pub attempts: u32,
|
|
#[serde(default)]
|
|
pub last_admission_result: String,
|
|
#[serde(default)]
|
|
pub last_admission_reason: String,
|
|
}
|
|
|
|
/// Data usage cache info
|
|
#[derive(Clone, Debug, Default, Deserialize)]
|
|
pub struct DataUsageCacheInfo {
|
|
pub name: String,
|
|
pub next_cycle: u64,
|
|
pub last_update: Option<SystemTime>,
|
|
pub skip_healing: bool,
|
|
pub lifecycle: Option<Arc<BucketLifecycleConfiguration>>,
|
|
pub replication: Option<Arc<ReplicationConfig>>,
|
|
#[serde(default)]
|
|
pub failed_objects: HashMap<String, u64>,
|
|
#[serde(default)]
|
|
pub scan_resume_after: Option<String>,
|
|
#[serde(default)]
|
|
pub scan_checkpoint: Option<DataUsageScanCheckpoint>,
|
|
#[serde(default)]
|
|
pub scan_raw_enumeration_cursor: Option<DataUsageRawEnumerationCursor>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub scan_raw_enumeration_page_index: Option<RawEnumerationPageIndex>,
|
|
#[serde(default)]
|
|
pub scan_identity: Option<DataUsageScanIdentity>,
|
|
#[serde(default)]
|
|
pub scan_progress: Option<DataUsageScanProgress>,
|
|
#[serde(default)]
|
|
pub scan_coverage_receipt: Option<DataUsageScanCoverageReceipt>,
|
|
#[serde(default)]
|
|
pub pending_heals: Vec<PendingScannerHeal>,
|
|
#[serde(default)]
|
|
pub object_lock: Option<Arc<ObjectLockConfiguration>>,
|
|
#[serde(default)]
|
|
pub leader_epoch: u64,
|
|
#[serde(default)]
|
|
pub source: Option<DataUsageCacheSource>,
|
|
#[serde(default)]
|
|
pub snapshot_complete: bool,
|
|
#[serde(default)]
|
|
pub scan_plan_digest: Option<DataUsageScanPlanDigest>,
|
|
/// Full activity and inventory scope of a set scan; only a complete
|
|
/// snapshot proves coverage. Bucket caches bind this scope into their
|
|
/// opaque scan plan digest instead.
|
|
#[serde(default)]
|
|
pub scan_coverage_digest: Option<DataUsageScanPlanDigest>,
|
|
#[serde(default)]
|
|
pub cache_key_format: u16,
|
|
/// Registry generation used for the completed/partial scan. This is
|
|
/// process-local audit data; older cache writers omit it.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub tier_registry_generation: Option<u64>,
|
|
/// Bounded durable debts for versions whose logical size was not trusted.
|
|
/// The map key is an identity key, never a user-controlled metric label.
|
|
#[serde(default)]
|
|
pub size_reconciliation: HashMap<String, SizeReconciliationEntry>,
|
|
/// Whether the entries retained while a set scan was incomplete come
|
|
/// from a prior complete set snapshot. This is observational input only.
|
|
#[serde(default)]
|
|
pub lkg_snapshot_complete: bool,
|
|
#[serde(default)]
|
|
pub lkg_next_cycle: Option<u64>,
|
|
#[serde(default)]
|
|
pub lkg_last_update: Option<SystemTime>,
|
|
#[serde(default)]
|
|
pub lkg_leader_epoch: Option<u64>,
|
|
#[serde(default)]
|
|
pub lkg_scan_plan_digest: Option<DataUsageScanPlanDigest>,
|
|
/// Activity-sensitive identity for same-cycle set snapshot reuse. The
|
|
/// structural plan remains reusable across ordinary bucket writes.
|
|
#[serde(default)]
|
|
pub scan_execution_digest: Option<DataUsageScanPlanDigest>,
|
|
}
|
|
|
|
impl Serialize for DataUsageCacheInfo {
|
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
// Keep this metadata map-encoded so older readers can ignore fields
|
|
// appended by newer scanner versions during rolling upgrades.
|
|
let field_count = 16
|
|
+ usize::from(self.scan_raw_enumeration_cursor.is_some())
|
|
+ usize::from(self.scan_raw_enumeration_page_index.is_some())
|
|
+ usize::from(self.scan_identity.is_some())
|
|
+ usize::from(self.scan_progress.is_some())
|
|
+ usize::from(self.scan_coverage_receipt.is_some())
|
|
+ usize::from(self.scan_coverage_digest.is_some())
|
|
+ usize::from(self.tier_registry_generation.is_some())
|
|
+ usize::from(!self.size_reconciliation.is_empty())
|
|
+ usize::from(self.lkg_snapshot_complete)
|
|
+ usize::from(self.lkg_next_cycle.is_some())
|
|
+ usize::from(self.lkg_last_update.is_some())
|
|
+ usize::from(self.lkg_leader_epoch.is_some())
|
|
+ usize::from(self.lkg_scan_plan_digest.is_some())
|
|
+ usize::from(self.scan_execution_digest.is_some());
|
|
let mut state = serializer.serialize_map(Some(field_count))?;
|
|
state.serialize_entry("name", &self.name)?;
|
|
state.serialize_entry("next_cycle", &self.next_cycle)?;
|
|
state.serialize_entry("leader_epoch", &self.leader_epoch)?;
|
|
state.serialize_entry("last_update", &self.last_update)?;
|
|
state.serialize_entry("skip_healing", &self.skip_healing)?;
|
|
state.serialize_entry("lifecycle", &self.lifecycle)?;
|
|
state.serialize_entry("replication", &self.replication)?;
|
|
state.serialize_entry("failed_objects", &self.failed_objects)?;
|
|
state.serialize_entry("scan_resume_after", &self.scan_resume_after)?;
|
|
state.serialize_entry("scan_checkpoint", &self.scan_checkpoint)?;
|
|
if let Some(cursor) = &self.scan_raw_enumeration_cursor {
|
|
state.serialize_entry("scan_raw_enumeration_cursor", cursor)?;
|
|
}
|
|
if let Some(index) = &self.scan_raw_enumeration_page_index {
|
|
state.serialize_entry("scan_raw_enumeration_page_index", index)?;
|
|
}
|
|
if let Some(identity) = self.scan_identity {
|
|
state.serialize_entry("scan_identity", &identity)?;
|
|
}
|
|
if let Some(progress) = self.scan_progress {
|
|
state.serialize_entry("scan_progress", &progress)?;
|
|
}
|
|
if let Some(receipt) = &self.scan_coverage_receipt {
|
|
state.serialize_entry("scan_coverage_receipt", receipt)?;
|
|
}
|
|
state.serialize_entry("pending_heals", &self.pending_heals)?;
|
|
state.serialize_entry("object_lock", &self.object_lock)?;
|
|
state.serialize_entry("source", &self.source)?;
|
|
state.serialize_entry("snapshot_complete", &self.snapshot_complete)?;
|
|
state.serialize_entry("scan_plan_digest", &self.scan_plan_digest)?;
|
|
if let Some(coverage) = self.scan_coverage_digest {
|
|
state.serialize_entry("scan_coverage_digest", &coverage)?;
|
|
}
|
|
state.serialize_entry("cache_key_format", &self.cache_key_format)?;
|
|
if let Some(generation) = self.tier_registry_generation {
|
|
state.serialize_entry("tier_registry_generation", &generation)?;
|
|
}
|
|
if !self.size_reconciliation.is_empty() {
|
|
state.serialize_entry("size_reconciliation", &self.size_reconciliation)?;
|
|
}
|
|
if self.lkg_snapshot_complete {
|
|
state.serialize_entry("lkg_snapshot_complete", &true)?;
|
|
}
|
|
if let Some(next_cycle) = self.lkg_next_cycle {
|
|
state.serialize_entry("lkg_next_cycle", &next_cycle)?;
|
|
}
|
|
if let Some(last_update) = self.lkg_last_update {
|
|
state.serialize_entry("lkg_last_update", &last_update)?;
|
|
}
|
|
if let Some(leader_epoch) = self.lkg_leader_epoch {
|
|
state.serialize_entry("lkg_leader_epoch", &leader_epoch)?;
|
|
}
|
|
if let Some(scan_plan_digest) = self.lkg_scan_plan_digest {
|
|
state.serialize_entry("lkg_scan_plan_digest", &scan_plan_digest)?;
|
|
}
|
|
if let Some(scan_execution_digest) = self.scan_execution_digest {
|
|
state.serialize_entry("scan_execution_digest", &scan_execution_digest)?;
|
|
}
|
|
state.end()
|
|
}
|
|
}
|
|
|
|
/// Data usage cache
|
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
|
pub struct DataUsageCache {
|
|
pub info: DataUsageCacheInfo,
|
|
pub cache: HashMap<String, DataUsageEntry>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(crate) enum DataUsageCachePrepareOutcome {
|
|
Reused,
|
|
Reset,
|
|
RejectedNewerCycle,
|
|
RejectedNewerLeader,
|
|
}
|
|
|
|
impl DataUsageCache {
|
|
/// Reconcile tier keys loaded from an older cache against the registry
|
|
/// frozen for this scan. New metadata is already routed through
|
|
/// `UNKNOWN_TIER`; this pass handles retired keys that predate that rule.
|
|
/// Legacy `TierStats` carries physical bytes only, so this migration does
|
|
/// not manufacture a logical unknown-byte value from that physical total.
|
|
pub(crate) fn fold_retired_tiers(&mut self, tier_names: &[String]) {
|
|
let known_tiers = tier_names.iter().map(String::as_str).collect::<HashSet<_>>();
|
|
for entry in self.cache.values_mut() {
|
|
let Some(tiers) = entry.all_tier_stats.as_mut() else { continue };
|
|
let existing_unknown = tiers.tiers.get(UNKNOWN_TIER).cloned().unwrap_or_default();
|
|
let companion_present = entry.unknown_tier_stats.as_ref().is_some_and(|stats| !stats.is_empty());
|
|
let migrate_existing_unknown = !companion_present;
|
|
let mut retired = TierStats::default();
|
|
let mut retired_key_found = false;
|
|
if migrate_existing_unknown {
|
|
retired = retired.add(&existing_unknown);
|
|
}
|
|
for (tier, stats) in &tiers.tiers {
|
|
if tier != UNKNOWN_TIER
|
|
&& tier != storageclass::STANDARD
|
|
&& tier != storageclass::RRS
|
|
&& !known_tiers.contains(tier.as_str())
|
|
{
|
|
retired_key_found = true;
|
|
retired = retired.add(stats);
|
|
}
|
|
}
|
|
tiers.fold_unknown_tiers(tier_names.iter().map(String::as_str));
|
|
if !retired.is_empty() && !companion_present {
|
|
entry.add_unknown_tier_stats(&UnknownTierStats {
|
|
// The legacy map stores physical bytes only. Logical
|
|
// bytes remain zero until a fresh object scan observes
|
|
// them under the current metadata format.
|
|
unknown_physical_bytes: retired.total_size,
|
|
unknown_objects: retired.num_objects,
|
|
unknown_versions: retired.num_versions,
|
|
..Default::default()
|
|
});
|
|
// The legacy tier map has no logical-byte dimension, so a
|
|
// proof that classified this retired key as known cannot be
|
|
// repaired safely. Mark it unvalidated and require a fresh
|
|
// scan rather than guessing a logical subtraction.
|
|
entry.tier_accounting_proof = None;
|
|
} else if retired_key_found {
|
|
// A nonempty companion has no provenance tying it to the
|
|
// retired map keys. Reject the mixed cache until a fresh scan
|
|
// reconciles the dimensions instead of double-counting them.
|
|
entry.tier_accounting_proof = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Prefix-level usage query over this (writer-side) cache; see
|
|
/// [`prefix_usage_in_cache`] for the semantics
|
|
/// (rustfs/backlog#1872).
|
|
pub fn prefix_usage(&self, bucket: &str, prefix: &str, max_entries: usize) -> Option<PrefixUsageQuery> {
|
|
prefix_usage_in_cache(&self.cache, bucket, prefix, max_entries)
|
|
}
|
|
|
|
pub(crate) fn prepare_for_scan(
|
|
&mut self,
|
|
name: &str,
|
|
next_cycle: u64,
|
|
leader_epoch: u64,
|
|
source: DataUsageCacheSource,
|
|
scan_plan_digest: DataUsageScanPlanDigest,
|
|
require_source: bool,
|
|
) -> DataUsageCachePrepareOutcome {
|
|
if self.info.next_cycle > next_cycle {
|
|
return DataUsageCachePrepareOutcome::RejectedNewerCycle;
|
|
}
|
|
if self.info.leader_epoch > leader_epoch {
|
|
return DataUsageCachePrepareOutcome::RejectedNewerLeader;
|
|
}
|
|
|
|
let source_matches = self.info.source == Some(source);
|
|
let plan_matches = self.info.scan_plan_digest == Some(scan_plan_digest);
|
|
let metadata_is_reusable = self.info.name == name
|
|
&& self.info.leader_epoch == leader_epoch
|
|
&& plan_matches
|
|
&& (source_matches || (!require_source && self.info.source.is_none()))
|
|
&& self.info.cache_key_format == DATA_USAGE_CACHE_KEY_FORMAT;
|
|
let reusable = metadata_is_reusable
|
|
&& (self.cache.is_empty()
|
|
|| if name == DATA_USAGE_ROOT || self.info.snapshot_complete {
|
|
self.checked_flatten_complete_scope(name).is_some()
|
|
} else {
|
|
self.checked_flatten(name).is_some()
|
|
});
|
|
if !reusable {
|
|
let (pending_heals, size_reconciliation) = if self.info.name == name {
|
|
(
|
|
std::mem::take(&mut self.info.pending_heals),
|
|
std::mem::take(&mut self.info.size_reconciliation),
|
|
)
|
|
} else {
|
|
(Vec::new(), HashMap::new())
|
|
};
|
|
*self = Self::default();
|
|
self.info.name = name.to_string();
|
|
self.info.pending_heals = pending_heals;
|
|
self.info.size_reconciliation = size_reconciliation;
|
|
}
|
|
|
|
self.info.next_cycle = next_cycle;
|
|
self.info.leader_epoch = leader_epoch;
|
|
self.info.source = Some(source);
|
|
self.info.scan_plan_digest = Some(scan_plan_digest);
|
|
self.info.cache_key_format = DATA_USAGE_CACHE_KEY_FORMAT;
|
|
self.info.snapshot_complete = false;
|
|
if reusable {
|
|
DataUsageCachePrepareOutcome::Reused
|
|
} else {
|
|
DataUsageCachePrepareOutcome::Reset
|
|
}
|
|
}
|
|
|
|
pub(crate) fn prepare_bucket_checkpoint(
|
|
&mut self,
|
|
name: &str,
|
|
next_cycle: u64,
|
|
leader_epoch: u64,
|
|
source: DataUsageCacheSource,
|
|
scan_plan_digest: DataUsageScanPlanDigest,
|
|
identity: DataUsageScanIdentity,
|
|
) -> DataUsageCachePrepareOutcome {
|
|
if self.info.next_cycle > next_cycle {
|
|
return DataUsageCachePrepareOutcome::RejectedNewerCycle;
|
|
}
|
|
if self.info.leader_epoch > leader_epoch {
|
|
return DataUsageCachePrepareOutcome::RejectedNewerLeader;
|
|
}
|
|
let reusable = identity.is_valid()
|
|
&& name != DATA_USAGE_ROOT
|
|
&& self.info.name == name
|
|
&& self.info.source == Some(source)
|
|
&& self.info.leader_epoch == leader_epoch
|
|
&& self.info.cache_key_format == DATA_USAGE_CACHE_KEY_FORMAT
|
|
&& self.info.scan_identity == Some(identity)
|
|
&& self.info.tier_registry_generation == Some(identity.tier_registry_generation)
|
|
&& (self.cache.is_empty() || self.checked_flatten_complete_scope(name).is_some());
|
|
if reusable
|
|
&& self.info.snapshot_complete
|
|
&& self.info.scan_progress.is_none()
|
|
&& self.info.scan_checkpoint.is_none()
|
|
&& self.info.scan_raw_enumeration_cursor.is_none()
|
|
&& self.info.scan_raw_enumeration_page_index.is_none()
|
|
&& self.info.scan_resume_after.is_none()
|
|
&& self.info.scan_coverage_receipt.is_none()
|
|
&& self.info.scan_plan_digest == Some(scan_plan_digest)
|
|
{
|
|
return self.prepare_for_scan(name, next_cycle, leader_epoch, source, scan_plan_digest, true);
|
|
}
|
|
if !reusable {
|
|
let keep_debts = self.info.name == name
|
|
&& self
|
|
.info
|
|
.scan_identity
|
|
.is_none_or(|previous| previous.bucket_incarnation == identity.bucket_incarnation);
|
|
let (pending_heals, size_reconciliation) = if keep_debts {
|
|
(
|
|
std::mem::take(&mut self.info.pending_heals),
|
|
std::mem::take(&mut self.info.size_reconciliation),
|
|
)
|
|
} else {
|
|
(Vec::new(), HashMap::new())
|
|
};
|
|
*self = Self::default();
|
|
self.info.pending_heals = pending_heals;
|
|
self.info.size_reconciliation = size_reconciliation;
|
|
}
|
|
if self.validated_raw_enumeration_cursor().is_none() {
|
|
self.info.scan_raw_enumeration_cursor = None;
|
|
}
|
|
if self.validated_raw_enumeration_page_index().is_none() {
|
|
self.info.scan_raw_enumeration_page_index = None;
|
|
}
|
|
let cursor_is_valid = (self.info.scan_checkpoint.is_none()
|
|
&& self.info.scan_raw_enumeration_cursor.is_none()
|
|
&& self.info.scan_raw_enumeration_page_index.is_none()
|
|
&& self.info.scan_resume_after.is_none()
|
|
&& self.info.scan_coverage_receipt.is_none())
|
|
|| self.validated_scan_frontier().is_some()
|
|
|| self.info.scan_raw_enumeration_cursor.is_some()
|
|
|| self.info.scan_raw_enumeration_page_index.is_some();
|
|
if !cursor_is_valid {
|
|
self.info.scan_progress = None;
|
|
}
|
|
self.info.name = name.to_owned();
|
|
self.info.next_cycle = next_cycle;
|
|
self.info.leader_epoch = leader_epoch;
|
|
self.info.source = Some(source);
|
|
self.info.cache_key_format = DATA_USAGE_CACHE_KEY_FORMAT;
|
|
self.info.tier_registry_generation = Some(identity.tier_registry_generation);
|
|
self.info.scan_identity = Some(identity);
|
|
self.info.snapshot_complete = false;
|
|
if let Some(progress) = &mut self.info.scan_progress {
|
|
progress.requested_plan = scan_plan_digest;
|
|
} else {
|
|
self.info.scan_progress = Some(DataUsageScanProgress {
|
|
started_plan: scan_plan_digest,
|
|
requested_plan: scan_plan_digest,
|
|
});
|
|
self.info.scan_resume_after = None;
|
|
self.info.scan_checkpoint = None;
|
|
self.info.scan_raw_enumeration_cursor = None;
|
|
self.info.scan_raw_enumeration_page_index = None;
|
|
self.info.scan_coverage_receipt = None;
|
|
}
|
|
// Old readers do not understand coverage sweeps. An absent plan makes
|
|
// their existing prepare path rebuild instead of promoting mixed data.
|
|
self.info.scan_plan_digest = None;
|
|
if reusable {
|
|
DataUsageCachePrepareOutcome::Reused
|
|
} else {
|
|
DataUsageCachePrepareOutcome::Reset
|
|
}
|
|
}
|
|
|
|
fn coverage_prefix_digest(&self, through: &str) -> Result<[u8; 32], serde_json::Error> {
|
|
let mut writer = CheckpointDigestWriter(Sha256::new());
|
|
serde_json::to_writer(
|
|
&mut writer,
|
|
&(
|
|
&self.info.name,
|
|
self.info.scan_identity,
|
|
self.info.source,
|
|
self.info.leader_epoch,
|
|
self.info.cache_key_format,
|
|
self.info.scan_progress.map(|progress| progress.started_plan),
|
|
through,
|
|
),
|
|
)?;
|
|
let mut prefix = self
|
|
.cache
|
|
.iter()
|
|
.filter(|(key, _)| {
|
|
let ancestor = through
|
|
.strip_prefix(key.as_str())
|
|
.is_some_and(|suffix| suffix.starts_with('/'));
|
|
let descendant = key.strip_prefix(through).is_some_and(|suffix| suffix.starts_with('/'));
|
|
(key.as_str() <= through && !ancestor) || descendant
|
|
})
|
|
.collect::<Vec<_>>();
|
|
prefix.sort_unstable_by_key(|(key, _)| *key);
|
|
for (key, entry) in prefix {
|
|
let mut value = serde_json::to_value(entry)?;
|
|
value.sort_all_objects();
|
|
if let Some(children) = value.get_mut("children").and_then(serde_json::Value::as_array_mut) {
|
|
children.sort_unstable_by(|left, right| left.as_str().cmp(&right.as_str()));
|
|
}
|
|
serde_json::to_writer(&mut writer, &(key, value))?;
|
|
}
|
|
Ok(writer.0.finalize().into())
|
|
}
|
|
|
|
pub(crate) fn validated_scan_frontier(&self) -> Option<&str> {
|
|
let receipt = self.info.scan_coverage_receipt.as_ref()?;
|
|
let checkpoint = self.info.scan_checkpoint.as_ref()?;
|
|
(self.info.scan_progress.is_some()
|
|
&& self.info.scan_identity.is_some_and(|identity| identity.is_valid())
|
|
&& self.info.source.is_some()
|
|
&& receipt.through.len() <= 16 * 1024
|
|
&& checkpoint.version == DATA_USAGE_SCAN_CHECKPOINT_VERSION
|
|
&& checkpoint.resume_after == receipt.through
|
|
&& self.info.scan_resume_after.as_deref() == Some(receipt.through.as_str())
|
|
&& receipt
|
|
.through
|
|
.strip_prefix(&self.info.name)
|
|
.is_some_and(|suffix| suffix.starts_with('/'))
|
|
&& self.find(&receipt.through).is_some()
|
|
&& self.coverage_prefix_digest(&receipt.through).ok() == Some(receipt.digest))
|
|
.then_some(receipt.through.as_str())
|
|
}
|
|
|
|
pub(crate) fn validated_raw_enumeration_cursor(&self) -> Option<&DataUsageRawEnumerationCursor> {
|
|
let cursor = self.info.scan_raw_enumeration_cursor.as_ref()?;
|
|
(self.info.scan_progress.is_some()
|
|
&& self.info.scan_identity.is_some_and(|identity| identity.is_valid())
|
|
&& self.info.source.is_some()
|
|
&& cursor.is_valid_for_bucket(&self.info.name))
|
|
.then_some(cursor)
|
|
}
|
|
|
|
pub(crate) fn validated_raw_enumeration_page_index(&self) -> Option<&RawEnumerationPageIndex> {
|
|
let index = self.info.scan_raw_enumeration_page_index.as_ref()?;
|
|
if self.info.scan_progress.is_none()
|
|
|| !self.info.scan_identity.is_some_and(|identity| identity.is_valid())
|
|
|| self.info.source.is_none()
|
|
|| index.committed_entries().is_err()
|
|
|| index.indexed_entries().is_err()
|
|
{
|
|
return None;
|
|
}
|
|
let parent = match index.status() {
|
|
RawEnumerationPageOwnerStatus::Unsupported => return None,
|
|
RawEnumerationPageOwnerStatus::Building { parent, .. } | RawEnumerationPageOwnerStatus::Ready { parent, .. } => {
|
|
parent
|
|
}
|
|
};
|
|
path_is_in_bucket_scope(&self.info.name, &parent).then_some(index)
|
|
}
|
|
|
|
/// Seal only the frontier supplied by completed traversal, never a restored cursor.
|
|
pub(crate) fn seal_scan_frontier(&mut self, frontier: Option<&str>) -> Result<(), serde_json::Error> {
|
|
if self.info.scan_progress.is_none() {
|
|
self.info.scan_coverage_receipt = None;
|
|
return Ok(());
|
|
}
|
|
let frontier = frontier.filter(|path| path.len() <= 16 * 1024 && self.find(path).is_some());
|
|
self.info.scan_coverage_receipt = match frontier {
|
|
Some(through) => Some(DataUsageScanCoverageReceipt {
|
|
through: through.to_owned(),
|
|
digest: self.coverage_prefix_digest(through)?,
|
|
}),
|
|
None => None,
|
|
};
|
|
self.info.scan_resume_after = frontier.map(str::to_owned);
|
|
let reason = self
|
|
.info
|
|
.scan_checkpoint
|
|
.as_ref()
|
|
.map_or(DataUsageScanCheckpointReason::Unknown, |checkpoint| checkpoint.reason);
|
|
self.info.scan_checkpoint = frontier.map(|through| DataUsageScanCheckpoint::new(through.to_owned(), reason));
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_cache_save_metrics_registered() {
|
|
CACHE_SAVE_METRICS_ONCE.call_once(|| {
|
|
describe_counter!(
|
|
METRIC_CACHE_SAVE_ATTEMPT_TOTAL,
|
|
"Total scanner data usage cache save attempts by result and cache type."
|
|
);
|
|
describe_counter!(
|
|
METRIC_CACHE_SAVE_TIMEOUT_TOTAL,
|
|
"Total scanner data usage cache save timeouts by cache type."
|
|
);
|
|
describe_counter!(
|
|
METRIC_CACHE_SAVE_RETRY_TOTAL,
|
|
"Total scanner data usage cache save retries by cache type."
|
|
);
|
|
describe_histogram!(
|
|
METRIC_CACHE_SAVE_DURATION_SECONDS,
|
|
"Duration of scanner data usage cache save attempts in seconds."
|
|
);
|
|
});
|
|
}
|
|
|
|
fn cache_path_type(path: &str) -> &'static str {
|
|
if path.ends_with(".bkp") { "backup" } else { "main" }
|
|
}
|
|
|
|
pub fn replace(&mut self, path: &str, parent: &str, e: DataUsageEntry) {
|
|
let hash = hash_path(path);
|
|
self.cache.insert(hash.key(), e);
|
|
if !parent.is_empty() {
|
|
let parent_hash = hash_path(parent);
|
|
self.cache.entry(parent_hash.key()).or_default().add_child(&hash);
|
|
}
|
|
}
|
|
|
|
pub fn replace_hashed(&mut self, hash: &DataUsageHash, parent: &Option<DataUsageHash>, e: &DataUsageEntry) {
|
|
self.cache.insert(hash.key(), e.clone());
|
|
if let Some(parent) = parent {
|
|
self.cache.entry(parent.key()).or_default().add_child(hash);
|
|
}
|
|
}
|
|
|
|
pub fn find(&self, path: &str) -> Option<&DataUsageEntry> {
|
|
self.cache.get(&hash_path(path).key())
|
|
}
|
|
|
|
pub fn find_children_copy(&mut self, h: DataUsageHash) -> DataUsageHashMap {
|
|
self.cache.entry(h.string()).or_default().children.clone()
|
|
}
|
|
|
|
pub fn flatten(&self, root: &DataUsageEntry) -> DataUsageEntry {
|
|
let mut visited = HashSet::new();
|
|
self.flatten_with_guard(root, &mut visited, 0)
|
|
}
|
|
|
|
pub(crate) fn checked_flatten(&self, path: &str) -> Option<DataUsageEntry> {
|
|
self.checked_flatten_inner(path).map(|(entry, _)| entry)
|
|
}
|
|
|
|
pub(crate) fn checked_flatten_complete(&self, path: &str) -> Option<DataUsageEntry> {
|
|
self.checked_flatten_inner(path)
|
|
.filter(|(_, visited)| *visited == self.cache.len())
|
|
.map(|(entry, _)| entry)
|
|
}
|
|
|
|
pub(crate) fn checked_flatten_complete_scope(&self, path: &str) -> Option<DataUsageEntry> {
|
|
if path == DATA_USAGE_ROOT {
|
|
return self.checked_flatten_complete(path);
|
|
}
|
|
let (entry, visited) = self.checked_flatten_inner(path)?;
|
|
let root_parent_only = {
|
|
let path_key = hash_path(path).key();
|
|
self.cache
|
|
.get(DATA_USAGE_ROOT)
|
|
.is_some_and(|root| root_is_parent_only(root, &path_key))
|
|
};
|
|
let expected_entries = self.cache.len().saturating_sub(usize::from(root_parent_only));
|
|
(visited == expected_entries).then_some(entry)
|
|
}
|
|
|
|
pub(crate) fn has_complete_root_inventory(&self, bucket_keys: &HashSet<String>) -> bool {
|
|
let Some(root) = self.find(DATA_USAGE_ROOT) else {
|
|
return false;
|
|
};
|
|
// Set roots only connect bucket entries. Scalar data at the root, an
|
|
// extra bucket, or an orphan must not disappear during bucket folding.
|
|
root.children.len() == bucket_keys.len()
|
|
&& bucket_keys.iter().all(|key| root.children.contains(key))
|
|
&& root.size == 0
|
|
&& root.objects == 0
|
|
&& root.versions == 0
|
|
&& root.delete_markers == 0
|
|
&& root.failed_objects == 0
|
|
&& !root.compacted
|
|
&& root.obj_sizes.is_empty()
|
|
&& root.obj_versions.is_empty()
|
|
&& root.replication_stats.is_none()
|
|
&& root.all_tier_stats.is_none()
|
|
&& root.unknown_tier_stats.is_none()
|
|
&& root.tier_accounting_proof.is_none()
|
|
&& self.checked_flatten_complete(DATA_USAGE_ROOT).is_some()
|
|
}
|
|
|
|
fn checked_flatten_inner(&self, path: &str) -> Option<(DataUsageEntry, usize)> {
|
|
let root_key = hash_path(path).key();
|
|
let (root_key, root) = self.cache.get_key_value(&root_key)?;
|
|
if root.compacted && !root.children.is_empty() {
|
|
return None;
|
|
}
|
|
let mut visited = HashSet::from([root_key.as_str()]);
|
|
let mut pending = root.children.iter().map(|child| (child.as_str(), 1usize)).collect::<Vec<_>>();
|
|
let mut flattened = DataUsageEntry::default();
|
|
if !flattened.checked_merge(root) {
|
|
return None;
|
|
}
|
|
flattened.compacted = root.compacted;
|
|
|
|
while let Some((key, depth)) = pending.pop() {
|
|
if depth > MAX_DATA_USAGE_CACHE_DEPTH || !visited.insert(key) {
|
|
return None;
|
|
}
|
|
let entry = self.cache.get(key)?;
|
|
if (entry.compacted || depth == MAX_DATA_USAGE_CACHE_DEPTH) && !entry.children.is_empty() {
|
|
return None;
|
|
}
|
|
pending.extend(entry.children.iter().map(|child| (child.as_str(), depth + 1)));
|
|
|
|
if !flattened.checked_merge(entry) {
|
|
return None;
|
|
}
|
|
}
|
|
|
|
Some((flattened, visited.len()))
|
|
}
|
|
|
|
fn flatten_with_guard(&self, root: &DataUsageEntry, visited: &mut HashSet<String>, depth: usize) -> DataUsageEntry {
|
|
let mut root = root.clone();
|
|
if depth >= MAX_DATA_USAGE_CACHE_DEPTH {
|
|
root.children.clear();
|
|
return root;
|
|
}
|
|
|
|
for id in root.children.clone().iter() {
|
|
if !visited.insert(id.clone()) {
|
|
continue;
|
|
}
|
|
if let Some(e) = self.cache.get(id) {
|
|
let mut e = e.clone();
|
|
if !e.children.is_empty() {
|
|
e = self.flatten_with_guard(&e, visited, depth + 1);
|
|
}
|
|
root.merge(&e);
|
|
}
|
|
}
|
|
root.children.clear();
|
|
root
|
|
}
|
|
|
|
pub fn copy_with_children(&mut self, src: &DataUsageCache, hash: &DataUsageHash, parent: &Option<DataUsageHash>) {
|
|
let mut visited = HashSet::new();
|
|
self.copy_with_children_guard(src, hash, parent, &mut visited, 0);
|
|
}
|
|
|
|
fn copy_with_children_guard(
|
|
&mut self,
|
|
src: &DataUsageCache,
|
|
hash: &DataUsageHash,
|
|
parent: &Option<DataUsageHash>,
|
|
visited: &mut HashSet<String>,
|
|
depth: usize,
|
|
) {
|
|
if !visited.insert(hash.key()) {
|
|
return;
|
|
}
|
|
|
|
if let Some(e) = src.cache.get(&hash.string()) {
|
|
self.cache.insert(hash.key(), e.clone());
|
|
if depth < MAX_DATA_USAGE_CACHE_DEPTH {
|
|
for ch in e.children.iter() {
|
|
if *ch == hash.key() {
|
|
continue;
|
|
}
|
|
self.copy_with_children_guard(src, &DataUsageHash(ch.to_string()), &Some(hash.clone()), visited, depth + 1);
|
|
}
|
|
}
|
|
if let Some(parent) = parent {
|
|
self.cache.entry(parent.key()).or_default().add_child(hash);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn delete_recursive(&mut self, hash: &DataUsageHash) {
|
|
let mut visited = HashSet::new();
|
|
self.delete_recursive_guard(hash, &mut visited, 0);
|
|
}
|
|
|
|
fn delete_recursive_guard(&mut self, hash: &DataUsageHash, visited: &mut HashSet<String>, depth: usize) {
|
|
if !visited.insert(hash.key()) {
|
|
return;
|
|
}
|
|
|
|
let mut need_remove = Vec::new();
|
|
if let Some(v) = self.cache.get(&hash.string()) {
|
|
for child in v.children.iter() {
|
|
need_remove.push(child.clone());
|
|
}
|
|
}
|
|
self.cache.remove(&hash.string());
|
|
if depth >= MAX_DATA_USAGE_CACHE_DEPTH {
|
|
return;
|
|
}
|
|
for child in need_remove {
|
|
self.delete_recursive_guard(&DataUsageHash(child), visited, depth + 1);
|
|
}
|
|
}
|
|
|
|
pub fn size_recursive(&self, path: &str) -> Option<DataUsageEntry> {
|
|
match self.find(path) {
|
|
Some(root) => {
|
|
if root.children.is_empty() {
|
|
return Some(root.clone());
|
|
}
|
|
let mut visited = HashSet::new();
|
|
visited.insert(hash_path(path).key());
|
|
let mut flat = self.flatten_with_guard(root, &mut visited, 0);
|
|
if flat.replication_stats.as_ref().is_some_and(|stats| stats.is_empty()) {
|
|
flat.replication_stats = None;
|
|
}
|
|
Some(flat)
|
|
}
|
|
None => None,
|
|
}
|
|
}
|
|
|
|
pub fn search_parent(&self, hash: &DataUsageHash) -> Option<DataUsageHash> {
|
|
let want = hash.key();
|
|
if let Some(last_index) = want.rfind('/')
|
|
&& let Some(v) = self.find(&want[0..last_index])
|
|
&& v.children.contains(&want)
|
|
{
|
|
return Some(hash_path(&want[0..last_index]));
|
|
}
|
|
|
|
for (k, v) in self.cache.iter() {
|
|
if v.children.contains(&want) {
|
|
return Some(DataUsageHash(k.clone()));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn is_compacted(&self, hash: &DataUsageHash) -> bool {
|
|
self.cache.get(&hash.key()).is_some_and(|due| due.compacted)
|
|
}
|
|
|
|
pub fn force_compact(&mut self, limit: usize) {
|
|
if self.cache.len() < limit {
|
|
return;
|
|
}
|
|
let top = hash_path(&self.info.name).key();
|
|
let Some(top_e) = self.find(&top).cloned() else {
|
|
return;
|
|
};
|
|
|
|
if top_e.children.len() > 250_000 {
|
|
self.reduce_children_of(&hash_path(&self.info.name), limit, true);
|
|
}
|
|
if self.cache.len() <= limit {
|
|
return;
|
|
}
|
|
|
|
let mut found = HashSet::new();
|
|
found.insert(top);
|
|
mark(self, &top_e, &mut found);
|
|
self.cache.retain(|k, _| found.contains(k));
|
|
}
|
|
|
|
pub fn reduce_children_of(&mut self, path: &DataUsageHash, limit: usize, compact_self: bool) {
|
|
let Some(e) = self.cache.get(&path.key()).cloned() else {
|
|
return;
|
|
};
|
|
|
|
if e.compacted {
|
|
return;
|
|
}
|
|
|
|
if e.children.len() > limit && compact_self {
|
|
let mut flat = self.size_recursive(&path.key()).unwrap_or_default();
|
|
flat.compacted = true;
|
|
self.delete_recursive(path);
|
|
self.replace_hashed(path, &None, &flat);
|
|
return;
|
|
}
|
|
|
|
let total = self.total_children_rec(&path.key());
|
|
if total < limit {
|
|
return;
|
|
}
|
|
|
|
let mut candidates = Vec::new();
|
|
let mut remove = total - limit;
|
|
add(self, path, &mut candidates);
|
|
candidates.sort_by_key(|a| a.objects);
|
|
|
|
let mut candidate_index = 0;
|
|
while remove > 0 && candidate_index < candidates.len() {
|
|
let e = &candidates[candidate_index];
|
|
let candidate = e.path.clone();
|
|
if candidate == *path && !compact_self {
|
|
break;
|
|
}
|
|
let removing = self.total_children_rec(&candidate.key());
|
|
let mut flat = match self.size_recursive(&candidate.key()) {
|
|
Some(flat) => flat,
|
|
None => {
|
|
candidate_index += 1;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
flat.compacted = true;
|
|
self.delete_recursive(&candidate);
|
|
self.replace_hashed(&candidate, &None, &flat);
|
|
|
|
remove = remove.saturating_sub(removing);
|
|
candidate_index += 1;
|
|
}
|
|
}
|
|
|
|
pub fn total_children_rec(&self, path: &str) -> usize {
|
|
let mut visited = HashSet::new();
|
|
visited.insert(hash_path(path).key());
|
|
self.total_children_rec_guard(path, &mut visited, 0)
|
|
}
|
|
|
|
fn total_children_rec_guard(&self, path: &str, visited: &mut HashSet<String>, depth: usize) -> usize {
|
|
let Some(root) = self.find(path) else {
|
|
return 0;
|
|
};
|
|
if root.children.is_empty() || depth >= MAX_DATA_USAGE_CACHE_DEPTH {
|
|
return 0;
|
|
}
|
|
|
|
let mut n = 0;
|
|
for ch in root.children.iter() {
|
|
if visited.insert(ch.clone()) {
|
|
n += 1 + self.total_children_rec_guard(ch, visited, depth + 1);
|
|
}
|
|
}
|
|
n
|
|
}
|
|
|
|
pub fn merge(&mut self, o: &DataUsageCache) {
|
|
let Some(mut existing_root) = self.root() else {
|
|
if o.root().is_none() {
|
|
return;
|
|
}
|
|
*self = o.clone();
|
|
return;
|
|
};
|
|
|
|
let Some(other_root) = o.root() else {
|
|
return;
|
|
};
|
|
|
|
if o.info.last_update > self.info.last_update {
|
|
self.info.last_update = o.info.last_update;
|
|
}
|
|
|
|
existing_root.merge(&other_root);
|
|
self.cache.insert(hash_path(&self.info.name).key(), existing_root);
|
|
|
|
let root_hash = self.root_hash();
|
|
for key in other_root.children.iter() {
|
|
let Some(entry) = o.cache.get(key) else {
|
|
continue;
|
|
};
|
|
let flat = o.flatten(entry);
|
|
if let Some(existing) = self.cache.get_mut(key) {
|
|
existing.merge(&flat);
|
|
} else {
|
|
self.replace_hashed(&DataUsageHash(key.clone()), &Some(root_hash.clone()), &flat);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn root_hash(&self) -> DataUsageHash {
|
|
hash_path(&self.info.name)
|
|
}
|
|
|
|
pub fn root(&self) -> Option<DataUsageEntry> {
|
|
self.find(&self.info.name).cloned()
|
|
}
|
|
|
|
/// Convert cache to DataUsageInfo for a specific path
|
|
pub fn dui(&self, path: &str, buckets: &[String]) -> DataUsageInfo {
|
|
let e = match self.find(path) {
|
|
Some(e) => e,
|
|
None => return DataUsageInfo::default(),
|
|
};
|
|
let flat = self.flatten(e);
|
|
|
|
let mut buckets_usage = HashMap::new();
|
|
for bucket_name in buckets.iter() {
|
|
let e = match self.find(bucket_name) {
|
|
Some(e) => e,
|
|
None => continue,
|
|
};
|
|
let flat = self.flatten(e);
|
|
let mut bui = BucketUsageInfo {
|
|
size: flat.size as u64,
|
|
versions_count: flat.versions as u64,
|
|
objects_count: flat.objects as u64,
|
|
delete_markers_count: flat.delete_markers as u64,
|
|
object_size_histogram: flat.obj_sizes.to_map(),
|
|
object_versions_histogram: flat.obj_versions.to_map(),
|
|
..Default::default()
|
|
};
|
|
|
|
if let Some(rs) = &flat.replication_stats {
|
|
bui.replica_size = rs.replica_size;
|
|
bui.replica_count = rs.replica_count;
|
|
|
|
for (arn, stat) in rs.targets.iter() {
|
|
bui.replication_info.insert(
|
|
arn.clone(),
|
|
BucketTargetUsageInfo {
|
|
replication_pending_size: stat.pending_size,
|
|
replicated_size: stat.replicated_size,
|
|
replication_failed_size: stat.failed_size,
|
|
replication_pending_count: stat.pending_count,
|
|
replication_failed_count: stat.failed_count,
|
|
replicated_count: stat.replicated_count,
|
|
..Default::default()
|
|
},
|
|
);
|
|
}
|
|
}
|
|
buckets_usage.insert(bucket_name.clone(), bui);
|
|
}
|
|
|
|
DataUsageInfo {
|
|
last_update: self.info.last_update,
|
|
objects_total_count: flat.objects as u64,
|
|
versions_total_count: flat.versions as u64,
|
|
delete_markers_total_count: flat.delete_markers as u64,
|
|
objects_total_size: flat.size as u64,
|
|
tier_stats: flat.all_tier_stats.filter(|tiers| !tiers.is_empty()),
|
|
unknown_tier_stats: flat.unknown_tier_stats.filter(|stats| !stats.is_empty()),
|
|
buckets_count: u64::try_from(buckets.len()).unwrap_or(u64::MAX),
|
|
buckets_usage,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
pub fn marshal_msg(&self) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
|
|
let mut buf = Vec::new();
|
|
self.serialize(&mut rmp_serde::Serializer::new(&mut buf))?;
|
|
Ok(buf)
|
|
}
|
|
|
|
pub fn unmarshal(buf: &[u8]) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
|
let t: Self = rmp_serde::from_slice(buf)?;
|
|
Ok(t)
|
|
}
|
|
}
|
|
|
|
mod persistence;
|
|
|
|
#[derive(Default, Clone)]
|
|
struct Inner {
|
|
objects: usize,
|
|
path: DataUsageHash,
|
|
}
|
|
|
|
fn add(data_usage_cache: &DataUsageCache, path: &DataUsageHash, candidates: &mut Vec<Inner>) -> usize {
|
|
let mut visited = HashSet::new();
|
|
visited.insert(path.key());
|
|
add_with_guard(data_usage_cache, path, candidates, &mut visited, 0)
|
|
}
|
|
|
|
fn add_with_guard(
|
|
data_usage_cache: &DataUsageCache,
|
|
path: &DataUsageHash,
|
|
candidates: &mut Vec<Inner>,
|
|
visited: &mut HashSet<String>,
|
|
depth: usize,
|
|
) -> usize {
|
|
let e = match data_usage_cache.cache.get(&path.key()) {
|
|
Some(e) => e,
|
|
None => return 0,
|
|
};
|
|
let mut objects = e.objects;
|
|
if depth < MAX_DATA_USAGE_CACHE_DEPTH {
|
|
for ch in e.children.iter() {
|
|
if visited.insert(ch.clone()) {
|
|
objects += add_with_guard(data_usage_cache, &DataUsageHash(ch.clone()), candidates, visited, depth + 1);
|
|
}
|
|
}
|
|
}
|
|
// Collect internal nodes (with children) as compaction candidates.
|
|
// Leaf nodes have no children to remove, so compacting them is a no-op —
|
|
// total_children_rec returns 0 for leaves, so `remove` would never decrement.
|
|
if !e.children.is_empty() {
|
|
candidates.push(Inner {
|
|
objects,
|
|
path: path.clone(),
|
|
});
|
|
}
|
|
objects
|
|
}
|
|
|
|
fn mark(duc: &DataUsageCache, entry: &DataUsageEntry, found: &mut HashSet<String>) {
|
|
mark_with_depth(duc, entry, found, 0);
|
|
}
|
|
|
|
fn mark_with_depth(duc: &DataUsageCache, entry: &DataUsageEntry, found: &mut HashSet<String>, depth: usize) {
|
|
if depth >= MAX_DATA_USAGE_CACHE_DEPTH {
|
|
return;
|
|
}
|
|
|
|
for k in entry.children.iter() {
|
|
if !found.insert(k.to_string()) {
|
|
continue;
|
|
}
|
|
if let Some(ch) = duc.cache.get(k) {
|
|
mark_with_depth(duc, ch, found, depth + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn root_is_parent_only(root: &DataUsageEntry, child: &str) -> bool {
|
|
root.children.len() == 1
|
|
&& root.children.contains(child)
|
|
&& root.size == 0
|
|
&& root.objects == 0
|
|
&& root.versions == 0
|
|
&& root.delete_markers == 0
|
|
&& root.replication_stats.is_none()
|
|
&& !root.compacted
|
|
&& root.failed_objects == 0
|
|
}
|
|
|
|
/// Trait for storage-specific operations on DataUsageCache
|
|
#[async_trait::async_trait]
|
|
pub trait DataUsageCacheStorage {
|
|
/// Load data usage cache from backend storage
|
|
async fn load(store: &dyn std::any::Any, name: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>>
|
|
where
|
|
Self: Sized;
|
|
|
|
/// Save data usage cache to backend storage
|
|
async fn save(&self, name: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|