mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-21 03:46:37 +00:00
fix(capacity): harden scope registry, scan symlink guard, and test temp dir cleanup (#2432)
Co-authored-by: heihutu <heihutu@gmail.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: houseme <4829346+houseme@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
// 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 std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use uuid::Uuid;
|
||||
|
||||
const CAPACITY_SCOPE_REGISTRY_SOFT_LIMIT: usize = 2_048;
|
||||
const CAPACITY_SCOPE_REGISTRY_HARD_LIMIT: usize = 4_096;
|
||||
const CAPACITY_SCOPE_TTL: Duration = Duration::from_secs(300);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct CapacityScopeDisk {
|
||||
pub endpoint: String,
|
||||
pub drive_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct CapacityScope {
|
||||
pub disks: Vec<CapacityScopeDisk>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CapacityScopeEntry {
|
||||
scope: CapacityScope,
|
||||
recorded_at: Instant,
|
||||
}
|
||||
|
||||
fn capacity_scope_registry() -> &'static Mutex<HashMap<Uuid, CapacityScopeEntry>> {
|
||||
static REGISTRY: OnceLock<Mutex<HashMap<Uuid, CapacityScopeEntry>>> = OnceLock::new();
|
||||
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
fn global_dirty_scope_registry() -> &'static Mutex<HashSet<CapacityScopeDisk>> {
|
||||
static REGISTRY: OnceLock<Mutex<HashSet<CapacityScopeDisk>>> = OnceLock::new();
|
||||
REGISTRY.get_or_init(|| Mutex::new(HashSet::new()))
|
||||
}
|
||||
|
||||
fn prune_expired_entries(entries: &mut HashMap<Uuid, CapacityScopeEntry>, now: Instant) {
|
||||
entries.retain(|_, entry| now.duration_since(entry.recorded_at) <= CAPACITY_SCOPE_TTL);
|
||||
}
|
||||
|
||||
fn enforce_hard_limit(entries: &mut HashMap<Uuid, CapacityScopeEntry>, max_len: usize) {
|
||||
if entries.len() < max_len {
|
||||
return;
|
||||
}
|
||||
|
||||
let evict_count = entries.len() - max_len + 1;
|
||||
let mut eviction_order: Vec<_> = entries.iter().map(|(token, entry)| (*token, entry.recorded_at)).collect();
|
||||
eviction_order.sort_unstable_by_key(|(_, recorded_at)| *recorded_at);
|
||||
|
||||
for (token, _) in eviction_order.into_iter().take(evict_count) {
|
||||
entries.remove(&token);
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_capacity_scopes(existing: &mut CapacityScope, incoming: CapacityScope) {
|
||||
let mut seen: HashSet<CapacityScopeDisk> = existing.disks.iter().cloned().collect();
|
||||
for disk in incoming.disks {
|
||||
if seen.insert(disk.clone()) {
|
||||
existing.disks.push(disk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_capacity_scope(token: Uuid, scope: CapacityScope) {
|
||||
let now = Instant::now();
|
||||
let mut entries = capacity_scope_registry().lock().unwrap_or_else(|p| p.into_inner());
|
||||
if !entries.contains_key(&token) && entries.len() >= CAPACITY_SCOPE_REGISTRY_SOFT_LIMIT {
|
||||
prune_expired_entries(&mut entries, now);
|
||||
enforce_hard_limit(&mut entries, CAPACITY_SCOPE_REGISTRY_HARD_LIMIT);
|
||||
}
|
||||
if let Some(entry) = entries.get_mut(&token) {
|
||||
merge_capacity_scopes(&mut entry.scope, scope);
|
||||
entry.recorded_at = now;
|
||||
} else {
|
||||
entries.insert(token, CapacityScopeEntry { scope, recorded_at: now });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_capacity_scope(token: Uuid) -> Option<CapacityScope> {
|
||||
let now = Instant::now();
|
||||
let mut entries = capacity_scope_registry().lock().unwrap_or_else(|p| p.into_inner());
|
||||
let entry = entries.remove(&token)?;
|
||||
if now.duration_since(entry.recorded_at) > CAPACITY_SCOPE_TTL {
|
||||
return None;
|
||||
}
|
||||
Some(entry.scope)
|
||||
}
|
||||
|
||||
pub fn record_global_dirty_scope(scope: CapacityScope) {
|
||||
if scope.disks.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut dirty_scopes = global_dirty_scope_registry().lock().unwrap_or_else(|p| p.into_inner());
|
||||
dirty_scopes.extend(scope.disks);
|
||||
}
|
||||
|
||||
pub fn drain_global_dirty_scopes() -> Vec<CapacityScopeDisk> {
|
||||
let mut dirty_scopes = global_dirty_scope_registry().lock().unwrap_or_else(|p| p.into_inner());
|
||||
dirty_scopes.drain().collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn test_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn clear_capacity_scope_registry_for_test() {
|
||||
capacity_scope_registry()
|
||||
.lock()
|
||||
.expect("capacity scope registry poisoned")
|
||||
.clear();
|
||||
global_dirty_scope_registry()
|
||||
.lock()
|
||||
.expect("global dirty scope registry poisoned")
|
||||
.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_and_take_capacity_scope_round_trips() {
|
||||
let _guard = test_lock().lock().expect("test lock poisoned");
|
||||
clear_capacity_scope_registry_for_test();
|
||||
let token = Uuid::new_v4();
|
||||
let scope = CapacityScope {
|
||||
disks: vec![CapacityScopeDisk {
|
||||
endpoint: "node-a".to_string(),
|
||||
drive_path: "/tmp/disk-a".to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
record_capacity_scope(token, scope.clone());
|
||||
|
||||
assert_eq!(take_capacity_scope(token), Some(scope));
|
||||
assert_eq!(take_capacity_scope(token), None);
|
||||
clear_capacity_scope_registry_for_test();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_capacity_scope_merges_disks_for_same_token() {
|
||||
let _guard = test_lock().lock().expect("test lock poisoned");
|
||||
clear_capacity_scope_registry_for_test();
|
||||
let token = Uuid::new_v4();
|
||||
record_capacity_scope(
|
||||
token,
|
||||
CapacityScope {
|
||||
disks: vec![CapacityScopeDisk {
|
||||
endpoint: "node-a".to_string(),
|
||||
drive_path: "/tmp/disk-a".to_string(),
|
||||
}],
|
||||
},
|
||||
);
|
||||
record_capacity_scope(
|
||||
token,
|
||||
CapacityScope {
|
||||
disks: vec![
|
||||
CapacityScopeDisk {
|
||||
endpoint: "node-b".to_string(),
|
||||
drive_path: "/tmp/disk-b".to_string(),
|
||||
},
|
||||
CapacityScopeDisk {
|
||||
endpoint: "node-a".to_string(),
|
||||
drive_path: "/tmp/disk-a".to_string(),
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
let scope = take_capacity_scope(token).expect("scope should exist");
|
||||
assert_eq!(scope.disks.len(), 2);
|
||||
assert!(scope.disks.iter().any(|disk| disk.endpoint == "node-a"));
|
||||
assert!(scope.disks.iter().any(|disk| disk.endpoint == "node-b"));
|
||||
clear_capacity_scope_registry_for_test();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_capacity_scope_enforces_hard_limit() {
|
||||
let _guard = test_lock().lock().expect("test lock poisoned");
|
||||
clear_capacity_scope_registry_for_test();
|
||||
|
||||
for _ in 0..(CAPACITY_SCOPE_REGISTRY_HARD_LIMIT + 32) {
|
||||
record_capacity_scope(
|
||||
Uuid::new_v4(),
|
||||
CapacityScope {
|
||||
disks: vec![CapacityScopeDisk {
|
||||
endpoint: "node-a".to_string(),
|
||||
drive_path: "/tmp/disk-a".to_string(),
|
||||
}],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let entries = capacity_scope_registry().lock().expect("capacity scope registry poisoned");
|
||||
assert!(entries.len() <= CAPACITY_SCOPE_REGISTRY_HARD_LIMIT);
|
||||
drop(entries);
|
||||
clear_capacity_scope_registry_for_test();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_and_drain_global_dirty_scope_round_trips() {
|
||||
let _guard = test_lock().lock().expect("test lock poisoned");
|
||||
clear_capacity_scope_registry_for_test();
|
||||
record_global_dirty_scope(CapacityScope {
|
||||
disks: vec![CapacityScopeDisk {
|
||||
endpoint: "node-a".to_string(),
|
||||
drive_path: "/tmp/disk-a".to_string(),
|
||||
}],
|
||||
});
|
||||
record_global_dirty_scope(CapacityScope {
|
||||
disks: vec![
|
||||
CapacityScopeDisk {
|
||||
endpoint: "node-b".to_string(),
|
||||
drive_path: "/tmp/disk-b".to_string(),
|
||||
},
|
||||
CapacityScopeDisk {
|
||||
endpoint: "node-a".to_string(),
|
||||
drive_path: "/tmp/disk-a".to_string(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let drained = drain_global_dirty_scopes();
|
||||
assert_eq!(drained.len(), 2);
|
||||
assert!(drained.iter().any(|disk| disk.endpoint == "node-a"));
|
||||
assert!(drained.iter().any(|disk| disk.endpoint == "node-b"));
|
||||
assert!(drain_global_dirty_scopes().is_empty());
|
||||
clear_capacity_scope_registry_for_test();
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
pub mod bucket_stats;
|
||||
pub mod capacity_scope;
|
||||
// pub mod error;
|
||||
pub mod data_usage;
|
||||
pub mod globals;
|
||||
|
||||
@@ -67,6 +67,7 @@ use http::HeaderMap;
|
||||
use md5::{Digest as Md5Digest, Md5};
|
||||
use rand::{Rng, seq::SliceRandom};
|
||||
use regex::Regex;
|
||||
use rustfs_common::capacity_scope::{CapacityScope, CapacityScopeDisk, record_capacity_scope, record_global_dirty_scope};
|
||||
use rustfs_common::heal_channel::{DriveState, HealChannelPriority, HealItemType, HealOpts, HealScanMode, send_heal_disk};
|
||||
use rustfs_config::MI_B;
|
||||
use rustfs_filemeta::{
|
||||
@@ -133,6 +134,36 @@ fn env_non_negative_usize(name: &str) -> Option<usize> {
|
||||
rustfs_utils::get_env_opt_usize(name)
|
||||
}
|
||||
|
||||
fn capacity_scope_from_disks(disks: &[Option<DiskStore>]) -> CapacityScope {
|
||||
let mut unique = HashSet::with_capacity(disks.len());
|
||||
let mut scoped_disks = Vec::with_capacity(disks.len());
|
||||
|
||||
for disk in disks.iter().flatten() {
|
||||
let scope_disk = CapacityScopeDisk {
|
||||
endpoint: disk.endpoint().to_string(),
|
||||
drive_path: disk.to_string(),
|
||||
};
|
||||
if unique.insert(scope_disk.clone()) {
|
||||
scoped_disks.push(scope_disk);
|
||||
}
|
||||
}
|
||||
|
||||
CapacityScope { disks: scoped_disks }
|
||||
}
|
||||
|
||||
fn record_capacity_scope_if_needed(scope_token: Option<Uuid>, disks: &[Option<DiskStore>]) {
|
||||
let scope = capacity_scope_from_disks(disks);
|
||||
if scope.disks.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
record_global_dirty_scope(scope.clone());
|
||||
|
||||
if let Some(token) = scope_token {
|
||||
record_capacity_scope(token, scope);
|
||||
}
|
||||
}
|
||||
|
||||
fn resolved_put_inline_buffer_enabled(object_size: i64, inline_by_topology: bool) -> bool {
|
||||
if !inline_by_topology || object_size < 0 {
|
||||
return false;
|
||||
@@ -1087,6 +1118,8 @@ impl ObjectIO for SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
record_capacity_scope_if_needed(opts.capacity_scope_token, &online_disks);
|
||||
|
||||
fi.replication_state_internal = Some(opts.put_replication_state());
|
||||
|
||||
fi.is_latest = true;
|
||||
@@ -1681,6 +1714,8 @@ impl ObjectOperations for SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
record_capacity_scope_if_needed(opts.capacity_scope_token, &disks);
|
||||
|
||||
// TODO: add_partial
|
||||
|
||||
if dist_erasure {
|
||||
@@ -1829,6 +1864,10 @@ impl ObjectOperations for SetDisks {
|
||||
.await
|
||||
.map_err(|e| to_object_err(e, vec![bucket, object]))?;
|
||||
|
||||
if let Ok(disks) = self.get_disks(0, 0).await {
|
||||
record_capacity_scope_if_needed(opts.capacity_scope_token, &disks);
|
||||
}
|
||||
|
||||
let mut oi = ObjectInfo::from_file_info(&fi, bucket, object, opts.versioned || opts.version_suspended);
|
||||
oi.replication_decision = goi.replication_decision;
|
||||
return Ok(oi);
|
||||
@@ -1855,6 +1894,10 @@ impl ObjectOperations for SetDisks {
|
||||
.await
|
||||
.map_err(|e| to_object_err(e, vec![bucket, object]))?;
|
||||
|
||||
if let Ok(disks) = self.get_disks(0, 0).await {
|
||||
record_capacity_scope_if_needed(opts.capacity_scope_token, &disks);
|
||||
}
|
||||
|
||||
let mut obj_info = ObjectInfo::from_file_info(&dfi, bucket, object, opts.versioned || opts.version_suspended);
|
||||
obj_info.size = goi.size;
|
||||
Ok(obj_info)
|
||||
@@ -2157,6 +2200,8 @@ impl ObjectOperations for SetDisks {
|
||||
error = ?err,
|
||||
"transition completed on remote tier but source cleanup failed; skipping external lifecycle transition notification"
|
||||
);
|
||||
} else {
|
||||
record_capacity_scope_if_needed(opts.capacity_scope_token, &disks);
|
||||
}
|
||||
|
||||
for disk in disks.iter() {
|
||||
@@ -3515,6 +3560,8 @@ impl MultipartOperations for SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
record_capacity_scope_if_needed(opts.capacity_scope_token, &online_disks);
|
||||
|
||||
fi.is_latest = true;
|
||||
|
||||
Ok(ObjectInfo::from_file_info(&fi, bucket, object, opts.versioned || opts.version_suspended))
|
||||
|
||||
@@ -558,6 +558,8 @@ impl SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
record_capacity_scope_if_needed(None, &out_dated_disks);
|
||||
|
||||
Ok((result, None))
|
||||
}
|
||||
Err(err) => Ok((result, Some(err))),
|
||||
|
||||
@@ -73,6 +73,7 @@ pub struct ObjectOptions {
|
||||
pub resolved_checksum: Option<Bytes>,
|
||||
pub want_checksum: Option<Checksum>,
|
||||
pub skip_verify_bitrot: bool,
|
||||
pub capacity_scope_token: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl ObjectOptions {
|
||||
|
||||
@@ -29,6 +29,12 @@ pub fn record_capacity_cache_miss() {
|
||||
counter!("rustfs.capacity.cache.misses").increment(1);
|
||||
}
|
||||
|
||||
/// Record how capacity cache was served to the caller.
|
||||
#[inline(always)]
|
||||
pub fn record_capacity_cache_served(state: &'static str) {
|
||||
counter!("rustfs.capacity.cache.served.total", "state" => state).increment(1);
|
||||
}
|
||||
|
||||
/// Record current capacity gauge.
|
||||
#[inline(always)]
|
||||
pub fn record_capacity_current_bytes(used_bytes: u64) {
|
||||
@@ -55,6 +61,44 @@ pub fn record_capacity_update_failed(source: &'static str) {
|
||||
counter!("rustfs.capacity.update.failures", "source" => source).increment(1);
|
||||
}
|
||||
|
||||
/// Record a capacity refresh request.
|
||||
#[inline(always)]
|
||||
pub fn record_capacity_refresh_request(mode: &'static str, source: &'static str) {
|
||||
counter!("rustfs.capacity.refresh.requests.total", "mode" => mode, "source" => source).increment(1);
|
||||
}
|
||||
|
||||
/// Record a refresh joiner waiting for an inflight refresh.
|
||||
#[inline(always)]
|
||||
pub fn record_capacity_refresh_joiner(source: &'static str) {
|
||||
counter!("rustfs.capacity.refresh.joiners.total", "source" => source).increment(1);
|
||||
}
|
||||
|
||||
/// Record the number of inflight capacity refreshes.
|
||||
#[inline(always)]
|
||||
pub fn record_capacity_refresh_inflight(count: usize) {
|
||||
gauge!("rustfs.capacity.refresh.inflight").set(count as f64);
|
||||
}
|
||||
|
||||
/// Record the final result of a capacity refresh.
|
||||
#[inline(always)]
|
||||
pub fn record_capacity_refresh_result(source: &'static str, result: &'static str, duration: Duration) {
|
||||
counter!("rustfs.capacity.refresh.result.total", "source" => source, "result" => result).increment(1);
|
||||
histogram!("rustfs.capacity.refresh.duration.seconds", "source" => source, "result" => result).record(duration.as_secs_f64());
|
||||
}
|
||||
|
||||
/// Record the refresh scope selected for a capacity refresh.
|
||||
#[inline(always)]
|
||||
pub fn record_capacity_refresh_scope(scope: &'static str, disk_count: usize) {
|
||||
counter!("rustfs.capacity.refresh.scope.total", "scope" => scope).increment(1);
|
||||
histogram!("rustfs.capacity.refresh.scope.disks", "scope" => scope).record(disk_count as f64);
|
||||
}
|
||||
|
||||
/// Record the current number of dirty disks tracked by capacity management.
|
||||
#[inline(always)]
|
||||
pub fn record_capacity_dirty_disk_count(count: usize) {
|
||||
gauge!("rustfs.capacity.dirty.disks").set(count as f64);
|
||||
}
|
||||
|
||||
/// Record capacity write activity.
|
||||
#[inline(always)]
|
||||
pub fn record_capacity_write_operation(write_frequency: usize) {
|
||||
@@ -98,3 +142,33 @@ pub fn record_capacity_scan_sampling(sampled_count: usize, estimated: bool) {
|
||||
)
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
/// Record the scan mode used for a capacity result.
|
||||
#[inline(always)]
|
||||
pub fn record_capacity_scan_mode(mode: &'static str) {
|
||||
counter!("rustfs.capacity.scan.mode.total", "mode" => mode).increment(1);
|
||||
}
|
||||
|
||||
/// Record per-disk capacity scan statistics.
|
||||
#[inline(always)]
|
||||
pub fn record_capacity_scan_disk(
|
||||
disk: &str,
|
||||
duration: Duration,
|
||||
file_count: usize,
|
||||
sampled_count: usize,
|
||||
estimated: bool,
|
||||
partial_errors: bool,
|
||||
) {
|
||||
histogram!("rustfs.capacity.scan.disk.duration.seconds", "disk" => disk.to_owned()).record(duration.as_secs_f64());
|
||||
histogram!("rustfs.capacity.scan.disk.files", "disk" => disk.to_owned()).record(file_count as f64);
|
||||
histogram!("rustfs.capacity.scan.disk.sampled", "disk" => disk.to_owned()).record(sampled_count as f64);
|
||||
counter!(
|
||||
"rustfs.capacity.scan.disk.estimated.total",
|
||||
"disk" => disk.to_owned(),
|
||||
"estimated" => if estimated { "true" } else { "false" }
|
||||
)
|
||||
.increment(1);
|
||||
if partial_errors {
|
||||
counter!("rustfs.capacity.scan.disk.partial_errors.total", "disk" => disk.to_owned()).increment(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,9 +74,12 @@ pub use adaptive_ttl::{
|
||||
|
||||
// Capacity metrics exports
|
||||
pub use capacity_metrics::{
|
||||
record_capacity_cache_hit, record_capacity_cache_miss, record_capacity_current_bytes, record_capacity_dynamic_timeout,
|
||||
record_capacity_scan_sampling, record_capacity_stall_detected, record_capacity_symlink, record_capacity_timeout_fallback,
|
||||
record_capacity_update_completed, record_capacity_update_failed, record_capacity_write_operation,
|
||||
record_capacity_cache_hit, record_capacity_cache_miss, record_capacity_cache_served, record_capacity_current_bytes,
|
||||
record_capacity_dirty_disk_count, record_capacity_dynamic_timeout, record_capacity_refresh_inflight,
|
||||
record_capacity_refresh_joiner, record_capacity_refresh_request, record_capacity_refresh_result,
|
||||
record_capacity_refresh_scope, record_capacity_scan_disk, record_capacity_scan_mode, record_capacity_scan_sampling,
|
||||
record_capacity_stall_detected, record_capacity_symlink, record_capacity_timeout_fallback, record_capacity_update_completed,
|
||||
record_capacity_update_failed, record_capacity_write_operation,
|
||||
};
|
||||
|
||||
// I/O metrics exports
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# 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.
|
||||
|
||||
[package]
|
||||
name = "rustfs-object-capacity"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
homepage.workspace = true
|
||||
description = "Capacity scan and refresh core for RustFS."
|
||||
keywords = ["capacity", "storage", "rustfs", "scan", "metrics"]
|
||||
categories = ["filesystem", "development-tools"]
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[[bench]]
|
||||
name = "capacity_scan"
|
||||
harness = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
rustfs-common = { workspace = true }
|
||||
rustfs-config = { workspace = true, features = ["constants"] }
|
||||
rustfs-io-metrics = { workspace = true }
|
||||
rustfs-utils = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync", "time"] }
|
||||
tracing = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
walkdir = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { workspace = true }
|
||||
serial_test = { workspace = true }
|
||||
temp-env = { workspace = true, features = ["async_closure"] }
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
@@ -0,0 +1,136 @@
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use rustfs_object_capacity::{CapacityDiskRef, scan_used_capacity_disks};
|
||||
use std::fs;
|
||||
use std::hint::black_box;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
const EXACT_FILE_SIZE: usize = 4 * 1024;
|
||||
const SAMPLED_FILE_SIZE: usize = 1;
|
||||
const DEFAULT_SAMPLE_TRIGGER_FILE_COUNT: usize = 202_048;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct DiskSpec {
|
||||
file_count: usize,
|
||||
file_size: usize,
|
||||
}
|
||||
|
||||
struct CapacityScanFixture {
|
||||
_dirs: Vec<TempDir>,
|
||||
disks: Vec<CapacityDiskRef>,
|
||||
}
|
||||
|
||||
impl CapacityScanFixture {
|
||||
fn new(specs: &[DiskSpec]) -> Self {
|
||||
let mut dirs = Vec::with_capacity(specs.len());
|
||||
let mut disks = Vec::with_capacity(specs.len());
|
||||
|
||||
for (idx, spec) in specs.iter().enumerate() {
|
||||
let dir = TempDir::new().expect("create temp dir");
|
||||
populate_files(dir.path(), spec.file_count, spec.file_size).expect("populate files");
|
||||
disks.push(CapacityDiskRef {
|
||||
endpoint: format!("bench-disk-{idx}"),
|
||||
drive_path: dir.path().to_string_lossy().into_owned(),
|
||||
});
|
||||
dirs.push(dir);
|
||||
}
|
||||
|
||||
Self { _dirs: dirs, disks }
|
||||
}
|
||||
}
|
||||
|
||||
fn populate_files(root: &Path, file_count: usize, file_size: usize) -> std::io::Result<()> {
|
||||
let payload = vec![b'x'; file_size];
|
||||
let shard_count = (file_count / 512).clamp(1, 256);
|
||||
|
||||
for shard_idx in 0..shard_count {
|
||||
fs::create_dir_all(root.join(format!("bucket-{shard_idx:03}")))?;
|
||||
}
|
||||
|
||||
for file_idx in 0..file_count {
|
||||
let subdir = root.join(format!("bucket-{:03}", file_idx % shard_count));
|
||||
let file_path: PathBuf = subdir.join(format!("object-{file_idx:08}.bin"));
|
||||
fs::write(file_path, &payload)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bench_capacity_scan(c: &mut Criterion) {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("create runtime");
|
||||
|
||||
let exact_fixture = CapacityScanFixture::new(&[DiskSpec {
|
||||
file_count: 10_000,
|
||||
file_size: EXACT_FILE_SIZE,
|
||||
}]);
|
||||
|
||||
let sampled_fixture = CapacityScanFixture::new(&[DiskSpec {
|
||||
file_count: DEFAULT_SAMPLE_TRIGGER_FILE_COUNT,
|
||||
file_size: SAMPLED_FILE_SIZE,
|
||||
}]);
|
||||
|
||||
let multi_disk_fixture = CapacityScanFixture::new(&[
|
||||
DiskSpec {
|
||||
file_count: 4_000,
|
||||
file_size: 1024,
|
||||
},
|
||||
DiskSpec {
|
||||
file_count: 6_000,
|
||||
file_size: 2048,
|
||||
},
|
||||
DiskSpec {
|
||||
file_count: 8_000,
|
||||
file_size: 4096,
|
||||
},
|
||||
DiskSpec {
|
||||
file_count: 10_000,
|
||||
file_size: 1024,
|
||||
},
|
||||
]);
|
||||
|
||||
let mut exact_group = c.benchmark_group("capacity_scan_exact");
|
||||
exact_group.sample_size(10);
|
||||
exact_group.measurement_time(Duration::from_secs(10));
|
||||
exact_group.bench_function("single_disk_10k_4k", |b| {
|
||||
b.iter(|| {
|
||||
let summary = runtime
|
||||
.block_on(scan_used_capacity_disks(black_box(&exact_fixture.disks)))
|
||||
.expect("exact scan");
|
||||
black_box(summary);
|
||||
});
|
||||
});
|
||||
exact_group.finish();
|
||||
|
||||
let mut sampled_group = c.benchmark_group("capacity_scan_sampled");
|
||||
sampled_group.sample_size(10);
|
||||
sampled_group.measurement_time(Duration::from_secs(10));
|
||||
sampled_group.bench_function("single_disk_202k_1b", |b| {
|
||||
b.iter(|| {
|
||||
let summary = runtime
|
||||
.block_on(scan_used_capacity_disks(black_box(&sampled_fixture.disks)))
|
||||
.expect("sampled scan");
|
||||
black_box(summary);
|
||||
});
|
||||
});
|
||||
sampled_group.finish();
|
||||
|
||||
let mut multi_disk_group = c.benchmark_group("capacity_scan_multi_disk");
|
||||
multi_disk_group.sample_size(10);
|
||||
multi_disk_group.measurement_time(Duration::from_secs(10));
|
||||
multi_disk_group.bench_function("four_disks_mixed_exact", |b| {
|
||||
b.iter(|| {
|
||||
let summary = runtime
|
||||
.block_on(scan_used_capacity_disks(black_box(&multi_disk_fixture.disks)))
|
||||
.expect("multi-disk scan");
|
||||
black_box(summary);
|
||||
});
|
||||
});
|
||||
multi_disk_group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_capacity_scan);
|
||||
criterion_main!(benches);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
// 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.
|
||||
|
||||
pub mod capacity_manager;
|
||||
pub mod scan;
|
||||
pub mod types;
|
||||
|
||||
pub use scan::scan_used_capacity_disks;
|
||||
pub use types::{CapacityDiskRef, CapacityScanSummary};
|
||||
@@ -0,0 +1,903 @@
|
||||
// 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 super::capacity_manager::{
|
||||
CapacityUpdate, DiskCapacityUpdate, HybridCapacityManager, get_enable_dynamic_timeout, get_follow_symlinks,
|
||||
get_max_files_threshold, get_max_symlink_depth, get_max_timeout, get_min_timeout, get_sample_rate, get_stall_timeout,
|
||||
get_stat_timeout,
|
||||
};
|
||||
use super::types::{CapacityDiskRef, CapacityScanResult, CapacityScanSummary};
|
||||
use futures::{StreamExt, stream};
|
||||
use rustfs_common::capacity_scope::CapacityScopeDisk;
|
||||
use rustfs_io_metrics::capacity_metrics::{
|
||||
record_capacity_dynamic_timeout, record_capacity_scan_disk, record_capacity_scan_mode, record_capacity_scan_sampling,
|
||||
record_capacity_stall_detected, record_capacity_symlink, record_capacity_timeout_fallback,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{debug, info, warn};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
const MAX_CAPACITY_SCAN_CONCURRENCY: usize = 4;
|
||||
const CAPACITY_PROGRESS_CHECK_STRIDE: usize = 512;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DiskScanOutcome {
|
||||
disk_label: String,
|
||||
drive_path: String,
|
||||
duration: Duration,
|
||||
result: Result<CapacityScanResult, std::io::Error>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct DiskCapacityScanResult {
|
||||
disk: CapacityScopeDisk,
|
||||
scan: CapacityScanResult,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CapacityScanReport {
|
||||
summary: CapacityScanResult,
|
||||
per_disk: Vec<DiskCapacityScanResult>,
|
||||
}
|
||||
|
||||
impl CapacityScanReport {
|
||||
fn into_capacity_update(self, expected_disk_count: usize, replaces_disk_cache: bool) -> CapacityUpdate {
|
||||
let mut update = if self.summary.is_estimated {
|
||||
CapacityUpdate::estimated(self.summary.used_bytes, self.summary.file_count)
|
||||
} else {
|
||||
CapacityUpdate::exact(self.summary.used_bytes, self.summary.file_count)
|
||||
};
|
||||
|
||||
if !self.summary.had_partial_errors && self.per_disk.len() == expected_disk_count {
|
||||
update.per_disk = self
|
||||
.per_disk
|
||||
.into_iter()
|
||||
.map(|entry| DiskCapacityUpdate {
|
||||
disk: entry.disk,
|
||||
used_bytes: entry.scan.used_bytes,
|
||||
file_count: entry.scan.file_count,
|
||||
is_estimated: entry.scan.is_estimated,
|
||||
})
|
||||
.collect();
|
||||
update.expected_disk_count = Some(expected_disk_count);
|
||||
update.replaces_disk_cache = replaces_disk_cache;
|
||||
update.clear_dirty_disks = update.per_disk.iter().map(|entry| entry.disk.clone()).collect();
|
||||
}
|
||||
|
||||
update
|
||||
}
|
||||
}
|
||||
|
||||
fn disk_metric_label(disk: &CapacityDiskRef) -> String {
|
||||
let mount_name = Path::new(&disk.drive_path)
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(disk.drive_path.as_str());
|
||||
format!("{}:{mount_name}", disk.endpoint)
|
||||
}
|
||||
|
||||
fn disk_scope_key(disk: &CapacityDiskRef) -> CapacityScopeDisk {
|
||||
CapacityScopeDisk {
|
||||
endpoint: disk.endpoint.clone(),
|
||||
drive_path: disk.drive_path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn scan_disk_used_capacity(disk: CapacityDiskRef) -> DiskScanOutcome {
|
||||
let disk_label = disk_metric_label(&disk);
|
||||
let drive_path = disk.drive_path.clone();
|
||||
let start = Instant::now();
|
||||
let result = get_dir_size_async(Path::new(&drive_path)).await;
|
||||
|
||||
DiskScanOutcome {
|
||||
disk_label,
|
||||
drive_path,
|
||||
duration: start.elapsed(),
|
||||
result,
|
||||
}
|
||||
}
|
||||
|
||||
async fn calculate_data_dir_used_capacity_report(
|
||||
disks: &[CapacityDiskRef],
|
||||
) -> Result<CapacityScanReport, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let start = Instant::now();
|
||||
let mut total_used = 0u64;
|
||||
let mut total_files = 0usize;
|
||||
let mut total_sampled = 0usize;
|
||||
let mut has_failure = false;
|
||||
let mut has_success = false;
|
||||
let mut is_estimated = false;
|
||||
let mut per_disk = Vec::with_capacity(disks.len());
|
||||
|
||||
let concurrency_limit = disks.len().clamp(1, MAX_CAPACITY_SCAN_CONCURRENCY);
|
||||
let mut scans = stream::iter(disks.iter().cloned().map(scan_disk_used_capacity)).buffer_unordered(concurrency_limit);
|
||||
|
||||
while let Some(outcome) = scans.next().await {
|
||||
match outcome.result {
|
||||
Ok(scan) => {
|
||||
record_capacity_scan_disk(
|
||||
outcome.disk_label.as_str(),
|
||||
outcome.duration,
|
||||
scan.file_count,
|
||||
scan.sampled_count,
|
||||
scan.is_estimated,
|
||||
scan.had_partial_errors,
|
||||
);
|
||||
debug!(
|
||||
"Data directory {} size: {} bytes, files={}, sampled={}, estimated={}, duration={:?}",
|
||||
outcome.drive_path, scan.used_bytes, scan.file_count, scan.sampled_count, scan.is_estimated, outcome.duration
|
||||
);
|
||||
total_used += scan.used_bytes;
|
||||
total_files += scan.file_count;
|
||||
total_sampled += scan.sampled_count;
|
||||
is_estimated |= scan.is_estimated;
|
||||
has_failure |= scan.had_partial_errors;
|
||||
has_success = true;
|
||||
if let Some(disk) = disks
|
||||
.iter()
|
||||
.find(|disk| disk.drive_path == outcome.drive_path && disk_metric_label(disk) == outcome.disk_label)
|
||||
{
|
||||
per_disk.push(DiskCapacityScanResult {
|
||||
disk: disk_scope_key(disk),
|
||||
scan,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
record_capacity_scan_disk(outcome.disk_label.as_str(), outcome.duration, 0, 0, false, true);
|
||||
warn!("Failed to get size for directory {}: {:?}", outcome.drive_path, e);
|
||||
has_failure = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_success {
|
||||
return Err("All directories failed to calculate size".into());
|
||||
}
|
||||
|
||||
if has_failure {
|
||||
warn!("Some directories failed to calculate size, result may be incomplete");
|
||||
}
|
||||
|
||||
let mut summary = CapacityScanResult {
|
||||
used_bytes: total_used,
|
||||
file_count: total_files,
|
||||
sampled_count: total_sampled,
|
||||
is_estimated,
|
||||
scan_duration: start.elapsed(),
|
||||
had_partial_errors: false,
|
||||
};
|
||||
|
||||
if has_failure {
|
||||
summary = summary.with_partial_errors();
|
||||
}
|
||||
|
||||
Ok(CapacityScanReport { summary, per_disk })
|
||||
}
|
||||
|
||||
/// Calculate actual used capacity of all data directories.
|
||||
pub(crate) async fn calculate_data_dir_used_capacity(
|
||||
disks: &[CapacityDiskRef],
|
||||
) -> Result<CapacityScanResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||
Ok(calculate_data_dir_used_capacity_report(disks).await?.summary)
|
||||
}
|
||||
|
||||
pub async fn select_capacity_refresh_disks(
|
||||
capacity_manager: &HybridCapacityManager,
|
||||
disks: &[CapacityDiskRef],
|
||||
) -> (Vec<CapacityDiskRef>, bool) {
|
||||
if !capacity_manager.can_refresh_dirty_subset().await {
|
||||
return (disks.to_vec(), false);
|
||||
}
|
||||
|
||||
let dirty_disks = capacity_manager.get_dirty_disks().await;
|
||||
if dirty_disks.is_empty() {
|
||||
return (disks.to_vec(), false);
|
||||
}
|
||||
|
||||
let dirty_set: HashSet<CapacityScopeDisk> = dirty_disks.into_iter().collect();
|
||||
let selected: Vec<_> = disks
|
||||
.iter()
|
||||
.filter(|disk| dirty_set.contains(&disk_scope_key(disk)))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if selected.is_empty() || selected.len() >= disks.len() {
|
||||
(disks.to_vec(), false)
|
||||
} else {
|
||||
(selected, true)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn refresh_capacity_with_scope(disks: Vec<CapacityDiskRef>, dirty_subset: bool) -> Result<CapacityUpdate, String> {
|
||||
let report = calculate_data_dir_used_capacity_report(&disks)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if dirty_subset && report.summary.had_partial_errors {
|
||||
return Err("dirty subset refresh had partial errors".to_string());
|
||||
}
|
||||
|
||||
Ok(report.into_capacity_update(disks.len(), !dirty_subset))
|
||||
}
|
||||
|
||||
/// Scan the provided local disk roots and return a summarized used-capacity result.
|
||||
///
|
||||
/// This is primarily intended for benchmarks and operational tooling that need to exercise
|
||||
/// the same scan path as admin capacity queries without going through the full admin stack.
|
||||
pub async fn scan_used_capacity_disks(
|
||||
disks: &[CapacityDiskRef],
|
||||
) -> Result<CapacityScanSummary, Box<dyn std::error::Error + Send + Sync>> {
|
||||
Ok(calculate_data_dir_used_capacity(disks).await?.into())
|
||||
}
|
||||
|
||||
/// Tracker for symlink resolution with circular reference detection.
|
||||
struct SymlinkTracker {
|
||||
visited: HashSet<PathBuf>,
|
||||
symlink_count: usize,
|
||||
symlink_size: u64,
|
||||
max_depth: u8,
|
||||
}
|
||||
|
||||
impl SymlinkTracker {
|
||||
fn new(max_depth: u8) -> Self {
|
||||
Self {
|
||||
visited: HashSet::new(),
|
||||
symlink_count: 0,
|
||||
symlink_size: 0,
|
||||
max_depth,
|
||||
}
|
||||
}
|
||||
|
||||
fn should_follow(&self, path: &Path, depth: u8) -> bool {
|
||||
if depth >= self.max_depth {
|
||||
debug!("Symlink depth limit reached: {} >= {}, not following {:?}", depth, self.max_depth, path);
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.visited.contains(path) {
|
||||
warn!("Circular symlink reference detected: {:?}, skipping", path);
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn record_symlink(&mut self, path: PathBuf, size: u64) {
|
||||
if self.visited.insert(path) {
|
||||
self.symlink_count += 1;
|
||||
self.symlink_size += size;
|
||||
record_capacity_symlink(size);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_stats(&self) -> (usize, u64) {
|
||||
(self.symlink_count, self.symlink_size)
|
||||
}
|
||||
}
|
||||
|
||||
/// Monitor for directory traversal progress with timeout and stall detection.
|
||||
struct ProgressMonitor {
|
||||
start_time: Instant,
|
||||
last_check: Instant,
|
||||
last_checkpoint_files: usize,
|
||||
timeout: Duration,
|
||||
min_timeout: Duration,
|
||||
max_timeout: Duration,
|
||||
stall_timeout: Duration,
|
||||
enable_dynamic_timeout: bool,
|
||||
used_dynamic_timeout: bool,
|
||||
}
|
||||
|
||||
impl ProgressMonitor {
|
||||
fn new(
|
||||
base_timeout: Duration,
|
||||
min_timeout: Duration,
|
||||
max_timeout: Duration,
|
||||
stall_timeout: Duration,
|
||||
enable_dynamic: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
start_time: Instant::now(),
|
||||
last_check: Instant::now(),
|
||||
last_checkpoint_files: 0,
|
||||
timeout: base_timeout,
|
||||
min_timeout,
|
||||
max_timeout,
|
||||
stall_timeout,
|
||||
enable_dynamic_timeout: enable_dynamic,
|
||||
used_dynamic_timeout: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_dynamic_timeout(&mut self, file_count: usize, avg_file_size: u64) -> Duration {
|
||||
if !self.enable_dynamic_timeout {
|
||||
return self.timeout;
|
||||
}
|
||||
|
||||
self.used_dynamic_timeout = true;
|
||||
|
||||
let file_factor = (file_count as f64).sqrt() * 0.01;
|
||||
let size_factor = if avg_file_size > 0 {
|
||||
(avg_file_size as f64).log(10.0) * 0.05
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let multiplier = 1.0 + file_factor + size_factor;
|
||||
let adjusted_timeout = self.timeout.mul_f64(multiplier.min(5.0));
|
||||
let clamped_timeout = adjusted_timeout.max(self.min_timeout).min(self.max_timeout);
|
||||
|
||||
debug!(
|
||||
"Dynamic timeout calculation: files={}, avg_size={}, multiplier={:.2}, base_timeout={:?}, adjusted_timeout={:?}, clamped_timeout={:?}",
|
||||
file_count, avg_file_size, multiplier, self.timeout, adjusted_timeout, clamped_timeout
|
||||
);
|
||||
|
||||
clamped_timeout
|
||||
}
|
||||
|
||||
fn update_and_check_timeout(&mut self, files_processed: usize, avg_file_size: u64) -> Result<(), std::io::Error> {
|
||||
let elapsed = self.start_time.elapsed();
|
||||
let dynamic_timeout = if self.enable_dynamic_timeout {
|
||||
self.calculate_dynamic_timeout(files_processed, avg_file_size)
|
||||
} else {
|
||||
self.timeout
|
||||
};
|
||||
|
||||
if elapsed >= dynamic_timeout {
|
||||
warn!(
|
||||
"Directory size calculation timeout after {} files, elapsed: {:?}, timeout: {:?}",
|
||||
files_processed, elapsed, dynamic_timeout
|
||||
);
|
||||
|
||||
if self.enable_dynamic_timeout {
|
||||
record_capacity_dynamic_timeout(dynamic_timeout);
|
||||
}
|
||||
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!("Timeout after {} files", files_processed),
|
||||
));
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
if now.duration_since(self.last_check) >= self.stall_timeout {
|
||||
let files_per_checkpoint = files_processed.saturating_sub(self.last_checkpoint_files);
|
||||
|
||||
if files_per_checkpoint == 0 && files_processed > 0 {
|
||||
warn!(
|
||||
"No progress detected for {:?}, possible stall at {} files",
|
||||
self.stall_timeout, files_processed
|
||||
);
|
||||
|
||||
record_capacity_stall_detected();
|
||||
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!("Stall detected at {} files", files_processed),
|
||||
));
|
||||
}
|
||||
|
||||
self.last_check = now;
|
||||
self.last_checkpoint_files = files_processed;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_timeout_fallback(&self) {
|
||||
record_capacity_timeout_fallback();
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_dir_size_async(path: &Path) -> Result<CapacityScanResult, std::io::Error> {
|
||||
let path = path.to_path_buf();
|
||||
|
||||
let max_files_threshold = get_max_files_threshold();
|
||||
let base_timeout = get_stat_timeout();
|
||||
let min_timeout = get_min_timeout();
|
||||
let max_timeout = get_max_timeout();
|
||||
let stall_timeout = get_stall_timeout();
|
||||
let sample_rate = get_sample_rate();
|
||||
let enable_dynamic_timeout = get_enable_dynamic_timeout();
|
||||
let follow_symlinks = get_follow_symlinks();
|
||||
let max_symlink_depth = get_max_symlink_depth();
|
||||
|
||||
let effective_sample_rate = if sample_rate == 0 {
|
||||
warn!("Invalid sampling configuration: sample_rate=0. Clamping to 1 to avoid panic.");
|
||||
1
|
||||
} else {
|
||||
sample_rate
|
||||
};
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if !path.exists() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("Directory not found: {:?}", path),
|
||||
));
|
||||
}
|
||||
|
||||
let start_time = Instant::now();
|
||||
let mut exact_prefix_bytes = 0u64;
|
||||
let mut overflow_sampled_bytes = 0u64;
|
||||
let mut file_count = 0usize;
|
||||
let mut sampled_count = 0usize;
|
||||
let mut had_partial_errors = false;
|
||||
let mut last_progress_check_files = 0usize;
|
||||
|
||||
let mut symlink_tracker = SymlinkTracker::new(max_symlink_depth);
|
||||
let mut progress_monitor =
|
||||
ProgressMonitor::new(base_timeout, min_timeout, max_timeout, stall_timeout, enable_dynamic_timeout);
|
||||
|
||||
let walker = WalkDir::new(&path)
|
||||
.follow_links(follow_symlinks)
|
||||
.follow_root_links(follow_symlinks)
|
||||
.into_iter();
|
||||
|
||||
for entry_result in walker {
|
||||
let entry = match entry_result {
|
||||
Ok(entry) => entry,
|
||||
Err(err) => {
|
||||
warn!("Failed to traverse directory entry under {:?}: {}", path, err);
|
||||
had_partial_errors = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if follow_symlinks
|
||||
&& entry.path_is_symlink()
|
||||
&& let Ok(target) = std::fs::read_link(entry.path())
|
||||
&& symlink_tracker.should_follow(&target, entry.depth().min(u8::MAX as usize) as u8)
|
||||
{
|
||||
symlink_tracker.record_symlink(target, 0);
|
||||
}
|
||||
|
||||
let file_type = entry.file_type();
|
||||
if file_type.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if file_type.is_symlink() || !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let metadata = match entry.metadata() {
|
||||
Ok(meta) => meta,
|
||||
Err(err) => {
|
||||
warn!("Failed to get metadata for {:?}: {}", entry.path(), err);
|
||||
had_partial_errors = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
file_count += 1;
|
||||
let exact_count = file_count.min(max_files_threshold);
|
||||
let avg_size = if exact_count > 0 {
|
||||
exact_prefix_bytes / exact_count as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let should_check_progress =
|
||||
file_count == 1 || file_count.saturating_sub(last_progress_check_files) >= CAPACITY_PROGRESS_CHECK_STRIDE;
|
||||
|
||||
if should_check_progress && let Err(e) = progress_monitor.update_and_check_timeout(file_count, avg_size) {
|
||||
if sampled_count > 0 {
|
||||
let overflow_count = file_count.saturating_sub(max_files_threshold);
|
||||
let estimated_overflow = overflow_sampled_bytes.saturating_mul(overflow_count as u64) / sampled_count as u64;
|
||||
let estimated_total = exact_prefix_bytes.saturating_add(estimated_overflow);
|
||||
info!(
|
||||
"Timeout/stall at {} files, using sampled estimate: exact_prefix={} overflow_estimate={} sampled={}",
|
||||
file_count, exact_prefix_bytes, estimated_overflow, sampled_count
|
||||
);
|
||||
progress_monitor.record_timeout_fallback();
|
||||
record_capacity_scan_sampling(sampled_count, true);
|
||||
record_capacity_scan_mode("timeout_fallback");
|
||||
return Ok(CapacityScanResult {
|
||||
used_bytes: estimated_total,
|
||||
file_count,
|
||||
sampled_count,
|
||||
is_estimated: true,
|
||||
scan_duration: start_time.elapsed(),
|
||||
had_partial_errors,
|
||||
});
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
if should_check_progress {
|
||||
last_progress_check_files = file_count;
|
||||
}
|
||||
|
||||
if file_count <= max_files_threshold {
|
||||
exact_prefix_bytes += metadata.len();
|
||||
} else {
|
||||
let overflow_index = file_count - max_files_threshold;
|
||||
if overflow_index.is_multiple_of(effective_sample_rate) {
|
||||
overflow_sampled_bytes += metadata.len();
|
||||
sampled_count += 1;
|
||||
}
|
||||
|
||||
if file_count.is_multiple_of(100_000) {
|
||||
debug!(
|
||||
"Processed {} files, exact_prefix_bytes={}, sampled_overflow={} files/{} bytes",
|
||||
file_count, exact_prefix_bytes, sampled_count, overflow_sampled_bytes
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if file_count > last_progress_check_files {
|
||||
let exact_count = file_count.min(max_files_threshold);
|
||||
let avg_size = if exact_count > 0 {
|
||||
exact_prefix_bytes / exact_count as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
if let Err(e) = progress_monitor.update_and_check_timeout(file_count, avg_size) {
|
||||
if sampled_count > 0 {
|
||||
let overflow_count = file_count.saturating_sub(max_files_threshold);
|
||||
let estimated_overflow = overflow_sampled_bytes.saturating_mul(overflow_count as u64) / sampled_count as u64;
|
||||
let estimated_total = exact_prefix_bytes.saturating_add(estimated_overflow);
|
||||
info!(
|
||||
"Timeout/stall at {} files during final check, using sampled estimate: exact_prefix={} overflow_estimate={} sampled={}",
|
||||
file_count, exact_prefix_bytes, estimated_overflow, sampled_count
|
||||
);
|
||||
progress_monitor.record_timeout_fallback();
|
||||
record_capacity_scan_sampling(sampled_count, true);
|
||||
record_capacity_scan_mode("timeout_fallback");
|
||||
return Ok(CapacityScanResult {
|
||||
used_bytes: estimated_total,
|
||||
file_count,
|
||||
sampled_count,
|
||||
is_estimated: true,
|
||||
scan_duration: start_time.elapsed(),
|
||||
had_partial_errors,
|
||||
});
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
let (symlink_count, symlink_size) = symlink_tracker.get_stats();
|
||||
if symlink_count > 0 {
|
||||
info!(
|
||||
"Symlink tracking: {} symlinks processed, total tracked size: {} bytes",
|
||||
symlink_count, symlink_size
|
||||
);
|
||||
}
|
||||
|
||||
if file_count > max_files_threshold && sampled_count > 0 {
|
||||
let overflow_count = file_count - max_files_threshold;
|
||||
let estimated_overflow = overflow_sampled_bytes.saturating_mul(overflow_count as u64) / sampled_count as u64;
|
||||
let estimated_size = exact_prefix_bytes.saturating_add(estimated_overflow);
|
||||
info!(
|
||||
"Large directory detected: {} files, estimated size: {} bytes (exact prefix: {}, sampled overflow {}/{})",
|
||||
file_count, estimated_size, exact_prefix_bytes, sampled_count, overflow_count
|
||||
);
|
||||
record_capacity_scan_sampling(sampled_count, true);
|
||||
record_capacity_scan_mode("estimated");
|
||||
Ok(CapacityScanResult {
|
||||
used_bytes: estimated_size,
|
||||
file_count,
|
||||
sampled_count,
|
||||
is_estimated: true,
|
||||
scan_duration: start_time.elapsed(),
|
||||
had_partial_errors,
|
||||
})
|
||||
} else if file_count > max_files_threshold {
|
||||
let overflow_count = file_count - max_files_threshold;
|
||||
let exact_prefix_count = file_count.min(max_files_threshold) as u64;
|
||||
let avg_prefix_size = if exact_prefix_count > 0 {
|
||||
exact_prefix_bytes / exact_prefix_count
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let estimated_overflow = avg_prefix_size.saturating_mul(overflow_count as u64);
|
||||
let estimated_size = exact_prefix_bytes.saturating_add(estimated_overflow);
|
||||
info!(
|
||||
"Large directory detected: {} files, estimated size: {} bytes (no overflow samples, used prefix average {} bytes/file)",
|
||||
file_count, estimated_size, avg_prefix_size
|
||||
);
|
||||
record_capacity_scan_sampling(0, true);
|
||||
record_capacity_scan_mode("estimated");
|
||||
Ok(CapacityScanResult {
|
||||
used_bytes: estimated_size,
|
||||
file_count,
|
||||
sampled_count: 0,
|
||||
is_estimated: true,
|
||||
scan_duration: start_time.elapsed(),
|
||||
had_partial_errors,
|
||||
})
|
||||
} else {
|
||||
record_capacity_scan_sampling(0, false);
|
||||
debug!(
|
||||
"Directory size calculation completed: {} files, {} bytes, took {:?}",
|
||||
file_count,
|
||||
exact_prefix_bytes,
|
||||
start_time.elapsed()
|
||||
);
|
||||
record_capacity_scan_mode("exact");
|
||||
Ok(CapacityScanResult {
|
||||
used_bytes: exact_prefix_bytes,
|
||||
file_count,
|
||||
sampled_count,
|
||||
is_estimated: false,
|
||||
scan_duration: start_time.elapsed(),
|
||||
had_partial_errors,
|
||||
})
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(std::io::Error::other)?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::capacity_manager::{DataSource, HybridStrategyConfig, create_isolated_manager};
|
||||
use rustfs_common::capacity_scope::{CapacityScope, CapacityScopeDisk};
|
||||
use rustfs_config::ENV_CAPACITY_FOLLOW_SYMLINKS;
|
||||
use serial_test::serial;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_dir_size_async_empty_directory() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let size = get_dir_size_async(temp_dir.path()).await.unwrap();
|
||||
assert_eq!(size.used_bytes, 0);
|
||||
assert_eq!(size.file_count, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_dir_size_async_single_file() {
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("test.txt");
|
||||
let mut file = File::create(&file_path).unwrap();
|
||||
file.write_all(b"Hello, World!").unwrap();
|
||||
|
||||
let size = get_dir_size_async(temp_dir.path()).await.unwrap();
|
||||
assert_eq!(size.used_bytes, 13);
|
||||
assert_eq!(size.file_count, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_dir_size_async_multiple_files() {
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
for i in 0..10 {
|
||||
let file_path = temp_dir.path().join(format!("file_{}.txt", i));
|
||||
let mut file = File::create(&file_path).unwrap();
|
||||
file.write_all(b"test").unwrap();
|
||||
}
|
||||
|
||||
let size = get_dir_size_async(temp_dir.path()).await.unwrap();
|
||||
assert_eq!(size.used_bytes, 40);
|
||||
assert_eq!(size.file_count, 10);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_dir_size_async_nested_directories() {
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let subdir = temp_dir.path().join("subdir");
|
||||
std::fs::create_dir(&subdir).unwrap();
|
||||
|
||||
let file1 = temp_dir.path().join("file1.txt");
|
||||
let mut f1 = File::create(&file1).unwrap();
|
||||
f1.write_all(b"content1").unwrap();
|
||||
|
||||
let file2 = subdir.join("file2.txt");
|
||||
let mut f2 = File::create(&file2).unwrap();
|
||||
f2.write_all(b"content2").unwrap();
|
||||
|
||||
let size = get_dir_size_async(temp_dir.path()).await.unwrap();
|
||||
assert_eq!(size.used_bytes, 16);
|
||||
assert_eq!(size.file_count, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_get_dir_size_async_nonexistent_directory() {
|
||||
let result = get_dir_size_async(Path::new("/nonexistent/path")).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_calculate_data_dir_used_capacity_returns_partial_success() {
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("test.txt");
|
||||
let mut file = File::create(&file_path).unwrap();
|
||||
file.write_all(b"Hello, World!").unwrap();
|
||||
|
||||
let disks = vec![
|
||||
CapacityDiskRef {
|
||||
endpoint: "disk-1".to_string(),
|
||||
drive_path: temp_dir.path().to_string_lossy().into_owned(),
|
||||
},
|
||||
CapacityDiskRef {
|
||||
endpoint: "disk-2".to_string(),
|
||||
drive_path: "/nonexistent/path".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let result = calculate_data_dir_used_capacity(&disks).await.unwrap();
|
||||
assert_eq!(result.used_bytes, 13);
|
||||
assert_eq!(result.file_count, 1);
|
||||
assert!(result.had_partial_errors);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_select_capacity_refresh_disks_returns_full_when_disk_cache_incomplete() {
|
||||
let manager = create_isolated_manager(HybridStrategyConfig::default());
|
||||
manager
|
||||
.mark_dirty_scope(&CapacityScope {
|
||||
disks: vec![CapacityScopeDisk {
|
||||
endpoint: "disk-1".to_string(),
|
||||
drive_path: "/tmp/disk-1".to_string(),
|
||||
}],
|
||||
})
|
||||
.await;
|
||||
|
||||
let disks = vec![
|
||||
CapacityDiskRef {
|
||||
endpoint: "disk-1".to_string(),
|
||||
drive_path: "/tmp/disk-1".to_string(),
|
||||
},
|
||||
CapacityDiskRef {
|
||||
endpoint: "disk-2".to_string(),
|
||||
drive_path: "/tmp/disk-2".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let (selected, dirty_subset) = select_capacity_refresh_disks(manager.as_ref(), &disks).await;
|
||||
assert!(!dirty_subset);
|
||||
assert_eq!(selected.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_select_capacity_refresh_disks_returns_dirty_subset_when_cache_complete() {
|
||||
let manager = create_isolated_manager(HybridStrategyConfig::default());
|
||||
manager
|
||||
.update_capacity(
|
||||
CapacityUpdate {
|
||||
total_used: 300,
|
||||
file_count: 3,
|
||||
is_estimated: false,
|
||||
per_disk: vec![
|
||||
DiskCapacityUpdate {
|
||||
disk: CapacityScopeDisk {
|
||||
endpoint: "disk-1".to_string(),
|
||||
drive_path: "/tmp/disk-1".to_string(),
|
||||
},
|
||||
used_bytes: 100,
|
||||
file_count: 1,
|
||||
is_estimated: false,
|
||||
},
|
||||
DiskCapacityUpdate {
|
||||
disk: CapacityScopeDisk {
|
||||
endpoint: "disk-2".to_string(),
|
||||
drive_path: "/tmp/disk-2".to_string(),
|
||||
},
|
||||
used_bytes: 200,
|
||||
file_count: 2,
|
||||
is_estimated: false,
|
||||
},
|
||||
],
|
||||
expected_disk_count: Some(2),
|
||||
replaces_disk_cache: true,
|
||||
clear_dirty_disks: Vec::new(),
|
||||
},
|
||||
DataSource::RealTime,
|
||||
)
|
||||
.await;
|
||||
manager
|
||||
.mark_dirty_scope(&CapacityScope {
|
||||
disks: vec![CapacityScopeDisk {
|
||||
endpoint: "disk-2".to_string(),
|
||||
drive_path: "/tmp/disk-2".to_string(),
|
||||
}],
|
||||
})
|
||||
.await;
|
||||
|
||||
let disks = vec![
|
||||
CapacityDiskRef {
|
||||
endpoint: "disk-1".to_string(),
|
||||
drive_path: "/tmp/disk-1".to_string(),
|
||||
},
|
||||
CapacityDiskRef {
|
||||
endpoint: "disk-2".to_string(),
|
||||
drive_path: "/tmp/disk-2".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let (selected, dirty_subset) = select_capacity_refresh_disks(manager.as_ref(), &disks).await;
|
||||
assert!(dirty_subset);
|
||||
assert_eq!(selected.len(), 1);
|
||||
assert_eq!(selected[0].endpoint, "disk-2");
|
||||
assert_eq!(selected[0].drive_path, "/tmp/disk-2");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_get_dir_size_async_ignores_symlink_targets_when_follow_disabled() {
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::symlink;
|
||||
use tempfile::TempDir;
|
||||
|
||||
let scan_dir = TempDir::new().unwrap();
|
||||
let target_dir = TempDir::new().unwrap();
|
||||
let target_path = target_dir.path().join("external.txt");
|
||||
let mut file = File::create(&target_path).unwrap();
|
||||
file.write_all(b"external-bytes").unwrap();
|
||||
symlink(&target_path, scan_dir.path().join("external-link")).unwrap();
|
||||
|
||||
let size = temp_env::async_with_vars([(ENV_CAPACITY_FOLLOW_SYMLINKS, Some("false"))], async {
|
||||
get_dir_size_async(scan_dir.path()).await
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(size.used_bytes, 0);
|
||||
assert_eq!(size.file_count, 0);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_get_dir_size_async_counts_symlink_targets_when_follow_enabled() {
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::symlink;
|
||||
use tempfile::TempDir;
|
||||
|
||||
let scan_dir = TempDir::new().unwrap();
|
||||
let target_dir = TempDir::new().unwrap();
|
||||
let target_path = target_dir.path().join("external.txt");
|
||||
let mut file = File::create(&target_path).unwrap();
|
||||
file.write_all(b"external-bytes").unwrap();
|
||||
symlink(&target_path, scan_dir.path().join("external-link")).unwrap();
|
||||
|
||||
let size = temp_env::async_with_vars([(ENV_CAPACITY_FOLLOW_SYMLINKS, Some("true"))], async {
|
||||
get_dir_size_async(scan_dir.path()).await
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(size.used_bytes, "external-bytes".len() as u64);
|
||||
assert_eq!(size.file_count, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// 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 std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct CapacityDiskRef {
|
||||
pub endpoint: String,
|
||||
pub drive_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub(crate) struct CapacityScanResult {
|
||||
pub used_bytes: u64,
|
||||
pub file_count: usize,
|
||||
pub sampled_count: usize,
|
||||
pub is_estimated: bool,
|
||||
pub scan_duration: Duration,
|
||||
pub had_partial_errors: bool,
|
||||
}
|
||||
|
||||
impl CapacityScanResult {
|
||||
pub(crate) fn with_partial_errors(mut self) -> Self {
|
||||
self.had_partial_errors = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Public summary type for external tooling such as benches.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CapacityScanSummary {
|
||||
pub used_bytes: u64,
|
||||
pub file_count: usize,
|
||||
pub sampled_count: usize,
|
||||
pub is_estimated: bool,
|
||||
pub had_partial_errors: bool,
|
||||
pub scan_duration: Duration,
|
||||
}
|
||||
|
||||
impl From<CapacityScanResult> for CapacityScanSummary {
|
||||
fn from(scan: CapacityScanResult) -> Self {
|
||||
Self {
|
||||
used_bytes: scan.used_bytes,
|
||||
file_count: scan.file_count,
|
||||
sampled_count: scan.sampled_count,
|
||||
is_estimated: scan.is_estimated,
|
||||
had_partial_errors: scan.had_partial_errors,
|
||||
scan_duration: scan.scan_duration,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user