// 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. #![allow(dead_code)] //! Statistics collection functions for metrics. //! //! This module contains functions that collect statistics from various //! RustFS internal sources (storage layer, bucket monitor, system info) //! and convert them to the Stats structs used by collectors. use crate::metrics::collectors::{ BucketReplicationBandwidthStats, BucketReplicationStats, BucketReplicationTargetStats, BucketStats, BucketUsageStats, ClusterConfigStats, ClusterHealthStats, ClusterStats, ClusterUsageStats, CompressionClusterStats, CpuStats, DiskStats, DriveCountStats, DriveDetailedStats, ErasureSetStats, HostNetworkStats, IamStats, IlmStats, MemoryStats, NetworkStats, ProcessStats, ProcessStatusType, ReplicationStats, ResourceStats, ScannerStats, }; use crate::metrics::runtime_sources::{ObsIlmRuntimeSnapshot, bucket_monitor_handle, iam_metrics_snapshot, ilm_runtime_snapshot}; use crate::metrics::{ BucketOperations, BucketOptions, ObsEcstoreResult, ObsStore, StorageAdminApi, obs_bucket_replication_stats_snapshot, obs_get_quota_config, obs_get_total_usable_capacity, obs_get_total_usable_capacity_free, obs_load_compression_total_from_memory, obs_load_data_usage_from_backend, obs_replication_site_stats_snapshot, obs_resolve_object_store_handle, }; use chrono::Utc; use rustfs_common::heal_channel::HealScanMode; use rustfs_common::metrics::{ScannerMetricsReport, global_metrics}; use rustfs_io_metrics::internode_metrics::global_internode_metrics; use rustfs_io_metrics::{ ProcessResourceSnapshot, ProcessSampler, ProcessStatusSnapshot, ProcessSystemSnapshot, snapshot_process_resource_and_system, snapshot_process_resource_and_system_with, }; use std::{collections::HashMap, sync::Arc, time::SystemTime}; use sysinfo::{Networks, System}; use tracing::{instrument, warn}; const LOG_COMPONENT_OBS: &str = "obs"; const LOG_SUBSYSTEM_METRICS_COLLECTOR: &str = "metrics_collector"; const EVENT_METRICS_COLLECTOR_STATE: &str = "metrics_collector_state"; type ObsStorageInfo = ::StorageInfo; type ObsBackendInfo = ::BackendInfo; struct ObsDataUsageInfo { last_update: Option, buckets_count: u64, objects_total_count: u64, versions_total_count: u64, delete_markers_total_count: u64, objects_total_size: u64, buckets_usage: HashMap, } struct ObsBucketUsageInfo { size: u64, objects_count: u64, object_size_histogram: HashMap, object_versions_histogram: HashMap, versions_count: u64, delete_markers_count: u64, } #[derive(Debug, Clone, PartialEq)] struct ObsBucketReplicationBandwidthStats { bucket: String, target_arn: String, limit_bytes_per_sec: i64, current_bandwidth_bytes_per_sec: f64, } fn usize_to_u64_saturating(value: usize) -> u64 { u64::try_from(value).unwrap_or(u64::MAX) } async fn load_obs_data_usage_from_backend(store: Arc) -> ObsEcstoreResult { let data_usage = obs_load_data_usage_from_backend(store).await?; Ok(ObsDataUsageInfo { last_update: data_usage.last_update, buckets_count: data_usage.buckets_count, objects_total_count: data_usage.objects_total_count, versions_total_count: data_usage.versions_total_count, delete_markers_total_count: data_usage.delete_markers_total_count, objects_total_size: data_usage.objects_total_size, buckets_usage: data_usage .buckets_usage .into_iter() .map(|(bucket, usage)| { ( bucket, ObsBucketUsageInfo { size: usage.size, objects_count: usage.objects_count, object_size_histogram: usage.object_size_histogram, object_versions_histogram: usage.object_versions_histogram, versions_count: usage.versions_count, delete_markers_count: usage.delete_markers_count, }, ) }) .collect(), }) } fn resolve_obs_object_store_handle() -> Option> { obs_resolve_object_store_handle() } fn scanner_lifecycle_checked_versions(metrics: &ScannerMetricsReport) -> u64 { metrics .source_work .iter() .find(|source| source.source == "lifecycle") .map(|source| source.checked) .unwrap_or_default() } fn obs_total_usable_capacity_bytes(storage_info: &ObsStorageInfo) -> u64 { usize_to_u64_saturating(obs_get_total_usable_capacity(&storage_info.disks, storage_info)) } fn obs_total_usable_capacity_free_bytes(storage_info: &ObsStorageInfo) -> u64 { usize_to_u64_saturating(obs_get_total_usable_capacity_free(&storage_info.disks, storage_info)) } fn usable_capacity_used_bytes(usable_capacity: u64, free_bytes: u64) -> u64 { usable_capacity.saturating_sub(free_bytes) } async fn obs_bucket_quota_limit_bytes(bucket: &str) -> u64 { obs_get_quota_config(bucket) .await .ok() .and_then(|(quota, _)| quota.get_quota_limit()) .unwrap_or(0) } fn obs_bucket_replication_bandwidth_stats() -> Option> { let monitor = bucket_monitor_handle()?; Some( monitor .get_report(|_| true) .bucket_stats .into_iter() .map(|(opts, details)| ObsBucketReplicationBandwidthStats { bucket: opts.name, target_arn: opts.replication_arn, limit_bytes_per_sec: details.limit_bytes_per_sec, current_bandwidth_bytes_per_sec: details.current_bandwidth_bytes_per_sec, }) .collect(), ) } async fn obs_ilm_runtime_snapshot() -> ObsIlmRuntimeSnapshot { ilm_runtime_snapshot().await } async fn obs_bucket_replication_detail_stats() -> Vec { obs_bucket_replication_stats_snapshot() .await .into_iter() .map(|stats| BucketReplicationStats { bucket: stats.bucket, total_failed_bytes: stats.total_failed_bytes, total_failed_count: stats.total_failed_count, last_min_failed_bytes: stats.last_min_failed_bytes, last_min_failed_count: stats.last_min_failed_count, last_hour_failed_bytes: stats.last_hour_failed_bytes, last_hour_failed_count: stats.last_hour_failed_count, sent_bytes: stats.sent_bytes, sent_count: stats.sent_count, proxied_get_requests_total: stats.proxied_get_requests_total, proxied_get_requests_failures: stats.proxied_get_requests_failures, proxied_head_requests_total: stats.proxied_head_requests_total, proxied_head_requests_failures: stats.proxied_head_requests_failures, proxied_put_requests_total: stats.proxied_put_requests_total, proxied_put_requests_failures: stats.proxied_put_requests_failures, proxied_put_tagging_requests_total: stats.proxied_put_tagging_requests_total, proxied_put_tagging_requests_failures: stats.proxied_put_tagging_requests_failures, proxied_get_tagging_requests_total: stats.proxied_get_tagging_requests_total, proxied_get_tagging_requests_failures: stats.proxied_get_tagging_requests_failures, proxied_delete_tagging_requests_total: stats.proxied_delete_tagging_requests_total, proxied_delete_tagging_requests_failures: stats.proxied_delete_tagging_requests_failures, resync_started_count: stats.resync_started_count, resync_completed_count: stats.resync_completed_count, resync_failed_count: stats.resync_failed_count, resync_canceled_count: stats.resync_canceled_count, resync_duration_ms: stats.resync_duration_ms, targets: stats .targets .into_iter() .map(|target| BucketReplicationTargetStats { target_arn: target.target_arn, bandwidth_limit_bytes_per_sec: target.bandwidth_limit_bytes_per_sec, current_bandwidth_bytes_per_sec: target.current_bandwidth_bytes_per_sec, latency_ms: target.latency_ms, }) .collect(), }) .collect() } async fn obs_site_replication_stats() -> ReplicationStats { let current_data_transfer_rate = obs_bucket_replication_bandwidth_stats() .into_iter() .flatten() .map(|stat| stat.current_bandwidth_bytes_per_sec) .sum::(); let stats = obs_replication_site_stats_snapshot(current_data_transfer_rate).await; ReplicationStats { average_active_workers: stats.average_active_workers, average_queued_bytes: stats.average_queued_bytes, average_queued_count: stats.average_queued_count, average_data_transfer_rate: stats.average_data_transfer_rate, active_workers: stats.active_workers, current_data_transfer_rate: stats.current_data_transfer_rate, last_minute_queued_bytes: stats.last_minute_queued_bytes, last_minute_queued_count: stats.last_minute_queued_count, max_active_workers: stats.max_active_workers, max_queued_bytes: stats.max_queued_bytes, max_queued_count: stats.max_queued_count, max_data_transfer_rate: stats.max_data_transfer_rate, recent_backlog_count: stats.recent_backlog_count, } } fn current_scanner_cycle_age_seconds( current_cycle: u64, current_started: chrono::DateTime, now: chrono::DateTime, ) -> u64 { if current_cycle == 0 { 0 } else { now.signed_duration_since(current_started).num_seconds().max(0) as u64 } } fn scanner_scan_mode_code(scan_mode: &str) -> u64 { match scan_mode { mode if mode == HealScanMode::Normal.as_str() => HealScanMode::Normal as u8 as u64, mode if mode == HealScanMode::Deep.as_str() => HealScanMode::Deep as u8 as u64, _ => HealScanMode::Unknown as u8 as u64, } } fn scanner_work_rate_per_second(count: u64, seconds: f64) -> f64 { if seconds > 0.0 && seconds.is_finite() { count as f64 / seconds } else { 0.0 } } const DRIVE_STATE_OK: &str = "ok"; const DRIVE_STATE_ONLINE: &str = "online"; const DRIVE_STATE_UNFORMATTED: &str = "unformatted"; const DRIVE_RUNTIME_STATE_RETURNING: &str = "returning"; const CAPACITY_OBSERVATION_LIVE: &str = "live"; const CAPACITY_OBSERVATION_STALE: &str = "stale"; const CAPACITY_OBSERVATION_MISSING: &str = "missing"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ErasureSetQuorumShape { data_shards: u32, read_quorum: u32, write_quorum: u32, read_tolerance: u32, write_tolerance: u32, } fn disk_is_online_for_metrics(state: &str, runtime_state: Option<&str>) -> bool { let state_is_acceptable = state.eq_ignore_ascii_case(DRIVE_STATE_OK) || state.eq_ignore_ascii_case(DRIVE_STATE_ONLINE) || state.eq_ignore_ascii_case(DRIVE_STATE_UNFORMATTED); if let Some(runtime_state) = runtime_state { let runtime_state_is_acceptable = runtime_state.eq_ignore_ascii_case(DRIVE_STATE_ONLINE) || runtime_state.eq_ignore_ascii_case(DRIVE_RUNTIME_STATE_RETURNING); return runtime_state_is_acceptable && state_is_acceptable; } state_is_acceptable } fn disk_capacity_observation_state(source: Option<&str>, age_seconds: Option) -> (&'static str, u64) { let age_seconds = age_seconds.unwrap_or(0); match source { Some("live_probe") => (CAPACITY_OBSERVATION_LIVE, age_seconds), Some("snapshot") => (CAPACITY_OBSERVATION_STALE, age_seconds), _ => (CAPACITY_OBSERVATION_MISSING, age_seconds), } } fn derive_erasure_set_quorum_shape(set_drive_count: usize, parity: usize) -> ErasureSetQuorumShape { let data_shards = set_drive_count.saturating_sub(parity); let read_quorum = data_shards.max(1); let mut write_quorum = read_quorum; if data_shards == parity { write_quorum += 1; } ErasureSetQuorumShape { data_shards: data_shards as u32, read_quorum: read_quorum as u32, write_quorum: write_quorum as u32, read_tolerance: parity as u32, write_tolerance: set_drive_count.saturating_sub(write_quorum) as u32, } } fn apply_erasure_set_health(entry: &mut ErasureSetStats) { let online = entry.online_drives_count; entry.read_health = u8::from(online >= entry.read_quorum); entry.write_health = u8::from(online >= entry.write_quorum); entry.health = u8::from(entry.write_health == 1); } #[derive(Debug, Clone, Default)] pub struct ProcessMetricBundle { pub resource: ResourceStats, pub process: ProcessStats, pub disk_read_bytes: u64, pub disk_write_bytes: u64, } /// Collect cluster and cluster-health statistics from a single storage snapshot. pub async fn collect_cluster_and_health_stats() -> (ClusterStats, ClusterHealthStats) { let Some(store) = resolve_obs_object_store_handle() else { return (ClusterStats::default(), ClusterHealthStats::default()); }; let storage_info = StorageAdminApi::storage_info(store.as_ref()).await; let raw_capacity: u64 = storage_info.disks.iter().map(|d| d.total_space).sum(); let usable_capacity = obs_total_usable_capacity_bytes(&storage_info); let free = obs_total_usable_capacity_free_bytes(&storage_info); let used = usable_capacity_used_bytes(usable_capacity, free); let stale_capacity_drives = storage_info .disks .iter() .filter(|disk| { disk_capacity_observation_state(disk.capacity_observation_source.as_deref(), disk.capacity_observation_age_seconds).0 == CAPACITY_OBSERVATION_STALE }) .count() as u64; let missing_capacity_drives = storage_info .disks .iter() .filter(|disk| { disk_capacity_observation_state(disk.capacity_observation_source.as_deref(), disk.capacity_observation_age_seconds).0 == CAPACITY_OBSERVATION_MISSING }) .count() as u64; // Get bucket and object counts from data usage info. let (buckets_count, objects_count) = match load_obs_data_usage_from_backend(store.clone()).await { Ok(data_usage) => (data_usage.buckets_count, data_usage.objects_total_count), Err(e) => { warn!(event = EVENT_METRICS_COLLECTOR_STATE, component = LOG_COMPONENT_OBS, subsystem = LOG_SUBSYSTEM_METRICS_COLLECTOR, collector = "cluster_stats", result = "data_usage_load_failed", error = %e, "metrics collector state changed"); // Fall back to bucket list for buckets_count, objects_count stays 0. let buckets = store .list_bucket(&BucketOptions { cached: true, ..Default::default() }) .await .unwrap_or_else(|err| { warn!(event = EVENT_METRICS_COLLECTOR_STATE, component = LOG_COMPONENT_OBS, subsystem = LOG_SUBSYSTEM_METRICS_COLLECTOR, collector = "cluster_stats", result = "bucket_list_failed", error = %err, "metrics collector state changed"); Vec::new() }); (buckets.len() as u64, 0) } }; let mut online = 0u64; let mut offline = 0u64; for disk in &storage_info.disks { if disk_is_online_for_metrics(disk.state.as_str(), disk.runtime_state.as_deref()) { online += 1; } else { offline += 1; } } ( ClusterStats { raw_capacity_bytes: raw_capacity, usable_capacity_bytes: usable_capacity, used_bytes: used, free_bytes: free, stale_capacity_drives, missing_capacity_drives, objects_count, buckets_count, }, ClusterHealthStats { drives_offline_count: offline, drives_online_count: online, drives_count: storage_info.disks.len() as u64, }, ) } /// Collect cluster statistics from the storage layer. #[instrument] pub async fn collect_cluster_stats() -> ClusterStats { let (cluster_stats, _) = collect_cluster_and_health_stats().await; cluster_stats } /// Collect cluster health statistics from the storage layer. pub async fn collect_cluster_health_stats() -> ClusterHealthStats { let (_, cluster_health_stats) = collect_cluster_and_health_stats().await; cluster_health_stats } /// Collect bucket statistics from the storage layer. pub async fn collect_bucket_stats() -> Vec { let Some(store) = resolve_obs_object_store_handle() else { return Vec::new(); }; // Load data usage info from backend to get bucket sizes and object counts let data_usage = match load_obs_data_usage_from_backend(store.clone()).await { Ok(info) => Some(info), Err(e) => { warn!(event = EVENT_METRICS_COLLECTOR_STATE, component = LOG_COMPONENT_OBS, subsystem = LOG_SUBSYSTEM_METRICS_COLLECTOR, collector = "bucket_stats", result = "data_usage_load_failed", error = %e, "metrics collector state changed"); None } }; // List all buckets let buckets = match store .list_bucket(&BucketOptions { cached: true, ..Default::default() }) .await { Ok(buckets) => buckets, Err(e) => { warn!(event = EVENT_METRICS_COLLECTOR_STATE, component = LOG_COMPONENT_OBS, subsystem = LOG_SUBSYSTEM_METRICS_COLLECTOR, collector = "bucket_stats", result = "bucket_list_failed", error = %e, "metrics collector state changed"); return Vec::new(); } }; let mut stats = Vec::with_capacity(buckets.len()); for bucket in buckets { if bucket.name.starts_with('.') { continue; } // Get size and objects_count from data usage info let (size_bytes, objects_count) = data_usage .as_ref() .and_then(|du| du.buckets_usage.get(&bucket.name)) .map(|bui| (bui.size, bui.objects_count)) .unwrap_or((0, 0)); // Get quota from bucket metadata let quota_bytes = obs_bucket_quota_limit_bytes(&bucket.name).await; stats.push(BucketStats { name: bucket.name, size_bytes, objects_count, quota_bytes, }); } stats } /// Collect bucket replication bandwidth stats from the global monitor. pub fn collect_bucket_replication_bandwidth_stats() -> Vec { let Some(bandwidth_stats) = obs_bucket_replication_bandwidth_stats() else { return Vec::new(); }; bandwidth_stats .into_iter() .map(|stat| { let target_arn = stat.target_arn; let limit_bytes_per_sec = u64::try_from(stat.limit_bytes_per_sec).unwrap_or_else(|_| { warn!(event = EVENT_METRICS_COLLECTOR_STATE, component = LOG_COMPONENT_OBS, subsystem = LOG_SUBSYSTEM_METRICS_COLLECTOR, collector = "bucket_replication_bandwidth", result = "invalid_limit_value", target_arn = ?target_arn, limit_value = stat.limit_bytes_per_sec, "metrics collector state changed"); 0 }); BucketReplicationBandwidthStats { bucket: stat.bucket, target_arn, limit_bytes_per_sec, current_bandwidth_bytes_per_sec: stat.current_bandwidth_bytes_per_sec, } }) .collect() } /// Collect bucket and target level replication stats from the global replication runtime. pub async fn collect_bucket_replication_detail_stats() -> Vec { obs_bucket_replication_detail_stats().await } /// Collect site-level replication stats from the global replication runtime. pub async fn collect_replication_stats() -> ReplicationStats { obs_site_replication_stats().await } /// Collect disk statistics from the storage layer. pub async fn collect_disk_stats() -> Vec { let (disk_stats, _, _) = collect_disk_and_system_drive_stats().await; disk_stats } fn build_system_cpu_stats(system: &System) -> CpuStats { let cpu_usage = system.global_cpu_usage() as f64; let cpu_count = system.cpus().len().max(1) as f64; let load_avg = System::load_average().one; CpuStats { avg_idle: (100.0 - cpu_usage).max(0.0), load_avg, load_avg_perc: (load_avg / cpu_count) * 100.0, usage_perc: cpu_usage, } } fn build_system_memory_stats(system: &System) -> MemoryStats { let total = system.total_memory(); let used = system.used_memory(); MemoryStats { total, used, used_perc: if total > 0 { (used as f64 / total as f64) * 100.0 } else { 0.0 }, free: system.free_memory(), buffers: 0, cache: 0, shared: 0, available: system.available_memory(), } } /// Collect system CPU and memory statistics from a shared sysinfo snapshot. pub fn collect_system_cpu_and_memory_stats() -> (CpuStats, MemoryStats) { let mut system = System::new_all(); collect_system_cpu_and_memory_stats_with(&mut system) } /// Collect system CPU and memory statistics by refreshing a reusable sysinfo instance. pub fn collect_system_cpu_and_memory_stats_with(system: &mut System) -> (CpuStats, MemoryStats) { system.refresh_cpu_all(); system.refresh_memory(); (build_system_cpu_stats(system), build_system_memory_stats(system)) } /// Collect system CPU statistics from the current host. pub fn collect_system_cpu_stats() -> CpuStats { let (cpu_stats, _) = collect_system_cpu_and_memory_stats(); cpu_stats } /// Collect system memory statistics from the current host. pub fn collect_system_memory_stats() -> MemoryStats { let (_, memory_stats) = collect_system_cpu_and_memory_stats(); memory_stats } /// Collect node disk stats and drive stats from a single storage snapshot. pub async fn collect_disk_and_system_drive_stats() -> (Vec, Vec, DriveCountStats) { let Some(store) = resolve_obs_object_store_handle() else { return (Vec::new(), Vec::new(), DriveCountStats::default()); }; let storage_info = StorageAdminApi::storage_info(store.as_ref()).await; let disk_stats = storage_info .disks .iter() .map(|disk| DiskStats { server: disk.endpoint.clone(), drive: disk.drive_path.clone(), total_bytes: disk.total_space, used_bytes: disk.used_space, free_bytes: disk.available_space, }) .collect(); let mut online_count = 0u64; let mut offline_count = 0u64; let drive_stats = storage_info .disks .iter() .map(|disk| { let is_online = disk_is_online_for_metrics(disk.state.as_str(), disk.runtime_state.as_deref()); let (capacity_observation_state, capacity_observation_age_seconds) = disk_capacity_observation_state( disk.capacity_observation_source.as_deref(), disk.capacity_observation_age_seconds, ); if is_online { online_count += 1; } else { offline_count += 1; } DriveDetailedStats { server: disk.endpoint.clone(), drive: disk.drive_path.clone(), total_bytes: disk.total_space, used_bytes: disk.used_space, free_bytes: disk.available_space, capacity_observation_state, capacity_observation_age_seconds, used_inodes: None, free_inodes: None, total_inodes: None, timeout_errors_total: None, io_errors_total: None, availability_errors_total: None, waiting_io: None, api_latency_micros: None, health: if is_online { 1 } else { 0 }, reads_per_sec: None, reads_kb_per_sec: None, reads_await: None, writes_per_sec: None, writes_kb_per_sec: None, writes_await: None, perc_util: None, } }) .collect(); let drive_count_stats = DriveCountStats { offline_count, online_count, total_count: online_count + offline_count, }; (disk_stats, drive_stats, drive_count_stats) } /// Collect system drive statistics using the storage layer snapshot. pub async fn collect_system_drive_stats() -> (Vec, DriveCountStats) { let (_, drive_stats, drive_count_stats) = collect_disk_and_system_drive_stats().await; (drive_stats, drive_count_stats) } /// Collect resource and process statistics for the current process in one sysinfo refresh. #[inline] pub fn collect_process_metric_bundle() -> ProcessMetricBundle { let (resource_snapshot, process_snapshot) = snapshot_process_resource_and_system(); process_metric_bundle_from_snapshots(resource_snapshot, process_snapshot) } #[inline] pub fn collect_process_metric_bundle_with(sampler: &mut ProcessSampler) -> ProcessMetricBundle { let (resource_snapshot, process_snapshot) = snapshot_process_resource_and_system_with(sampler); process_metric_bundle_from_snapshots(resource_snapshot, process_snapshot) } fn process_metric_bundle_from_snapshots( resource_snapshot: ProcessResourceSnapshot, process_snapshot: ProcessSystemSnapshot, ) -> ProcessMetricBundle { let status = match process_snapshot.status { ProcessStatusSnapshot::Running => ProcessStatusType::Running, ProcessStatusSnapshot::Sleeping => ProcessStatusType::Sleeping, ProcessStatusSnapshot::Zombie => ProcessStatusType::Zombie, ProcessStatusSnapshot::Other => ProcessStatusType::Other, }; let resource_stats = ResourceStats { cpu_percent: resource_snapshot.cpu_percent, memory_bytes: resource_snapshot.memory_bytes, uptime_seconds: resource_snapshot.uptime_seconds, }; let process_stats = ProcessStats { locks_read_total: process_snapshot.locks_read_total, locks_write_total: process_snapshot.locks_write_total, cpu_total_seconds: process_snapshot.cpu_total_seconds, file_descriptor_limit_total: process_snapshot.file_descriptor_limit_total, file_descriptor_open_total: process_snapshot.file_descriptor_open_total, go_routine_total: process_snapshot.go_routine_total, io_rchar_bytes: process_snapshot.io_rchar_bytes, io_read_bytes: process_snapshot.io_read_bytes, io_wchar_bytes: process_snapshot.io_wchar_bytes, io_write_bytes: process_snapshot.io_write_bytes, resident_memory_bytes: process_snapshot.resident_memory_bytes, start_time_seconds: process_snapshot.start_time_seconds, status, status_value: process_snapshot.status_value, syscall_read_total: process_snapshot.syscall_read_total, syscall_write_total: process_snapshot.syscall_write_total, uptime_seconds: process_snapshot.uptime_seconds, virtual_memory_bytes: process_snapshot.virtual_memory_bytes, virtual_memory_max_bytes: process_snapshot.virtual_memory_max_bytes, }; ProcessMetricBundle { resource: resource_stats, process: process_stats, disk_read_bytes: process_snapshot.disk_read_bytes, disk_write_bytes: process_snapshot.disk_write_bytes, } } /// Collect resource and process statistics for the current process in one sysinfo refresh. #[inline] pub fn collect_process_resource_and_system_stats() -> (ResourceStats, ProcessStats) { let bundle = collect_process_metric_bundle(); (bundle.resource, bundle.process) } /// Collect resource statistics for the current process. #[inline] pub fn collect_process_stats() -> ResourceStats { collect_process_metric_bundle().resource } /// Collect process statistics for the current process. #[inline] pub fn collect_process_system_stats() -> ProcessStats { collect_process_metric_bundle().process } /// Collect host network statistics from a refreshed network interface snapshot. /// /// These counters come from system interfaces and are host-wide, not process-scoped. pub fn collect_host_network_stats_with(networks: &Networks) -> HostNetworkStats { let mut total_received = 0u64; let mut total_transmitted = 0u64; let mut per_interface = Vec::with_capacity(networks.len()); for (interface_name, data) in networks { let received = data.received(); let transmitted = data.transmitted(); total_received += received; total_transmitted += transmitted; per_interface.push((interface_name.to_string(), received, transmitted)); } HostNetworkStats { total_received, total_transmitted, per_interface, } } /// Collect host network statistics using a persistent `sysinfo::Networks` snapshot. /// /// `sysinfo` reports network I/O as deltas since the previous refresh, so /// callers must reuse the same `Networks` instance across collection ticks. pub fn collect_host_network_stats(networks: &mut Networks) -> HostNetworkStats { networks.refresh(true); collect_host_network_stats_with(networks) } /// Collect internode network metrics from the global internode metrics snapshot. /// /// The returned values come directly from `global_internode_metrics().snapshot()` /// and currently include only the counters and dial timing data tracked by the /// internode metrics runtime. pub fn collect_internode_network_stats() -> Option { let snapshot = global_internode_metrics().snapshot(); Some(NetworkStats { internode_errors_total: snapshot.errors_total, internode_dial_errors_total: snapshot.dial_errors_total, internode_dial_avg_time_nanos: snapshot.dial_avg_time_nanos, internode_sent_bytes_total: snapshot.sent_bytes_total, internode_recv_bytes_total: snapshot.recv_bytes_total, }) } /// Collect cluster config metrics from backend parity configuration. fn cluster_config_stats_from_backend_parities( rr_sc_parity: Option, standard_sc_parity: Option, ) -> Option { Some(ClusterConfigStats { rrs_parity: u32::try_from(rr_sc_parity?).ok()?, standard_parity: u32::try_from(standard_sc_parity?).ok()?, }) } pub async fn collect_cluster_config_stats() -> Option { let store = resolve_obs_object_store_handle()?; let backend = StorageAdminApi::backend_info(store.as_ref()).await; cluster_config_stats_from_backend_parities(backend.rr_sc_parity, backend.standard_sc_parity) } fn standard_erasure_layout_from_backend(backend: &ObsBackendInfo, pool_idx: usize) -> Option<(usize, usize)> { let drives_per_set = backend.drives_per_set.get(pool_idx).copied()?; if drives_per_set == 0 || (!backend.standard_sc_data.is_empty() && backend.standard_sc_data.len() != backend.drives_per_set.len()) || (!backend.standard_sc_parities.is_empty() && backend.standard_sc_parities.len() != backend.drives_per_set.len()) { return None; } let has_data = !backend.standard_sc_data.is_empty(); let has_parities = !backend.standard_sc_parities.is_empty(); let (data, parity) = match (has_data, has_parities) { (true, true) => ( backend.standard_sc_data.get(pool_idx).copied()?, backend.standard_sc_parities.get(pool_idx).copied()?, ), (true, false) => { let data = backend.standard_sc_data.get(pool_idx).copied()?; (data, drives_per_set.checked_sub(data)?) } (false, true) => { let parity = backend.standard_sc_parities.get(pool_idx).copied()?; (drives_per_set.checked_sub(parity)?, parity) } (false, false) => { let parity = backend.standard_sc_parity?; (drives_per_set.checked_sub(parity)?, parity) } }; if data == 0 || parity > data || data.checked_add(parity) != Some(drives_per_set) { return None; } Some((data, parity)) } fn erasure_set_stats_from_backend(storage_info: &ObsStorageInfo, backend: &ObsBackendInfo) -> Vec { let mut grouped: HashMap<(usize, usize), ErasureSetStats> = HashMap::new(); for disk in &storage_info.disks { let (Ok(pool_idx), Ok(set_idx)) = (usize::try_from(disk.pool_index), usize::try_from(disk.set_index)) else { continue; }; let Some((_, parity)) = standard_erasure_layout_from_backend(backend, pool_idx) else { continue; }; let set_drive_count = backend.drives_per_set[pool_idx]; let (Ok(pool_id), Ok(set_id), Ok(size), Ok(parity_metric)) = ( u32::try_from(pool_idx), u32::try_from(set_idx), u32::try_from(set_drive_count), u32::try_from(parity), ) else { continue; }; let quorum_shape = derive_erasure_set_quorum_shape(set_drive_count, parity); let entry = grouped.entry((pool_idx, set_idx)).or_insert_with(|| ErasureSetStats { pool_id, set_id, size, parity: parity_metric, data_shards: quorum_shape.data_shards, read_quorum: quorum_shape.read_quorum, write_quorum: quorum_shape.write_quorum, online_drives_count: 0, healing_drives_count: 0, health: 0, read_tolerance: quorum_shape.read_tolerance, write_tolerance: quorum_shape.write_tolerance, read_health: 0, write_health: 0, }); if disk_is_online_for_metrics(disk.state.as_str(), disk.runtime_state.as_deref()) { entry.online_drives_count += 1; } if disk.healing { entry.healing_drives_count += 1; } } for entry in grouped.values_mut() { apply_erasure_set_health(entry); } let mut stats = grouped.into_values().collect::>(); stats.sort_by_key(|stat| (stat.pool_id, stat.set_id)); stats } /// Collect cluster erasure set metrics from storage and backend topology info. pub async fn collect_erasure_set_stats() -> Vec { let Some(store) = resolve_obs_object_store_handle() else { return Vec::new(); }; let storage_info = StorageAdminApi::storage_info(store.as_ref()).await; let backend = StorageAdminApi::backend_info(store.as_ref()).await; erasure_set_stats_from_backend(&storage_info, &backend) } pub async fn collect_iam_stats() -> Option { let snapshot = iam_metrics_snapshot()?; Some(IamStats { last_sync_duration_millis: snapshot.last_sync_duration_millis, plugin_authn_service_failed_requests_minute: snapshot.plugin_authn_service_failed_requests_minute, plugin_authn_service_last_fail_seconds: snapshot.plugin_authn_service_last_fail_seconds, plugin_authn_service_last_succ_seconds: snapshot.plugin_authn_service_last_succ_seconds, plugin_authn_service_succ_avg_rtt_ms_minute: snapshot.plugin_authn_service_succ_avg_rtt_ms_minute, plugin_authn_service_succ_max_rtt_ms_minute: snapshot.plugin_authn_service_succ_max_rtt_ms_minute, plugin_authn_service_total_requests_minute: snapshot.plugin_authn_service_total_requests_minute, since_last_sync_millis: snapshot.since_last_sync_millis, sync_failures: snapshot.sync_failures, sync_successes: snapshot.sync_successes, }) } /// Collect cluster and per-bucket usage metrics from backend usage snapshots. /// /// This reads persisted usage data via `load_data_usage_from_backend()` and /// builds cluster totals plus per-bucket distributions from the returned /// histograms. It does not trigger an inline object-data rescan. pub async fn collect_cluster_usage_metric_stats() -> Option<(ClusterUsageStats, Vec)> { let store = resolve_obs_object_store_handle()?; let data_usage = load_obs_data_usage_from_backend(store.clone()).await.ok()?; let mut buckets = Vec::with_capacity(data_usage.buckets_usage.len()); for (bucket_name, usage) in &data_usage.buckets_usage { if bucket_name.starts_with('.') { continue; } let quota_bytes = obs_bucket_quota_limit_bytes(bucket_name).await; buckets.push(BucketUsageStats { bucket: bucket_name.clone(), total_bytes: usage.size, objects_count: usage.objects_count, versions_count: usage.versions_count, delete_markers_count: usage.delete_markers_count, quota_bytes, object_size_distribution: usage .object_size_histogram .iter() .map(|(range, count)| (range.clone(), *count)) .collect(), version_count_distribution: usage .object_versions_histogram .iter() .map(|(range, count)| (range.clone(), *count)) .collect(), }); } buckets.sort_by(|a, b| a.bucket.cmp(&b.bucket)); Some(( ClusterUsageStats { since_last_update_seconds: crate::metrics::collectors::cluster_usage::usage_since_last_update_seconds( data_usage.last_update, SystemTime::now(), ), total_bytes: data_usage.objects_total_size, objects_count: data_usage.objects_total_count, versions_count: data_usage.versions_total_count, delete_markers_count: data_usage.delete_markers_total_count, buckets_count: data_usage.buckets_count, object_size_distribution: data_usage .buckets_usage .values() .flat_map(|usage| usage.object_size_histogram.iter()) .fold(HashMap::::new(), |mut acc, (range, count)| { *acc.entry(range.clone()).or_default() += *count; acc }) .into_iter() .collect(), versions_distribution: data_usage .buckets_usage .values() .flat_map(|usage| usage.object_versions_histogram.iter()) .fold(HashMap::::new(), |mut acc, (range, count)| { *acc.entry(range.clone()).or_default() += *count; acc }) .into_iter() .collect(), }, buckets, )) } /// Collect ILM metrics from the current lifecycle runtime state. pub async fn collect_ilm_metric_stats() -> Option { let ilm = obs_ilm_runtime_snapshot().await; let metrics = global_metrics().report().await; let versions_scanned = scanner_lifecycle_checked_versions(&metrics); Some(IlmStats { expiry_pending_tasks: ilm.expiry_pending_tasks, transition_active_tasks: ilm.transition_active_tasks, transition_pending_tasks: ilm.transition_pending_tasks, transition_missed_immediate_tasks: ilm.transition_missed_immediate_tasks, transition_queue_full_tasks: ilm.transition_queue_full_tasks, transition_queue_send_timeout_tasks: ilm.transition_queue_send_timeout_tasks, transition_compensation_scheduled_tasks: ilm.transition_compensation_scheduled_tasks, transition_compensation_running_tasks: ilm.transition_compensation_running_tasks, versions_scanned, }) } /// Collect scanner metrics from a runtime source. /// /// Task 5 maps scanner runtime snapshots from `global_metrics()` into the /// rustfs-obs scanner collector shape. fn scanner_bucket_scans_started(life_time_ops: &HashMap, bucket_scans_finished: u64) -> u64 { life_time_ops .get("scan_bucket_drive_start") .copied() .unwrap_or(bucket_scans_finished) } pub async fn collect_scanner_metric_stats() -> Option { let metrics = global_metrics().report().await; let now = Utc::now(); let bucket_scans_finished = metrics.life_time_ops.get("scan_bucket_drive").copied().unwrap_or_default(); let bucket_scans_started = scanner_bucket_scans_started(&metrics.life_time_ops, bucket_scans_finished); let bucket_scans_failed = metrics .life_time_ops .get("scan_bucket_drive_failure") .copied() .unwrap_or_default(); let completed_cycles = metrics.life_time_ops.get("scan_cycle").copied().unwrap_or_default(); let directories_scanned = metrics.life_time_ops.get("scan_folder").copied().unwrap_or_default(); let objects_scanned = metrics.life_time_ops.get("scan_object").copied().unwrap_or_default(); // Real scan coverage: every version the scanner walked, independent of ILM // rules. The ILM-checked subset (`scanner_lifecycle_checked_versions`) still // feeds the ILM collector's versions_scanned, but the scanner collector must // report the total scanned versions — otherwise clusters without lifecycle // rules report zero here while objects_scanned keeps climbing. let versions_scanned = metrics.versions_scanned; let reference_time = metrics.cycles_completed_at.last().copied().unwrap_or(metrics.current_started); let last_activity_seconds = now.signed_duration_since(reference_time).num_seconds().max(0) as u64; let active_paths = metrics.active_scan_paths as u64; let current_cycle_age_seconds = current_scanner_cycle_age_seconds(metrics.current_cycle, metrics.current_started, now); let current_scan_mode = scanner_scan_mode_code(&metrics.current_scan_mode); let current_cycle_age = current_cycle_age_seconds as f64; let last_cycle_duration = metrics.last_cycle_duration_seconds; Some(ScannerStats { bucket_scans_finished, bucket_scans_started, bucket_scans_failed, directories_scanned, objects_scanned, versions_scanned, last_activity_seconds, active_paths, oldest_active_path_age_seconds: metrics.oldest_active_path_age_seconds, current_set_scan_concurrency_limit: metrics.current_set_scan_concurrency_limit, current_set_scans_queued: metrics.current_set_scans_queued, current_set_scans_active: metrics.current_set_scans_active, current_disk_scan_concurrency_limit: metrics.current_disk_scan_concurrency_limit, current_disk_bucket_scans_queued: metrics.current_disk_bucket_scans_queued, current_disk_bucket_scans_active: metrics.current_disk_bucket_scans_active, throttle_idle_mode_enabled: metrics.throttle_idle_mode_enabled, throttle_sleep_factor: metrics.throttle_sleep_factor, throttle_max_sleep_seconds: metrics.throttle_max_sleep_seconds, yield_every_n_objects: metrics.yield_every_n_objects, cycle_interval_seconds: metrics.cycle_interval_seconds, cycle_max_duration_seconds: metrics.cycle_max_duration_seconds, cycle_max_objects: metrics.cycle_max_objects, cycle_max_directories: metrics.cycle_max_directories, bitrot_cycle_enabled: metrics.bitrot_cycle_enabled, bitrot_cycle_seconds: metrics.bitrot_cycle_seconds, current_cycle: metrics.current_cycle, completed_cycles, current_cycle_age_seconds, current_cycle_objects_scanned: metrics.current_cycle_objects_scanned, current_cycle_directories_scanned: metrics.current_cycle_directories_scanned, current_cycle_bucket_drive_scans: metrics.current_cycle_bucket_drive_scans, current_cycle_bucket_drive_failures: metrics.current_cycle_bucket_drive_failures, current_cycle_objects_per_second: scanner_work_rate_per_second(metrics.current_cycle_objects_scanned, current_cycle_age), current_cycle_directories_per_second: scanner_work_rate_per_second( metrics.current_cycle_directories_scanned, current_cycle_age, ), current_cycle_bucket_drive_scans_per_second: scanner_work_rate_per_second( metrics.current_cycle_bucket_drive_scans, current_cycle_age, ), current_cycle_yield_events: metrics.current_cycle_yield_events, current_cycle_yield_duration_seconds: metrics.current_cycle_yield_duration_seconds, current_cycle_throttle_sleep_events: metrics.current_cycle_throttle_sleep_events, current_cycle_throttle_sleep_duration_seconds: metrics.current_cycle_throttle_sleep_duration_seconds, current_cycle_ilm_actions: metrics.current_cycle_ilm_actions, current_cycle_heal_objects: metrics.current_cycle_heal_objects, current_cycle_replication_checks: metrics.current_cycle_replication_checks, current_cycle_usage_saves: metrics.current_cycle_usage_saves, current_scan_mode, last_cycle_result: metrics.last_cycle_result_code, last_cycle_partial_reason: metrics.last_cycle_partial_reason_code, last_cycle_duration_seconds: metrics.last_cycle_duration_seconds, last_cycle_objects_scanned: metrics.last_cycle_objects_scanned, last_cycle_directories_scanned: metrics.last_cycle_directories_scanned, last_cycle_bucket_drive_scans: metrics.last_cycle_bucket_drive_scans, last_cycle_bucket_drive_failures: metrics.last_cycle_bucket_drive_failures, last_cycle_objects_per_second: scanner_work_rate_per_second(metrics.last_cycle_objects_scanned, last_cycle_duration), last_cycle_directories_per_second: scanner_work_rate_per_second( metrics.last_cycle_directories_scanned, last_cycle_duration, ), last_cycle_bucket_drive_scans_per_second: scanner_work_rate_per_second( metrics.last_cycle_bucket_drive_scans, last_cycle_duration, ), last_cycle_yield_events: metrics.last_cycle_yield_events, last_cycle_yield_duration_seconds: metrics.last_cycle_yield_duration_seconds, last_cycle_throttle_sleep_events: metrics.last_cycle_throttle_sleep_events, last_cycle_throttle_sleep_duration_seconds: metrics.last_cycle_throttle_sleep_duration_seconds, last_cycle_ilm_actions: metrics.last_cycle_ilm_actions, last_cycle_heal_objects: metrics.last_cycle_heal_objects, last_cycle_replication_checks: metrics.last_cycle_replication_checks, last_cycle_usage_saves: metrics.last_cycle_usage_saves, failed_cycles: metrics.failed_cycles, superseded_cycles: metrics.superseded_cycles, partial_cycles: metrics.partial_cycles, partial_cycles_unknown: metrics.partial_cycles_unknown, partial_cycles_runtime: metrics.partial_cycles_runtime, partial_cycles_objects: metrics.partial_cycles_objects, partial_cycles_directories: metrics.partial_cycles_directories, }) } /// Collect cluster-level compression statistics. pub async fn collect_compression_cluster_stats() -> Option { let compression_data_usage = obs_load_compression_total_from_memory().await?; let original_bytes_total = compression_data_usage.original_bytes_total; let compressed_bytes_total = compression_data_usage.compressed_bytes_total; let bytes_saved_total = original_bytes_total.saturating_sub(compressed_bytes_total); let compression_ratio = if original_bytes_total > 0 { compressed_bytes_total as f64 / original_bytes_total as f64 } else { 0.0 }; let compression_operations_total = compression_data_usage.compression_operations_total; Some(CompressionClusterStats { original_bytes_total, compressed_bytes_total, bytes_saved_total, compression_ratio, compression_operations_total, }) } #[cfg(test)] mod tests { use super::*; use rustfs_common::metrics::ScannerSourceWorkSnapshot; use std::io::{Read, Write}; use std::net::{Shutdown, TcpListener, TcpStream}; use std::thread; use std::time::Duration; fn storage_info_with_one_online_disk() -> ObsStorageInfo { let mut info = ObsStorageInfo::default(); info.disks.push(Default::default()); let disk = info.disks.last_mut().expect("inserted disk should exist"); disk.pool_index = 0; disk.set_index = 0; disk.disk_index = 0; disk.state = DRIVE_STATE_OK.to_string(); disk.runtime_state = Some(DRIVE_STATE_ONLINE.to_string()); info } #[test] fn cluster_config_stats_accept_homogeneous_backend_parities() { let stats = cluster_config_stats_from_backend_parities(Some(1), Some(2)) .expect("homogeneous scalar parities should produce cluster config metrics"); assert_eq!(stats.rrs_parity, 1); assert_eq!(stats.standard_parity, 2); } #[test] fn cluster_config_stats_skip_heterogeneous_backend_parities() { assert!(cluster_config_stats_from_backend_parities(Some(1), None).is_none()); assert!(cluster_config_stats_from_backend_parities(None, Some(2)).is_none()); } #[test] fn cluster_config_stats_reject_parity_larger_than_u32() { let Ok(overflow) = usize::try_from(u64::from(u32::MAX) + 1) else { return; }; assert!(cluster_config_stats_from_backend_parities(Some(overflow), Some(2)).is_none()); assert!(cluster_config_stats_from_backend_parities(Some(1), Some(overflow)).is_none()); } #[test] fn erasure_set_stats_skip_unknown_backend_layout() { let storage_info = storage_info_with_one_online_disk(); let backend = ObsBackendInfo { drives_per_set: vec![8], ..Default::default() }; assert!(erasure_set_stats_from_backend(&storage_info, &backend).is_empty()); } #[test] fn erasure_set_stats_skip_inconsistent_exact_layout_without_scalar_fallback() { let storage_info = storage_info_with_one_online_disk(); let backend = ObsBackendInfo { standard_sc_data: vec![6], standard_sc_parities: vec![4], standard_sc_parity: Some(2), drives_per_set: vec![8], ..Default::default() }; assert!(erasure_set_stats_from_backend(&storage_info, &backend).is_empty()); } #[test] fn erasure_set_stats_accept_truthful_legacy_scalar_layout() { let storage_info = storage_info_with_one_online_disk(); let backend = ObsBackendInfo { standard_sc_parity: Some(2), drives_per_set: vec![8], ..Default::default() }; let stats = erasure_set_stats_from_backend(&storage_info, &backend); assert_eq!(stats.len(), 1); assert_eq!(stats[0].size, 8); assert_eq!(stats[0].parity, 2); assert_eq!(stats[0].data_shards, 6); } #[test] fn erasure_set_stats_exact_data_ignores_stale_scalar() { let storage_info = storage_info_with_one_online_disk(); let backend = ObsBackendInfo { standard_sc_data: vec![6], standard_sc_parity: Some(4), drives_per_set: vec![8], ..Default::default() }; let stats = erasure_set_stats_from_backend(&storage_info, &backend); assert_eq!(stats.len(), 1); assert_eq!(stats[0].parity, 2); assert_eq!(stats[0].data_shards, 6); } fn generate_loopback_traffic() -> std::io::Result<()> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let addr = listener.local_addr()?; let payload = vec![0x5Au8; 64 * 1024]; let expected_len = payload.len(); let server = thread::spawn(move || -> std::io::Result<()> { let (mut stream, _) = listener.accept()?; let mut received = 0usize; let mut buf = [0u8; 8192]; while received < expected_len { let read = stream.read(&mut buf)?; if read == 0 { break; } received += read; } Ok(()) }); let mut client = TcpStream::connect(addr)?; client.write_all(&payload)?; client.flush()?; client.shutdown(Shutdown::Write)?; server .join() .expect("loopback traffic server thread should complete successfully")?; Ok(()) } #[test] fn disk_is_online_for_metrics_accepts_online_state_case_insensitive() { assert!(disk_is_online_for_metrics("OnLiNe", Some("online"))); } #[test] fn disk_is_online_for_metrics_rejects_offline_runtime_state() { assert!(!disk_is_online_for_metrics(DRIVE_STATE_OK, Some("offline"))); } #[test] fn derive_erasure_set_quorum_shape_handles_standard_layout() { let shape = derive_erasure_set_quorum_shape(16, 4); assert_eq!( shape, ErasureSetQuorumShape { data_shards: 12, read_quorum: 12, write_quorum: 12, read_tolerance: 4, write_tolerance: 4, } ); } #[test] fn derive_erasure_set_quorum_shape_handles_equal_data_and_parity() { let shape = derive_erasure_set_quorum_shape(4, 2); assert_eq!( shape, ErasureSetQuorumShape { data_shards: 2, read_quorum: 2, write_quorum: 3, read_tolerance: 2, write_tolerance: 1, } ); } #[test] fn apply_erasure_set_health_marks_read_and_write_health_from_online_count() { let mut stats = ErasureSetStats { read_quorum: 3, write_quorum: 4, online_drives_count: 3, ..Default::default() }; apply_erasure_set_health(&mut stats); assert_eq!(stats.read_health, 1); assert_eq!(stats.write_health, 0); assert_eq!(stats.health, 0); stats.online_drives_count = 4; apply_erasure_set_health(&mut stats); assert_eq!(stats.read_health, 1); assert_eq!(stats.write_health, 1); assert_eq!(stats.health, 1); } #[test] fn current_scanner_cycle_age_seconds_returns_zero_when_idle() { let now = Utc::now(); assert_eq!(current_scanner_cycle_age_seconds(0, now - chrono::Duration::seconds(30), now), 0); } #[test] fn current_scanner_cycle_age_seconds_clamps_future_start() { let now = Utc::now(); assert_eq!(current_scanner_cycle_age_seconds(4, now + chrono::Duration::seconds(30), now), 0); } #[test] fn current_scanner_cycle_age_seconds_reports_active_elapsed_time() { let now = Utc::now(); assert_eq!(current_scanner_cycle_age_seconds(4, now - chrono::Duration::seconds(45), now), 45); } #[test] fn scanner_scan_mode_code_maps_known_modes() { assert_eq!(scanner_scan_mode_code(HealScanMode::Normal.as_str()), HealScanMode::Normal as u8 as u64); assert_eq!(scanner_scan_mode_code(HealScanMode::Deep.as_str()), HealScanMode::Deep as u8 as u64); } #[test] fn scanner_scan_mode_code_maps_unknown_mode() { assert_eq!(scanner_scan_mode_code(""), HealScanMode::Unknown as u8 as u64); } #[test] fn scanner_bucket_scans_started_uses_explicit_started_count() { let mut life_time_ops = HashMap::new(); life_time_ops.insert("scan_bucket_drive_start".to_string(), 7); assert_eq!(scanner_bucket_scans_started(&life_time_ops, 5), 7); } #[test] fn scanner_bucket_scans_started_falls_back_to_finished_count() { let life_time_ops = HashMap::new(); assert_eq!(scanner_bucket_scans_started(&life_time_ops, 5), 5); } #[test] fn scanner_lifecycle_checked_versions_uses_lifecycle_checked_source_work() { let report = ScannerMetricsReport { source_work: vec![ ScannerSourceWorkSnapshot { source: "usage".to_string(), checked: 11, ..Default::default() }, ScannerSourceWorkSnapshot { source: "lifecycle".to_string(), checked: 37, ..Default::default() }, ], life_time_ilm: HashMap::from([("TransitionAction".to_string(), 5), ("DeleteAction".to_string(), 7)]), ..Default::default() }; assert_eq!(scanner_lifecycle_checked_versions(&report), 37); } #[test] fn scanner_lifecycle_checked_versions_defaults_to_zero_when_lifecycle_missing() { let report = ScannerMetricsReport { source_work: vec![ScannerSourceWorkSnapshot { source: "usage".to_string(), checked: 11, ..Default::default() }], ..Default::default() }; assert_eq!(scanner_lifecycle_checked_versions(&report), 0); } #[test] fn host_network_stats_require_a_persistent_networks_snapshot() -> std::io::Result<()> { if !sysinfo::IS_SUPPORTED_SYSTEM { return Ok(()); } let mut persistent_networks = Networks::new(); persistent_networks.refresh(true); let initial_stats = collect_host_network_stats_with(&persistent_networks); assert_eq!( initial_stats.total_received + initial_stats.total_transmitted, 0, "the first refresh only seeds sysinfo's baseline snapshot" ); match generate_loopback_traffic() { Ok(()) => {} Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { return Ok(()); } Err(err) => return Err(err), } thread::sleep(Duration::from_millis(100)); let refreshed_stats = collect_host_network_stats(&mut persistent_networks); assert!( refreshed_stats.total_received > 0 || refreshed_stats.total_transmitted > 0, "a persistent Networks instance should report non-zero loopback deltas after traffic" ); let recreated_stats = collect_host_network_stats_with(&Networks::new_with_refreshed_list()); assert_eq!( recreated_stats.total_received + recreated_stats.total_transmitted, 0, "recreating Networks loses the prior refresh baseline and yields zero deltas" ); Ok(()) } #[test] fn scanner_work_rate_per_second_reports_rate() { assert_eq!(scanner_work_rate_per_second(90, 45.0), 2.0); } #[test] fn scanner_work_rate_per_second_returns_zero_for_invalid_seconds() { assert_eq!(scanner_work_rate_per_second(90, 0.0), 0.0); assert_eq!(scanner_work_rate_per_second(90, f64::INFINITY), 0.0); assert_eq!(scanner_work_rate_per_second(90, f64::NAN), 0.0); } #[test] fn usable_capacity_used_bytes_matches_usable_total_minus_free() { assert_eq!(usable_capacity_used_bytes(240, 90), 150); } #[test] fn usable_capacity_used_bytes_saturates_when_free_exceeds_total() { assert_eq!(usable_capacity_used_bytes(90, 240), 0); } }