Files
rustfs/crates/obs/src/metrics/stats_collector.rs
T
Zhengchao An d22cb5d07a fix: resolve release-blocking integration failures (#6320)
* fix: resolve release-blocking integration failures

* fix: satisfy stable clippy lints

* fix: satisfy Rust 1.98 CI lints
2026-08-21 06:39:29 +08:00

2353 lines
91 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.
//! Statistics collection functions for metrics.
//!
//! This module contains functions that collect statistics from various
//! RustFS internal sources (storage layer, bucket monitor, system info)
//! and convert them to the Stats structs used by collectors.
use crate::metrics::collectors::scanner::{ScannerActiveBucketDriveStats, ScannerBucketDriveResultStats, ScannerSourceWorkStats};
use crate::metrics::collectors::{
ApiRequestMetricSupport, ApiRequestStats, BucketReplicationBacklogStats, BucketReplicationBandwidthStats,
BucketReplicationRuntimeStats, BucketReplicationStats, BucketReplicationTargetBacklogStats, BucketReplicationTargetFlowStats,
BucketReplicationTargetStats, BucketStats, BucketUsageStats, ClusterConfigStats, ClusterHealthStats, ClusterStats,
ClusterUsageStats, CompressionClusterStats, CpuStats, DiskStats, DriveCountStats, DriveDetailedStats,
DriveRuntimeDetailedStats, ErasureSetStats, HostNetworkStats, IamStats, IlmActionTaskStats, IlmBackpressureStats,
IlmQueueTaskStats, IlmRuntimeStats, IlmStats, IlmTaskEventStats, MemoryStats, NetworkStats, ProcessStats, ProcessStatusType,
ReplicationStats, ResourceStats, ScannerRuntimeStats, ScannerStats,
};
use crate::metrics::runtime_sources::{ObsIlmRuntimeSnapshot, bucket_monitor_handle, iam_metrics_snapshot, ilm_runtime_snapshot};
use crate::metrics::{
BucketOperations, BucketOptions, ObsBucketReplicationStatsSnapshot, ObsEcstoreResult, ObsStore, StorageAdminApi,
obs_bucket_replication_stats_snapshot, obs_get_quota_config, obs_get_total_usable_capacity,
obs_get_total_usable_capacity_free, obs_load_compression_total_from_memory, obs_load_data_usage_from_backend,
obs_replication_site_stats_snapshot, obs_resolve_object_store_handle,
};
use crate::node_identity::current_local_node_identity;
use jiff::Timestamp;
use rustfs_common::heal_channel::HealScanMode;
use rustfs_common::metrics::{
ScannerActiveBucketDriveSnapshot, ScannerBucketDriveResultSnapshot, ScannerMetricsReport, ScannerSourceWorkSnapshot,
global_metrics,
};
use rustfs_io_metrics::internode_metrics::global_internode_metrics;
use rustfs_io_metrics::{
ProcessResourceSnapshot, ProcessSampler, ProcessStatusSnapshot, ProcessSystemSnapshot, s3_op_metrics_snapshot,
snapshot_process_resource_and_system, snapshot_process_resource_and_system_with,
};
use std::{
collections::{HashMap, HashSet},
sync::Arc,
time::SystemTime,
};
use sysinfo::{Networks, System};
use tracing::{instrument, warn};
const LOG_COMPONENT_OBS: &str = "obs";
const LOG_SUBSYSTEM_METRICS_COLLECTOR: &str = "metrics_collector";
const EVENT_METRICS_COLLECTOR_STATE: &str = "metrics_collector_state";
type ObsStorageInfo = <ObsStore as StorageAdminApi>::StorageInfo;
type ObsBackendInfo = <ObsStore as StorageAdminApi>::BackendInfo;
#[derive(Default)]
struct ObsDataUsageInfo {
last_update: Option<SystemTime>,
usage_snapshot_complete: bool,
usage_snapshot_converged: bool,
buckets_count: u64,
objects_total_count: u64,
versions_total_count: u64,
delete_markers_total_count: u64,
objects_total_size: u64,
buckets_usage: HashMap<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,
usage_snapshot_converged: data_usage.usage_snapshot_converged == Some(true),
buckets_count: data_usage.buckets_count,
objects_total_count: data_usage.objects_total_count,
versions_total_count: data_usage.versions_total_count,
delete_markers_total_count: data_usage.delete_markers_total_count,
objects_total_size: data_usage.objects_total_size,
buckets_usage: data_usage
.buckets_usage
.into_iter()
.map(|(bucket, usage)| {
(
bucket,
ObsBucketUsageInfo {
size: usage.size,
objects_count: usage.objects_count,
object_size_histogram: usage.object_size_histogram,
object_versions_histogram: usage.object_versions_histogram,
versions_count: usage.versions_count,
delete_markers_count: usage.delete_markers_count,
},
)
})
.collect(),
})
}
fn bucket_usage_metric_values(data_usage: Option<&ObsDataUsageInfo>, bucket: &str) -> (Option<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<BucketReplicationRuntimeStats>, 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,
target_backlogs: stats
.target_backlogs
.iter()
.map(|target| BucketReplicationTargetBacklogStats {
target_arn: target.target_arn.clone(),
current_backlog_count: target.current_backlog_count,
current_backlog_bytes: target.current_backlog_bytes,
durable_mrf_backlog_count: target.durable_mrf_backlog_count,
durable_mrf_backlog_bytes: target.durable_mrf_backlog_bytes,
})
.collect(),
});
detail_stats.push(bucket_replication_detail_from_snapshot(stats));
}
(detail_stats, backlog_stats)
}
fn bucket_replication_detail_from_snapshot(stats: ObsBucketReplicationStatsSnapshot) -> BucketReplicationRuntimeStats {
let bucket = stats.bucket;
let (targets, target_flows): (Vec<_>, Vec<_>) = stats
.targets
.into_iter()
.map(|target| {
(
BucketReplicationTargetStats {
target_arn: target.target_arn.clone(),
bandwidth_limit_bytes_per_sec: target.bandwidth_limit_bytes_per_sec,
current_bandwidth_bytes_per_sec: target.current_bandwidth_bytes_per_sec,
latency_ms: target.latency_ms,
},
BucketReplicationTargetFlowStats {
target_arn: target.target_arn,
sent_bytes: target.sent_bytes,
sent_count: target.sent_count,
total_failed_bytes: target.total_failed_bytes,
total_failed_count: target.total_failed_count,
last_min_failed_bytes: target.last_min_failed_bytes,
last_min_failed_count: target.last_min_failed_count,
last_hour_failed_bytes: target.last_hour_failed_bytes,
last_hour_failed_count: target.last_hour_failed_count,
},
)
})
.unzip();
BucketReplicationRuntimeStats {
target_flows,
stats: BucketReplicationStats {
bucket,
total_failed_bytes: stats.total_failed_bytes,
total_failed_count: stats.total_failed_count,
last_min_failed_bytes: stats.last_min_failed_bytes,
last_min_failed_count: stats.last_min_failed_count,
last_hour_failed_bytes: stats.last_hour_failed_bytes,
last_hour_failed_count: stats.last_hour_failed_count,
sent_bytes: stats.sent_bytes,
sent_count: stats.sent_count,
proxied_get_requests_total: stats.proxied_get_requests_total,
proxied_get_requests_failures: stats.proxied_get_requests_failures,
proxied_head_requests_total: stats.proxied_head_requests_total,
proxied_head_requests_failures: stats.proxied_head_requests_failures,
proxied_put_requests_total: stats.proxied_put_requests_total,
proxied_put_requests_failures: stats.proxied_put_requests_failures,
proxied_put_tagging_requests_total: stats.proxied_put_tagging_requests_total,
proxied_put_tagging_requests_failures: stats.proxied_put_tagging_requests_failures,
proxied_get_tagging_requests_total: stats.proxied_get_tagging_requests_total,
proxied_get_tagging_requests_failures: stats.proxied_get_tagging_requests_failures,
proxied_delete_tagging_requests_total: stats.proxied_delete_tagging_requests_total,
proxied_delete_tagging_requests_failures: stats.proxied_delete_tagging_requests_failures,
resync_started_count: stats.resync_started_count,
resync_completed_count: stats.resync_completed_count,
resync_failed_count: stats.resync_failed_count,
resync_canceled_count: stats.resync_canceled_count,
resync_duration_ms: stats.resync_duration_ms,
targets,
},
}
}
async fn obs_site_replication_stats() -> 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: Timestamp, now: Timestamp) -> u64 {
if !current_cycle_active {
0
} else {
timestamp_elapsed_seconds_since(now, current_started)
}
}
fn timestamp_elapsed_seconds_since(now: Timestamp, earlier: Timestamp) -> u64 {
let duration = now.duration_since(earlier);
if duration.is_negative() {
return 0;
}
u64::try_from(duration.as_secs()).unwrap_or(u64::MAX)
}
fn scanner_scan_mode_code(scan_mode: &str) -> u64 {
match scan_mode {
mode if mode == HealScanMode::Normal.as_str() => HealScanMode::Normal as u8 as u64,
mode if mode == HealScanMode::Deep.as_str() => HealScanMode::Deep as u8 as u64,
_ => HealScanMode::Unknown as u8 as u64,
}
}
fn scanner_work_rate_per_second(count: u64, seconds: f64) -> f64 {
if seconds > 0.0 && seconds.is_finite() {
count as f64 / seconds
} else {
0.0
}
}
const DRIVE_STATE_OK: &str = "ok";
const DRIVE_STATE_ONLINE: &str = "online";
const DRIVE_STATE_UNFORMATTED: &str = "unformatted";
const DRIVE_RUNTIME_STATE_RETURNING: &str = "returning";
const CAPACITY_OBSERVATION_LIVE: &str = "live";
const CAPACITY_OBSERVATION_STALE: &str = "stale";
const CAPACITY_OBSERVATION_MISSING: &str = "missing";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ErasureSetQuorumShape {
data_shards: u32,
read_quorum: u32,
write_quorum: u32,
read_tolerance: u32,
write_tolerance: u32,
}
fn disk_is_online_for_metrics(state: &str, runtime_state: Option<&str>) -> bool {
let state_is_acceptable = state.eq_ignore_ascii_case(DRIVE_STATE_OK)
|| state.eq_ignore_ascii_case(DRIVE_STATE_ONLINE)
|| state.eq_ignore_ascii_case(DRIVE_STATE_UNFORMATTED);
if let Some(runtime_state) = runtime_state {
let runtime_state_is_acceptable = runtime_state.eq_ignore_ascii_case(DRIVE_STATE_ONLINE)
|| runtime_state.eq_ignore_ascii_case(DRIVE_RUNTIME_STATE_RETURNING);
return runtime_state_is_acceptable && state_is_acceptable;
}
state_is_acceptable
}
fn disk_capacity_observation_state(source: Option<&str>, age_seconds: Option<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 disk_topology_label(index: i32) -> Option<String> {
if index >= 0 { Some(index.to_string()) } else { None }
}
fn non_empty_disk_id(uuid: &str) -> Option<String> {
let uuid = uuid.trim();
if uuid.is_empty() { None } else { Some(uuid.to_string()) }
}
fn drive_inode_stats(used_inodes: u64, free_inodes: u64) -> (Option<u64>, Option<u64>, Option<u64>) {
let total_inodes = used_inodes.saturating_add(free_inodes);
if total_inodes == 0 {
(None, None, None)
} else {
(Some(used_inodes), Some(free_inodes), Some(total_inodes))
}
}
fn drive_api_latency_micros(actions: impl Iterator<Item = (u64, u64)>) -> Option<u64> {
let mut count = 0u64;
let mut acc_time_ns = 0u64;
let mut saw_action = false;
for (action_count, action_acc_time_ns) in actions {
saw_action = true;
if action_count > 0 {
count = count.saturating_add(action_count);
acc_time_ns = acc_time_ns.saturating_add(action_acc_time_ns);
}
}
saw_action.then(|| acc_time_ns.checked_div(count).unwrap_or_default() / 1_000)
}
fn drive_api_latency_by_api_micros<'a>(actions: impl Iterator<Item = (&'a String, u64, u64)>) -> Vec<(String, u64)> {
let mut values = actions
.map(|(api, count, acc_time)| (api.clone(), acc_time.checked_div(count).unwrap_or_default() / 1_000))
.collect::<Vec<_>>();
values.sort_by(|left, right| left.0.cmp(&right.0));
values
}
fn drive_api_calls<'a>(api_calls: impl Iterator<Item = (&'a String, &'a u64)>) -> Vec<(String, u64)> {
let mut values = api_calls.map(|(api, calls)| (api.clone(), *calls)).collect::<Vec<_>>();
values.sort_by(|left, right| left.0.cmp(&right.0));
values
}
fn drive_server_label(endpoint: &str, local_server: &str) -> String {
endpoint
.strip_prefix("http://")
.or_else(|| endpoint.strip_prefix("https://"))
.and_then(|rest| rest.split('/').next())
.filter(|authority| !authority.is_empty())
.unwrap_or(local_server)
.to_string()
}
fn derive_erasure_set_quorum_shape(set_drive_count: usize, parity: usize) -> ErasureSetQuorumShape {
let data_shards = set_drive_count.saturating_sub(parity);
let read_quorum = data_shards.max(1);
let mut write_quorum = read_quorum;
if data_shards == parity {
write_quorum += 1;
}
ErasureSetQuorumShape {
data_shards: data_shards as u32,
read_quorum: read_quorum as u32,
write_quorum: write_quorum as u32,
read_tolerance: parity as u32,
write_tolerance: set_drive_count.saturating_sub(write_quorum) as u32,
}
}
fn apply_erasure_set_health(entry: &mut ErasureSetStats) {
let online = entry.online_drives_count;
entry.read_health = u8::from(online >= entry.read_quorum);
entry.write_health = u8::from(online >= entry.write_quorum);
entry.health = u8::from(entry.write_health == 1);
}
#[derive(Debug, Clone, Default)]
pub struct ProcessMetricBundle {
pub resource: ResourceStats,
pub process: ProcessStats,
pub disk_read_bytes: u64,
pub disk_write_bytes: u64,
}
/// Collect cluster and cluster-health statistics from a single storage snapshot.
pub async fn collect_cluster_and_health_stats() -> (ClusterStats, ClusterHealthStats) {
let Some(store) = resolve_obs_object_store_handle() else {
return (ClusterStats::default(), ClusterHealthStats::default());
};
let storage_info = StorageAdminApi::storage_info(store.as_ref()).await;
let raw_capacity: u64 = storage_info.disks.iter().map(|d| d.total_space).sum();
let usable_capacity = obs_total_usable_capacity_bytes(&storage_info);
let free = obs_total_usable_capacity_free_bytes(&storage_info);
let used = usable_capacity_used_bytes(usable_capacity, free);
let stale_capacity_drives = storage_info
.disks
.iter()
.filter(|disk| {
disk_capacity_observation_state(disk.capacity_observation_source.as_deref(), disk.capacity_observation_age_seconds).0
== CAPACITY_OBSERVATION_STALE
})
.count() as u64;
let missing_capacity_drives = storage_info
.disks
.iter()
.filter(|disk| {
disk_capacity_observation_state(disk.capacity_observation_source.as_deref(), disk.capacity_observation_age_seconds).0
== CAPACITY_OBSERVATION_MISSING
})
.count() as u64;
let data_usage = match load_obs_data_usage_from_backend(store).await {
Ok(data_usage) if data_usage.usage_snapshot_complete => Some(data_usage),
Ok(_) => None,
Err(error) => {
warn!(event = EVENT_METRICS_COLLECTOR_STATE, component = LOG_COMPONENT_OBS, subsystem = LOG_SUBSYSTEM_METRICS_COLLECTOR, collector = "cluster_stats", result = "data_usage_load_failed", error = %error, "metrics collector state changed");
None
}
};
let buckets_count = data_usage.as_ref().map(|usage| usage.buckets_count);
let objects_count = data_usage.as_ref().map(|usage| usage.objects_total_count);
let mut online = 0u64;
let mut offline = 0u64;
for disk in &storage_info.disks {
if disk_is_online_for_metrics(disk.state.as_str(), disk.runtime_state.as_deref()) {
online += 1;
} else {
offline += 1;
}
}
(
ClusterStats {
raw_capacity_bytes: raw_capacity,
usable_capacity_bytes: usable_capacity,
used_bytes: used,
free_bytes: free,
stale_capacity_drives,
missing_capacity_drives,
objects_count,
buckets_count,
},
ClusterHealthStats {
drives_offline_count: offline,
drives_online_count: online,
drives_count: storage_info.disks.len() as u64,
},
)
}
/// Collect cluster statistics from the storage layer.
#[instrument]
pub async fn collect_cluster_stats() -> ClusterStats {
let (cluster_stats, _) = collect_cluster_and_health_stats().await;
cluster_stats
}
/// Collect cluster health statistics from the storage layer.
pub async fn collect_cluster_health_stats() -> ClusterHealthStats {
let (_, cluster_health_stats) = collect_cluster_and_health_stats().await;
cluster_health_stats
}
/// Collect bucket statistics from the storage layer.
///
/// `None` means the bucket namespace could not be observed. Callers must keep
/// their prior metric-series state instead of treating that failure as an
/// authoritative empty namespace.
pub async fn collect_bucket_stats() -> Option<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(|snapshot| bucket_replication_detail_from_snapshot(snapshot).stats)
.collect()
}
pub(crate) async fn collect_bucket_replication_stats_bundle()
-> (Vec<BucketReplicationRuntimeStats>, 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 S3 API request totals from the in-process operation recorder.
pub(crate) fn collect_api_request_stats() -> Vec<ApiRequestStats> {
let server = current_local_node_identity();
s3_op_metrics_snapshot()
.into_iter()
.map(|snapshot| ApiRequestStats {
server: server.clone(),
name: snapshot.op.to_string(),
req_type: "s3".to_string(),
total: snapshot.total,
supported_metrics: ApiRequestMetricSupport::TOTALS_ONLY,
..Default::default()
})
.collect()
}
/// Collect disk statistics from the storage layer.
pub async fn collect_disk_stats() -> Vec<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 (disk_stats, drive_stats, drive_count_stats) = collect_disk_and_system_drive_runtime_stats().await;
(disk_stats, drive_stats.into_iter().map(|stat| stat.stats).collect(), drive_count_stats)
}
pub(crate) async fn collect_disk_and_system_drive_runtime_stats()
-> (Vec<DiskStats>, Vec<DriveRuntimeDetailedStats>, DriveCountStats) {
let Some(store) = resolve_obs_object_store_handle() else {
return (Vec::new(), Vec::new(), DriveCountStats::default());
};
let storage_info = StorageAdminApi::storage_info(store.as_ref()).await;
let local_server = current_local_node_identity();
let disk_stats = storage_info
.disks
.iter()
.map(|disk| DiskStats {
server: drive_server_label(&disk.endpoint, &local_server),
drive: disk.drive_path.clone(),
total_bytes: disk.total_space,
used_bytes: disk.used_space,
free_bytes: disk.available_space,
})
.collect();
let mut online_count = 0u64;
let mut offline_count = 0u64;
let drive_stats = storage_info
.disks
.iter()
.map(|disk| {
let is_online = disk_is_online_for_metrics(disk.state.as_str(), disk.runtime_state.as_deref());
let (capacity_observation_state, capacity_observation_age_seconds) = disk_capacity_observation_state(
disk.capacity_observation_source.as_deref(),
disk.capacity_observation_age_seconds,
);
if is_online {
online_count += 1;
} else {
offline_count += 1;
}
let (used_inodes, free_inodes, total_inodes) = drive_inode_stats(disk.used_inodes, disk.free_inodes);
DriveRuntimeDetailedStats {
pool_index: disk_topology_label(disk.pool_index),
set_index: disk_topology_label(disk.set_index),
drive_index: disk_topology_label(disk.disk_index),
disk_id: non_empty_disk_id(&disk.uuid),
runtime_state: Some(disk.runtime_state.as_deref().unwrap_or("unknown").to_ascii_lowercase()),
healing: disk.healing,
scanning: disk.scanning,
offline_duration_seconds: disk.offline_duration_seconds,
api_calls: disk
.metrics
.as_ref()
.map(|metrics| drive_api_calls(metrics.api_calls.iter()))
.unwrap_or_default(),
api_latency_by_api_micros: disk
.metrics
.as_ref()
.map(|metrics| {
drive_api_latency_by_api_micros(
metrics
.last_minute
.iter()
.map(|(api, action)| (api, action.count, action.acc_time)),
)
})
.unwrap_or_default(),
stats: DriveDetailedStats {
server: drive_server_label(&disk.endpoint, &local_server),
drive: disk.drive_path.clone(),
total_bytes: disk.total_space,
used_bytes: disk.used_space,
free_bytes: disk.available_space,
capacity_observation_state,
capacity_observation_age_seconds,
used_inodes,
free_inodes,
total_inodes,
timeout_errors_total: disk.metrics.as_ref().map(|metrics| metrics.total_errors_timeout),
io_errors_total: None,
availability_errors_total: disk.metrics.as_ref().map(|metrics| metrics.total_errors_availability),
waiting_io: disk.metrics.as_ref().map(|metrics| u64::from(metrics.total_waiting)),
api_latency_micros: disk.metrics.as_ref().and_then(|metrics| {
drive_api_latency_micros(metrics.last_minute.values().map(|action| (action.count, action.acc_time)))
}),
health: if is_online { 1 } else { 0 },
writes_total: disk.metrics.as_ref().map(|metrics| metrics.total_writes),
deletes_total: disk.metrics.as_ref().map(|metrics| metrics.total_deletes),
reads_per_sec: None,
reads_kb_per_sec: None,
reads_await: None,
writes_per_sec: None,
writes_kb_per_sec: None,
writes_await: None,
perc_util: None,
},
}
})
.collect();
let drive_count_stats = DriveCountStats {
offline_count,
online_count,
total_count: online_count + offline_count,
};
(disk_stats, drive_stats, drive_count_stats)
}
/// Collect system drive statistics using the storage layer snapshot.
pub async fn collect_system_drive_stats() -> (Vec<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,
snapshot_converged: data_usage.usage_snapshot_converged,
object_size_distribution: data_usage
.buckets_usage
.values()
.flat_map(|usage| usage.object_size_histogram.iter())
.fold(HashMap::<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,
))
}
fn ilm_action_task_stats(ilm: &ObsIlmRuntimeSnapshot) -> Vec<IlmActionTaskStats> {
vec![
IlmActionTaskStats {
action: "expiry".to_string(),
state: "pending".to_string(),
value: ilm.expiry_pending_tasks,
},
IlmActionTaskStats {
action: "transition".to_string(),
state: "active".to_string(),
value: ilm.transition_active_tasks,
},
IlmActionTaskStats {
action: "transition".to_string(),
state: "pending".to_string(),
value: ilm.transition_pending_tasks,
},
IlmActionTaskStats {
action: "transition".to_string(),
state: "missed_immediate".to_string(),
value: ilm.transition_missed_immediate_tasks,
},
IlmActionTaskStats {
action: "transition".to_string(),
state: "queue_full".to_string(),
value: ilm.transition_queue_full_tasks,
},
IlmActionTaskStats {
action: "transition".to_string(),
state: "queue_send_timeout".to_string(),
value: ilm.transition_queue_send_timeout_tasks,
},
IlmActionTaskStats {
action: "transition".to_string(),
state: "compensation_scheduled".to_string(),
value: ilm.transition_compensation_scheduled_tasks,
},
IlmActionTaskStats {
action: "transition".to_string(),
state: "compensation_running".to_string(),
value: ilm.transition_compensation_running_tasks,
},
]
}
fn ilm_queue_task_stats(metrics: &ScannerMetricsReport) -> Vec<IlmQueueTaskStats> {
let expiry = &metrics.lifecycle_expiry;
let transition = &metrics.lifecycle_transition;
vec![
IlmQueueTaskStats {
action: "expiry".to_string(),
state: "pending".to_string(),
value: expiry.current_queued,
},
IlmQueueTaskStats {
action: "expiry".to_string(),
state: "active".to_string(),
value: expiry.current_active,
},
IlmQueueTaskStats {
action: "transition".to_string(),
state: "pending".to_string(),
value: transition.current_queued,
},
IlmQueueTaskStats {
action: "transition".to_string(),
state: "active".to_string(),
value: transition.current_active,
},
IlmQueueTaskStats {
action: "transition".to_string(),
state: "compensation_running".to_string(),
value: transition.compensation_running,
},
]
}
fn ilm_task_event_stats(metrics: &ScannerMetricsReport) -> Vec<IlmTaskEventStats> {
let expiry = &metrics.lifecycle_expiry;
let transition = &metrics.lifecycle_transition;
vec![
IlmTaskEventStats {
action: "expiry".to_string(),
result: "queued".to_string(),
value: expiry.scanner_queued,
},
IlmTaskEventStats {
action: "expiry".to_string(),
result: "missed".to_string(),
value: expiry.scanner_missed,
},
IlmTaskEventStats {
action: "expiry".to_string(),
result: "blocked".to_string(),
value: expiry.scanner_blocked,
},
IlmTaskEventStats {
action: "expiry".to_string(),
result: "not_enqueued".to_string(),
value: expiry.scanner_not_enqueued,
},
IlmTaskEventStats {
action: "expiry".to_string(),
result: "failed".to_string(),
value: expiry.delete_failed,
},
IlmTaskEventStats {
action: "transition".to_string(),
result: "queued".to_string(),
value: transition.scanner_queued,
},
IlmTaskEventStats {
action: "transition".to_string(),
result: "missed".to_string(),
value: transition.scanner_missed,
},
IlmTaskEventStats {
action: "transition".to_string(),
result: "completed".to_string(),
value: transition.completed,
},
IlmTaskEventStats {
action: "transition".to_string(),
result: "failed".to_string(),
value: transition.failed,
},
]
}
fn ilm_backpressure_stats(metrics: &ScannerMetricsReport) -> Vec<IlmBackpressureStats> {
vec![
IlmBackpressureStats {
action: "expiry".to_string(),
reason: "queue_missed".to_string(),
value: metrics.lifecycle_expiry.queue_missed,
},
IlmBackpressureStats {
action: "transition".to_string(),
reason: "queue_full".to_string(),
value: metrics.lifecycle_transition.queue_full,
},
IlmBackpressureStats {
action: "transition".to_string(),
reason: "send_timeout".to_string(),
value: metrics.lifecycle_transition.queue_send_timeout,
},
]
}
/// Collect ILM metrics from the current lifecycle runtime state.
pub async fn collect_ilm_metric_stats() -> Option<IlmStats> {
collect_ilm_runtime_metric_stats().await.map(|stats| stats.stats)
}
pub(crate) async fn collect_ilm_runtime_metric_stats() -> Option<IlmRuntimeStats> {
let ilm = obs_ilm_runtime_snapshot().await;
let metrics = global_metrics().report().await;
let versions_scanned = scanner_lifecycle_checked_versions(&metrics);
Some(IlmRuntimeStats {
server: current_local_node_identity(),
action_tasks: ilm_action_task_stats(&ilm),
queue_tasks: ilm_queue_task_stats(&metrics),
task_events: ilm_task_event_stats(&metrics),
backpressure: ilm_backpressure_stats(&metrics),
versions_scanned,
stats: IlmStats {
expiry_pending_tasks: ilm.expiry_pending_tasks,
transition_active_tasks: ilm.transition_active_tasks,
transition_pending_tasks: ilm.transition_pending_tasks,
transition_missed_immediate_tasks: ilm.transition_missed_immediate_tasks,
transition_queue_full_tasks: ilm.transition_queue_full_tasks,
transition_queue_send_timeout_tasks: ilm.transition_queue_send_timeout_tasks,
transition_compensation_scheduled_tasks: ilm.transition_compensation_scheduled_tasks,
transition_compensation_running_tasks: ilm.transition_compensation_running_tasks,
versions_scanned,
},
})
}
/// Collect scanner metrics from a runtime source.
///
/// Task 5 maps scanner runtime snapshots from `global_metrics()` into the
/// rustfs-obs scanner collector shape.
fn scanner_bucket_scans_started(life_time_ops: &HashMap<String, u64>, bucket_scans_finished: u64) -> u64 {
life_time_ops
.get("scan_bucket_drive_start")
.copied()
.unwrap_or(bucket_scans_finished)
}
fn scanner_source_work_stats(source_work: &[ScannerSourceWorkSnapshot]) -> Vec<ScannerSourceWorkStats> {
let mut stats = source_work
.iter()
.filter(|work| !work.source.is_empty())
.map(|work| ScannerSourceWorkStats {
source: work.source.clone(),
checked: work.checked,
queued: work.queued,
executed: work.executed,
failed: work.failed,
skipped: work.skipped,
missed: work.missed,
})
.collect::<Vec<_>>();
stats.sort_by(|left, right| left.source.cmp(&right.source));
stats
}
fn scanner_current_cycle_source_work_stats(metrics: &ScannerMetricsReport) -> Vec<ScannerSourceWorkStats> {
let current = scanner_source_work_stats(&metrics.current_cycle_source_work);
if !current.is_empty() {
return current;
}
let mut sources = scanner_source_work_stats(&metrics.last_cycle_source_work)
.into_iter()
.map(|work| work.source)
.chain(
scanner_source_work_stats(&metrics.source_work)
.into_iter()
.map(|work| work.source),
)
.collect::<Vec<_>>();
sources.sort();
sources.dedup();
sources
.into_iter()
.map(|source| ScannerSourceWorkStats {
source,
..Default::default()
})
.collect()
}
fn scanner_bucket_drive_result_stats(results: &[ScannerBucketDriveResultSnapshot]) -> Vec<ScannerBucketDriveResultStats> {
let mut stats = results
.iter()
.filter(|result| !result.bucket.is_empty() && !result.drive.is_empty() && !result.result.is_empty() && result.count > 0)
.map(|result| ScannerBucketDriveResultStats {
bucket: result.bucket.clone(),
drive: result.drive.clone(),
result: result.result.clone(),
count: result.count,
})
.collect::<Vec<_>>();
stats.sort_by(|left, right| {
left.bucket
.cmp(&right.bucket)
.then_with(|| left.drive.cmp(&right.drive))
.then_with(|| left.result.cmp(&right.result))
});
stats
}
fn scanner_active_bucket_drive_stats(results: &[ScannerActiveBucketDriveSnapshot]) -> Vec<ScannerActiveBucketDriveStats> {
let mut stats = results
.iter()
.filter(|result| !result.source.is_empty() && !result.bucket.is_empty() && !result.drive.is_empty() && result.count > 0)
.map(|result| ScannerActiveBucketDriveStats {
source: result.source.clone(),
bucket: result.bucket.clone(),
drive: result.drive.clone(),
count: result.count,
age_seconds: result.age_seconds,
})
.collect::<Vec<_>>();
stats.sort_by(|left, right| {
left.source
.cmp(&right.source)
.then_with(|| left.bucket.cmp(&right.bucket))
.then_with(|| left.drive.cmp(&right.drive))
});
stats
}
pub async fn collect_scanner_metric_stats() -> Option<ScannerStats> {
collect_scanner_runtime_metric_stats().await.map(|stats| stats.stats)
}
pub(crate) async fn collect_scanner_runtime_metric_stats() -> Option<ScannerRuntimeStats> {
let (metrics, runtime_details) = global_metrics().report_with_runtime_details().await;
let now = Timestamp::now();
let bucket_scans_finished = metrics.life_time_ops.get("scan_bucket_drive").copied().unwrap_or_default();
let bucket_scans_started = scanner_bucket_scans_started(&metrics.life_time_ops, bucket_scans_finished);
let bucket_scans_failed = metrics
.life_time_ops
.get("scan_bucket_drive_failure")
.copied()
.unwrap_or_default();
let completed_cycles = metrics.life_time_ops.get("scan_cycle").copied().unwrap_or_default();
let directories_scanned = metrics.life_time_ops.get("scan_folder").copied().unwrap_or_default();
let objects_scanned = metrics.life_time_ops.get("scan_object").copied().unwrap_or_default();
// Real scan coverage: every version the scanner walked, independent of ILM
// rules. The ILM-checked subset (`scanner_lifecycle_checked_versions`) still
// feeds the ILM collector's versions_scanned, but the scanner collector must
// report the total scanned versions — otherwise clusters without lifecycle
// rules report zero here while objects_scanned keeps climbing.
let versions_scanned = metrics.versions_scanned;
let reference_time = metrics.cycles_completed_at.last().copied().unwrap_or(metrics.current_started);
let last_activity_seconds = timestamp_elapsed_seconds_since(now, reference_time);
let active_paths = metrics.active_scan_paths as u64;
let current_cycle_age_seconds = current_scanner_cycle_age_seconds(metrics.current_cycle_active, metrics.current_started, now);
let current_scan_mode = scanner_scan_mode_code(&metrics.current_scan_mode);
let current_cycle_age = current_cycle_age_seconds as f64;
let last_cycle_duration = metrics.last_cycle_duration_seconds;
Some(ScannerRuntimeStats {
server: current_local_node_identity(),
source_work: scanner_source_work_stats(&metrics.source_work),
current_cycle_source_work: scanner_current_cycle_source_work_stats(&metrics),
last_cycle_source_work: scanner_source_work_stats(&metrics.last_cycle_source_work),
bucket_drive_results: scanner_bucket_drive_result_stats(&runtime_details.bucket_drive_results),
current_cycle_bucket_drive_results: scanner_bucket_drive_result_stats(
&runtime_details.current_cycle_bucket_drive_results,
),
last_cycle_bucket_drive_results: scanner_bucket_drive_result_stats(&runtime_details.last_cycle_bucket_drive_results),
active_bucket_drive_scans: scanner_active_bucket_drive_stats(&runtime_details.active_bucket_drive_scans),
stats: ScannerStats {
bucket_scans_finished,
bucket_scans_started,
bucket_scans_failed,
directories_scanned,
objects_scanned,
versions_scanned,
last_activity_seconds,
active_paths,
oldest_active_path_age_seconds: metrics.oldest_active_path_age_seconds,
current_set_scan_concurrency_limit: metrics.current_set_scan_concurrency_limit,
current_set_scans_queued: metrics.current_set_scans_queued,
current_set_scans_active: metrics.current_set_scans_active,
current_disk_scan_concurrency_limit: metrics.current_disk_scan_concurrency_limit,
current_disk_bucket_scans_queued: metrics.current_disk_bucket_scans_queued,
current_disk_bucket_scans_active: metrics.current_disk_bucket_scans_active,
throttle_idle_mode_enabled: metrics.throttle_idle_mode_enabled,
throttle_sleep_factor: metrics.throttle_sleep_factor,
throttle_max_sleep_seconds: metrics.throttle_max_sleep_seconds,
yield_every_n_objects: metrics.yield_every_n_objects,
cycle_interval_seconds: metrics.cycle_interval_seconds,
cycle_max_duration_seconds: metrics.cycle_max_duration_seconds,
cycle_max_objects: metrics.cycle_max_objects,
cycle_max_directories: metrics.cycle_max_directories,
bitrot_cycle_enabled: metrics.bitrot_cycle_enabled,
bitrot_cycle_seconds: metrics.bitrot_cycle_seconds,
current_cycle: metrics.current_cycle,
completed_cycles,
current_cycle_age_seconds,
current_cycle_objects_scanned: metrics.current_cycle_objects_scanned,
current_cycle_directories_scanned: metrics.current_cycle_directories_scanned,
current_cycle_bucket_drive_scans: metrics.current_cycle_bucket_drive_scans,
current_cycle_bucket_drive_failures: metrics.current_cycle_bucket_drive_failures,
current_cycle_objects_per_second: scanner_work_rate_per_second(
metrics.current_cycle_objects_scanned,
current_cycle_age,
),
current_cycle_directories_per_second: scanner_work_rate_per_second(
metrics.current_cycle_directories_scanned,
current_cycle_age,
),
current_cycle_bucket_drive_scans_per_second: scanner_work_rate_per_second(
metrics.current_cycle_bucket_drive_scans,
current_cycle_age,
),
current_cycle_yield_events: metrics.current_cycle_yield_events,
current_cycle_yield_duration_seconds: metrics.current_cycle_yield_duration_seconds,
current_cycle_throttle_sleep_events: metrics.current_cycle_throttle_sleep_events,
current_cycle_throttle_sleep_duration_seconds: metrics.current_cycle_throttle_sleep_duration_seconds,
current_cycle_ilm_actions: metrics.current_cycle_ilm_actions,
current_cycle_heal_objects: metrics.current_cycle_heal_objects,
current_cycle_replication_checks: metrics.current_cycle_replication_checks,
current_cycle_usage_saves: metrics.current_cycle_usage_saves,
current_scan_mode,
last_cycle_result: metrics.last_cycle_result_code,
last_cycle_partial_reason: metrics.last_cycle_partial_reason_code,
last_cycle_duration_seconds: metrics.last_cycle_duration_seconds,
last_cycle_objects_scanned: metrics.last_cycle_objects_scanned,
last_cycle_directories_scanned: metrics.last_cycle_directories_scanned,
last_cycle_bucket_drive_scans: metrics.last_cycle_bucket_drive_scans,
last_cycle_bucket_drive_failures: metrics.last_cycle_bucket_drive_failures,
last_cycle_objects_per_second: scanner_work_rate_per_second(metrics.last_cycle_objects_scanned, last_cycle_duration),
last_cycle_directories_per_second: scanner_work_rate_per_second(
metrics.last_cycle_directories_scanned,
last_cycle_duration,
),
last_cycle_bucket_drive_scans_per_second: scanner_work_rate_per_second(
metrics.last_cycle_bucket_drive_scans,
last_cycle_duration,
),
last_cycle_yield_events: metrics.last_cycle_yield_events,
last_cycle_yield_duration_seconds: metrics.last_cycle_yield_duration_seconds,
last_cycle_throttle_sleep_events: metrics.last_cycle_throttle_sleep_events,
last_cycle_throttle_sleep_duration_seconds: metrics.last_cycle_throttle_sleep_duration_seconds,
last_cycle_ilm_actions: metrics.last_cycle_ilm_actions,
last_cycle_heal_objects: metrics.last_cycle_heal_objects,
last_cycle_replication_checks: metrics.last_cycle_replication_checks,
last_cycle_usage_saves: metrics.last_cycle_usage_saves,
failed_cycles: metrics.failed_cycles,
superseded_cycles: metrics.superseded_cycles,
partial_cycles: metrics.partial_cycles,
partial_cycles_unknown: metrics.partial_cycles_unknown,
partial_cycles_runtime: metrics.partial_cycles_runtime,
partial_cycles_objects: metrics.partial_cycles_objects,
partial_cycles_directories: metrics.partial_cycles_directories,
},
})
}
/// Collect cluster-level compression statistics.
pub async fn collect_compression_cluster_stats() -> Option<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,
usage_snapshot_converged: true,
..Default::default()
},
&HashSet::new(),
)
.await
.expect("complete empty usage should remain publishable");
assert_eq!(cluster.buckets_count, 0);
assert_eq!(cluster.objects_count, 0);
assert_eq!(cluster.total_bytes, 0);
assert!(cluster.snapshot_converged);
assert!(buckets.is_empty());
}
#[tokio::test]
async fn cluster_usage_metrics_publish_unknown_convergence_as_unconverged() {
let (cluster, _) = collect_cluster_usage_metric_stats_from_data_usage(
ObsDataUsageInfo {
usage_snapshot_complete: true,
usage_snapshot_converged: false,
..Default::default()
},
&HashSet::new(),
)
.await
.expect("complete usage with unknown convergence should remain publishable");
assert!(!cluster.snapshot_converged);
}
#[tokio::test]
async fn cluster_usage_metrics_skip_snapshot_for_a_different_bucket_namespace() {
let data_usage = ObsDataUsageInfo {
usage_snapshot_complete: true,
buckets_count: 1,
buckets_usage: HashMap::from([("stale-bucket".to_string(), ObsBucketUsageInfo::default())]),
..Default::default()
};
assert!(
collect_cluster_usage_metric_stats_from_data_usage(data_usage, &HashSet::from(["live-bucket".to_string()]))
.await
.is_none()
);
}
#[test]
fn cluster_config_stats_accept_homogeneous_backend_parities() {
let stats = cluster_config_stats_from_backend_parities(Some(1), Some(2))
.expect("homogeneous scalar parities should produce cluster config metrics");
assert_eq!(stats.rrs_parity, 1);
assert_eq!(stats.standard_parity, 2);
}
#[test]
fn cluster_config_stats_skip_heterogeneous_backend_parities() {
assert!(cluster_config_stats_from_backend_parities(Some(1), None).is_none());
assert!(cluster_config_stats_from_backend_parities(None, Some(2)).is_none());
}
#[test]
fn cluster_config_stats_reject_parity_larger_than_u32() {
let Ok(overflow) = usize::try_from(u64::from(u32::MAX) + 1) else {
return;
};
assert!(cluster_config_stats_from_backend_parities(Some(overflow), Some(2)).is_none());
assert!(cluster_config_stats_from_backend_parities(Some(1), Some(overflow)).is_none());
}
#[tokio::test]
async fn node_local_resource_stats_use_stable_local_node_identity() {
let _guard = crate::node_identity::local_node_identity_test_guard().await;
let previous = rustfs_common::get_global_local_node_name().await;
rustfs_common::set_global_local_node_name("node1:9000").await;
let mut system = System::new_all();
let (cpu, memory) = collect_system_cpu_and_memory_stats_with(&mut system);
let host_network = collect_host_network_stats_with(&Networks::new());
let process_bundle =
process_metric_bundle_from_snapshots(ProcessResourceSnapshot::default(), ProcessSystemSnapshot::default());
assert_eq!(cpu.server, "node1:9000");
assert_eq!(memory.server, "node1:9000");
assert_eq!(host_network.server, "node1:9000");
assert_eq!(process_bundle.resource.server, "node1:9000");
assert_eq!(process_bundle.process.server, "node1:9000");
rustfs_common::set_global_local_node_name(&previous).await;
}
#[test]
fn erasure_set_stats_skip_unknown_backend_layout() {
let storage_info = storage_info_with_one_online_disk();
let backend = ObsBackendInfo {
drives_per_set: vec![8],
..Default::default()
};
assert!(erasure_set_stats_from_backend(&storage_info, &backend).is_empty());
}
#[test]
fn erasure_set_stats_skip_inconsistent_exact_layout_without_scalar_fallback() {
let storage_info = storage_info_with_one_online_disk();
let backend = ObsBackendInfo {
standard_sc_data: vec![6],
standard_sc_parities: vec![4],
standard_sc_parity: Some(2),
drives_per_set: vec![8],
..Default::default()
};
assert!(erasure_set_stats_from_backend(&storage_info, &backend).is_empty());
}
#[test]
fn erasure_set_stats_accept_truthful_legacy_scalar_layout() {
let storage_info = storage_info_with_one_online_disk();
let backend = ObsBackendInfo {
standard_sc_parity: Some(2),
drives_per_set: vec![8],
..Default::default()
};
let stats = erasure_set_stats_from_backend(&storage_info, &backend);
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].size, 8);
assert_eq!(stats[0].parity, 2);
assert_eq!(stats[0].data_shards, 6);
}
#[test]
fn erasure_set_stats_exact_data_ignores_stale_scalar() {
let storage_info = storage_info_with_one_online_disk();
let backend = ObsBackendInfo {
standard_sc_data: vec![6],
standard_sc_parity: Some(4),
drives_per_set: vec![8],
..Default::default()
};
let stats = erasure_set_stats_from_backend(&storage_info, &backend);
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].parity, 2);
assert_eq!(stats[0].data_shards, 6);
}
fn generate_loopback_traffic() -> std::io::Result<()> {
let listener = TcpListener::bind(("127.0.0.1", 0))?;
let addr = listener.local_addr()?;
let payload = vec![0x5Au8; 64 * 1024];
let expected_len = payload.len();
let server = thread::spawn(move || -> std::io::Result<()> {
let (mut stream, _) = listener.accept()?;
let mut received = 0usize;
let mut buf = [0u8; 8192];
while received < expected_len {
let read = stream.read(&mut buf)?;
if read == 0 {
break;
}
received += read;
}
Ok(())
});
let mut client = TcpStream::connect(addr)?;
client.write_all(&payload)?;
client.flush()?;
client.shutdown(Shutdown::Write)?;
server
.join()
.expect("loopback traffic server thread should complete successfully")?;
Ok(())
}
#[test]
fn disk_is_online_for_metrics_accepts_online_state_case_insensitive() {
assert!(disk_is_online_for_metrics("OnLiNe", Some("online")));
}
#[test]
fn disk_is_online_for_metrics_rejects_offline_runtime_state() {
assert!(!disk_is_online_for_metrics(DRIVE_STATE_OK, Some("offline")));
}
#[test]
fn disk_topology_label_rejects_unknown_negative_index() {
assert_eq!(disk_topology_label(-1), None);
assert_eq!(disk_topology_label(3), Some("3".to_string()));
}
#[test]
fn non_empty_disk_id_rejects_blank_uuid() {
assert_eq!(non_empty_disk_id(" "), None);
assert_eq!(non_empty_disk_id("disk-1"), Some("disk-1".to_string()));
}
#[test]
fn drive_server_label_uses_node_identity_for_urls_and_local_paths() {
assert_eq!(drive_server_label("http://node1:9000/data", "local:9000"), "node1:9000");
assert_eq!(drive_server_label("https://node2:9443/export/d1", "local:9000"), "node2:9443");
assert_eq!(drive_server_label("/mnt/data1", "local:9000"), "local:9000");
}
#[test]
fn drive_inode_stats_skip_unknown_zero_inode_totals() {
assert_eq!(drive_inode_stats(0, 0), (None, None, None));
assert_eq!(drive_inode_stats(2, 3), (Some(2), Some(3), Some(5)));
}
#[test]
fn drive_api_metrics_are_sorted_and_average_latency() {
let last_minute = HashMap::from([
("write".to_string(), (2, 6_000)),
("read".to_string(), (1, 3_000)),
("zero".to_string(), (0, 9_000)),
]);
let api_calls = HashMap::from([("write".to_string(), 9), ("read".to_string(), 4)]);
assert_eq!(drive_api_latency_micros(last_minute.values().copied()), Some(3));
assert_eq!(
drive_api_latency_by_api_micros(last_minute.iter().map(|(api, (count, acc_time))| (api, *count, *acc_time))),
vec![("read".to_string(), 3), ("write".to_string(), 3), ("zero".to_string(), 0)]
);
assert_eq!(drive_api_calls(api_calls.iter()), vec![("read".to_string(), 4), ("write".to_string(), 9)]);
}
#[test]
fn drive_api_latency_skips_zero_denominators() {
let last_minute = HashMap::from([("zero".to_string(), (0, 9_000))]);
assert_eq!(drive_api_latency_micros(last_minute.values().copied()), Some(0));
assert_eq!(
drive_api_latency_by_api_micros(last_minute.iter().map(|(api, (count, acc_time))| (api, *count, *acc_time))),
vec![("zero".to_string(), 0)]
);
assert_eq!(drive_api_latency_micros([].into_iter()), None);
}
#[test]
fn derive_erasure_set_quorum_shape_handles_standard_layout() {
let shape = derive_erasure_set_quorum_shape(16, 4);
assert_eq!(
shape,
ErasureSetQuorumShape {
data_shards: 12,
read_quorum: 12,
write_quorum: 12,
read_tolerance: 4,
write_tolerance: 4,
}
);
}
#[test]
fn derive_erasure_set_quorum_shape_handles_equal_data_and_parity() {
let shape = derive_erasure_set_quorum_shape(4, 2);
assert_eq!(
shape,
ErasureSetQuorumShape {
data_shards: 2,
read_quorum: 2,
write_quorum: 3,
read_tolerance: 2,
write_tolerance: 1,
}
);
}
#[test]
fn apply_erasure_set_health_marks_read_and_write_health_from_online_count() {
let mut stats = ErasureSetStats {
read_quorum: 3,
write_quorum: 4,
online_drives_count: 3,
..Default::default()
};
apply_erasure_set_health(&mut stats);
assert_eq!(stats.read_health, 1);
assert_eq!(stats.write_health, 0);
assert_eq!(stats.health, 0);
stats.online_drives_count = 4;
apply_erasure_set_health(&mut stats);
assert_eq!(stats.read_health, 1);
assert_eq!(stats.write_health, 1);
assert_eq!(stats.health, 1);
}
#[test]
fn current_scanner_cycle_age_seconds_returns_zero_when_idle() {
let now = Timestamp::constant(1_700_000_000, 0);
assert_eq!(
current_scanner_cycle_age_seconds(false, now - jiff::SignedDuration::from_secs(30), now),
0
);
}
#[test]
fn current_scanner_cycle_age_seconds_clamps_future_start() {
let now = Timestamp::constant(1_700_000_000, 0);
assert_eq!(current_scanner_cycle_age_seconds(true, now + jiff::SignedDuration::from_secs(30), now), 0);
}
#[test]
fn current_scanner_cycle_age_seconds_reports_active_first_cycle_elapsed_time() {
let now = Timestamp::constant(1_700_000_000, 0);
assert_eq!(
current_scanner_cycle_age_seconds(true, now - jiff::SignedDuration::from_secs(45), now),
45
);
}
#[test]
fn scanner_scan_mode_code_maps_known_modes() {
assert_eq!(scanner_scan_mode_code(HealScanMode::Normal.as_str()), HealScanMode::Normal as u8 as u64);
assert_eq!(scanner_scan_mode_code(HealScanMode::Deep.as_str()), HealScanMode::Deep as u8 as u64);
}
#[test]
fn scanner_scan_mode_code_maps_unknown_mode() {
assert_eq!(scanner_scan_mode_code(""), HealScanMode::Unknown as u8 as u64);
}
#[test]
fn scanner_bucket_scans_started_uses_explicit_started_count() {
let mut life_time_ops = HashMap::new();
life_time_ops.insert("scan_bucket_drive_start".to_string(), 7);
assert_eq!(scanner_bucket_scans_started(&life_time_ops, 5), 7);
}
#[test]
fn scanner_bucket_scans_started_falls_back_to_finished_count() {
let life_time_ops = HashMap::new();
assert_eq!(scanner_bucket_scans_started(&life_time_ops, 5), 5);
}
#[test]
fn ilm_action_task_stats_maps_runtime_states() {
let stats = ilm_action_task_stats(&ObsIlmRuntimeSnapshot {
expiry_pending_tasks: 1,
transition_active_tasks: 2,
transition_pending_tasks: 3,
transition_missed_immediate_tasks: 4,
transition_queue_full_tasks: 5,
transition_queue_send_timeout_tasks: 6,
transition_compensation_scheduled_tasks: 7,
transition_compensation_running_tasks: 8,
});
assert_eq!(stats.len(), 8);
assert_eq!(stats[0].action, "expiry");
assert_eq!(stats[0].state, "pending");
assert_eq!(stats[0].value, 1);
assert!(
stats
.iter()
.any(|task| { task.action == "transition" && task.state == "queue_send_timeout" && task.value == 6 })
);
assert!(
stats
.iter()
.any(|task| { task.action == "transition" && task.state == "compensation_running" && task.value == 8 })
);
}
#[test]
fn scanner_lifecycle_checked_versions_uses_lifecycle_checked_source_work() {
let report = ScannerMetricsReport {
source_work: vec![
ScannerSourceWorkSnapshot {
source: "usage".to_string(),
checked: 11,
..Default::default()
},
ScannerSourceWorkSnapshot {
source: "lifecycle".to_string(),
checked: 37,
..Default::default()
},
],
life_time_ilm: HashMap::from([("TransitionAction".to_string(), 5), ("DeleteAction".to_string(), 7)]),
..Default::default()
};
assert_eq!(scanner_lifecycle_checked_versions(&report), 37);
}
#[test]
fn ilm_detail_stats_keep_expiry_and_transition_results_separate() {
let report = ScannerMetricsReport {
lifecycle_expiry: rustfs_common::metrics::ScannerLifecycleExpirySnapshot {
current_queued: 2,
current_active: 1,
scanner_queued: 10,
scanner_missed: 3,
delete_failed: 4,
..Default::default()
},
lifecycle_transition: rustfs_common::metrics::ScannerLifecycleTransitionSnapshot {
current_queued: 5,
current_active: 6,
queue_full: 7,
queue_send_timeout: 8,
scanner_queued: 11,
completed: 12,
failed: 13,
..Default::default()
},
..Default::default()
};
let queues = ilm_queue_task_stats(&report);
assert!(
queues
.iter()
.any(|task| task.action == "expiry" && task.state == "pending" && task.value == 2)
);
assert!(
queues
.iter()
.any(|task| task.action == "transition" && task.state == "active" && task.value == 6)
);
let events = ilm_task_event_stats(&report);
assert!(
events
.iter()
.any(|event| event.action == "expiry" && event.result == "failed" && event.value == 4)
);
assert!(
events
.iter()
.any(|event| event.action == "transition" && event.result == "completed" && event.value == 12)
);
assert!(
events
.iter()
.any(|event| event.action == "transition" && event.result == "failed" && event.value == 13)
);
let backpressure = ilm_backpressure_stats(&report);
assert!(
backpressure
.iter()
.any(|event| event.action == "transition" && event.reason == "queue_full" && event.value == 7)
);
assert!(
backpressure
.iter()
.any(|event| event.action == "transition" && event.reason == "send_timeout" && event.value == 8)
);
}
#[test]
fn scanner_source_work_stats_sorts_and_skips_empty_source() {
let stats = scanner_source_work_stats(&[
ScannerSourceWorkSnapshot {
source: "usage".to_string(),
checked: 11,
queued: 2,
executed: 3,
failed: 4,
skipped: 5,
missed: 6,
},
ScannerSourceWorkSnapshot {
checked: 99,
queued: 99,
executed: 99,
failed: 99,
skipped: 99,
missed: 99,
..Default::default()
},
ScannerSourceWorkSnapshot {
source: "lifecycle".to_string(),
checked: 21,
queued: 7,
executed: 8,
failed: 9,
skipped: 10,
missed: 12,
},
]);
assert_eq!(stats.len(), 2);
assert_eq!(stats[0].source, "lifecycle");
assert_eq!(stats[0].checked, 21);
assert_eq!(stats[0].missed, 12);
assert_eq!(stats[1].source, "usage");
assert_eq!(stats[1].failed, 4);
}
#[test]
fn scanner_current_cycle_source_work_stats_zeroes_idle_sources() {
let report = ScannerMetricsReport {
source_work: vec![ScannerSourceWorkSnapshot {
source: "usage".to_string(),
checked: 11,
queued: 2,
..Default::default()
}],
last_cycle_source_work: vec![ScannerSourceWorkSnapshot {
source: "lifecycle".to_string(),
checked: 21,
queued: 7,
..Default::default()
}],
..Default::default()
};
let stats = scanner_current_cycle_source_work_stats(&report);
assert_eq!(stats.len(), 2);
assert_eq!(stats[0].source, "lifecycle");
assert_eq!(stats[0].checked, 0);
assert_eq!(stats[1].source, "usage");
assert_eq!(stats[1].queued, 0);
}
#[test]
fn scanner_current_cycle_source_work_stats_keeps_active_values() {
let report = ScannerMetricsReport {
source_work: vec![ScannerSourceWorkSnapshot {
source: "usage".to_string(),
checked: 11,
..Default::default()
}],
current_cycle_source_work: vec![ScannerSourceWorkSnapshot {
source: "usage".to_string(),
checked: 3,
..Default::default()
}],
..Default::default()
};
let stats = scanner_current_cycle_source_work_stats(&report);
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].source, "usage");
assert_eq!(stats[0].checked, 3);
}
#[test]
fn scanner_lifecycle_checked_versions_defaults_to_zero_when_lifecycle_missing() {
let report = ScannerMetricsReport {
source_work: vec![ScannerSourceWorkSnapshot {
source: "usage".to_string(),
checked: 11,
..Default::default()
}],
..Default::default()
};
assert_eq!(scanner_lifecycle_checked_versions(&report), 0);
}
#[test]
fn host_network_stats_require_a_persistent_networks_snapshot() -> std::io::Result<()> {
if !sysinfo::IS_SUPPORTED_SYSTEM {
return Ok(());
}
let mut persistent_networks = Networks::new();
persistent_networks.refresh(true);
let initial_stats = collect_host_network_stats_with(&persistent_networks);
assert_eq!(
initial_stats.total_received + initial_stats.total_transmitted,
0,
"the first refresh only seeds sysinfo's baseline snapshot"
);
match generate_loopback_traffic() {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
return Ok(());
}
Err(err) => return Err(err),
}
thread::sleep(Duration::from_millis(100));
let refreshed_stats = collect_host_network_stats(&mut persistent_networks);
assert!(
refreshed_stats.total_received > 0 || refreshed_stats.total_transmitted > 0,
"a persistent Networks instance should report non-zero loopback deltas after traffic"
);
let recreated_stats = collect_host_network_stats_with(&Networks::new_with_refreshed_list());
assert_eq!(
recreated_stats.total_received + recreated_stats.total_transmitted,
0,
"recreating Networks loses the prior refresh baseline and yields zero deltas"
);
Ok(())
}
#[test]
fn scanner_work_rate_per_second_reports_rate() {
assert_eq!(scanner_work_rate_per_second(90, 45.0), 2.0);
}
#[test]
fn scanner_work_rate_per_second_returns_zero_for_invalid_seconds() {
assert_eq!(scanner_work_rate_per_second(90, 0.0), 0.0);
assert_eq!(scanner_work_rate_per_second(90, f64::INFINITY), 0.0);
assert_eq!(scanner_work_rate_per_second(90, f64::NAN), 0.0);
}
#[test]
fn usable_capacity_used_bytes_matches_usable_total_minus_free() {
assert_eq!(usable_capacity_used_bytes(240, 90), 150);
}
#[test]
fn usable_capacity_used_bytes_saturates_when_free_exceeds_total() {
assert_eq!(usable_capacity_used_bytes(90, 240), 0);
}
}