// 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 crate::{Error, Result}; use rustfs_common::data_usage::DiskUsageStatus; use rustfs_ecstore::data_usage::{ LocalUsageSnapshot, LocalUsageSnapshotMeta, data_usage_state_dir, ensure_data_usage_layout, snapshot_file_name, write_local_snapshot, }; use rustfs_ecstore::disk::DiskAPI; use rustfs_ecstore::store::ECStore; use rustfs_ecstore::store_api::ObjectInfo; use rustfs_filemeta::{FileInfo, FileMeta, FileMetaVersion, VersionType}; use serde::{Deserialize, Serialize}; use serde_json::{from_slice, to_vec}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use tokio::{fs, task}; use tracing::warn; use walkdir::WalkDir; const STATE_FILE_EXTENSION: &str = ""; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct LocalObjectUsage { pub bucket: String, pub object: String, pub last_modified_ns: Option, pub versions_count: u64, pub delete_markers_count: u64, pub total_size: u64, pub has_live_object: bool, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] struct IncrementalScanState { last_scan_ns: Option, objects: HashMap, } struct DiskScanResult { snapshot: LocalUsageSnapshot, state: IncrementalScanState, objects_by_bucket: HashMap>, status: DiskUsageStatus, } #[derive(Debug, Clone)] pub struct LocalObjectRecord { pub usage: LocalObjectUsage, pub object_info: Option, } #[derive(Debug, Default)] pub struct LocalScanOutcome { pub snapshots: Vec, pub bucket_objects: HashMap>, pub disk_status: Vec, } /// Scan all local primary disks and persist refreshed usage snapshots. pub async fn scan_and_persist_local_usage(store: Arc) -> Result { let mut snapshots = Vec::new(); let mut bucket_objects: HashMap> = HashMap::new(); let mut disk_status = Vec::new(); for (pool_idx, pool) in store.pools.iter().enumerate() { for set_disks in pool.disk_set.iter() { let disks = { let guard = set_disks.disks.read().await; guard.clone() }; for (disk_index, disk_opt) in disks.into_iter().enumerate() { let Some(disk) = disk_opt else { continue; }; if !disk.is_local() { continue; } // Count objects once by scanning only disk index zero from each set. if disk_index != 0 { continue; } let disk_id = match disk.get_disk_id().await.map_err(Error::from)? { Some(id) => id.to_string(), None => { warn!("Skipping disk without ID: {}", disk.to_string()); continue; } }; let root = disk.path(); ensure_data_usage_layout(root.as_path()).await.map_err(Error::from)?; let meta = LocalUsageSnapshotMeta { disk_id: disk_id.clone(), pool_index: Some(pool_idx), set_index: Some(set_disks.set_index), disk_index: Some(disk_index), }; let state_path = state_file_path(root.as_path(), &disk_id); let state = read_scan_state(&state_path).await?; let root_clone = root.clone(); let meta_clone = meta.clone(); let handle = task::spawn_blocking(move || scan_disk_blocking(root_clone, meta_clone, state)); match handle.await { Ok(Ok(result)) => { write_local_snapshot(root.as_path(), &disk_id, &result.snapshot) .await .map_err(Error::from)?; write_scan_state(&state_path, &result.state).await?; snapshots.push(result.snapshot); for (bucket, records) in result.objects_by_bucket { bucket_objects.entry(bucket).or_default().extend(records.into_iter()); } disk_status.push(result.status); } Ok(Err(err)) => { warn!("Failed to scan disk {}: {}", disk.to_string(), err); } Err(join_err) => { warn!("Disk scan task panicked for disk {}: {}", disk.to_string(), join_err); } } } } } Ok(LocalScanOutcome { snapshots, bucket_objects, disk_status, }) } fn scan_disk_blocking(root: PathBuf, meta: LocalUsageSnapshotMeta, mut state: IncrementalScanState) -> Result { let now = SystemTime::now(); let now_ns = system_time_to_ns(now); let mut visited: HashSet = HashSet::new(); let mut emitted: HashSet = HashSet::new(); let mut objects_by_bucket: HashMap> = HashMap::new(); let mut status = DiskUsageStatus { disk_id: meta.disk_id.clone(), pool_index: meta.pool_index, set_index: meta.set_index, disk_index: meta.disk_index, last_update: None, snapshot_exists: false, }; for entry in WalkDir::new(&root).follow_links(false).into_iter().filter_map(|res| res.ok()) { if !entry.file_type().is_file() { continue; } if entry.file_name() != "xl.meta" { continue; } let xl_path = entry.path().to_path_buf(); let Some(object_dir) = xl_path.parent() else { continue; }; let Some(rel_path) = object_dir.strip_prefix(&root).ok().map(normalize_path) else { continue; }; let mut components = rel_path.split('/'); let Some(bucket_name) = components.next() else { continue; }; if bucket_name.starts_with('.') { continue; } let object_key = components.collect::>().join("/"); visited.insert(rel_path.clone()); let metadata = match std::fs::metadata(&xl_path) { Ok(meta) => meta, Err(err) => { warn!("Failed to read metadata for {xl_path:?}: {err}"); continue; } }; let mtime_ns = metadata.modified().ok().map(system_time_to_ns); let should_parse = match state.objects.get(&rel_path) { Some(existing) => existing.last_modified_ns != mtime_ns, None => true, }; if should_parse { match std::fs::read(&xl_path) { Ok(buf) => match FileMeta::load(&buf) { Ok(file_meta) => match compute_object_usage(bucket_name, object_key.as_str(), &file_meta) { Ok(Some(mut record)) => { record.usage.last_modified_ns = mtime_ns; state.objects.insert(rel_path.clone(), record.usage.clone()); emitted.insert(rel_path.clone()); objects_by_bucket.entry(record.usage.bucket.clone()).or_default().push(record); } Ok(None) => { state.objects.remove(&rel_path); } Err(err) => { warn!("Failed to parse usage from {:?}: {}", xl_path, err); } }, Err(err) => { warn!("Failed to decode xl.meta {:?}: {}", xl_path, err); } }, Err(err) => { warn!("Failed to read xl.meta {:?}: {}", xl_path, err); } } } } state.objects.retain(|key, _| visited.contains(key)); state.last_scan_ns = Some(now_ns); for (key, usage) in &state.objects { if emitted.contains(key) { continue; } objects_by_bucket .entry(usage.bucket.clone()) .or_default() .push(LocalObjectRecord { usage: usage.clone(), object_info: None, }); } let snapshot = build_snapshot(meta, &state.objects, now); status.snapshot_exists = true; status.last_update = Some(now); Ok(DiskScanResult { snapshot, state, objects_by_bucket, status, }) } fn compute_object_usage(bucket: &str, object: &str, file_meta: &FileMeta) -> Result> { let mut versions_count = 0u64; let mut delete_markers_count = 0u64; let mut total_size = 0u64; let mut has_live_object = false; let mut latest_file_info: Option = None; for shallow in &file_meta.versions { match shallow.header.version_type { VersionType::Object => { let version = match FileMetaVersion::try_from(shallow.meta.as_slice()) { Ok(version) => version, Err(err) => { warn!("Failed to parse file meta version: {}", err); continue; } }; if let Some(obj) = version.object { if !has_live_object { total_size = obj.size.max(0) as u64; } has_live_object = true; versions_count = versions_count.saturating_add(1); if latest_file_info.is_none() { if let Ok(info) = file_meta.into_fileinfo(bucket, object, "", false, false) { latest_file_info = Some(info); } } } } VersionType::Delete => { delete_markers_count = delete_markers_count.saturating_add(1); versions_count = versions_count.saturating_add(1); } _ => {} } } if !has_live_object && delete_markers_count == 0 { return Ok(None); } let object_info = latest_file_info.as_ref().map(|fi| { let versioned = fi.version_id.is_some(); ObjectInfo::from_file_info(fi, bucket, object, versioned) }); Ok(Some(LocalObjectRecord { usage: LocalObjectUsage { bucket: bucket.to_string(), object: object.to_string(), last_modified_ns: None, versions_count, delete_markers_count, total_size, has_live_object, }, object_info, })) } fn build_snapshot( meta: LocalUsageSnapshotMeta, objects: &HashMap, now: SystemTime, ) -> LocalUsageSnapshot { let mut snapshot = LocalUsageSnapshot::new(meta); for usage in objects.values() { let bucket_entry = snapshot.buckets_usage.entry(usage.bucket.clone()).or_default(); if usage.has_live_object { bucket_entry.objects_count = bucket_entry.objects_count.saturating_add(1); } bucket_entry.versions_count = bucket_entry.versions_count.saturating_add(usage.versions_count); bucket_entry.delete_markers_count = bucket_entry.delete_markers_count.saturating_add(usage.delete_markers_count); bucket_entry.size = bucket_entry.size.saturating_add(usage.total_size); } snapshot.last_update = Some(now); snapshot.recompute_totals(); snapshot } fn normalize_path(path: &Path) -> String { path.iter() .map(|component| component.to_string_lossy()) .collect::>() .join("/") } fn system_time_to_ns(time: SystemTime) -> i128 { match time.duration_since(UNIX_EPOCH) { Ok(duration) => { let secs = duration.as_secs() as i128; let nanos = duration.subsec_nanos() as i128; secs * 1_000_000_000 + nanos } Err(err) => { let duration = err.duration(); let secs = duration.as_secs() as i128; let nanos = duration.subsec_nanos() as i128; -(secs * 1_000_000_000 + nanos) } } } fn state_file_path(root: &Path, disk_id: &str) -> PathBuf { let mut path = data_usage_state_dir(root); path.push(format!("{}{}", snapshot_file_name(disk_id), STATE_FILE_EXTENSION)); path } async fn read_scan_state(path: &Path) -> Result { match fs::read(path).await { Ok(bytes) => from_slice(&bytes).map_err(|err| Error::Serialization(err.to_string())), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(IncrementalScanState::default()), Err(err) => Err(err.into()), } } async fn write_scan_state(path: &Path, state: &IncrementalScanState) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).await?; } let data = to_vec(state).map_err(|err| Error::Serialization(err.to_string()))?; fs::write(path, data).await?; Ok(()) } #[cfg(test)] mod tests { use super::*; use rustfs_filemeta::{ChecksumAlgo, ErasureAlgo, FileMetaShallowVersion, MetaDeleteMarker, MetaObject}; use std::collections::HashMap; use std::fs; use tempfile::TempDir; use time::OffsetDateTime; use uuid::Uuid; fn build_file_meta_with_object(erasure_index: usize, size: i64) -> FileMeta { let mut file_meta = FileMeta::default(); let meta_object = MetaObject { version_id: Some(Uuid::new_v4()), data_dir: Some(Uuid::new_v4()), erasure_algorithm: ErasureAlgo::ReedSolomon, erasure_m: 2, erasure_n: 2, erasure_block_size: 4096, erasure_index, erasure_dist: vec![0_u8, 1, 2, 3], bitrot_checksum_algo: ChecksumAlgo::HighwayHash, part_numbers: vec![1], part_etags: vec!["etag".to_string()], part_sizes: vec![size as usize], part_actual_sizes: vec![size], part_indices: Vec::new(), size, mod_time: Some(OffsetDateTime::now_utc()), meta_sys: HashMap::new(), meta_user: HashMap::new(), }; let version = FileMetaVersion { version_type: VersionType::Object, object: Some(meta_object), delete_marker: None, write_version: 1, }; let shallow = FileMetaShallowVersion::try_from(version).expect("convert version"); file_meta.versions.push(shallow); file_meta } fn build_file_meta_with_delete_marker() -> FileMeta { let mut file_meta = FileMeta::default(); let delete_marker = MetaDeleteMarker { version_id: Some(Uuid::new_v4()), mod_time: Some(OffsetDateTime::now_utc()), meta_sys: HashMap::new(), }; let version = FileMetaVersion { version_type: VersionType::Delete, object: None, delete_marker: Some(delete_marker), write_version: 2, }; let shallow = FileMetaShallowVersion::try_from(version).expect("convert delete marker"); file_meta.versions.push(shallow); file_meta } #[test] fn compute_object_usage_primary_disk() { let file_meta = build_file_meta_with_object(0, 1024); let record = compute_object_usage("bucket", "foo/bar", &file_meta) .expect("compute usage") .expect("record should exist"); assert!(record.usage.has_live_object); assert_eq!(record.usage.bucket, "bucket"); assert_eq!(record.usage.object, "foo/bar"); assert_eq!(record.usage.total_size, 1024); assert!(record.object_info.is_some(), "object info should be synthesized"); } #[test] fn compute_object_usage_handles_non_primary_disk() { let file_meta = build_file_meta_with_object(1, 2048); let record = compute_object_usage("bucket", "obj", &file_meta) .expect("compute usage") .expect("record should exist for non-primary shard"); assert!(record.usage.has_live_object); } #[test] fn compute_object_usage_reports_delete_marker() { let file_meta = build_file_meta_with_delete_marker(); let record = compute_object_usage("bucket", "obj", &file_meta) .expect("compute usage") .expect("delete marker record"); assert!(!record.usage.has_live_object); assert_eq!(record.usage.delete_markers_count, 1); assert_eq!(record.usage.versions_count, 1); } #[test] fn build_snapshot_accumulates_usage() { let mut objects = HashMap::new(); objects.insert( "bucket/a".to_string(), LocalObjectUsage { bucket: "bucket".to_string(), object: "a".to_string(), last_modified_ns: None, versions_count: 2, delete_markers_count: 1, total_size: 512, has_live_object: true, }, ); let snapshot = build_snapshot(LocalUsageSnapshotMeta::default(), &objects, SystemTime::now()); let usage = snapshot.buckets_usage.get("bucket").expect("bucket entry should exist"); assert_eq!(usage.objects_count, 1); assert_eq!(usage.versions_count, 2); assert_eq!(usage.delete_markers_count, 1); assert_eq!(usage.size, 512); } #[test] fn scan_disk_blocking_handles_incremental_updates() { let temp_dir = TempDir::new().expect("create temp dir"); let root = temp_dir.path(); let bucket_dir = root.join("bench"); let object1_dir = bucket_dir.join("obj1"); fs::create_dir_all(&object1_dir).expect("create first object directory"); let file_meta = build_file_meta_with_object(0, 1024); let bytes = file_meta.marshal_msg().expect("serialize first object"); fs::write(object1_dir.join("xl.meta"), bytes).expect("write first xl.meta"); let meta = LocalUsageSnapshotMeta { disk_id: "disk-test".to_string(), ..Default::default() }; let DiskScanResult { snapshot: snapshot1, state, .. } = scan_disk_blocking(root.to_path_buf(), meta.clone(), IncrementalScanState::default()).expect("initial scan succeeds"); let usage1 = snapshot1.buckets_usage.get("bench").expect("bucket stats recorded"); assert_eq!(usage1.objects_count, 1); assert_eq!(usage1.size, 1024); assert_eq!(state.objects.len(), 1); let object2_dir = bucket_dir.join("nested").join("obj2"); fs::create_dir_all(&object2_dir).expect("create second object directory"); let second_meta = build_file_meta_with_object(0, 2048); let bytes = second_meta.marshal_msg().expect("serialize second object"); fs::write(object2_dir.join("xl.meta"), bytes).expect("write second xl.meta"); let DiskScanResult { snapshot: snapshot2, state: state_next, .. } = scan_disk_blocking(root.to_path_buf(), meta.clone(), state).expect("incremental scan succeeds"); let usage2 = snapshot2 .buckets_usage .get("bench") .expect("bucket stats recorded after addition"); assert_eq!(usage2.objects_count, 2); assert_eq!(usage2.size, 1024 + 2048); assert_eq!(state_next.objects.len(), 2); fs::remove_dir_all(&object1_dir).expect("remove first object"); let DiskScanResult { snapshot: snapshot3, state: state_final, .. } = scan_disk_blocking(root.to_path_buf(), meta, state_next).expect("scan after deletion succeeds"); let usage3 = snapshot3 .buckets_usage .get("bench") .expect("bucket stats recorded after deletion"); assert_eq!(usage3.objects_count, 1); assert_eq!(usage3.size, 2048); assert_eq!(state_final.objects.len(), 1); assert!( state_final.objects.keys().all(|path| path.contains("nested")), "state should only keep surviving object" ); } #[test] fn scan_disk_blocking_recovers_from_stale_state_entries() { let temp_dir = TempDir::new().expect("create temp dir"); let root = temp_dir.path(); let mut stale_state = IncrementalScanState::default(); stale_state.objects.insert( "bench/stale".to_string(), LocalObjectUsage { bucket: "bench".to_string(), object: "stale".to_string(), last_modified_ns: Some(42), versions_count: 1, delete_markers_count: 0, total_size: 512, has_live_object: true, }, ); stale_state.last_scan_ns = Some(99); let meta = LocalUsageSnapshotMeta { disk_id: "disk-test".to_string(), ..Default::default() }; let DiskScanResult { snapshot, state, status, .. } = scan_disk_blocking(root.to_path_buf(), meta, stale_state).expect("scan succeeds"); assert!(state.objects.is_empty(), "stale entries should be cleared when files disappear"); assert!( snapshot.buckets_usage.is_empty(), "no real xl.meta files means bucket usage should stay empty" ); assert!(status.snapshot_exists, "snapshot status should indicate a refresh"); } #[test] fn scan_disk_blocking_handles_large_volume() { const OBJECTS: usize = 256; let temp_dir = TempDir::new().expect("create temp dir"); let root = temp_dir.path(); let bucket_dir = root.join("bulk"); for idx in 0..OBJECTS { let object_dir = bucket_dir.join(format!("obj-{idx:03}")); fs::create_dir_all(&object_dir).expect("create object directory"); let size = 1024 + idx as i64; let file_meta = build_file_meta_with_object(0, size); let bytes = file_meta.marshal_msg().expect("serialize file meta"); fs::write(object_dir.join("xl.meta"), bytes).expect("write xl.meta"); } let meta = LocalUsageSnapshotMeta { disk_id: "disk-test".to_string(), ..Default::default() }; let DiskScanResult { snapshot, state, .. } = scan_disk_blocking(root.to_path_buf(), meta, IncrementalScanState::default()).expect("bulk scan succeeds"); let bucket_usage = snapshot .buckets_usage .get("bulk") .expect("bucket usage present for bulk scan"); assert_eq!(bucket_usage.objects_count as usize, OBJECTS, "should count all objects once"); assert!( bucket_usage.size >= (1024 * OBJECTS) as u64, "aggregated size should grow with object count" ); assert_eq!(state.objects.len(), OBJECTS, "incremental state tracks every object"); } }