use crate::error::CoreResult;
use std::io;
use std::net::{SocketAddr, TcpStream};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use super::types::{
HealthCheck, HealthCheckResult, HealthCheckStatus, NodeHealthStatus, NodeInfo, NodeStatus,
};
const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_millis(750);
const CPU_LOAD_PER_CORE_UNHEALTHY: f64 = 2.0;
const MEMORY_USAGE_UNHEALTHY_PERCENT: f64 = 90.0;
#[cfg(feature = "sysinfo")]
const DISK_FREE_UNHEALTHY_PERCENT: f64 = 5.0;
#[derive(Debug)]
pub struct HealthMonitor {
health_checks: Vec<HealthCheck>,
check_interval: Duration,
probe_timeout: Duration,
}
impl Default for HealthMonitor {
fn default() -> Self {
Self {
health_checks: Self::default_health_checks(),
check_interval: Duration::from_secs(30),
probe_timeout: DEFAULT_PROBE_TIMEOUT,
}
}
}
impl HealthMonitor {
pub fn new() -> CoreResult<Self> {
Ok(Self::default())
}
fn default_health_checks() -> Vec<HealthCheck> {
vec![
HealthCheck::Ping,
HealthCheck::CpuLoad,
HealthCheck::MemoryUsage,
HealthCheck::DiskSpace,
HealthCheck::NetworkConnectivity,
]
}
pub fn check_node_health(&mut self, node: &NodeInfo) -> CoreResult<NodeHealthStatus> {
let mut health_score = 100.0f64;
let mut failing_checks = Vec::new();
let mut unknown_checks = Vec::new();
for check in &self.health_checks {
match self.execute_health_check(check, node) {
Ok(result) => match result.status {
HealthCheckStatus::Healthy => {}
HealthCheckStatus::Unhealthy => {
health_score -= result.impact_score;
failing_checks.push(check.clone());
}
HealthCheckStatus::Unknown => {
unknown_checks.push(check.clone());
}
},
Err(_) => {
health_score -= 20.0f64; failing_checks.push(check.clone());
}
}
}
let status = Self::classify_status(health_score, &unknown_checks, 80.0, 50.0);
Ok(NodeHealthStatus {
status,
health_score,
failing_checks,
unknown_checks,
last_checked: Instant::now(),
})
}
fn classify_status(
health_score: f64,
unknown_checks: &[HealthCheck],
healthy_threshold: f64,
degraded_threshold: f64,
) -> NodeStatus {
if health_score >= healthy_threshold {
if unknown_checks.is_empty() {
NodeStatus::Healthy
} else {
NodeStatus::Unknown
}
} else if health_score >= degraded_threshold {
NodeStatus::Degraded
} else {
NodeStatus::Unhealthy
}
}
fn execute_health_check(
&self,
check: &HealthCheck,
node: &NodeInfo,
) -> CoreResult<HealthCheckResult> {
match check {
HealthCheck::Ping => Ok(Self::check_ping(node.address, self.probe_timeout)),
HealthCheck::NetworkConnectivity => Ok(Self::check_network_connectivity(
node.address,
self.probe_timeout,
)),
HealthCheck::CpuLoad => Ok(Self::check_cpu_load(node)),
HealthCheck::MemoryUsage => Ok(Self::check_memory_usage(node)),
HealthCheck::DiskSpace => Ok(Self::check_disk_space(node)),
}
}
fn check_ping(address: SocketAddr, timeout: Duration) -> HealthCheckResult {
let start = Instant::now();
match TcpStream::connect_timeout(&address, timeout) {
Ok(_stream) => HealthCheckResult {
status: HealthCheckStatus::Healthy,
impact_score: 10.0,
details: format!(
"TCP connect to {address} succeeded in {:?}",
start.elapsed()
),
},
Err(e) => HealthCheckResult {
status: HealthCheckStatus::Unhealthy,
impact_score: 10.0,
details: format!("TCP connect to {address} failed: {e}"),
},
}
}
fn check_network_connectivity(address: SocketAddr, timeout: Duration) -> HealthCheckResult {
let acceptable = timeout / 2;
let start = Instant::now();
match TcpStream::connect_timeout(&address, timeout) {
Ok(_stream) => {
let elapsed = start.elapsed();
let healthy = elapsed <= acceptable;
HealthCheckResult {
status: if healthy {
HealthCheckStatus::Healthy
} else {
HealthCheckStatus::Unhealthy
},
impact_score: 15.0,
details: format!(
"Network round-trip to {address} took {elapsed:?} (acceptable <= {acceptable:?})"
),
}
}
Err(e) => HealthCheckResult {
status: HealthCheckStatus::Unhealthy,
impact_score: 15.0,
details: format!("Network connectivity check to {address} failed: {e}"),
},
}
}
fn check_cpu_load(node: &NodeInfo) -> HealthCheckResult {
if !Self::node_is_local(node) {
return HealthCheckResult {
status: HealthCheckStatus::Unknown,
impact_score: 15.0,
details: format!(
"CPU load of remote node {} ({}) cannot be measured: no metrics-collection \
transport is implemented for remote nodes",
node.id, node.address
),
};
}
match Self::read_local_load_average() {
Some(load_one) => {
let cores = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1);
let per_core = load_one / cores as f64;
let healthy = per_core < CPU_LOAD_PER_CORE_UNHEALTHY;
HealthCheckResult {
status: if healthy {
HealthCheckStatus::Healthy
} else {
HealthCheckStatus::Unhealthy
},
impact_score: 15.0,
details: format!(
"1-minute load average {load_one:.2} across {cores} logical core(s) \
({per_core:.2}/core, unhealthy at >= {CPU_LOAD_PER_CORE_UNHEALTHY:.2}/core)"
),
}
}
None => HealthCheckResult {
status: HealthCheckStatus::Unknown,
impact_score: 15.0,
details: "CPU load average is unavailable on this platform build (Linux reads \
/proc/loadavg natively; other platforms require the `sysinfo` feature)"
.to_string(),
},
}
}
fn check_memory_usage(node: &NodeInfo) -> HealthCheckResult {
if !Self::node_is_local(node) {
return HealthCheckResult {
status: HealthCheckStatus::Unknown,
impact_score: 20.0,
details: format!(
"Memory usage of remote node {} ({}) cannot be measured: no \
metrics-collection transport is implemented for remote nodes",
node.id, node.address
),
};
}
match Self::read_local_memory_kb() {
Some((total_kb, available_kb)) if total_kb > 0 => {
let used_percent =
100.0 * (total_kb.saturating_sub(available_kb)) as f64 / total_kb as f64;
let healthy = used_percent < MEMORY_USAGE_UNHEALTHY_PERCENT;
HealthCheckResult {
status: if healthy {
HealthCheckStatus::Healthy
} else {
HealthCheckStatus::Unhealthy
},
impact_score: 20.0,
details: format!(
"Memory in use: {used_percent:.1}% of {:.2} GiB (unhealthy at >= \
{MEMORY_USAGE_UNHEALTHY_PERCENT:.1}%)",
total_kb as f64 / (1024.0 * 1024.0)
),
}
}
_ => HealthCheckResult {
status: HealthCheckStatus::Unknown,
impact_score: 20.0,
details: "Memory usage is unavailable on this platform build (Linux reads \
/proc/meminfo natively; other platforms require the `sysinfo` feature)"
.to_string(),
},
}
}
fn check_disk_space(node: &NodeInfo) -> HealthCheckResult {
if !Self::node_is_local(node) {
return HealthCheckResult {
status: HealthCheckStatus::Unknown,
impact_score: 10.0,
details: format!(
"Disk space of remote node {} ({}) cannot be measured: no \
metrics-collection transport is implemented for remote nodes",
node.id, node.address
),
};
}
let dir = std::env::temp_dir();
match Self::probe_local_disk_write(&dir) {
Ok(bytes_written) => {
#[cfg(feature = "sysinfo")]
{
if let Some(free_percent) = Self::read_local_disk_free_percent(&dir) {
let healthy = free_percent >= DISK_FREE_UNHEALTHY_PERCENT;
return HealthCheckResult {
status: if healthy {
HealthCheckStatus::Healthy
} else {
HealthCheckStatus::Unhealthy
},
impact_score: 10.0,
details: format!(
"Write/read probe of {bytes_written} bytes to {} succeeded; \
{free_percent:.1}% free space remaining (unhealthy at < \
{DISK_FREE_UNHEALTHY_PERCENT:.1}%)",
dir.display()
),
};
}
}
HealthCheckResult {
status: HealthCheckStatus::Healthy,
impact_score: 10.0,
details: format!(
"Write/read/delete probe of {bytes_written} bytes to {} succeeded",
dir.display()
),
}
}
Err(e) => HealthCheckResult {
status: HealthCheckStatus::Unhealthy,
impact_score: 10.0,
details: format!(
"Disk write/read probe against {} failed: {e}",
dir.display()
),
},
}
}
fn node_is_local(node: &NodeInfo) -> bool {
node.address.ip().is_loopback()
}
fn probe_local_disk_write(dir: &std::path::Path) -> io::Result<usize> {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let marker = dir.join(format!(
"scirs2_health_probe_{}_{nanos}.tmp",
std::process::id()
));
let payload = b"scirs2-core cluster health disk probe";
std::fs::write(&marker, payload)?;
let readback = std::fs::read(&marker);
let _ = std::fs::remove_file(&marker);
let readback = readback?;
if readback != payload {
return Err(io::Error::other(format!(
"read-back mismatch: wrote {} bytes, read back {} bytes",
payload.len(),
readback.len()
)));
}
Ok(payload.len())
}
#[cfg(target_os = "linux")]
fn read_local_load_average() -> Option<f64> {
let content = std::fs::read_to_string("/proc/loadavg").ok()?;
content.split_whitespace().next()?.parse::<f64>().ok()
}
#[cfg(all(not(target_os = "linux"), feature = "sysinfo"))]
fn read_local_load_average() -> Option<f64> {
Some(sysinfo::System::load_average().one)
}
#[cfg(all(not(target_os = "linux"), not(feature = "sysinfo")))]
fn read_local_load_average() -> Option<f64> {
None
}
#[cfg(target_os = "linux")]
fn read_local_memory_kb() -> Option<(u64, u64)> {
let content = std::fs::read_to_string("/proc/meminfo").ok()?;
let field = |key: &str| -> Option<u64> {
content.lines().find_map(|line| {
line.strip_prefix(key)
.and_then(|rest| rest.split_whitespace().next())
.and_then(|value| value.parse::<u64>().ok())
})
};
let total = field("MemTotal:")?;
let available = field("MemAvailable:")?;
Some((total, available))
}
#[cfg(all(not(target_os = "linux"), feature = "sysinfo"))]
fn read_local_memory_kb() -> Option<(u64, u64)> {
let mut system = sysinfo::System::new();
system.refresh_memory();
let total = system.total_memory() / 1024;
let available = system.available_memory() / 1024;
Some((total, available))
}
#[cfg(all(not(target_os = "linux"), not(feature = "sysinfo")))]
fn read_local_memory_kb() -> Option<(u64, u64)> {
None
}
#[cfg(feature = "sysinfo")]
fn read_local_disk_free_percent(path: &std::path::Path) -> Option<f64> {
let disks = sysinfo::Disks::new_with_refreshed_list();
let mut best: Option<(&std::path::Path, u64, u64)> = None;
for disk in disks.list() {
let mount = disk.mount_point();
if path.starts_with(mount) {
let is_better_match = best.is_none_or(|(current, _, _)| {
mount.as_os_str().len() > current.as_os_str().len()
});
if is_better_match {
best = Some((mount, disk.total_space(), disk.available_space()));
}
}
}
best.and_then(|(_, total, available)| {
if total == 0 {
None
} else {
Some(100.0 * available as f64 / total as f64)
}
})
}
pub fn add_health_check(&mut self, check: HealthCheck) {
if !self.health_checks.contains(&check) {
self.health_checks.push(check);
}
}
pub fn remove_health_check(&mut self, check: &HealthCheck) {
self.health_checks.retain(|c| c != check);
}
pub fn get_health_checks(&self) -> &[HealthCheck] {
&self.health_checks
}
pub fn set_check_interval(&mut self, interval: Duration) {
self.check_interval = interval;
}
pub fn get_check_interval(&self) -> Duration {
self.check_interval
}
pub fn set_probe_timeout(&mut self, timeout: Duration) {
self.probe_timeout = timeout;
}
pub fn get_probe_timeout(&self) -> Duration {
self.probe_timeout
}
pub fn quick_health_check(&mut self, node: &NodeInfo) -> CoreResult<NodeHealthStatus> {
let quick_checks = vec![HealthCheck::Ping, HealthCheck::NetworkConnectivity];
let mut health_score = 100.0f64;
let mut failing_checks = Vec::new();
let mut unknown_checks = Vec::new();
for check in &quick_checks {
match self.execute_health_check(check, node) {
Ok(result) => match result.status {
HealthCheckStatus::Healthy => {}
HealthCheckStatus::Unhealthy => {
health_score -= result.impact_score;
failing_checks.push(check.clone());
}
HealthCheckStatus::Unknown => {
unknown_checks.push(check.clone());
}
},
Err(_) => {
health_score -= 30.0f64; failing_checks.push(check.clone());
}
}
}
let status = if !failing_checks.is_empty() {
NodeStatus::Unhealthy
} else if !unknown_checks.is_empty() {
NodeStatus::Unknown
} else {
NodeStatus::Healthy
};
Ok(NodeHealthStatus {
status,
health_score,
failing_checks,
unknown_checks,
last_checked: Instant::now(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr, TcpListener};
fn make_node(address: SocketAddr) -> NodeInfo {
NodeInfo {
id: format!("node_{address}"),
address,
node_type: super::super::types::NodeType::Worker,
capabilities: super::super::types::NodeCapabilities::default(),
status: NodeStatus::Unknown,
last_seen: Instant::now(),
metadata: super::super::types::NodeMetadata::default(),
}
}
fn dead_loopback_address() -> SocketAddr {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind ephemeral port");
let addr = listener.local_addr().expect("local_addr");
drop(listener);
addr
}
#[test]
fn ping_against_dead_endpoint_is_not_healthy() {
let addr = dead_loopback_address();
let result = HealthMonitor::check_ping(addr, Duration::from_millis(200));
assert_eq!(result.status, HealthCheckStatus::Unhealthy);
assert!(!result.status.is_healthy());
}
#[test]
fn network_connectivity_against_dead_endpoint_is_not_healthy() {
let addr = dead_loopback_address();
let result = HealthMonitor::check_network_connectivity(addr, Duration::from_millis(200));
assert_eq!(result.status, HealthCheckStatus::Unhealthy);
}
#[test]
fn ping_against_live_listener_is_healthy() {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind ephemeral port");
let addr = listener.local_addr().expect("local_addr");
let handle = std::thread::spawn(move || {
let _ = listener.accept();
});
let result = HealthMonitor::check_ping(addr, Duration::from_millis(500));
assert_eq!(result.status, HealthCheckStatus::Healthy);
assert!(result.status.is_healthy());
handle.join().expect("acceptor thread panicked");
}
#[test]
fn full_node_health_check_never_fabricates_healthy_for_dead_node() {
let addr = dead_loopback_address();
let mut monitor = HealthMonitor::new().expect("construct monitor");
let node = make_node(addr);
let health = monitor.check_node_health(&node).expect("check health");
assert_ne!(health.status, NodeStatus::Healthy);
assert!(health.failing_checks.contains(&HealthCheck::Ping));
assert!(health
.failing_checks
.contains(&HealthCheck::NetworkConnectivity));
assert!(health.health_score < 100.0);
}
#[test]
fn quick_health_check_never_fabricates_healthy_for_dead_node() {
let addr = dead_loopback_address();
let mut monitor = HealthMonitor::new().expect("construct monitor");
let node = make_node(addr);
let health = monitor.quick_health_check(&node).expect("quick check");
assert_eq!(health.status, NodeStatus::Unhealthy);
}
#[test]
fn remote_resource_checks_report_unknown_not_healthy() {
let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 42, 42, 42)), 9); let node = make_node(remote);
for result in [
HealthMonitor::check_cpu_load(&node),
HealthMonitor::check_memory_usage(&node),
HealthMonitor::check_disk_space(&node),
] {
assert_eq!(result.status, HealthCheckStatus::Unknown);
assert!(!result.status.is_healthy());
}
}
#[test]
fn local_resource_checks_perform_a_real_measurement() {
let local = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
let node = make_node(local);
let cpu = HealthMonitor::check_cpu_load(&node);
let mem = HealthMonitor::check_memory_usage(&node);
let disk = HealthMonitor::check_disk_space(&node);
assert_ne!(disk.status, HealthCheckStatus::Unknown);
assert!(disk.details.contains("bytes"));
if cfg!(any(target_os = "linux", feature = "sysinfo")) {
assert_ne!(cpu.status, HealthCheckStatus::Unknown);
assert_ne!(mem.status, HealthCheckStatus::Unknown);
assert!(cpu.details.contains("load average"));
assert!(mem.details.contains('%'));
}
}
#[test]
fn classify_status_distinguishes_unknown_from_healthy() {
let with_unknown =
HealthMonitor::classify_status(100.0, &[HealthCheck::CpuLoad], 80.0, 50.0);
assert_eq!(with_unknown, NodeStatus::Unknown);
assert_ne!(with_unknown, NodeStatus::Healthy);
let fully_confirmed = HealthMonitor::classify_status(100.0, &[], 80.0, 50.0);
assert_eq!(fully_confirmed, NodeStatus::Healthy);
let unhealthy = HealthMonitor::classify_status(10.0, &[HealthCheck::CpuLoad], 80.0, 50.0);
assert_eq!(unhealthy, NodeStatus::Unhealthy);
}
#[test]
fn probe_timeout_is_configurable() {
let mut monitor = HealthMonitor::new().expect("construct monitor");
let custom = Duration::from_millis(42);
monitor.set_probe_timeout(custom);
assert_eq!(monitor.get_probe_timeout(), custom);
}
}