// 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 local_snapshot; use crate::{ bucket::metadata_sys::get_replication_config, config::com::read_config, disk::DiskAPI, error::{Error, classify_system_path_failure_reason}, store::ECStore, store_api::ListOperations, }; pub use local_snapshot::{ DATA_USAGE_DIR, DATA_USAGE_STATE_DIR, LOCAL_USAGE_SNAPSHOT_VERSION, LocalUsageSnapshot, LocalUsageSnapshotMeta, data_usage_dir, data_usage_state_dir, ensure_data_usage_layout, read_snapshot as read_local_snapshot, snapshot_file_name, snapshot_object_path, snapshot_path, write_snapshot as write_local_snapshot, }; use rustfs_data_usage::{ BucketTargetUsageInfo, BucketUsageInfo, DataUsageCache, DataUsageEntry, DataUsageInfo, DiskUsageStatus, SizeSummary, }; use rustfs_io_metrics::record_system_path_failure; use rustfs_utils::path::SLASH_SEPARATOR; use std::{ collections::{HashMap, HashSet, hash_map::Entry}, sync::{Arc, OnceLock}, time::{Duration, SystemTime}, }; use tokio::fs; use tokio::sync::RwLock; use tracing::{debug, error, info, instrument}; // Data usage storage constants pub const DATA_USAGE_ROOT: &str = SLASH_SEPARATOR; const DATA_USAGE_OBJ_NAME: &str = ".usage.json"; const DATA_USAGE_BLOOM_NAME: &str = ".bloomcycle.bin"; pub const DATA_USAGE_CACHE_NAME: &str = ".usage-cache.bin"; const DATA_USAGE_CACHE_TTL_SECS: u64 = 30; #[derive(Debug, Clone)] struct CachedBucketUsage { usage: BucketUsageInfo, refreshed_at: SystemTime, usage_updated_at: SystemTime, } type UsageMemoryCache = Arc>>; type CacheUpdating = Arc>; static USAGE_MEMORY_CACHE: OnceLock = OnceLock::new(); static USAGE_CACHE_UPDATING: OnceLock = OnceLock::new(); fn memory_cache() -> &'static UsageMemoryCache { USAGE_MEMORY_CACHE.get_or_init(|| Arc::new(RwLock::new(HashMap::new()))) } fn cache_updating() -> &'static CacheUpdating { USAGE_CACHE_UPDATING.get_or_init(|| Arc::new(RwLock::new(false))) } // Data usage storage paths lazy_static::lazy_static! { pub static ref DATA_USAGE_BUCKET: String = format!("{}{}{}", crate::disk::RUSTFS_META_BUCKET, SLASH_SEPARATOR, crate::disk::BUCKET_META_PREFIX ); pub static ref DATA_USAGE_OBJ_NAME_PATH: String = format!("{}{}{}", crate::disk::BUCKET_META_PREFIX, SLASH_SEPARATOR, DATA_USAGE_OBJ_NAME ); pub static ref DATA_USAGE_BLOOM_NAME_PATH: String = format!("{}{}{}", crate::disk::BUCKET_META_PREFIX, SLASH_SEPARATOR, DATA_USAGE_BLOOM_NAME ); } /// Store data usage info to backend storage #[instrument(skip(store))] pub async fn store_data_usage_in_backend(data_usage_info: DataUsageInfo, store: Arc) -> Result<(), Error> { // Prevent older data from overwriting newer persisted stats if let Ok(buf) = read_config(store.clone(), &DATA_USAGE_OBJ_NAME_PATH).await && let Ok(existing) = serde_json::from_slice::(&buf) && let (Some(new_ts), Some(existing_ts)) = (data_usage_info.last_update, existing.last_update) && new_ts <= existing_ts { info!( "Skip persisting data usage: incoming last_update {:?} <= existing {:?}", new_ts, existing_ts ); return Ok(()); } let data = serde_json::to_vec(&data_usage_info).map_err(|e| Error::other(format!("Failed to serialize data usage info: {e}")))?; // Save to backend using the same mechanism as original code crate::config::com::save_config(store, &DATA_USAGE_OBJ_NAME_PATH, data) .await .map_err(Error::other)?; Ok(()) } /// Load data usage info from backend storage #[instrument(skip(store))] pub async fn load_data_usage_from_backend(store: Arc) -> Result { let buf: Vec = match read_config(store.clone(), &DATA_USAGE_OBJ_NAME_PATH).await { Ok(data) => data, Err(e) => { let reason = classify_system_path_failure_reason(&e); record_system_path_failure("data_usage", "read_primary", reason); error!( path_kind = "data_usage", operation = "read_primary", reason, object = %DATA_USAGE_OBJ_NAME_PATH.as_str(), error = %e, "system path read failed" ); match read_config(store.clone(), format!("{}.bkp", DATA_USAGE_OBJ_NAME_PATH.as_str()).as_str()).await { Ok(data) => data, Err(e) => { if e == Error::ConfigNotFound { return Ok(DataUsageInfo::default()); } let reason = classify_system_path_failure_reason(&e); record_system_path_failure("data_usage", "read_backup", reason); error!( path_kind = "data_usage", operation = "read_backup", reason, object = %format!("{}.bkp", DATA_USAGE_OBJ_NAME_PATH.as_str()), error = %e, "system path read failed" ); return Err(Error::other(e)); } } } }; let mut data_usage_info: DataUsageInfo = serde_json::from_slice(&buf).map_err(|e| Error::other(format!("Failed to deserialize data usage info: {e}")))?; info!("Loaded data usage info from backend with {} buckets", data_usage_info.buckets_count); // Handle backward compatibility if data_usage_info.buckets_usage.is_empty() { data_usage_info.buckets_usage = data_usage_info .bucket_sizes .iter() .map(|(bucket, &size)| { ( bucket.clone(), BucketUsageInfo { size, ..Default::default() }, ) }) .collect(); } if data_usage_info.bucket_sizes.is_empty() { data_usage_info.bucket_sizes = data_usage_info .buckets_usage .iter() .map(|(bucket, bui)| (bucket.clone(), bui.size)) .collect(); } // Handle replication info for (bucket, bui) in &data_usage_info.buckets_usage { if (bui.replicated_size_v1 > 0 || bui.replication_failed_count_v1 > 0 || bui.replication_failed_size_v1 > 0 || bui.replication_pending_count_v1 > 0) && let Ok((cfg, _)) = get_replication_config(bucket).await && !cfg.role.is_empty() { data_usage_info.replication_info.insert( cfg.role.clone(), BucketTargetUsageInfo { replication_failed_size: bui.replication_failed_size_v1, replication_failed_count: bui.replication_failed_count_v1, replicated_size: bui.replicated_size_v1, replication_pending_count: bui.replication_pending_count_v1, replication_pending_size: bui.replication_pending_size_v1, ..Default::default() }, ); } } Ok(data_usage_info) } /// Aggregate usage information from local disk snapshots. fn merge_snapshot(aggregated: &mut DataUsageInfo, mut snapshot: LocalUsageSnapshot, latest_update: &mut Option) { if let Some(update) = snapshot.last_update && latest_update.is_none_or(|current| update > current) { *latest_update = Some(update); } snapshot.recompute_totals(); aggregated.objects_total_count = aggregated.objects_total_count.saturating_add(snapshot.objects_total_count); aggregated.versions_total_count = aggregated.versions_total_count.saturating_add(snapshot.versions_total_count); aggregated.delete_markers_total_count = aggregated .delete_markers_total_count .saturating_add(snapshot.delete_markers_total_count); aggregated.objects_total_size = aggregated.objects_total_size.saturating_add(snapshot.objects_total_size); for (bucket, usage) in snapshot.buckets_usage.into_iter() { let bucket_size = usage.size; match aggregated.buckets_usage.entry(bucket.clone()) { Entry::Occupied(mut entry) => entry.get_mut().merge(&usage), Entry::Vacant(entry) => { entry.insert(usage.clone()); } } aggregated .bucket_sizes .entry(bucket) .and_modify(|size| *size = size.saturating_add(bucket_size)) .or_insert(bucket_size); } } pub async fn aggregate_local_snapshots(store: Arc) -> Result<(Vec, DataUsageInfo), Error> { let mut aggregated = DataUsageInfo::default(); let mut latest_update: Option = None; let mut statuses: Vec = Vec::new(); let mut processed_disks: HashSet = HashSet::new(); for (pool_idx, pool) in store.pools.iter().enumerate() { for set_disks in pool.disk_set.iter() { let disk_entries = { let guard = set_disks.disks.read().await; guard.clone() }; for (disk_index, disk_opt) in disk_entries.into_iter().enumerate() { let Some(disk) = disk_opt else { continue; }; if !disk.is_local() { continue; } let disk_id = match disk.get_disk_id().await.map_err(Error::from)? { Some(id) => id.to_string(), None => continue, }; let root = disk.path(); let disk_key = format!("{}|{}", disk.endpoint(), root.display()); // Skip if we've already processed this physical disk if !processed_disks.insert(disk_key.clone()) { continue; } let mut status = DiskUsageStatus { disk_id: disk_id.clone(), pool_index: Some(pool_idx), set_index: Some(set_disks.set_index), disk_index: Some(disk_index), last_update: None, snapshot_exists: false, }; let snapshot_result = read_local_snapshot(root.as_path(), &disk_id).await; // If a snapshot is corrupted or unreadable, skip it but keep processing others if let Err(err) = &snapshot_result { info!( "Failed to read data usage snapshot for disk {} (pool {}, set {}, disk {}): {}", disk_id, pool_idx, set_disks.set_index, disk_index, err ); // Best-effort cleanup so next scan can rebuild a fresh snapshot instead of repeatedly failing let snapshot_file = snapshot_path(root.as_path(), &disk_id); if let Err(remove_err) = fs::remove_file(&snapshot_file).await && remove_err.kind() != std::io::ErrorKind::NotFound { info!("Failed to remove corrupted snapshot {:?}: {}", snapshot_file, remove_err); } } if let Ok(Some(mut snapshot)) = snapshot_result { status.last_update = snapshot.last_update; status.snapshot_exists = true; if snapshot.meta.disk_id.is_empty() { snapshot.meta.disk_id = disk_id.clone(); } if snapshot.meta.pool_index.is_none() { snapshot.meta.pool_index = Some(pool_idx); } if snapshot.meta.set_index.is_none() { snapshot.meta.set_index = Some(set_disks.set_index); } if snapshot.meta.disk_index.is_none() { snapshot.meta.disk_index = Some(disk_index); } merge_snapshot(&mut aggregated, snapshot, &mut latest_update); } statuses.push(status); } } } aggregated.buckets_count = aggregated.buckets_usage.len() as u64; aggregated.last_update = latest_update; aggregated.disk_usage_status = statuses.clone(); Ok((statuses, aggregated)) } /// Calculate accurate bucket usage statistics by enumerating objects through the object layer. pub async fn compute_bucket_usage(store: Arc, bucket_name: &str) -> Result { let mut continuation: Option = None; let mut objects_count: u64 = 0; let mut versions_count: u64 = 0; let mut total_size: u64 = 0; let mut delete_markers: u64 = 0; loop { let result = store .clone() .list_objects_v2( bucket_name, "", // prefix continuation.clone(), None, // delimiter 1000, // max_keys false, // fetch_owner None, // start_after false, // incl_deleted ) .await?; for object in result.objects.iter() { if object.is_dir { continue; } if object.delete_marker { delete_markers = delete_markers.saturating_add(1); continue; } let object_size = object.size.max(0) as u64; objects_count = objects_count.saturating_add(1); total_size = total_size.saturating_add(object_size); let detected_versions = if object.num_versions > 0 { object.num_versions as u64 } else { 1 }; versions_count = versions_count.saturating_add(detected_versions); } if !result.is_truncated { break; } continuation = result.next_continuation_token.clone(); if continuation.is_none() { info!( "Bucket {} listing marked truncated but no continuation token returned; stopping early", bucket_name ); break; } } if versions_count == 0 { versions_count = objects_count; } let usage = BucketUsageInfo { size: total_size, objects_count, versions_count, delete_markers_count: delete_markers, ..Default::default() }; Ok(usage) } async fn ensure_bucket_usage_cached(bucket: &str) { let cache = memory_cache().read().await; if cache.contains_key(bucket) { return; } drop(cache); update_usage_cache_if_needed().await; } fn cached_bucket_usage_from_backend(usage: BucketUsageInfo, updated_at: SystemTime) -> CachedBucketUsage { CachedBucketUsage { usage, refreshed_at: SystemTime::now(), usage_updated_at: updated_at, } } fn cached_bucket_usage_now(usage: BucketUsageInfo) -> CachedBucketUsage { let now = SystemTime::now(); CachedBucketUsage { usage, refreshed_at: now, usage_updated_at: now, } } fn data_usage_info_updated_at(data_usage_info: &DataUsageInfo) -> SystemTime { data_usage_info.last_update.unwrap_or(SystemTime::UNIX_EPOCH) } /// Fast in-memory update for immediate quota and admin usage consistency. pub async fn record_bucket_object_write_memory(bucket: &str, previous_current_size: Option, new_size: u64) { ensure_bucket_usage_cached(bucket).await; let mut cache = memory_cache().write().await; let entry = cache .entry(bucket.to_string()) .or_insert_with(|| cached_bucket_usage_now(BucketUsageInfo::default())); match previous_current_size { Some(previous_size) => { entry.usage.size = entry.usage.size.saturating_sub(previous_size).saturating_add(new_size); } None => { entry.usage.size = entry.usage.size.saturating_add(new_size); entry.usage.objects_count = entry.usage.objects_count.saturating_add(1); entry.usage.versions_count = entry.usage.versions_count.saturating_add(1); } } let now = SystemTime::now(); entry.refreshed_at = now; entry.usage_updated_at = now; } /// Fast in-memory increment for immediate quota consistency. pub async fn increment_bucket_usage_memory(bucket: &str, size_increment: u64) { record_bucket_object_write_memory(bucket, None, size_increment).await; } /// Fast in-memory update for successful object deletes. pub async fn record_bucket_object_delete_memory(bucket: &str, deleted_size: u64, removed_current_object: bool) { ensure_bucket_usage_cached(bucket).await; let mut cache = memory_cache().write().await; let entry = cache .entry(bucket.to_string()) .or_insert_with(|| cached_bucket_usage_now(BucketUsageInfo::default())); entry.usage.size = entry.usage.size.saturating_sub(deleted_size); if removed_current_object { entry.usage.objects_count = entry.usage.objects_count.saturating_sub(1); entry.usage.versions_count = entry.usage.versions_count.saturating_sub(1); } let now = SystemTime::now(); entry.refreshed_at = now; entry.usage_updated_at = now; } /// Fast in-memory decrement for immediate quota consistency pub async fn decrement_bucket_usage_memory(bucket: &str, size_decrement: u64) { record_bucket_object_delete_memory(bucket, size_decrement, size_decrement > 0).await; } /// Get bucket usage from in-memory cache pub async fn get_bucket_usage_memory(bucket: &str) -> Option { update_usage_cache_if_needed().await; let cache = memory_cache().read().await; cache.get(bucket).map(|cached| cached.usage.size) } async fn update_usage_cache_if_needed() { let ttl = Duration::from_secs(DATA_USAGE_CACHE_TTL_SECS); let double_ttl = ttl * 2; let now = SystemTime::now(); let cache = memory_cache().read().await; let earliest_timestamp = cache.values().map(|cached| cached.refreshed_at).min(); drop(cache); let age = match earliest_timestamp { Some(ts) => now.duration_since(ts).unwrap_or_default(), None => double_ttl, }; if age < ttl { return; } let mut updating = cache_updating().write().await; if age < double_ttl { if *updating { return; } *updating = true; drop(updating); let cache_clone = (*memory_cache()).clone(); let updating_clone = (*cache_updating()).clone(); tokio::spawn(async move { if let Some(store) = crate::global::GLOBAL_OBJECT_API.get() && let Ok(data_usage_info) = load_data_usage_from_backend(store.clone()).await { let mut cache = cache_clone.write().await; let usage_updated_at = data_usage_info_updated_at(&data_usage_info); for (bucket_name, bucket_usage) in data_usage_info.buckets_usage.iter() { cache.insert( bucket_name.clone(), cached_bucket_usage_from_backend(bucket_usage.clone(), usage_updated_at), ); } } let mut updating = updating_clone.write().await; *updating = false; }); return; } for retry in 0..10 { if !*updating { break; } drop(updating); let delay = Duration::from_millis(1 << retry); tokio::time::sleep(delay).await; updating = cache_updating().write().await; } *updating = true; drop(updating); if let Some(store) = crate::global::GLOBAL_OBJECT_API.get() && let Ok(data_usage_info) = load_data_usage_from_backend(store.clone()).await { let mut cache = memory_cache().write().await; let usage_updated_at = data_usage_info_updated_at(&data_usage_info); for (bucket_name, bucket_usage) in data_usage_info.buckets_usage.iter() { cache.insert( bucket_name.clone(), cached_bucket_usage_from_backend(bucket_usage.clone(), usage_updated_at), ); } } let mut updating = cache_updating().write().await; *updating = false; } pub async fn replace_bucket_usage_memory_from_info(data_usage_info: &DataUsageInfo) { let usage_updated_at = data_usage_info_updated_at(data_usage_info); let mut cache = memory_cache().write().await; let mut next_cache = HashMap::new(); for (bucket, bucket_usage) in data_usage_info.buckets_usage.iter() { if let Some(existing) = cache.get(bucket) && existing.usage_updated_at > usage_updated_at { next_cache.insert(bucket.clone(), existing.clone()); continue; } next_cache.insert(bucket.clone(), cached_bucket_usage_from_backend(bucket_usage.clone(), usage_updated_at)); } for (bucket, existing) in cache.iter() { if !data_usage_info.buckets_usage.contains_key(bucket) && existing.usage_updated_at > usage_updated_at { next_cache.insert(bucket.clone(), existing.clone()); } } *cache = next_cache; } pub async fn apply_bucket_usage_memory_overlay(data_usage_info: &mut DataUsageInfo) { let cache = memory_cache().read().await; if cache.is_empty() { return; } let persisted_update = data_usage_info.last_update; let mut changed = false; for (bucket, cached) in cache.iter() { if persisted_update.is_some_and(|persisted| cached.usage_updated_at <= persisted) { continue; } data_usage_info.buckets_usage.insert(bucket.clone(), cached.usage.clone()); data_usage_info.bucket_sizes.insert(bucket.clone(), cached.usage.size); changed = true; } if changed { data_usage_info.buckets_count = data_usage_info.buckets_usage.len() as u64; data_usage_info.calculate_totals(); } } /// Sync memory cache with backend data (called by scanner) pub async fn sync_memory_cache_with_backend() -> Result<(), Error> { if let Some(store) = crate::global::GLOBAL_OBJECT_API.get() { match load_data_usage_from_backend(store.clone()).await { Ok(data_usage_info) => { replace_bucket_usage_memory_from_info(&data_usage_info).await; } Err(e) => { debug!("Failed to sync memory cache with backend: {}", e); } } } Ok(()) } /// Create a data usage cache entry from size summary pub fn create_cache_entry_from_summary(summary: &SizeSummary) -> DataUsageEntry { let mut entry = DataUsageEntry::default(); entry.add_sizes(summary); entry } /// Convert data usage cache to DataUsageInfo pub fn cache_to_data_usage_info(cache: &DataUsageCache, path: &str, buckets: &[crate::store_api::BucketInfo]) -> DataUsageInfo { let e = match cache.find(path) { Some(e) => e, None => return DataUsageInfo::default(), }; let flat = cache.flatten(&e); let mut buckets_usage = HashMap::new(); for bucket in buckets.iter() { let e = match cache.find(&bucket.name) { Some(e) => e, None => continue, }; let flat = cache.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: cache.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, buckets_count: e.children.len() as u64, buckets_usage, ..Default::default() } } // Helper functions for DataUsageCache operations pub async fn load_data_usage_cache(store: &crate::set_disk::SetDisks, name: &str) -> crate::error::Result { use crate::disk::{BUCKET_META_PREFIX, RUSTFS_META_BUCKET}; use crate::store_api::{ObjectIO, ObjectOptions}; use http::HeaderMap; use rand::RngExt; use std::path::Path; use std::time::Duration; use tokio::time::sleep; let mut d = DataUsageCache::default(); let mut retries = 0; while retries < 5 { let path = Path::new(BUCKET_META_PREFIX).join(name); match store .get_object_reader( RUSTFS_META_BUCKET, path.to_str().unwrap(), None, HeaderMap::new(), &ObjectOptions { no_lock: true, ..Default::default() }, ) .await { Ok(mut reader) => { if let Ok(info) = DataUsageCache::unmarshal(&reader.read_all().await?) { d = info } break; } Err(err) => match err { Error::FileNotFound | Error::VolumeNotFound => { match store .get_object_reader( RUSTFS_META_BUCKET, name, None, HeaderMap::new(), &ObjectOptions { no_lock: true, ..Default::default() }, ) .await { Ok(mut reader) => { if let Ok(info) = DataUsageCache::unmarshal(&reader.read_all().await?) { d = info } break; } Err(_) => match err { Error::FileNotFound | Error::VolumeNotFound => { break; } _ => {} }, } } _ => { break; } }, } retries += 1; let dur = { let mut rng = rand::rng(); rng.random_range(0..1_000) }; sleep(Duration::from_millis(dur)).await; } Ok(d) } #[instrument(skip(cache))] pub async fn save_data_usage_cache(cache: &DataUsageCache, name: &str) -> crate::error::Result<()> { use crate::config::com::save_config; use crate::disk::BUCKET_META_PREFIX; use crate::new_object_layer_fn; use std::path::Path; let Some(store) = new_object_layer_fn() else { return Err(Error::other("errServerNotInitialized")); }; let buf = cache.marshal_msg().map_err(Error::other)?; let buf_clone = buf.clone(); let store_clone = store.clone(); let name = Path::new(BUCKET_META_PREFIX).join(name).to_string_lossy().to_string(); let name_clone = name.clone(); tokio::spawn(async move { let _ = save_config(store_clone, &format!("{}{}", &name_clone, ".bkp"), buf_clone).await; }); save_config(store, &name, buf).await?; Ok(()) } #[cfg(test)] mod tests { use super::*; use rustfs_data_usage::BucketUsageInfo; use serial_test::serial; async fn clear_usage_memory_cache_for_test() { memory_cache().write().await.clear(); *cache_updating().write().await = false; } fn data_usage_info_for_test(bucket: &str, objects_count: u64, size: u64, last_update: SystemTime) -> DataUsageInfo { let mut info = DataUsageInfo { last_update: Some(last_update), ..Default::default() }; info.buckets_usage.insert( bucket.to_string(), BucketUsageInfo { objects_count, versions_count: objects_count, size, ..Default::default() }, ); info.bucket_sizes.insert(bucket.to_string(), size); info.buckets_count = info.buckets_usage.len() as u64; info.calculate_totals(); info } fn aggregate_for_test( inputs: Vec<(DiskUsageStatus, Result, Error>)>, ) -> (Vec, DataUsageInfo) { let mut aggregated = DataUsageInfo::default(); let mut latest_update: Option = None; let mut statuses = Vec::new(); for (mut status, snapshot_result) in inputs { if let Ok(Some(snapshot)) = snapshot_result { status.snapshot_exists = true; status.last_update = snapshot.last_update; merge_snapshot(&mut aggregated, snapshot, &mut latest_update); } statuses.push(status); } aggregated.buckets_count = aggregated.buckets_usage.len() as u64; aggregated.last_update = latest_update; aggregated.disk_usage_status = statuses.clone(); (statuses, aggregated) } #[test] fn aggregate_skips_corrupted_snapshot_and_preserves_other_disks() { let mut good_snapshot = LocalUsageSnapshot::new(LocalUsageSnapshotMeta { disk_id: "good-disk".to_string(), pool_index: Some(0), set_index: Some(0), disk_index: Some(0), }); good_snapshot.last_update = Some(SystemTime::now()); good_snapshot.buckets_usage.insert( "bucket-a".to_string(), BucketUsageInfo { objects_count: 3, versions_count: 3, size: 42, ..Default::default() }, ); good_snapshot.recompute_totals(); let bad_snapshot_err: Result, Error> = Err(Error::other("corrupted snapshot payload")); let inputs = vec![ ( DiskUsageStatus { disk_id: "bad-disk".to_string(), pool_index: Some(0), set_index: Some(0), disk_index: Some(1), last_update: None, snapshot_exists: false, }, bad_snapshot_err, ), ( DiskUsageStatus { disk_id: "good-disk".to_string(), pool_index: Some(0), set_index: Some(0), disk_index: Some(0), last_update: None, snapshot_exists: false, }, Ok(Some(good_snapshot)), ), ]; let (statuses, aggregated) = aggregate_for_test(inputs); // Bad disk stays non-existent, good disk is marked present let bad_status = statuses.iter().find(|s| s.disk_id == "bad-disk").unwrap(); assert!(!bad_status.snapshot_exists); let good_status = statuses.iter().find(|s| s.disk_id == "good-disk").unwrap(); assert!(good_status.snapshot_exists); // Aggregated data is from good snapshot only assert_eq!(aggregated.objects_total_count, 3); assert_eq!(aggregated.objects_total_size, 42); assert_eq!(aggregated.buckets_count, 1); assert_eq!(aggregated.buckets_usage.get("bucket-a").map(|b| (b.objects_count, b.size)), Some((3, 42))); } #[tokio::test] #[serial] async fn memory_overlay_reflects_recent_delete_before_scanner_persists() { clear_usage_memory_cache_for_test().await; let persisted = data_usage_info_for_test("bucket-a", 1, 42, SystemTime::now() - Duration::from_secs(10)); replace_bucket_usage_memory_from_info(&persisted).await; record_bucket_object_delete_memory("bucket-a", 42, true).await; let mut response = persisted.clone(); apply_bucket_usage_memory_overlay(&mut response).await; assert_eq!(response.objects_total_count, 0); assert_eq!(response.objects_total_size, 0); assert_eq!( response .buckets_usage .get("bucket-a") .map(|usage| (usage.objects_count, usage.size)), Some((0, 0)) ); } #[tokio::test] #[serial] async fn memory_overlay_preserves_object_count_for_overwrite() { clear_usage_memory_cache_for_test().await; let persisted = data_usage_info_for_test("bucket-a", 1, 10, SystemTime::now() - Duration::from_secs(10)); replace_bucket_usage_memory_from_info(&persisted).await; record_bucket_object_write_memory("bucket-a", Some(10), 20).await; let mut response = persisted.clone(); apply_bucket_usage_memory_overlay(&mut response).await; assert_eq!(response.objects_total_count, 1); assert_eq!(response.objects_total_size, 20); assert_eq!( response .buckets_usage .get("bucket-a") .map(|usage| (usage.objects_count, usage.size)), Some((1, 20)) ); } #[tokio::test] #[serial] async fn memory_overlay_does_not_replace_newer_persisted_usage() { clear_usage_memory_cache_for_test().await; let now = SystemTime::now(); let old_persisted = data_usage_info_for_test("bucket-a", 1, 42, now - Duration::from_secs(10)); replace_bucket_usage_memory_from_info(&old_persisted).await; record_bucket_object_delete_memory("bucket-a", 42, true).await; let mut newer_persisted = data_usage_info_for_test("bucket-a", 2, 84, now + Duration::from_secs(10)); apply_bucket_usage_memory_overlay(&mut newer_persisted).await; assert_eq!(newer_persisted.objects_total_count, 2); assert_eq!(newer_persisted.objects_total_size, 84); assert_eq!( newer_persisted .buckets_usage .get("bucket-a") .map(|usage| (usage.objects_count, usage.size)), Some((2, 84)) ); } #[tokio::test] #[serial] async fn scanner_sync_preserves_newer_memory_update() { clear_usage_memory_cache_for_test().await; let now = SystemTime::now(); let old_persisted = data_usage_info_for_test("bucket-a", 1, 42, now - Duration::from_secs(10)); replace_bucket_usage_memory_from_info(&old_persisted).await; record_bucket_object_delete_memory("bucket-a", 42, true).await; let scanner_snapshot = data_usage_info_for_test("bucket-a", 1, 42, now - Duration::from_secs(5)); replace_bucket_usage_memory_from_info(&scanner_snapshot).await; let mut response = scanner_snapshot.clone(); apply_bucket_usage_memory_overlay(&mut response).await; assert_eq!(response.objects_total_count, 0); assert_eq!(response.objects_total_size, 0); assert_eq!( response .buckets_usage .get("bucket-a") .map(|usage| (usage.objects_count, usage.size)), Some((0, 0)) ); } }