// 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. //! 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::scanner::{ScannerActiveBucketDriveStats, ScannerBucketDriveResultStats, ScannerSourceWorkStats}; use crate::metrics::collectors::{ ApiRequestMetricSupport, ApiRequestStats, BucketReplicationBacklogStats, BucketReplicationBandwidthStats, BucketReplicationMetricsSnapshot, BucketReplicationRuntimeStats, BucketReplicationTargetBacklogStats, BucketReplicationTargetFlowStats, BucketReplicationTargetStats, BucketStats, BucketUsageStats, ClusterConfigStats, ClusterHealthStats, ClusterStats, ClusterUsageStats, CompressionClusterStats, CpuStats, DiskStats, DriveCountStats, DriveDetailedStats, DriveRuntimeDetailedStats, ErasureSetStats, HostNetworkStats, IamStats, IlmActionTaskStats, IlmBackpressureStats, IlmQueueTaskStats, IlmRuntimeStats, IlmStats, IlmTaskEventStats, MemoryStats, NetworkStats, ProcessStats, ProcessStatusType, ReplicationMetricsSnapshot, ResourceStats, ScannerRuntimeStats, ScannerStats, }; use crate::metrics::runtime_sources::{ObsIlmRuntimeSnapshot, bucket_monitor_handle, iam_metrics_snapshot, ilm_runtime_snapshot}; use crate::metrics::{ BucketOperations, BucketOptions, ObsBucketReplicationStatsSnapshot, 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 crate::node_identity::current_local_node_identity; use jiff::Timestamp; use rustfs_common::heal_channel::HealScanMode; use rustfs_common::metrics::{ ScannerActiveBucketDriveSnapshot, ScannerBucketDriveResultSnapshot, ScannerMetricsReport, ScannerSourceWorkSnapshot, global_metrics, }; use rustfs_io_metrics::internode_metrics::global_internode_metrics; use rustfs_io_metrics::{ ProcessResourceSnapshot, ProcessSampler, ProcessStatusSnapshot, ProcessSystemSnapshot, s3_op_metrics_snapshot, snapshot_process_resource_and_system, snapshot_process_resource_and_system_with, }; use std::{ collections::{HashMap, HashSet}, 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; #[derive(Default)] struct ObsDataUsageInfo { last_update: Option, usage_snapshot_complete: bool, usage_snapshot_converged: bool, buckets_count: u64, objects_total_count: u64, versions_total_count: u64, delete_markers_total_count: u64, objects_total_size: u64, buckets_usage: HashMap, } #[derive(Default)] 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?; let usage_snapshot_complete = data_usage.is_complete_bucket_usage_snapshot(); Ok(ObsDataUsageInfo { last_update: data_usage.last_update, usage_snapshot_complete, usage_snapshot_converged: data_usage.usage_snapshot_converged == Some(true), 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 bucket_usage_metric_values(data_usage: Option<&ObsDataUsageInfo>, bucket: &str) -> (Option, Option) { data_usage .filter(|usage| usage.usage_snapshot_complete) .and_then(|usage| usage.buckets_usage.get(bucket)) .map(|usage| (Some(usage.size), Some(usage.objects_count))) .unwrap_or((None, None)) } fn data_usage_snapshot_covers_bucket_namespace(data_usage: &ObsDataUsageInfo, buckets: &HashSet) -> bool { data_usage.usage_snapshot_complete && u64::try_from(buckets.len()).ok() == Some(data_usage.buckets_count) && buckets.len() == data_usage.buckets_usage.len() && buckets.iter().all(|bucket| data_usage.buckets_usage.contains_key(bucket)) } 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_stats_bundle() -> (Vec, Vec) { let snapshots = obs_bucket_replication_stats_snapshot().await; let mut detail_stats = Vec::with_capacity(snapshots.len()); let mut backlog_stats = Vec::with_capacity(snapshots.len()); for stats in snapshots { backlog_stats.push(BucketReplicationBacklogStats { bucket: stats.bucket.clone(), current_backlog_count: stats.current_backlog_count, current_backlog_bytes: stats.current_backlog_bytes, durable_mrf_available: stats.durable_mrf_available, durable_mrf_backlog_count: stats.durable_mrf_backlog_count, durable_mrf_backlog_bytes: stats.durable_mrf_backlog_bytes, mrf_pending_count: stats.mrf_pending_count, mrf_pending_bytes: stats.mrf_pending_bytes, mrf_dropped_count: stats.mrf_dropped_count, mrf_missed_count: stats.mrf_missed_count, mrf_flush_failures: stats.mrf_flush_failures, mrf_last_flush_duration_millis: stats.mrf_last_flush_duration_millis, target_backlogs: stats .target_backlogs .iter() .map(|target| BucketReplicationTargetBacklogStats { target_arn: target.target_arn.clone(), current_backlog_count: target.current_backlog_count, current_backlog_bytes: target.current_backlog_bytes, durable_mrf_backlog_count: target.durable_mrf_backlog_count, durable_mrf_backlog_bytes: target.durable_mrf_backlog_bytes, }) .collect(), }); detail_stats.push(bucket_replication_detail_from_snapshot(stats)); } (detail_stats, backlog_stats) } fn bucket_replication_detail_from_snapshot(stats: ObsBucketReplicationStatsSnapshot) -> BucketReplicationRuntimeStats { let bucket = stats.bucket; let (targets, target_flows): (Vec<_>, Vec<_>) = stats .targets .into_iter() .map(|target| { ( BucketReplicationTargetStats { target_arn: target.target_arn.clone(), 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, }, BucketReplicationTargetFlowStats { target_arn: target.target_arn, sent_bytes: target.sent_bytes, sent_count: target.sent_count, total_failed_bytes: target.total_failed_bytes, total_failed_count: target.total_failed_count, last_min_failed_bytes: target.last_min_failed_bytes, last_min_failed_count: target.last_min_failed_count, last_hour_failed_bytes: target.last_hour_failed_bytes, last_hour_failed_count: target.last_hour_failed_count, }, ) }) .unzip(); BucketReplicationRuntimeStats { target_flows, stats: BucketReplicationMetricsSnapshot { 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, }, } } async fn obs_site_replication_stats() -> ReplicationMetricsSnapshot { 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; ReplicationMetricsSnapshot { 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_active: bool, current_started: Timestamp, now: Timestamp) -> u64 { if !current_cycle_active { 0 } else { timestamp_elapsed_seconds_since(now, current_started) } } fn timestamp_elapsed_seconds_since(now: Timestamp, earlier: Timestamp) -> u64 { let duration = now.duration_since(earlier); if duration.is_negative() { return 0; } u64::try_from(duration.as_secs()).unwrap_or(u64::MAX) } 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 disk_topology_label(index: i32) -> Option { if index >= 0 { Some(index.to_string()) } else { None } } fn non_empty_disk_id(uuid: &str) -> Option { let uuid = uuid.trim(); if uuid.is_empty() { None } else { Some(uuid.to_string()) } } fn drive_inode_stats(used_inodes: u64, free_inodes: u64) -> (Option, Option, Option) { let total_inodes = used_inodes.saturating_add(free_inodes); if total_inodes == 0 { (None, None, None) } else { (Some(used_inodes), Some(free_inodes), Some(total_inodes)) } } fn drive_api_latency_micros(actions: impl Iterator) -> Option { let mut count = 0u64; let mut acc_time_ns = 0u64; let mut saw_action = false; for (action_count, action_acc_time_ns) in actions { saw_action = true; if action_count > 0 { count = count.saturating_add(action_count); acc_time_ns = acc_time_ns.saturating_add(action_acc_time_ns); } } saw_action.then(|| acc_time_ns.checked_div(count).unwrap_or_default() / 1_000) } fn drive_api_latency_by_api_micros<'a>(actions: impl Iterator) -> Vec<(String, u64)> { let mut values = actions .map(|(api, count, acc_time)| (api.clone(), acc_time.checked_div(count).unwrap_or_default() / 1_000)) .collect::>(); values.sort_by(|left, right| left.0.cmp(&right.0)); values } fn drive_api_calls<'a>(api_calls: impl Iterator) -> Vec<(String, u64)> { let mut values = api_calls.map(|(api, calls)| (api.clone(), *calls)).collect::>(); values.sort_by(|left, right| left.0.cmp(&right.0)); values } fn drive_server_label(endpoint: &str, local_server: &str) -> String { endpoint .strip_prefix("http://") .or_else(|| endpoint.strip_prefix("https://")) .and_then(|rest| rest.split('/').next()) .filter(|authority| !authority.is_empty()) .unwrap_or(local_server) .to_string() } 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; let data_usage = match load_obs_data_usage_from_backend(store).await { Ok(data_usage) if data_usage.usage_snapshot_complete => Some(data_usage), Ok(_) => None, Err(error) => { warn!(event = EVENT_METRICS_COLLECTOR_STATE, component = LOG_COMPONENT_OBS, subsystem = LOG_SUBSYSTEM_METRICS_COLLECTOR, collector = "cluster_stats", result = "data_usage_load_failed", error = %error, "metrics collector state changed"); None } }; let buckets_count = data_usage.as_ref().map(|usage| usage.buckets_count); let objects_count = data_usage.as_ref().map(|usage| usage.objects_total_count); 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. /// /// `None` means the bucket namespace could not be observed. Callers must keep /// their prior metric-series state instead of treating that failure as an /// authoritative empty namespace. pub async fn collect_bucket_stats() -> Option> { let store = resolve_obs_object_store_handle()?; // 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 None; } }; 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) = bucket_usage_metric_values(data_usage.as_ref(), &bucket.name); // 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, }); } Some(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_stats_snapshot() .await .into_iter() .map(|snapshot| bucket_replication_detail_from_snapshot(snapshot).stats) .collect() } pub(crate) async fn collect_bucket_replication_stats_bundle() -> (Vec, Vec) { obs_bucket_replication_stats_bundle().await } /// Collect site-level replication stats from the global replication runtime. pub async fn collect_replication_stats() -> ReplicationMetricsSnapshot { obs_site_replication_stats().await } /// Collect S3 API request totals from the in-process operation recorder. pub(crate) fn collect_api_request_stats() -> Vec { let server = current_local_node_identity(); s3_op_metrics_snapshot() .into_iter() .map(|snapshot| ApiRequestStats { server: server.clone(), name: snapshot.op.to_string(), req_type: "s3".to_string(), total: snapshot.total, supported_metrics: ApiRequestMetricSupport::TOTALS_ONLY, ..Default::default() }) .collect() } /// 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, server: &str) -> 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 { server: server.to_string(), 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, server: &str) -> MemoryStats { let total = system.total_memory(); let used = system.used_memory(); MemoryStats { server: server.to_string(), 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(); let server = current_local_node_identity(); (build_system_cpu_stats(system, &server), build_system_memory_stats(system, &server)) } /// 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 (disk_stats, drive_stats, drive_count_stats) = collect_disk_and_system_drive_runtime_stats().await; (disk_stats, drive_stats.into_iter().map(|stat| stat.stats).collect(), drive_count_stats) } pub(crate) async fn collect_disk_and_system_drive_runtime_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 local_server = current_local_node_identity(); let disk_stats = storage_info .disks .iter() .map(|disk| DiskStats { server: drive_server_label(&disk.endpoint, &local_server), 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; } let (used_inodes, free_inodes, total_inodes) = drive_inode_stats(disk.used_inodes, disk.free_inodes); DriveRuntimeDetailedStats { pool_index: disk_topology_label(disk.pool_index), set_index: disk_topology_label(disk.set_index), drive_index: disk_topology_label(disk.disk_index), disk_id: non_empty_disk_id(&disk.uuid), runtime_state: Some(disk.runtime_state.as_deref().unwrap_or("unknown").to_ascii_lowercase()), healing: disk.healing, scanning: disk.scanning, offline_duration_seconds: disk.offline_duration_seconds, api_calls: disk .metrics .as_ref() .map(|metrics| drive_api_calls(metrics.api_calls.iter())) .unwrap_or_default(), api_latency_by_api_micros: disk .metrics .as_ref() .map(|metrics| { drive_api_latency_by_api_micros( metrics .last_minute .iter() .map(|(api, action)| (api, action.count, action.acc_time)), ) }) .unwrap_or_default(), stats: DriveDetailedStats { server: drive_server_label(&disk.endpoint, &local_server), 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, free_inodes, total_inodes, timeout_errors_total: disk.metrics.as_ref().map(|metrics| metrics.total_errors_timeout), io_errors_total: None, availability_errors_total: disk.metrics.as_ref().map(|metrics| metrics.total_errors_availability), waiting_io: disk.metrics.as_ref().map(|metrics| u64::from(metrics.total_waiting)), api_latency_micros: disk.metrics.as_ref().and_then(|metrics| { drive_api_latency_micros(metrics.last_minute.values().map(|action| (action.count, action.acc_time))) }), health: if is_online { 1 } else { 0 }, writes_total: disk.metrics.as_ref().map(|metrics| metrics.total_writes), deletes_total: disk.metrics.as_ref().map(|metrics| metrics.total_deletes), 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 server = current_local_node_identity(); 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 { server: server.clone(), cpu_percent: resource_snapshot.cpu_percent, memory_bytes: resource_snapshot.memory_bytes, uptime_seconds: resource_snapshot.uptime_seconds, }; let process_stats = ProcessStats { server, 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 { server: current_local_node_identity(), 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 { server: current_local_node_identity(), 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 bucket_namespace = store .list_bucket(&BucketOptions { cached: true, no_metadata: true, ..Default::default() }) .await .ok()? .into_iter() .filter(|bucket| !bucket.name.starts_with('.')) .map(|bucket| bucket.name) .collect::>(); collect_cluster_usage_metric_stats_from_data_usage(data_usage, &bucket_namespace).await } async fn collect_cluster_usage_metric_stats_from_data_usage( data_usage: ObsDataUsageInfo, bucket_namespace: &HashSet, ) -> Option<(ClusterUsageStats, Vec)> { if !data_usage_snapshot_covers_bucket_namespace(&data_usage, bucket_namespace) { return None; } 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, snapshot_converged: data_usage.usage_snapshot_converged, 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, )) } fn ilm_action_task_stats(ilm: &ObsIlmRuntimeSnapshot) -> Vec { vec![ IlmActionTaskStats { action: "expiry".to_string(), state: "pending".to_string(), value: ilm.expiry_pending_tasks, }, IlmActionTaskStats { action: "transition".to_string(), state: "active".to_string(), value: ilm.transition_active_tasks, }, IlmActionTaskStats { action: "transition".to_string(), state: "pending".to_string(), value: ilm.transition_pending_tasks, }, IlmActionTaskStats { action: "transition".to_string(), state: "missed_immediate".to_string(), value: ilm.transition_missed_immediate_tasks, }, IlmActionTaskStats { action: "transition".to_string(), state: "queue_full".to_string(), value: ilm.transition_queue_full_tasks, }, IlmActionTaskStats { action: "transition".to_string(), state: "queue_send_timeout".to_string(), value: ilm.transition_queue_send_timeout_tasks, }, IlmActionTaskStats { action: "transition".to_string(), state: "compensation_scheduled".to_string(), value: ilm.transition_compensation_scheduled_tasks, }, IlmActionTaskStats { action: "transition".to_string(), state: "compensation_running".to_string(), value: ilm.transition_compensation_running_tasks, }, ] } fn ilm_queue_task_stats(metrics: &ScannerMetricsReport) -> Vec { let expiry = &metrics.lifecycle_expiry; let transition = &metrics.lifecycle_transition; vec![ IlmQueueTaskStats { action: "expiry".to_string(), state: "pending".to_string(), value: expiry.current_queued, }, IlmQueueTaskStats { action: "expiry".to_string(), state: "active".to_string(), value: expiry.current_active, }, IlmQueueTaskStats { action: "transition".to_string(), state: "pending".to_string(), value: transition.current_queued, }, IlmQueueTaskStats { action: "transition".to_string(), state: "active".to_string(), value: transition.current_active, }, IlmQueueTaskStats { action: "transition".to_string(), state: "compensation_running".to_string(), value: transition.compensation_running, }, ] } fn ilm_task_event_stats(metrics: &ScannerMetricsReport) -> Vec { let expiry = &metrics.lifecycle_expiry; let transition = &metrics.lifecycle_transition; vec![ IlmTaskEventStats { action: "expiry".to_string(), result: "queued".to_string(), value: expiry.scanner_queued, }, IlmTaskEventStats { action: "expiry".to_string(), result: "missed".to_string(), value: expiry.scanner_missed, }, IlmTaskEventStats { action: "expiry".to_string(), result: "blocked".to_string(), value: expiry.scanner_blocked, }, IlmTaskEventStats { action: "expiry".to_string(), result: "not_enqueued".to_string(), value: expiry.scanner_not_enqueued, }, IlmTaskEventStats { action: "expiry".to_string(), result: "failed".to_string(), value: expiry.delete_failed, }, IlmTaskEventStats { action: "transition".to_string(), result: "queued".to_string(), value: transition.scanner_queued, }, IlmTaskEventStats { action: "transition".to_string(), result: "missed".to_string(), value: transition.scanner_missed, }, IlmTaskEventStats { action: "transition".to_string(), result: "completed".to_string(), value: transition.completed, }, IlmTaskEventStats { action: "transition".to_string(), result: "failed".to_string(), value: transition.failed, }, ] } fn ilm_backpressure_stats(metrics: &ScannerMetricsReport) -> Vec { vec![ IlmBackpressureStats { action: "expiry".to_string(), reason: "queue_missed".to_string(), value: metrics.lifecycle_expiry.queue_missed, }, IlmBackpressureStats { action: "transition".to_string(), reason: "queue_full".to_string(), value: metrics.lifecycle_transition.queue_full, }, IlmBackpressureStats { action: "transition".to_string(), reason: "send_timeout".to_string(), value: metrics.lifecycle_transition.queue_send_timeout, }, ] } /// Collect ILM metrics from the current lifecycle runtime state. pub async fn collect_ilm_metric_stats() -> Option { collect_ilm_runtime_metric_stats().await.map(|stats| stats.stats) } pub(crate) async fn collect_ilm_runtime_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(IlmRuntimeStats { server: current_local_node_identity(), action_tasks: ilm_action_task_stats(&ilm), queue_tasks: ilm_queue_task_stats(&metrics), task_events: ilm_task_event_stats(&metrics), backpressure: ilm_backpressure_stats(&metrics), versions_scanned, stats: 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) } fn scanner_source_work_stats(source_work: &[ScannerSourceWorkSnapshot]) -> Vec { let mut stats = source_work .iter() .filter(|work| !work.source.is_empty()) .map(|work| ScannerSourceWorkStats { source: work.source.clone(), checked: work.checked, queued: work.queued, executed: work.executed, failed: work.failed, skipped: work.skipped, missed: work.missed, }) .collect::>(); stats.sort_by(|left, right| left.source.cmp(&right.source)); stats } fn scanner_current_cycle_source_work_stats(metrics: &ScannerMetricsReport) -> Vec { let current = scanner_source_work_stats(&metrics.current_cycle_source_work); if !current.is_empty() { return current; } let mut sources = scanner_source_work_stats(&metrics.last_cycle_source_work) .into_iter() .map(|work| work.source) .chain( scanner_source_work_stats(&metrics.source_work) .into_iter() .map(|work| work.source), ) .collect::>(); sources.sort(); sources.dedup(); sources .into_iter() .map(|source| ScannerSourceWorkStats { source, ..Default::default() }) .collect() } fn scanner_bucket_drive_result_stats(results: &[ScannerBucketDriveResultSnapshot]) -> Vec { let mut stats = results .iter() .filter(|result| !result.bucket.is_empty() && !result.drive.is_empty() && !result.result.is_empty() && result.count > 0) .map(|result| ScannerBucketDriveResultStats { bucket: result.bucket.clone(), drive: result.drive.clone(), result: result.result.clone(), count: result.count, }) .collect::>(); stats.sort_by(|left, right| { left.bucket .cmp(&right.bucket) .then_with(|| left.drive.cmp(&right.drive)) .then_with(|| left.result.cmp(&right.result)) }); stats } fn scanner_active_bucket_drive_stats(results: &[ScannerActiveBucketDriveSnapshot]) -> Vec { let mut stats = results .iter() .filter(|result| !result.source.is_empty() && !result.bucket.is_empty() && !result.drive.is_empty() && result.count > 0) .map(|result| ScannerActiveBucketDriveStats { source: result.source.clone(), bucket: result.bucket.clone(), drive: result.drive.clone(), count: result.count, age_seconds: result.age_seconds, }) .collect::>(); stats.sort_by(|left, right| { left.source .cmp(&right.source) .then_with(|| left.bucket.cmp(&right.bucket)) .then_with(|| left.drive.cmp(&right.drive)) }); stats } pub async fn collect_scanner_metric_stats() -> Option { collect_scanner_runtime_metric_stats().await.map(|stats| stats.stats) } pub(crate) async fn collect_scanner_runtime_metric_stats() -> Option { let (metrics, runtime_details) = global_metrics().report_with_runtime_details().await; let now = Timestamp::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 = timestamp_elapsed_seconds_since(now, reference_time); let active_paths = metrics.active_scan_paths as u64; let current_cycle_age_seconds = current_scanner_cycle_age_seconds(metrics.current_cycle_active, 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(ScannerRuntimeStats { server: current_local_node_identity(), source_work: scanner_source_work_stats(&metrics.source_work), current_cycle_source_work: scanner_current_cycle_source_work_stats(&metrics), last_cycle_source_work: scanner_source_work_stats(&metrics.last_cycle_source_work), bucket_drive_results: scanner_bucket_drive_result_stats(&runtime_details.bucket_drive_results), current_cycle_bucket_drive_results: scanner_bucket_drive_result_stats( &runtime_details.current_cycle_bucket_drive_results, ), last_cycle_bucket_drive_results: scanner_bucket_drive_result_stats(&runtime_details.last_cycle_bucket_drive_results), active_bucket_drive_scans: scanner_active_bucket_drive_stats(&runtime_details.active_bucket_drive_scans), stats: 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 bucket_usage_metrics_distinguish_unknown_from_confirmed_zero() { assert_eq!(bucket_usage_metric_values(None, "bucket"), (None, None)); let mut data_usage = ObsDataUsageInfo { usage_snapshot_complete: true, ..Default::default() }; data_usage .buckets_usage .insert("bucket".to_string(), ObsBucketUsageInfo::default()); assert_eq!(bucket_usage_metric_values(Some(&data_usage), "bucket"), (Some(0), Some(0))); assert_eq!(bucket_usage_metric_values(Some(&data_usage), "missing"), (None, None)); } #[tokio::test] async fn cluster_usage_metrics_skip_incomplete_snapshot() { assert!( collect_cluster_usage_metric_stats_from_data_usage(ObsDataUsageInfo::default(), &HashSet::new()) .await .is_none() ); } #[tokio::test] async fn cluster_usage_metrics_publish_complete_empty_snapshot() { let (cluster, buckets) = collect_cluster_usage_metric_stats_from_data_usage( ObsDataUsageInfo { usage_snapshot_complete: true, usage_snapshot_converged: true, ..Default::default() }, &HashSet::new(), ) .await .expect("complete empty usage should remain publishable"); assert_eq!(cluster.buckets_count, 0); assert_eq!(cluster.objects_count, 0); assert_eq!(cluster.total_bytes, 0); assert!(cluster.snapshot_converged); assert!(buckets.is_empty()); } #[tokio::test] async fn cluster_usage_metrics_publish_unknown_convergence_as_unconverged() { let (cluster, _) = collect_cluster_usage_metric_stats_from_data_usage( ObsDataUsageInfo { usage_snapshot_complete: true, usage_snapshot_converged: false, ..Default::default() }, &HashSet::new(), ) .await .expect("complete usage with unknown convergence should remain publishable"); assert!(!cluster.snapshot_converged); } #[tokio::test] async fn cluster_usage_metrics_skip_snapshot_for_a_different_bucket_namespace() { let data_usage = ObsDataUsageInfo { usage_snapshot_complete: true, buckets_count: 1, buckets_usage: HashMap::from([("stale-bucket".to_string(), ObsBucketUsageInfo::default())]), ..Default::default() }; assert!( collect_cluster_usage_metric_stats_from_data_usage(data_usage, &HashSet::from(["live-bucket".to_string()])) .await .is_none() ); } #[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()); } #[tokio::test] async fn node_local_resource_stats_use_stable_local_node_identity() { let _guard = crate::node_identity::local_node_identity_test_guard().await; let previous = rustfs_common::get_global_local_node_name().await; rustfs_common::set_global_local_node_name("node1:9000").await; let mut system = System::new_all(); let (cpu, memory) = collect_system_cpu_and_memory_stats_with(&mut system); let host_network = collect_host_network_stats_with(&Networks::new()); let process_bundle = process_metric_bundle_from_snapshots(ProcessResourceSnapshot::default(), ProcessSystemSnapshot::default()); assert_eq!(cpu.server, "node1:9000"); assert_eq!(memory.server, "node1:9000"); assert_eq!(host_network.server, "node1:9000"); assert_eq!(process_bundle.resource.server, "node1:9000"); assert_eq!(process_bundle.process.server, "node1:9000"); rustfs_common::set_global_local_node_name(&previous).await; } #[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 disk_topology_label_rejects_unknown_negative_index() { assert_eq!(disk_topology_label(-1), None); assert_eq!(disk_topology_label(3), Some("3".to_string())); } #[test] fn non_empty_disk_id_rejects_blank_uuid() { assert_eq!(non_empty_disk_id(" "), None); assert_eq!(non_empty_disk_id("disk-1"), Some("disk-1".to_string())); } #[test] fn drive_server_label_uses_node_identity_for_urls_and_local_paths() { assert_eq!(drive_server_label("http://node1:9000/data", "local:9000"), "node1:9000"); assert_eq!(drive_server_label("https://node2:9443/export/d1", "local:9000"), "node2:9443"); assert_eq!(drive_server_label("/mnt/data1", "local:9000"), "local:9000"); } #[test] fn drive_inode_stats_skip_unknown_zero_inode_totals() { assert_eq!(drive_inode_stats(0, 0), (None, None, None)); assert_eq!(drive_inode_stats(2, 3), (Some(2), Some(3), Some(5))); } #[test] fn drive_api_metrics_are_sorted_and_average_latency() { let last_minute = HashMap::from([ ("write".to_string(), (2, 6_000)), ("read".to_string(), (1, 3_000)), ("zero".to_string(), (0, 9_000)), ]); let api_calls = HashMap::from([("write".to_string(), 9), ("read".to_string(), 4)]); assert_eq!(drive_api_latency_micros(last_minute.values().copied()), Some(3)); assert_eq!( drive_api_latency_by_api_micros(last_minute.iter().map(|(api, (count, acc_time))| (api, *count, *acc_time))), vec![("read".to_string(), 3), ("write".to_string(), 3), ("zero".to_string(), 0)] ); assert_eq!(drive_api_calls(api_calls.iter()), vec![("read".to_string(), 4), ("write".to_string(), 9)]); } #[test] fn drive_api_latency_skips_zero_denominators() { let last_minute = HashMap::from([("zero".to_string(), (0, 9_000))]); assert_eq!(drive_api_latency_micros(last_minute.values().copied()), Some(0)); assert_eq!( drive_api_latency_by_api_micros(last_minute.iter().map(|(api, (count, acc_time))| (api, *count, *acc_time))), vec![("zero".to_string(), 0)] ); assert_eq!(drive_api_latency_micros([].into_iter()), None); } #[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 = Timestamp::constant(1_700_000_000, 0); assert_eq!( current_scanner_cycle_age_seconds(false, now - jiff::SignedDuration::from_secs(30), now), 0 ); } #[test] fn current_scanner_cycle_age_seconds_clamps_future_start() { let now = Timestamp::constant(1_700_000_000, 0); assert_eq!(current_scanner_cycle_age_seconds(true, now + jiff::SignedDuration::from_secs(30), now), 0); } #[test] fn current_scanner_cycle_age_seconds_reports_active_first_cycle_elapsed_time() { let now = Timestamp::constant(1_700_000_000, 0); assert_eq!( current_scanner_cycle_age_seconds(true, now - jiff::SignedDuration::from_secs(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 ilm_action_task_stats_maps_runtime_states() { let stats = ilm_action_task_stats(&ObsIlmRuntimeSnapshot { expiry_pending_tasks: 1, transition_active_tasks: 2, transition_pending_tasks: 3, transition_missed_immediate_tasks: 4, transition_queue_full_tasks: 5, transition_queue_send_timeout_tasks: 6, transition_compensation_scheduled_tasks: 7, transition_compensation_running_tasks: 8, }); assert_eq!(stats.len(), 8); assert_eq!(stats[0].action, "expiry"); assert_eq!(stats[0].state, "pending"); assert_eq!(stats[0].value, 1); assert!( stats .iter() .any(|task| { task.action == "transition" && task.state == "queue_send_timeout" && task.value == 6 }) ); assert!( stats .iter() .any(|task| { task.action == "transition" && task.state == "compensation_running" && task.value == 8 }) ); } #[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 ilm_detail_stats_keep_expiry_and_transition_results_separate() { let report = ScannerMetricsReport { lifecycle_expiry: rustfs_common::metrics::ScannerLifecycleExpirySnapshot { current_queued: 2, current_active: 1, scanner_queued: 10, scanner_missed: 3, delete_failed: 4, ..Default::default() }, lifecycle_transition: rustfs_common::metrics::ScannerLifecycleTransitionSnapshot { current_queued: 5, current_active: 6, queue_full: 7, queue_send_timeout: 8, scanner_queued: 11, completed: 12, failed: 13, ..Default::default() }, ..Default::default() }; let queues = ilm_queue_task_stats(&report); assert!( queues .iter() .any(|task| task.action == "expiry" && task.state == "pending" && task.value == 2) ); assert!( queues .iter() .any(|task| task.action == "transition" && task.state == "active" && task.value == 6) ); let events = ilm_task_event_stats(&report); assert!( events .iter() .any(|event| event.action == "expiry" && event.result == "failed" && event.value == 4) ); assert!( events .iter() .any(|event| event.action == "transition" && event.result == "completed" && event.value == 12) ); assert!( events .iter() .any(|event| event.action == "transition" && event.result == "failed" && event.value == 13) ); let backpressure = ilm_backpressure_stats(&report); assert!( backpressure .iter() .any(|event| event.action == "transition" && event.reason == "queue_full" && event.value == 7) ); assert!( backpressure .iter() .any(|event| event.action == "transition" && event.reason == "send_timeout" && event.value == 8) ); } #[test] fn scanner_source_work_stats_sorts_and_skips_empty_source() { let stats = scanner_source_work_stats(&[ ScannerSourceWorkSnapshot { source: "usage".to_string(), checked: 11, queued: 2, executed: 3, failed: 4, skipped: 5, missed: 6, }, ScannerSourceWorkSnapshot { checked: 99, queued: 99, executed: 99, failed: 99, skipped: 99, missed: 99, ..Default::default() }, ScannerSourceWorkSnapshot { source: "lifecycle".to_string(), checked: 21, queued: 7, executed: 8, failed: 9, skipped: 10, missed: 12, }, ]); assert_eq!(stats.len(), 2); assert_eq!(stats[0].source, "lifecycle"); assert_eq!(stats[0].checked, 21); assert_eq!(stats[0].missed, 12); assert_eq!(stats[1].source, "usage"); assert_eq!(stats[1].failed, 4); } #[test] fn scanner_current_cycle_source_work_stats_zeroes_idle_sources() { let report = ScannerMetricsReport { source_work: vec![ScannerSourceWorkSnapshot { source: "usage".to_string(), checked: 11, queued: 2, ..Default::default() }], last_cycle_source_work: vec![ScannerSourceWorkSnapshot { source: "lifecycle".to_string(), checked: 21, queued: 7, ..Default::default() }], ..Default::default() }; let stats = scanner_current_cycle_source_work_stats(&report); assert_eq!(stats.len(), 2); assert_eq!(stats[0].source, "lifecycle"); assert_eq!(stats[0].checked, 0); assert_eq!(stats[1].source, "usage"); assert_eq!(stats[1].queued, 0); } #[test] fn scanner_current_cycle_source_work_stats_keeps_active_values() { let report = ScannerMetricsReport { source_work: vec![ScannerSourceWorkSnapshot { source: "usage".to_string(), checked: 11, ..Default::default() }], current_cycle_source_work: vec![ScannerSourceWorkSnapshot { source: "usage".to_string(), checked: 3, ..Default::default() }], ..Default::default() }; let stats = scanner_current_cycle_source_work_stats(&report); assert_eq!(stats.len(), 1); assert_eq!(stats[0].source, "usage"); assert_eq!(stats[0].checked, 3); } #[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); } }