Files
rustfs/crates/obs/src/metrics/stats_collector.rs
T
houseme da389c0e21 fix(replication): harden backlog observability (#5564)
Add RAII guards for replication runtime backlog tickets so active worker and queue counters unwind on every terminal path.

Expose node-local MRF pending, dropped, missed, and flush-failure metrics through the bucket replication Prometheus collector while keeping the existing current backlog and durable MRF gauges additive.

Update durable MRF summary maintenance to aggregate incrementally during the persister loop, avoiding repeated full-entry scans on each successful flush.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-08-01 11:12:58 +00:00

1669 lines
65 KiB
Rust

// 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::{
BucketReplicationBacklogStats, 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, 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 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, 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 = <ObsStore as StorageAdminApi>::StorageInfo;
type ObsBackendInfo = <ObsStore as StorageAdminApi>::BackendInfo;
#[derive(Default)]
struct ObsDataUsageInfo {
last_update: Option<SystemTime>,
usage_snapshot_complete: bool,
buckets_count: u64,
objects_total_count: u64,
versions_total_count: u64,
delete_markers_total_count: u64,
objects_total_size: u64,
buckets_usage: HashMap<String, ObsBucketUsageInfo>,
}
#[derive(Default)]
struct ObsBucketUsageInfo {
size: u64,
objects_count: u64,
object_size_histogram: HashMap<String, u64>,
object_versions_histogram: HashMap<String, u64>,
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<ObsStore>) -> ObsEcstoreResult<ObsDataUsageInfo> {
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,
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<u64>, Option<u64>) {
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<String>) -> 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<Arc<ObsStore>> {
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<Vec<ObsBucketReplicationBandwidthStats>> {
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<BucketReplicationStats>, Vec<BucketReplicationBacklogStats>) {
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,
});
detail_stats.push(bucket_replication_detail_from_snapshot(stats));
}
(detail_stats, backlog_stats)
}
fn bucket_replication_detail_from_snapshot(stats: ObsBucketReplicationStatsSnapshot) -> BucketReplicationStats {
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(),
}
}
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::<f64>();
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_active: bool,
current_started: chrono::DateTime<Utc>,
now: chrono::DateTime<Utc>,
) -> u64 {
if !current_cycle_active {
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<u64>) -> (&'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;
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<Vec<BucketStats>> {
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<BucketReplicationBandwidthStats> {
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<BucketReplicationStats> {
obs_bucket_replication_stats_snapshot()
.await
.into_iter()
.map(bucket_replication_detail_from_snapshot)
.collect()
}
pub(crate) async fn collect_bucket_replication_stats_bundle() -> (Vec<BucketReplicationStats>, Vec<BucketReplicationBacklogStats>)
{
obs_bucket_replication_stats_bundle().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<DiskStats> {
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<DiskStats>, Vec<DriveDetailedStats>, 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<DriveDetailedStats>, 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<NetworkStats> {
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<usize>,
standard_sc_parity: Option<usize>,
) -> Option<ClusterConfigStats> {
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<ClusterConfigStats> {
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<ErasureSetStats> {
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::<Vec<_>>();
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<ErasureSetStats> {
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<IamStats> {
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<BucketUsageStats>)> {
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::<HashSet<_>>();
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<String>,
) -> Option<(ClusterUsageStats, Vec<BucketUsageStats>)> {
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,
object_size_distribution: data_usage
.buckets_usage
.values()
.flat_map(|usage| usage.object_size_histogram.iter())
.fold(HashMap::<String, u64>::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::<String, u64>::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<IlmStats> {
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<String, u64>, 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<ScannerStats> {
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_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(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<CompressionClusterStats> {
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,
..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!(buckets.is_empty());
}
#[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 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(false, 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(true, now + chrono::Duration::seconds(30), now), 0);
}
#[test]
fn current_scanner_cycle_age_seconds_reports_active_first_cycle_elapsed_time() {
let now = Utc::now();
assert_eq!(current_scanner_cycle_age_seconds(true, 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);
}
}