Files
rustfs/crates/obs/src/metrics/stats_collector.rs
T
Henry Guo a63b79004c fix(scanner): make distributed usage convergence authoritative (#5151)
* fix(scanner): make distributed usage cycles authoritative

* fix(scanner): close distributed refresh races

* fix(config): align scanner reload integration

* fix(admin): scope config test helpers

* fix(scanner): harden distributed usage convergence

* fix(scanner): preserve rolling activity compatibility

* fix(admin): expose non-secret optional config values

* fix(scanner): acknowledge distributed dirty usage

* fix(ecstore): make bucket mutations cancellation safe

* fix(scanner): preserve pending dirty acknowledgements

* test(obs): account for superseded scanner metric

* fix(api): reject excess detached bucket mutations

* test: close scanner convergence coverage gaps

* fix(scanner): make path tracking cleanup one-shot

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-07-25 18:45:16 +08:00

1512 lines
59 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::{
BucketReplicationBandwidthStats, BucketReplicationStats, BucketReplicationTargetStats, BucketStats, BucketUsageStats,
ClusterConfigStats, ClusterHealthStats, ClusterStats, ClusterUsageStats, CompressionClusterStats, CpuStats, DiskStats,
DriveCountStats, DriveDetailedStats, ErasureSetStats, HostNetworkStats, IamStats, IlmStats, MemoryStats, NetworkStats,
ProcessStats, ProcessStatusType, ReplicationStats, ResourceStats, ScannerStats,
};
use crate::metrics::runtime_sources::{ObsIlmRuntimeSnapshot, bucket_monitor_handle, iam_metrics_snapshot, ilm_runtime_snapshot};
use crate::metrics::{
BucketOperations, BucketOptions, ObsEcstoreResult, ObsStore, StorageAdminApi, obs_bucket_replication_stats_snapshot,
obs_get_quota_config, obs_get_total_usable_capacity, obs_get_total_usable_capacity_free,
obs_load_compression_total_from_memory, obs_load_data_usage_from_backend, obs_replication_site_stats_snapshot,
obs_resolve_object_store_handle,
};
use chrono::Utc;
use rustfs_common::heal_channel::HealScanMode;
use rustfs_common::metrics::{ScannerMetricsReport, global_metrics};
use rustfs_io_metrics::internode_metrics::global_internode_metrics;
use rustfs_io_metrics::{
ProcessResourceSnapshot, ProcessSampler, ProcessStatusSnapshot, ProcessSystemSnapshot, snapshot_process_resource_and_system,
snapshot_process_resource_and_system_with,
};
use std::{collections::HashMap, sync::Arc, time::SystemTime};
use sysinfo::{Networks, System};
use tracing::{instrument, warn};
const LOG_COMPONENT_OBS: &str = "obs";
const LOG_SUBSYSTEM_METRICS_COLLECTOR: &str = "metrics_collector";
const EVENT_METRICS_COLLECTOR_STATE: &str = "metrics_collector_state";
type ObsStorageInfo = <ObsStore as StorageAdminApi>::StorageInfo;
type ObsBackendInfo = <ObsStore as StorageAdminApi>::BackendInfo;
struct ObsDataUsageInfo {
last_update: Option<SystemTime>,
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>,
}
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?;
Ok(ObsDataUsageInfo {
last_update: data_usage.last_update,
buckets_count: data_usage.buckets_count,
objects_total_count: data_usage.objects_total_count,
versions_total_count: data_usage.versions_total_count,
delete_markers_total_count: data_usage.delete_markers_total_count,
objects_total_size: data_usage.objects_total_size,
buckets_usage: data_usage
.buckets_usage
.into_iter()
.map(|(bucket, usage)| {
(
bucket,
ObsBucketUsageInfo {
size: usage.size,
objects_count: usage.objects_count,
object_size_histogram: usage.object_size_histogram,
object_versions_histogram: usage.object_versions_histogram,
versions_count: usage.versions_count,
delete_markers_count: usage.delete_markers_count,
},
)
})
.collect(),
})
}
fn resolve_obs_object_store_handle() -> Option<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_detail_stats() -> Vec<BucketReplicationStats> {
obs_bucket_replication_stats_snapshot()
.await
.into_iter()
.map(|stats| BucketReplicationStats {
bucket: stats.bucket,
total_failed_bytes: stats.total_failed_bytes,
total_failed_count: stats.total_failed_count,
last_min_failed_bytes: stats.last_min_failed_bytes,
last_min_failed_count: stats.last_min_failed_count,
last_hour_failed_bytes: stats.last_hour_failed_bytes,
last_hour_failed_count: stats.last_hour_failed_count,
sent_bytes: stats.sent_bytes,
sent_count: stats.sent_count,
proxied_get_requests_total: stats.proxied_get_requests_total,
proxied_get_requests_failures: stats.proxied_get_requests_failures,
proxied_head_requests_total: stats.proxied_head_requests_total,
proxied_head_requests_failures: stats.proxied_head_requests_failures,
proxied_put_requests_total: stats.proxied_put_requests_total,
proxied_put_requests_failures: stats.proxied_put_requests_failures,
proxied_put_tagging_requests_total: stats.proxied_put_tagging_requests_total,
proxied_put_tagging_requests_failures: stats.proxied_put_tagging_requests_failures,
proxied_get_tagging_requests_total: stats.proxied_get_tagging_requests_total,
proxied_get_tagging_requests_failures: stats.proxied_get_tagging_requests_failures,
proxied_delete_tagging_requests_total: stats.proxied_delete_tagging_requests_total,
proxied_delete_tagging_requests_failures: stats.proxied_delete_tagging_requests_failures,
resync_started_count: stats.resync_started_count,
resync_completed_count: stats.resync_completed_count,
resync_failed_count: stats.resync_failed_count,
resync_canceled_count: stats.resync_canceled_count,
resync_duration_ms: stats.resync_duration_ms,
targets: stats
.targets
.into_iter()
.map(|target| BucketReplicationTargetStats {
target_arn: target.target_arn,
bandwidth_limit_bytes_per_sec: target.bandwidth_limit_bytes_per_sec,
current_bandwidth_bytes_per_sec: target.current_bandwidth_bytes_per_sec,
latency_ms: target.latency_ms,
})
.collect(),
})
.collect()
}
async fn obs_site_replication_stats() -> ReplicationStats {
let current_data_transfer_rate = obs_bucket_replication_bandwidth_stats()
.into_iter()
.flatten()
.map(|stat| stat.current_bandwidth_bytes_per_sec)
.sum::<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: u64,
current_started: chrono::DateTime<Utc>,
now: chrono::DateTime<Utc>,
) -> u64 {
if current_cycle == 0 {
0
} else {
now.signed_duration_since(current_started).num_seconds().max(0) as u64
}
}
fn scanner_scan_mode_code(scan_mode: &str) -> u64 {
match scan_mode {
mode if mode == HealScanMode::Normal.as_str() => HealScanMode::Normal as u8 as u64,
mode if mode == HealScanMode::Deep.as_str() => HealScanMode::Deep as u8 as u64,
_ => HealScanMode::Unknown as u8 as u64,
}
}
fn scanner_work_rate_per_second(count: u64, seconds: f64) -> f64 {
if seconds > 0.0 && seconds.is_finite() {
count as f64 / seconds
} else {
0.0
}
}
const DRIVE_STATE_OK: &str = "ok";
const DRIVE_STATE_ONLINE: &str = "online";
const DRIVE_STATE_UNFORMATTED: &str = "unformatted";
const DRIVE_RUNTIME_STATE_RETURNING: &str = "returning";
const CAPACITY_OBSERVATION_LIVE: &str = "live";
const CAPACITY_OBSERVATION_STALE: &str = "stale";
const CAPACITY_OBSERVATION_MISSING: &str = "missing";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ErasureSetQuorumShape {
data_shards: u32,
read_quorum: u32,
write_quorum: u32,
read_tolerance: u32,
write_tolerance: u32,
}
fn disk_is_online_for_metrics(state: &str, runtime_state: Option<&str>) -> bool {
let state_is_acceptable = state.eq_ignore_ascii_case(DRIVE_STATE_OK)
|| state.eq_ignore_ascii_case(DRIVE_STATE_ONLINE)
|| state.eq_ignore_ascii_case(DRIVE_STATE_UNFORMATTED);
if let Some(runtime_state) = runtime_state {
let runtime_state_is_acceptable = runtime_state.eq_ignore_ascii_case(DRIVE_STATE_ONLINE)
|| runtime_state.eq_ignore_ascii_case(DRIVE_RUNTIME_STATE_RETURNING);
return runtime_state_is_acceptable && state_is_acceptable;
}
state_is_acceptable
}
fn disk_capacity_observation_state(source: Option<&str>, age_seconds: Option<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;
// Get bucket and object counts from data usage info.
let (buckets_count, objects_count) = match load_obs_data_usage_from_backend(store.clone()).await {
Ok(data_usage) => (data_usage.buckets_count, data_usage.objects_total_count),
Err(e) => {
warn!(event = EVENT_METRICS_COLLECTOR_STATE, component = LOG_COMPONENT_OBS, subsystem = LOG_SUBSYSTEM_METRICS_COLLECTOR, collector = "cluster_stats", result = "data_usage_load_failed", error = %e, "metrics collector state changed");
// Fall back to bucket list for buckets_count, objects_count stays 0.
let buckets = store
.list_bucket(&BucketOptions {
cached: true,
..Default::default()
})
.await
.unwrap_or_else(|err| {
warn!(event = EVENT_METRICS_COLLECTOR_STATE, component = LOG_COMPONENT_OBS, subsystem = LOG_SUBSYSTEM_METRICS_COLLECTOR, collector = "cluster_stats", result = "bucket_list_failed", error = %err, "metrics collector state changed");
Vec::new()
});
(buckets.len() as u64, 0)
}
};
let mut online = 0u64;
let mut offline = 0u64;
for disk in &storage_info.disks {
if disk_is_online_for_metrics(disk.state.as_str(), disk.runtime_state.as_deref()) {
online += 1;
} else {
offline += 1;
}
}
(
ClusterStats {
raw_capacity_bytes: raw_capacity,
usable_capacity_bytes: usable_capacity,
used_bytes: used,
free_bytes: free,
stale_capacity_drives,
missing_capacity_drives,
objects_count,
buckets_count,
},
ClusterHealthStats {
drives_offline_count: offline,
drives_online_count: online,
drives_count: storage_info.disks.len() as u64,
},
)
}
/// Collect cluster statistics from the storage layer.
#[instrument]
pub async fn collect_cluster_stats() -> ClusterStats {
let (cluster_stats, _) = collect_cluster_and_health_stats().await;
cluster_stats
}
/// Collect cluster health statistics from the storage layer.
pub async fn collect_cluster_health_stats() -> ClusterHealthStats {
let (_, cluster_health_stats) = collect_cluster_and_health_stats().await;
cluster_health_stats
}
/// Collect bucket statistics from the storage layer.
pub async fn collect_bucket_stats() -> Vec<BucketStats> {
let Some(store) = resolve_obs_object_store_handle() else {
return Vec::new();
};
// Load data usage info from backend to get bucket sizes and object counts
let data_usage = match load_obs_data_usage_from_backend(store.clone()).await {
Ok(info) => Some(info),
Err(e) => {
warn!(event = EVENT_METRICS_COLLECTOR_STATE, component = LOG_COMPONENT_OBS, subsystem = LOG_SUBSYSTEM_METRICS_COLLECTOR, collector = "bucket_stats", result = "data_usage_load_failed", error = %e, "metrics collector state changed");
None
}
};
// List all buckets
let buckets = match store
.list_bucket(&BucketOptions {
cached: true,
..Default::default()
})
.await
{
Ok(buckets) => buckets,
Err(e) => {
warn!(event = EVENT_METRICS_COLLECTOR_STATE, component = LOG_COMPONENT_OBS, subsystem = LOG_SUBSYSTEM_METRICS_COLLECTOR, collector = "bucket_stats", result = "bucket_list_failed", error = %e, "metrics collector state changed");
return Vec::new();
}
};
let mut stats = Vec::with_capacity(buckets.len());
for bucket in buckets {
if bucket.name.starts_with('.') {
continue;
}
// Get size and objects_count from data usage info
let (size_bytes, objects_count) = data_usage
.as_ref()
.and_then(|du| du.buckets_usage.get(&bucket.name))
.map(|bui| (bui.size, bui.objects_count))
.unwrap_or((0, 0));
// Get quota from bucket metadata
let quota_bytes = obs_bucket_quota_limit_bytes(&bucket.name).await;
stats.push(BucketStats {
name: bucket.name,
size_bytes,
objects_count,
quota_bytes,
});
}
stats
}
/// Collect bucket replication bandwidth stats from the global monitor.
pub fn collect_bucket_replication_bandwidth_stats() -> Vec<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_detail_stats().await
}
/// Collect site-level replication stats from the global replication runtime.
pub async fn collect_replication_stats() -> ReplicationStats {
obs_site_replication_stats().await
}
/// Collect disk statistics from the storage layer.
pub async fn collect_disk_stats() -> Vec<DiskStats> {
let (disk_stats, _, _) = collect_disk_and_system_drive_stats().await;
disk_stats
}
fn build_system_cpu_stats(system: &System) -> CpuStats {
let cpu_usage = system.global_cpu_usage() as f64;
let cpu_count = system.cpus().len().max(1) as f64;
let load_avg = System::load_average().one;
CpuStats {
avg_idle: (100.0 - cpu_usage).max(0.0),
load_avg,
load_avg_perc: (load_avg / cpu_count) * 100.0,
usage_perc: cpu_usage,
}
}
fn build_system_memory_stats(system: &System) -> MemoryStats {
let total = system.total_memory();
let used = system.used_memory();
MemoryStats {
total,
used,
used_perc: if total > 0 {
(used as f64 / total as f64) * 100.0
} else {
0.0
},
free: system.free_memory(),
buffers: 0,
cache: 0,
shared: 0,
available: system.available_memory(),
}
}
/// Collect system CPU and memory statistics from a shared sysinfo snapshot.
pub fn collect_system_cpu_and_memory_stats() -> (CpuStats, MemoryStats) {
let mut system = System::new_all();
collect_system_cpu_and_memory_stats_with(&mut system)
}
/// Collect system CPU and memory statistics by refreshing a reusable sysinfo instance.
pub fn collect_system_cpu_and_memory_stats_with(system: &mut System) -> (CpuStats, MemoryStats) {
system.refresh_cpu_all();
system.refresh_memory();
(build_system_cpu_stats(system), build_system_memory_stats(system))
}
/// Collect system CPU statistics from the current host.
pub fn collect_system_cpu_stats() -> CpuStats {
let (cpu_stats, _) = collect_system_cpu_and_memory_stats();
cpu_stats
}
/// Collect system memory statistics from the current host.
pub fn collect_system_memory_stats() -> MemoryStats {
let (_, memory_stats) = collect_system_cpu_and_memory_stats();
memory_stats
}
/// Collect node disk stats and drive stats from a single storage snapshot.
pub async fn collect_disk_and_system_drive_stats() -> (Vec<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 status = match process_snapshot.status {
ProcessStatusSnapshot::Running => ProcessStatusType::Running,
ProcessStatusSnapshot::Sleeping => ProcessStatusType::Sleeping,
ProcessStatusSnapshot::Zombie => ProcessStatusType::Zombie,
ProcessStatusSnapshot::Other => ProcessStatusType::Other,
};
let resource_stats = ResourceStats {
cpu_percent: resource_snapshot.cpu_percent,
memory_bytes: resource_snapshot.memory_bytes,
uptime_seconds: resource_snapshot.uptime_seconds,
};
let process_stats = ProcessStats {
locks_read_total: process_snapshot.locks_read_total,
locks_write_total: process_snapshot.locks_write_total,
cpu_total_seconds: process_snapshot.cpu_total_seconds,
file_descriptor_limit_total: process_snapshot.file_descriptor_limit_total,
file_descriptor_open_total: process_snapshot.file_descriptor_open_total,
go_routine_total: process_snapshot.go_routine_total,
io_rchar_bytes: process_snapshot.io_rchar_bytes,
io_read_bytes: process_snapshot.io_read_bytes,
io_wchar_bytes: process_snapshot.io_wchar_bytes,
io_write_bytes: process_snapshot.io_write_bytes,
resident_memory_bytes: process_snapshot.resident_memory_bytes,
start_time_seconds: process_snapshot.start_time_seconds,
status,
status_value: process_snapshot.status_value,
syscall_read_total: process_snapshot.syscall_read_total,
syscall_write_total: process_snapshot.syscall_write_total,
uptime_seconds: process_snapshot.uptime_seconds,
virtual_memory_bytes: process_snapshot.virtual_memory_bytes,
virtual_memory_max_bytes: process_snapshot.virtual_memory_max_bytes,
};
ProcessMetricBundle {
resource: resource_stats,
process: process_stats,
disk_read_bytes: process_snapshot.disk_read_bytes,
disk_write_bytes: process_snapshot.disk_write_bytes,
}
}
/// Collect resource and process statistics for the current process in one sysinfo refresh.
#[inline]
pub fn collect_process_resource_and_system_stats() -> (ResourceStats, ProcessStats) {
let bundle = collect_process_metric_bundle();
(bundle.resource, bundle.process)
}
/// Collect resource statistics for the current process.
#[inline]
pub fn collect_process_stats() -> ResourceStats {
collect_process_metric_bundle().resource
}
/// Collect process statistics for the current process.
#[inline]
pub fn collect_process_system_stats() -> ProcessStats {
collect_process_metric_bundle().process
}
/// Collect host network statistics from a refreshed network interface snapshot.
///
/// These counters come from system interfaces and are host-wide, not process-scoped.
pub fn collect_host_network_stats_with(networks: &Networks) -> HostNetworkStats {
let mut total_received = 0u64;
let mut total_transmitted = 0u64;
let mut per_interface = Vec::with_capacity(networks.len());
for (interface_name, data) in networks {
let received = data.received();
let transmitted = data.transmitted();
total_received += received;
total_transmitted += transmitted;
per_interface.push((interface_name.to_string(), received, transmitted));
}
HostNetworkStats {
total_received,
total_transmitted,
per_interface,
}
}
/// Collect host network statistics using a persistent `sysinfo::Networks` snapshot.
///
/// `sysinfo` reports network I/O as deltas since the previous refresh, so
/// callers must reuse the same `Networks` instance across collection ticks.
pub fn collect_host_network_stats(networks: &mut Networks) -> HostNetworkStats {
networks.refresh(true);
collect_host_network_stats_with(networks)
}
/// Collect internode network metrics from the global internode metrics snapshot.
///
/// The returned values come directly from `global_internode_metrics().snapshot()`
/// and currently include only the counters and dial timing data tracked by the
/// internode metrics runtime.
pub fn collect_internode_network_stats() -> Option<NetworkStats> {
let snapshot = global_internode_metrics().snapshot();
Some(NetworkStats {
internode_errors_total: snapshot.errors_total,
internode_dial_errors_total: snapshot.dial_errors_total,
internode_dial_avg_time_nanos: snapshot.dial_avg_time_nanos,
internode_sent_bytes_total: snapshot.sent_bytes_total,
internode_recv_bytes_total: snapshot.recv_bytes_total,
})
}
/// Collect cluster config metrics from backend parity configuration.
fn cluster_config_stats_from_backend_parities(
rr_sc_parity: Option<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 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, 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 cluster_config_stats_accept_homogeneous_backend_parities() {
let stats = cluster_config_stats_from_backend_parities(Some(1), Some(2))
.expect("homogeneous scalar parities should produce cluster config metrics");
assert_eq!(stats.rrs_parity, 1);
assert_eq!(stats.standard_parity, 2);
}
#[test]
fn cluster_config_stats_skip_heterogeneous_backend_parities() {
assert!(cluster_config_stats_from_backend_parities(Some(1), None).is_none());
assert!(cluster_config_stats_from_backend_parities(None, Some(2)).is_none());
}
#[test]
fn cluster_config_stats_reject_parity_larger_than_u32() {
let Ok(overflow) = usize::try_from(u64::from(u32::MAX) + 1) else {
return;
};
assert!(cluster_config_stats_from_backend_parities(Some(overflow), Some(2)).is_none());
assert!(cluster_config_stats_from_backend_parities(Some(1), Some(overflow)).is_none());
}
#[test]
fn erasure_set_stats_skip_unknown_backend_layout() {
let storage_info = storage_info_with_one_online_disk();
let backend = ObsBackendInfo {
drives_per_set: vec![8],
..Default::default()
};
assert!(erasure_set_stats_from_backend(&storage_info, &backend).is_empty());
}
#[test]
fn erasure_set_stats_skip_inconsistent_exact_layout_without_scalar_fallback() {
let storage_info = storage_info_with_one_online_disk();
let backend = ObsBackendInfo {
standard_sc_data: vec![6],
standard_sc_parities: vec![4],
standard_sc_parity: Some(2),
drives_per_set: vec![8],
..Default::default()
};
assert!(erasure_set_stats_from_backend(&storage_info, &backend).is_empty());
}
#[test]
fn erasure_set_stats_accept_truthful_legacy_scalar_layout() {
let storage_info = storage_info_with_one_online_disk();
let backend = ObsBackendInfo {
standard_sc_parity: Some(2),
drives_per_set: vec![8],
..Default::default()
};
let stats = erasure_set_stats_from_backend(&storage_info, &backend);
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].size, 8);
assert_eq!(stats[0].parity, 2);
assert_eq!(stats[0].data_shards, 6);
}
#[test]
fn erasure_set_stats_exact_data_ignores_stale_scalar() {
let storage_info = storage_info_with_one_online_disk();
let backend = ObsBackendInfo {
standard_sc_data: vec![6],
standard_sc_parity: Some(4),
drives_per_set: vec![8],
..Default::default()
};
let stats = erasure_set_stats_from_backend(&storage_info, &backend);
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].parity, 2);
assert_eq!(stats[0].data_shards, 6);
}
fn generate_loopback_traffic() -> std::io::Result<()> {
let listener = TcpListener::bind(("127.0.0.1", 0))?;
let addr = listener.local_addr()?;
let payload = vec![0x5Au8; 64 * 1024];
let expected_len = payload.len();
let server = thread::spawn(move || -> std::io::Result<()> {
let (mut stream, _) = listener.accept()?;
let mut received = 0usize;
let mut buf = [0u8; 8192];
while received < expected_len {
let read = stream.read(&mut buf)?;
if read == 0 {
break;
}
received += read;
}
Ok(())
});
let mut client = TcpStream::connect(addr)?;
client.write_all(&payload)?;
client.flush()?;
client.shutdown(Shutdown::Write)?;
server
.join()
.expect("loopback traffic server thread should complete successfully")?;
Ok(())
}
#[test]
fn disk_is_online_for_metrics_accepts_online_state_case_insensitive() {
assert!(disk_is_online_for_metrics("OnLiNe", Some("online")));
}
#[test]
fn disk_is_online_for_metrics_rejects_offline_runtime_state() {
assert!(!disk_is_online_for_metrics(DRIVE_STATE_OK, Some("offline")));
}
#[test]
fn derive_erasure_set_quorum_shape_handles_standard_layout() {
let shape = derive_erasure_set_quorum_shape(16, 4);
assert_eq!(
shape,
ErasureSetQuorumShape {
data_shards: 12,
read_quorum: 12,
write_quorum: 12,
read_tolerance: 4,
write_tolerance: 4,
}
);
}
#[test]
fn derive_erasure_set_quorum_shape_handles_equal_data_and_parity() {
let shape = derive_erasure_set_quorum_shape(4, 2);
assert_eq!(
shape,
ErasureSetQuorumShape {
data_shards: 2,
read_quorum: 2,
write_quorum: 3,
read_tolerance: 2,
write_tolerance: 1,
}
);
}
#[test]
fn apply_erasure_set_health_marks_read_and_write_health_from_online_count() {
let mut stats = ErasureSetStats {
read_quorum: 3,
write_quorum: 4,
online_drives_count: 3,
..Default::default()
};
apply_erasure_set_health(&mut stats);
assert_eq!(stats.read_health, 1);
assert_eq!(stats.write_health, 0);
assert_eq!(stats.health, 0);
stats.online_drives_count = 4;
apply_erasure_set_health(&mut stats);
assert_eq!(stats.read_health, 1);
assert_eq!(stats.write_health, 1);
assert_eq!(stats.health, 1);
}
#[test]
fn current_scanner_cycle_age_seconds_returns_zero_when_idle() {
let now = Utc::now();
assert_eq!(current_scanner_cycle_age_seconds(0, now - chrono::Duration::seconds(30), now), 0);
}
#[test]
fn current_scanner_cycle_age_seconds_clamps_future_start() {
let now = Utc::now();
assert_eq!(current_scanner_cycle_age_seconds(4, now + chrono::Duration::seconds(30), now), 0);
}
#[test]
fn current_scanner_cycle_age_seconds_reports_active_elapsed_time() {
let now = Utc::now();
assert_eq!(current_scanner_cycle_age_seconds(4, now - chrono::Duration::seconds(45), now), 45);
}
#[test]
fn scanner_scan_mode_code_maps_known_modes() {
assert_eq!(scanner_scan_mode_code(HealScanMode::Normal.as_str()), HealScanMode::Normal as u8 as u64);
assert_eq!(scanner_scan_mode_code(HealScanMode::Deep.as_str()), HealScanMode::Deep as u8 as u64);
}
#[test]
fn scanner_scan_mode_code_maps_unknown_mode() {
assert_eq!(scanner_scan_mode_code(""), HealScanMode::Unknown as u8 as u64);
}
#[test]
fn scanner_bucket_scans_started_uses_explicit_started_count() {
let mut life_time_ops = HashMap::new();
life_time_ops.insert("scan_bucket_drive_start".to_string(), 7);
assert_eq!(scanner_bucket_scans_started(&life_time_ops, 5), 7);
}
#[test]
fn scanner_bucket_scans_started_falls_back_to_finished_count() {
let life_time_ops = HashMap::new();
assert_eq!(scanner_bucket_scans_started(&life_time_ops, 5), 5);
}
#[test]
fn scanner_lifecycle_checked_versions_uses_lifecycle_checked_source_work() {
let report = ScannerMetricsReport {
source_work: vec![
ScannerSourceWorkSnapshot {
source: "usage".to_string(),
checked: 11,
..Default::default()
},
ScannerSourceWorkSnapshot {
source: "lifecycle".to_string(),
checked: 37,
..Default::default()
},
],
life_time_ilm: HashMap::from([("TransitionAction".to_string(), 5), ("DeleteAction".to_string(), 7)]),
..Default::default()
};
assert_eq!(scanner_lifecycle_checked_versions(&report), 37);
}
#[test]
fn scanner_lifecycle_checked_versions_defaults_to_zero_when_lifecycle_missing() {
let report = ScannerMetricsReport {
source_work: vec![ScannerSourceWorkSnapshot {
source: "usage".to_string(),
checked: 11,
..Default::default()
}],
..Default::default()
};
assert_eq!(scanner_lifecycle_checked_versions(&report), 0);
}
#[test]
fn host_network_stats_require_a_persistent_networks_snapshot() -> std::io::Result<()> {
if !sysinfo::IS_SUPPORTED_SYSTEM {
return Ok(());
}
let mut persistent_networks = Networks::new();
persistent_networks.refresh(true);
let initial_stats = collect_host_network_stats_with(&persistent_networks);
assert_eq!(
initial_stats.total_received + initial_stats.total_transmitted,
0,
"the first refresh only seeds sysinfo's baseline snapshot"
);
match generate_loopback_traffic() {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
return Ok(());
}
Err(err) => return Err(err),
}
thread::sleep(Duration::from_millis(100));
let refreshed_stats = collect_host_network_stats(&mut persistent_networks);
assert!(
refreshed_stats.total_received > 0 || refreshed_stats.total_transmitted > 0,
"a persistent Networks instance should report non-zero loopback deltas after traffic"
);
let recreated_stats = collect_host_network_stats_with(&Networks::new_with_refreshed_list());
assert_eq!(
recreated_stats.total_received + recreated_stats.total_transmitted,
0,
"recreating Networks loses the prior refresh baseline and yields zero deltas"
);
Ok(())
}
#[test]
fn scanner_work_rate_per_second_reports_rate() {
assert_eq!(scanner_work_rate_per_second(90, 45.0), 2.0);
}
#[test]
fn scanner_work_rate_per_second_returns_zero_for_invalid_seconds() {
assert_eq!(scanner_work_rate_per_second(90, 0.0), 0.0);
assert_eq!(scanner_work_rate_per_second(90, f64::INFINITY), 0.0);
assert_eq!(scanner_work_rate_per_second(90, f64::NAN), 0.0);
}
#[test]
fn usable_capacity_used_bytes_matches_usable_total_minus_free() {
assert_eq!(usable_capacity_used_bytes(240, 90), 150);
}
#[test]
fn usable_capacity_used_bytes_saturates_when_free_exceeds_total() {
assert_eq!(usable_capacity_used_bytes(90, 240), 0);
}
}