mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-12 08:06:54 +00:00
a63b79004c
* 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>
1512 lines
59 KiB
Rust
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);
|
|
}
|
|
}
|