// 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. use crate::cluster::rpc::{PeerRestClient, ScannerPeerActivity}; use crate::diagnostics::admin_server_info::get_commit_id; use crate::disk::DiskAPI; use crate::error::{Error, Result}; use crate::layout::endpoints::EndpointServerPools; use crate::runtime::sources as runtime_sources; use crate::services::metrics_realtime::{CollectMetricsOpts, MetricType}; use crate::services::rebalance::RebalSaveOpt; use crate::storage_api_contracts::admin::StorageAdminApi; use bytes::Bytes; use futures::future::join_all; use lazy_static::lazy_static; use rustfs_madmin::health::{Cpus, MemInfo, OsInfo, Partitions, ProcInfo, SysConfig, SysErrors, SysServices}; use rustfs_madmin::metrics::RealtimeMetrics; use rustfs_madmin::net::NetInfo; use rustfs_madmin::{ItemState, ServerProperties, StorageInfo}; use rustfs_utils::XHost; use std::collections::hash_map::DefaultHasher; use std::future::Future; use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, SystemTime}; use tokio::time::timeout; use tracing::{debug, error, info, warn}; use uuid::Uuid; /// After this many consecutive admin-call failures, mark the peer as offline. const CONSECUTIVE_FAILURE_THRESHOLD: u32 = 3; const LOG_COMPONENT_ECSTORE: &str = "ecstore"; const LOG_SUBSYSTEM_NOTIFICATION: &str = "notification"; const EVENT_NOTIFICATION_PEER_PROPAGATION: &str = "notification_peer_propagation"; const SCANNER_ACTIVITY_PROBE_TIMEOUT: Duration = Duration::from_secs(5); /// Cached result from the last successful admin call to a peer. struct PeerAdminCache { last_storage_info: Option, last_server_info: Option, storage_failures: u32, server_failures: u32, /// When the last successful server_info probe landed. Used to stop a stale /// cached `online` snapshot from being served indefinitely while a peer is /// actually down (rustfs/backlog#1049 P2). last_server_success: Option, } impl PeerAdminCache { fn new() -> Self { Self { last_storage_info: None, last_server_info: None, storage_failures: 0, server_failures: 0, last_server_success: None, } } } /// A cached `online` snapshot older than this is no longer trusted on a probe /// failure: rather than reporting a stale `online`, the member falls through to /// the live unknown/degraded/offline classification (rustfs/backlog#1049 P2). const SERVER_INFO_CACHE_MAX_AGE: Duration = Duration::from_secs(60); lazy_static! { pub static ref GLOBAL_NOTIFICATION_SYS: OnceLock> = OnceLock::new(); } pub async fn new_global_notification_sys(eps: EndpointServerPools) -> Result<()> { let _ = GLOBAL_NOTIFICATION_SYS .set(Arc::new(NotificationSys::new(eps).await)) .map_err(|_| Error::other("init notification_sys fail")); Ok(()) } // Owned handle rather than `&'static` (backlog#1052 S3): per-server contexts // need to hold their own notification system, which a process-lifetime // borrow cannot express. pub fn get_global_notification_sys() -> Option> { GLOBAL_NOTIFICATION_SYS.get().cloned() } pub struct NotificationSys { pub peer_clients: Vec>, pub all_peer_clients: Vec>, peer_admin_caches: Vec>, } impl NotificationSys { pub async fn new(eps: EndpointServerPools) -> Self { let (peer_clients, all_peer_clients) = PeerRestClient::new_clients(eps).await; let peer_admin_caches = (0..peer_clients.len()).map(|_| Mutex::new(PeerAdminCache::new())).collect(); Self { peer_clients, all_peer_clients, peer_admin_caches, } } } pub struct NotificationPeerErr { pub host: String, pub err: Option, } fn notification_peer_result(host: String, result: Result) -> NotificationPeerErr { NotificationPeerErr { host, err: result.err() } } fn unreachable_notification_peer_err() -> NotificationPeerErr { NotificationPeerErr { host: String::new(), err: Some(Error::other("peer is not reachable")), } } impl NotificationSys { pub fn rest_client_from_hash(&self, s: &str) -> Option { if self.all_peer_clients.is_empty() { return None; } let mut hasher = DefaultHasher::new(); s.hash(&mut hasher); let idx = (hasher.finish() as usize) % self.all_peer_clients.len(); self.all_peer_clients[idx].clone() } pub fn peer_client_for_grid_host(&self, grid_host: &str) -> Option { self.all_peer_clients .iter() .flatten() .find(|client| client.grid_host == grid_host) .cloned() } pub async fn delete_policy(&self, policy_name: &str) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter() { let policy = policy_name.to_string(); futures.push(async move { if let Some(client) = client { match client.delete_policy(&policy).await { Ok(_) => NotificationPeerErr { host: client.host.to_string(), err: None, }, Err(e) => NotificationPeerErr { host: client.host.to_string(), err: Some(e), }, } } else { NotificationPeerErr { host: "".to_string(), err: Some(Error::other("peer is not reachable")), } } }); } join_all(futures).await } pub async fn load_policy(&self, policy_name: &str) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter() { let policy = policy_name.to_string(); futures.push(async move { if let Some(client) = client { match client.load_policy(&policy).await { Ok(_) => NotificationPeerErr { host: client.host.to_string(), err: None, }, Err(e) => NotificationPeerErr { host: client.host.to_string(), err: Some(e), }, } } else { NotificationPeerErr { host: "".to_string(), err: Some(Error::other("peer is not reachable")), } } }); } join_all(futures).await } pub async fn load_policy_mapping(&self, user_or_group: &str, user_type: u64, is_group: bool) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter() { let uog = user_or_group.to_string(); futures.push(async move { if let Some(client) = client { match client.load_policy_mapping(&uog, user_type, is_group).await { Ok(_) => NotificationPeerErr { host: client.host.to_string(), err: None, }, Err(e) => NotificationPeerErr { host: client.host.to_string(), err: Some(e), }, } } else { NotificationPeerErr { host: "".to_string(), err: Some(Error::other("peer is not reachable")), } } }); } join_all(futures).await } pub async fn delete_user(&self, access_key: &str) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter() { let ak = access_key.to_string(); futures.push(async move { if let Some(client) = client { match client.delete_user(&ak).await { Ok(_) => NotificationPeerErr { host: client.host.to_string(), err: None, }, Err(e) => NotificationPeerErr { host: client.host.to_string(), err: Some(e), }, } } else { NotificationPeerErr { host: "".to_string(), err: Some(Error::other("peer is not reachable")), } } }); } join_all(futures).await } pub async fn reload_dynamic_config(&self, sub_sys: &str) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter() { let sub_sys = sub_sys.to_string(); futures.push(async move { if let Some(client) = client { match client .signal_service( crate::cluster::rpc::SERVICE_SIGNAL_RELOAD_DYNAMIC, &sub_sys, false, SystemTime::UNIX_EPOCH, ) .await { Ok(_) => NotificationPeerErr { host: client.host.to_string(), err: None, }, Err(e) => NotificationPeerErr { host: client.host.to_string(), err: Some(e), }, } } else { NotificationPeerErr { host: "".to_string(), err: Some(Error::other("peer is not reachable")), } } }); } join_all(futures).await } pub async fn refresh_config_snapshot(&self) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter() { futures.push(async move { if let Some(client) = client { match client .signal_service(crate::cluster::rpc::SERVICE_SIGNAL_REFRESH_CONFIG, "", false, SystemTime::UNIX_EPOCH) .await { Ok(_) => NotificationPeerErr { host: client.host.to_string(), err: None, }, Err(e) => NotificationPeerErr { host: client.host.to_string(), err: Some(e), }, } } else { NotificationPeerErr { host: "".to_string(), err: Some(Error::other("peer is not reachable")), } } }); } join_all(futures).await } pub async fn storage_info(&self, api: &S) -> rustfs_madmin::StorageInfo where S: StorageAdminApi, { let mut futures = Vec::with_capacity(self.peer_clients.len()); let endpoints = runtime_sources::endpoint_pools().unwrap_or_else(|| Vec::new().into()); let peer_timeout = Duration::from_secs(5); for (idx, client) in self.peer_clients.iter().enumerate() { let endpoints = endpoints.clone(); let cache = self.peer_admin_caches.get(idx); futures.push(async move { if let Some(client) = client { let host = client.host.to_string(); match timeout(peer_timeout, client.local_storage_info()).await { Ok(Ok(mut info)) => { normalize_and_cache_peer_storage_info(cache, &host, &mut info); Some(info) } Ok(Err(err)) => { warn!("peer {} storage_info failed: {}", host, err); handle_peer_failure(cache, &host, &endpoints) } Err(_) => { warn!("peer {} storage_info timed out after {:?}", host, peer_timeout); client.evict_connection().await; handle_peer_failure(cache, &host, &endpoints) } } } else { None } }); } let mut replies = join_all(futures).await; replies.push(Some(StorageAdminApi::local_storage_info(api).await)); let mut disks = Vec::new(); for info in replies.into_iter().flatten() { disks.extend(info.disks); } let backend = StorageAdminApi::backend_info(api).await; rustfs_madmin::StorageInfo { disks, backend } } pub async fn server_info(&self) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); let endpoints = runtime_sources::endpoint_pools().unwrap_or_else(|| Vec::new().into()); let peer_timeout = Duration::from_secs(5); for (idx, client) in self.peer_clients.iter().enumerate() { let endpoints = endpoints.clone(); let cache = self.peer_admin_caches.get(idx); futures.push(async move { // `peer_clients` comes from `new_clients`, which only ever pushes // `Some(client)` (local hosts are excluded, not slotted as // `None`), so this branch is unreachable in practice. Kept as a // defensive fallback: report an explicit `unknown` state rather // than a blank `default()` entry, so it can never contribute a // hollow row to `servers[]` (rustfs/backlog#1049 P3). let Some(client) = client else { return ServerProperties { state: ItemState::Unknown.to_string().to_owned(), ..Default::default() }; }; let host = client.host.to_string(); // First attempt. A single evicted or half-open internode channel // is enough to fail one probe and, before retrying, would drop // the member to unknown/offline for this whole snapshot. So on any // first-attempt failure we evict the channel and re-dial once // before falling back (rustfs/backlog#1049, P1-B). match timeout(peer_timeout, client.server_info()).await { Ok(Ok(info)) => { update_server_info_cache(cache, &host, &info); return info; } Ok(Err(err)) => debug!("peer {host} server_info failed (attempt 1/2): {err}"), Err(_) => debug!("peer {host} server_info timed out (attempt 1/2) after {peer_timeout:?}"), } // Drop the suspect channel AND clear the offline gate so the // retry actually re-dials. A network-like first failure runs // through `finalize_result`, which sets the offline gate; a bare // `evict_connection` would leave that gate up and the retry would // fast-fail with "temporarily offline" instead of reconnecting // (rustfs/backlog#1049 P1-B). client.prepare_retry().await; // Second and final attempt on the fresh channel. match timeout(peer_timeout, client.server_info()).await { Ok(Ok(info)) => { update_server_info_cache(cache, &host, &info); info } Ok(Err(err)) => { warn!("peer {host} server_info failed after retry: {err}"); let health = peer_disk_health(&host).await; handle_server_info_failure(cache, &host, &endpoints, health.as_ref()) } Err(_) => { warn!("peer {host} server_info timed out after retry ({peer_timeout:?})"); client.evict_connection().await; let health = peer_disk_health(&host).await; handle_server_info_failure(cache, &host, &endpoints, health.as_ref()) } } }); } join_all(futures).await } pub async fn load_user(&self, access_key: &str, temp: bool) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter() { let ak = access_key.to_string(); futures.push(async move { if let Some(client) = client { match client.load_user(&ak, temp).await { Ok(_) => NotificationPeerErr { host: client.host.to_string(), err: None, }, Err(e) => NotificationPeerErr { host: client.host.to_string(), err: Some(e), }, } } else { NotificationPeerErr { host: "".to_string(), err: Some(Error::other("peer is not reachable")), } } }); } join_all(futures).await } pub async fn load_group(&self, group: &str) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter() { let gname = group.to_string(); futures.push(async move { if let Some(client) = client { match client.load_group(&gname).await { Ok(_) => NotificationPeerErr { host: client.host.to_string(), err: None, }, Err(e) => NotificationPeerErr { host: client.host.to_string(), err: Some(e), }, } } else { NotificationPeerErr { host: "".to_string(), err: Some(Error::other("peer is not reachable")), } } }); } join_all(futures).await } pub async fn delete_service_account(&self, access_key: &str) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter() { let ak = access_key.to_string(); futures.push(async move { if let Some(client) = client { match client.delete_service_account(&ak).await { Ok(_) => NotificationPeerErr { host: client.host.to_string(), err: None, }, Err(e) => NotificationPeerErr { host: client.host.to_string(), err: Some(e), }, } } else { NotificationPeerErr { host: "".to_string(), err: Some(Error::other("peer is not reachable")), } } }); } join_all(futures).await } pub async fn load_service_account(&self, access_key: &str) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter() { let ak = access_key.to_string(); futures.push(async move { if let Some(client) = client { match client.load_service_account(&ak).await { Ok(_) => NotificationPeerErr { host: client.host.to_string(), err: None, }, Err(e) => NotificationPeerErr { host: client.host.to_string(), err: Some(e), }, } } else { NotificationPeerErr { host: "".to_string(), err: Some(Error::other("peer is not reachable")), } } }); } join_all(futures).await } pub async fn reload_pool_meta(&self) -> Result<()> { let mut failures = Vec::new(); let mut futures = Vec::with_capacity(self.peer_clients.len()); for (idx, client) in self.peer_clients.iter().enumerate() { if let Some(client) = client { let host = client.grid_host.clone(); futures.push(async move { client.reload_pool_meta().await.map_err(|err| (host, err)) }); } else { warn!( event = EVENT_NOTIFICATION_PEER_PROPAGATION, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_NOTIFICATION, action = "reload_pool_meta", result = "peer_unreachable", peer_index = idx, "notification peer propagation" ); failures.push(format!("peer[{idx}] reload_pool_meta failed: peer is not reachable")); } } for result in join_all(futures).await { if let Err((host, err)) = result { let failure = format!("peer {host} reload_pool_meta failed: {err}"); error!( event = EVENT_NOTIFICATION_PEER_PROPAGATION, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_NOTIFICATION, action = "reload_pool_meta", result = "peer_failed", peer = %host, error = %err, "notification peer propagation" ); failures.push(failure); } } aggregate_notification_failures("reload_pool_meta", failures) } #[tracing::instrument(skip(self))] pub async fn load_rebalance_meta(&self, start: bool) -> Result<()> { let failures = self.load_rebalance_meta_failures(start).await?; aggregate_notification_failures("load_rebalance_meta", failures) } #[tracing::instrument(skip(self))] pub async fn load_rebalance_meta_failures(&self, start: bool) -> Result> { let operation = format!("load_rebalance_meta(start={start})"); let mut failures = Vec::new(); let mut futures = Vec::with_capacity(self.peer_clients.len()); for (idx, client) in self.peer_clients.iter().enumerate() { if let Some(client) = client { let host = client.grid_host.clone(); futures.push(async move { let result = client.load_rebalance_meta(start).await; (host, result) }); } else { warn!( event = EVENT_NOTIFICATION_PEER_PROPAGATION, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_NOTIFICATION, action = "load_rebalance_meta", result = "peer_unreachable", peer_index = idx, start_rebalance = start, "notification peer propagation" ); failures.push(format!("peer[{idx}] {operation} failed: peer is not reachable")); } } for (host, result) in join_all(futures).await { if let Err(err) = result { let failure = format!("peer {host} {operation} failed: {err}"); error!( event = EVENT_NOTIFICATION_PEER_PROPAGATION, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_NOTIFICATION, action = "load_rebalance_meta", result = "peer_failed", peer = %host, start_rebalance = start, error = %err, "notification peer propagation" ); failures.push(failure); } else { debug!( event = EVENT_NOTIFICATION_PEER_PROPAGATION, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_NOTIFICATION, action = "load_rebalance_meta", result = "peer_success", peer = %host, start_rebalance = start, "notification peer propagation" ); } } Ok(failures) } pub async fn stop_rebalance(&self, expected_rebalance_id: Option<&str>) -> Result<()> { let failures = self.stop_rebalance_failures(expected_rebalance_id).await?; aggregate_notification_failures("stop_rebalance", failures) } pub async fn stop_rebalance_failures(&self, expected_rebalance_id: Option<&str>) -> Result> { info!( event = EVENT_NOTIFICATION_PEER_PROPAGATION, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_NOTIFICATION, action = "stop_rebalance", state = "started", "notification peer propagation" ); let Some(store) = runtime_sources::object_store_handle() else { error!( event = EVENT_NOTIFICATION_PEER_PROPAGATION, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_NOTIFICATION, action = "stop_rebalance", result = "failed", reason = "object_layer_not_initialized", "notification peer propagation" ); return Err(Error::other("stop_rebalance: object layer not initialized")); }; let mut failures = Vec::new(); let mut futures = Vec::with_capacity(self.peer_clients.len()); for (idx, client) in self.peer_clients.iter().enumerate() { if let Some(client) = client { let host = client.grid_host.clone(); futures.push(async move { let result = client.stop_rebalance(expected_rebalance_id).await; (host, result) }); } else { warn!( event = EVENT_NOTIFICATION_PEER_PROPAGATION, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_NOTIFICATION, action = "stop_rebalance", result = "peer_unreachable", peer_index = idx, "notification peer propagation" ); failures.push(format!("peer[{idx}] stop_rebalance failed: peer is not reachable")); } } for (host, result) in join_all(futures).await { if let Err(err) = result { let failure = format!("peer {host} stop_rebalance failed: {err}"); error!( event = EVENT_NOTIFICATION_PEER_PROPAGATION, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_NOTIFICATION, action = "stop_rebalance", result = "peer_failed", peer = %host, error = %err, "notification peer propagation" ); failures.push(failure); } else { debug!( event = EVENT_NOTIFICATION_PEER_PROPAGATION, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_NOTIFICATION, action = "stop_rebalance", result = "peer_success", peer = %host, "notification peer propagation" ); } } match store.stop_rebalance_for_id(expected_rebalance_id).await { Ok(_) => { if let Err(err) = store.save_rebalance_stats(usize::MAX, RebalSaveOpt::StoppedAt).await { error!( event = EVENT_NOTIFICATION_PEER_PROPAGATION, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_NOTIFICATION, action = "stop_rebalance", result = "local_save_failed", error = %err, "notification peer propagation" ); return Err(Error::other(format!( "local stop_rebalance save_rebalance_stats(stopped_at) failed: {err}" ))); } } Err(err) => { error!( event = EVENT_NOTIFICATION_PEER_PROPAGATION, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_NOTIFICATION, action = "stop_rebalance", result = "local_stop_failed", error = %err, "notification peer propagation" ); return Err(Error::other(format!("local stop_rebalance stop failed: {err}"))); } } info!( event = EVENT_NOTIFICATION_PEER_PROPAGATION, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_NOTIFICATION, action = "stop_rebalance", result = if failures.is_empty() { "success" } else { "partial_failure" }, "notification peer propagation" ); Ok(failures) } pub async fn load_bucket_metadata(&self, bucket: &str) -> Result<()> { self.load_bucket_metadata_with_scanner_maintenance(bucket, false).await } pub async fn load_bucket_metadata_for_scanner_maintenance(&self, bucket: &str) -> Result<()> { self.load_bucket_metadata_with_scanner_maintenance(bucket, true).await } async fn load_bucket_metadata_with_scanner_maintenance(&self, bucket: &str, scanner_maintenance_change: bool) -> Result<()> { let operation = format!("load_bucket_metadata({bucket})"); let mut failures = Vec::new(); let mut futures = Vec::with_capacity(self.peer_clients.len()); for (idx, client) in self.peer_clients.iter().enumerate() { if let Some(client) = client { let host = client.host.to_string(); let b = bucket.to_string(); futures.push(async move { client .load_bucket_metadata(&b, scanner_maintenance_change) .await .map_err(|err| (host, err)) }); } else { failures.push(format!("peer[{idx}] {operation} failed: peer is not reachable")); } } for result in join_all(futures).await { if let Err((host, err)) = result { let failure = format!("peer {host} {operation} failed: {err}"); error!("notification {operation} err {failure}"); failures.push(failure); } } aggregate_notification_failures(&operation, failures) } pub async fn delete_bucket_metadata(&self, bucket: &str) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter() { let b = bucket.to_string(); futures.push(async move { if let Some(client) = client { match client.delete_bucket_metadata(&b).await { Ok(_) => NotificationPeerErr { host: client.host.to_string(), err: None, }, Err(e) => NotificationPeerErr { host: client.host.to_string(), err: Some(e), }, } } else { NotificationPeerErr { host: "".to_string(), err: Some(Error::other("peer is not reachable")), } } }); } join_all(futures).await } pub async fn start_profiling(&self, profiler: &str) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter() { let pf = profiler.to_string(); futures.push(async move { if let Some(client) = client { match client.start_profiling(&pf).await { Ok(_) => NotificationPeerErr { host: client.host.to_string(), err: None, }, Err(e) => NotificationPeerErr { host: client.host.to_string(), err: Some(e), }, } } else { NotificationPeerErr { host: "".to_string(), err: Some(Error::other("peer is not reachable")), } } }); } join_all(futures).await } pub async fn get_cpus(&self) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter().cloned() { futures.push(async move { if let Some(client) = client { client.get_cpus().await.unwrap_or_default() } else { Cpus::default() } }); } join_all(futures).await } pub async fn get_net_info(&self) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter().cloned() { futures.push(async move { if let Some(client) = client { client.get_net_info().await.unwrap_or_default() } else { NetInfo::default() } }); } join_all(futures).await } pub async fn get_partitions(&self) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter().cloned() { futures.push(async move { if let Some(client) = client { client.get_partitions().await.unwrap_or_default() } else { Partitions::default() } }); } join_all(futures).await } pub async fn get_os_info(&self) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter().cloned() { futures.push(async move { if let Some(client) = client { client.get_os_info().await.unwrap_or_default() } else { OsInfo::default() } }); } join_all(futures).await } pub async fn get_sys_services(&self) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter().cloned() { futures.push(async move { if let Some(client) = client { client.get_se_linux_info().await.unwrap_or_default() } else { SysServices::default() } }); } join_all(futures).await } pub async fn get_sys_config(&self) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter().cloned() { futures.push(async move { if let Some(client) = client { client.get_sys_config().await.unwrap_or_default() } else { SysConfig::default() } }); } join_all(futures).await } pub async fn get_sys_errors(&self) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter().cloned() { futures.push(async move { if let Some(client) = client { client.get_sys_errors().await.unwrap_or_default() } else { SysErrors::default() } }); } join_all(futures).await } pub async fn get_mem_info(&self) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter().cloned() { futures.push(async move { if let Some(client) = client { client.get_mem_info().await.unwrap_or_default() } else { MemInfo::default() } }); } join_all(futures).await } pub async fn get_proc_info(&self) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter().cloned() { futures.push(async move { if let Some(client) = client { client.get_proc_info().await.unwrap_or_default() } else { ProcInfo::default() } }); } join_all(futures).await } pub async fn get_metrics(&self, t: MetricType, opts: &CollectMetricsOpts) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter().cloned() { let t_clone = t; let opts_clone = opts; futures.push(async move { if let Some(client) = client { client.get_metrics(t_clone, opts_clone).await.unwrap_or_default() } else { RealtimeMetrics::default() } }); } join_all(futures).await } pub async fn scanner_activity_snapshots(&self) -> Result> { if self.peer_clients.is_empty() { return Err(Error::other("scanner activity probe has no remote peers")); } if self.all_peer_clients.len() != self.peer_clients.len() + 1 { return Err(Error::other(format!( "scanner activity peer topology is incomplete: {} remote peers for {} cluster members", self.peer_clients.len(), self.all_peer_clients.len() ))); } let mut futures = Vec::with_capacity(self.peer_clients.len()); for (idx, client) in self.peer_clients.iter().cloned().enumerate() { futures.push(async move { let client = client.ok_or_else(|| Error::other(format!("scanner activity peer[{idx}] is unreachable")))?; let host = client.grid_host.clone(); scanner_activity_with_timeout(SCANNER_ACTIVITY_PROBE_TIMEOUT, &host, client.scanner_activity()) .await .map(|activity| (host, activity)) }); } let mut generations = Vec::with_capacity(futures.len()); for result in join_all(futures).await { generations.push(result?); } Ok(generations) } pub async fn reload_site_replication_config(&self) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter() { futures.push(async move { if let Some(client) = client { match client.reload_site_replication_config().await { Ok(_) => NotificationPeerErr { host: client.host.to_string(), err: None, }, Err(e) => NotificationPeerErr { host: client.host.to_string(), err: Some(e), }, } } else { NotificationPeerErr { host: "".to_string(), err: Some(Error::other("peer is not reachable")), } } }); } join_all(futures).await } pub async fn load_transition_tier_config(&self) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter() { futures.push(async move { if let Some(client) = client { match client.load_transition_tier_config().await { Ok(_) => NotificationPeerErr { host: client.host.to_string(), err: None, }, Err(e) => NotificationPeerErr { host: client.host.to_string(), err: Some(e), }, } } else { NotificationPeerErr { host: "".to_string(), err: Some(Error::other("peer is not reachable")), } } }); } join_all(futures).await } pub async fn prepare_tier_mutation(&self, mutation_id: Uuid, canonical_payload: Bytes) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter().cloned() { let payload = canonical_payload.clone(); futures.push(async move { if let Some(client) = client { notification_peer_result(client.host.to_string(), client.prepare_tier_mutation(mutation_id, payload).await) } else { unreachable_notification_peer_err() } }); } join_all(futures).await } pub async fn commit_tier_mutation(&self, mutation_id: Uuid, canonical_payload: Bytes) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter().cloned() { let payload = canonical_payload.clone(); futures.push(async move { if let Some(client) = client { notification_peer_result(client.host.to_string(), client.commit_tier_mutation(mutation_id, payload).await) } else { unreachable_notification_peer_err() } }); } join_all(futures).await } pub async fn abort_tier_mutation(&self, mutation_id: Uuid) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter().cloned() { futures.push(async move { if let Some(client) = client { notification_peer_result(client.host.to_string(), client.abort_tier_mutation(mutation_id).await) } else { unreachable_notification_peer_err() } }); } join_all(futures).await } } async fn scanner_activity_with_timeout(timeout_duration: Duration, host: &str, activity: F) -> Result where F: Future>, { timeout(timeout_duration, activity) .await .map_err(|_| Error::other(format!("scanner activity peer {host} timed out after {timeout_duration:?}")))? } async fn call_peer_with_timeout( timeout_dur: Duration, host_label: &str, op: F, fallback: impl FnOnce() -> ServerProperties, ) -> ServerProperties where F: FnOnce() -> Fut, Fut: Future> + Send, { match timeout(timeout_dur, op()).await { Ok(Ok(info)) => info, Ok(Err(err)) => { warn!("peer {host_label} server_info failed: {err}"); fallback() } Err(_) => { warn!("peer {host_label} server_info timed out after {:?}", timeout_dur); fallback() } } } /// Handle a peer failure for storage_info: return cached data if available, /// or mark offline only after consecutive failures exceed the threshold. fn handle_peer_failure( cache: Option<&Mutex>, host: &str, endpoints: &EndpointServerPools, ) -> Option { let cache = cache?; let mut c = match cache.lock() { Ok(cache) => cache, Err(poisoned) => { warn!("peer {host} storage_info cache mutex poisoned"); poisoned.into_inner() } }; c.storage_failures += 1; if let Some(ref cached) = c.last_storage_info && c.storage_failures < CONSECUTIVE_FAILURE_THRESHOLD { debug!( event = "peer_probe_failure", peer = host, probe = "storage_info", consecutive_failures = c.storage_failures, threshold = CONSECUTIVE_FAILURE_THRESHOLD, "peer storage_info probe failed; returning cached state until the offline threshold is reached" ); return Some(cached.clone()); } if c.storage_failures >= CONSECUTIVE_FAILURE_THRESHOLD { if c.storage_failures == CONSECUTIVE_FAILURE_THRESHOLD { warn!( event = "peer_marked_offline", peer = host, probe = "storage_info", consecutive_failures = c.storage_failures, threshold = CONSECUTIVE_FAILURE_THRESHOLD, "reporting peer disks offline after consecutive storage_info failures" ); } return Some(StorageInfo { disks: synthesized_disks(host, endpoints, ItemState::Offline), ..Default::default() }); } None } fn normalize_and_cache_peer_storage_info(cache: Option<&Mutex>, host: &str, info: &mut StorageInfo) { // `Disk::local` is relative to this aggregator, not to the peer that // produced the response. for disk in &mut info.disks { disk.local = false; } let Some(cache) = cache else { return; }; let mut c = match cache.lock() { Ok(cache) => cache, Err(poisoned) => { warn!("peer {host} storage_info cache mutex poisoned"); poisoned.into_inner() } }; if c.storage_failures >= CONSECUTIVE_FAILURE_THRESHOLD { info!( event = "peer_recovered_online", peer = host, probe = "storage_info", consecutive_failures = c.storage_failures, "peer storage_info probe succeeded again; peer disks reported online" ); } c.last_storage_info = Some(info.clone()); c.storage_failures = 0; } /// Independent liveness evidence for a peer, gathered from the local disk-health /// heartbeat rather than the admin RPC path. `any_online` is true when at least /// one of the peer's drives is still answering the ~15s health check; `disks` /// carries a per-drive entry (state `"ok"` when online, `"offline"` when the /// heartbeat marks it faulty) so a `degraded` member's drives are counted for /// real. See rustfs/backlog#1049 (P0-B). struct PeerDiskHealth { any_online: bool, disks: Vec, } /// Consult the local disk-health state for `host` without issuing any RPC. /// /// On the aggregating node a peer's drives are remote-disk handles whose /// `is_online()` is a pure atomic read of the heartbeat tracker (independent of /// the admin `server_info` RPC that just failed). Returns `None` when the store /// is not initialized or the host owns no drives in the topology. async fn peer_disk_health(host: &str) -> Option { let store = runtime_sources::object_store_handle()?; let mut disks = Vec::new(); let mut any_online = false; for sets in store.pools.iter() { for set in sets.disk_set.iter() { let guard = set.disks.read().await; for (idx, slot) in guard.iter().enumerate() { let Some(ep) = set.set_endpoints.get(idx) else { continue; }; if !endpoint_host_matches(host, &ep.host_port()) { continue; } let online = match slot { Some(disk) => disk.is_online().await, None => false, }; any_online |= online; // A live drive is counted online via the DriveState "ok" string; // a faulty one is counted offline. This keeps a degraded member's // drives in the real online/offline buckets. disks.push(rustfs_madmin::Disk { endpoint: ep.to_string(), state: if online { rustfs_common::heal_channel::DriveState::Ok.to_string() } else { ItemState::Offline.to_string().to_owned() }, pool_index: ep.pool_idx, set_index: ep.set_idx, disk_index: ep.disk_idx, ..Default::default() }); } } } if disks.is_empty() { None } else { Some(PeerDiskHealth { any_online, disks }) } } /// Handle a peer failure for server_info: return cached data if available, or /// classify the member as `unknown` / `degraded` / `offline` depending on how /// many consecutive probes have failed and whether the peer's drives are still /// answering the local disk-health heartbeat. /// /// - Below the failure threshold with no cached snapshot: `unknown` (probe /// missed this cycle but the member is not confirmed down). /// - At/after the threshold with drives still online: `degraded` (the admin RPC /// is stuck but the node is alive and serving data) — this is what stops a /// healthy node from rotating through a false `offline` (rustfs/backlog#1049). /// - At/after the threshold with drives also offline: `offline` (confirmed). /// /// Synthesized entries always carry one drive per endpoint so the pool's drive /// totals stay balanced. fn handle_server_info_failure( cache: Option<&Mutex>, host: &str, endpoints: &EndpointServerPools, peer_health: Option<&PeerDiskHealth>, ) -> ServerProperties { let Some(cache) = cache else { return unknown_server_properties(host, endpoints); }; let mut c = match cache.lock() { Ok(cache) => cache, Err(poisoned) => { warn!("peer {host} server_info cache mutex poisoned"); poisoned.into_inner() } }; c.server_failures += 1; if let Some(ref cached) = c.last_server_info && c.server_failures < CONSECUTIVE_FAILURE_THRESHOLD { if cached_snapshot_is_fresh(c.last_server_success) { debug!( event = "peer_probe_failure", peer = host, consecutive_failures = c.server_failures, threshold = CONSECUTIVE_FAILURE_THRESHOLD, "peer server_info probe failed; returning cached state until the offline threshold is reached" ); return cached.clone(); } // The cached snapshot is too old to keep reporting as `online`; fall // through to the live unknown/degraded/offline classification below // instead of masking a down peer with a stale success (P2). debug!( event = "peer_cache_stale", peer = host, max_age_secs = SERVER_INFO_CACHE_MAX_AGE.as_secs(), "cached server_info snapshot is stale; reclassifying from live signals instead of reporting stale online" ); } if c.server_failures >= CONSECUTIVE_FAILURE_THRESHOLD { // Drives still answering the heartbeat: the node is alive, only its // admin surface is unreachable — report `degraded`, not `offline`, so a // stuck admin path does not read as an ejected node. if let Some(health) = peer_health.filter(|h| h.any_online) { if c.server_failures == CONSECUTIVE_FAILURE_THRESHOLD { warn!( event = "peer_marked_degraded", peer = host, consecutive_failures = c.server_failures, threshold = CONSECUTIVE_FAILURE_THRESHOLD, "peer admin server_info keeps failing but its drives are online; reporting degraded (not offline)" ); } else { debug!( event = "peer_still_degraded", peer = host, consecutive_failures = c.server_failures, "peer admin server_info still failing while its drives remain online" ); } return degraded_server_properties(host, &health.disks); } // Log the transition exactly once (at the crossing) so the console's // "node offline" verdict has a matching WARN in the observer's logs // (rustfs/backlog#888: nodes were marked offline with no log naming // the transition). Later failures while already offline stay at DEBUG // to avoid repeating the warning every probe cycle. if c.server_failures == CONSECUTIVE_FAILURE_THRESHOLD { warn!( event = "peer_marked_offline", peer = host, consecutive_failures = c.server_failures, threshold = CONSECUTIVE_FAILURE_THRESHOLD, "marking peer offline for admin/console reporting after consecutive server_info failures; \ a background recovery probe will restore it automatically once reachable" ); } else { debug!( event = "peer_still_offline", peer = host, consecutive_failures = c.server_failures, "peer server_info probe failed while peer is already reported offline" ); } return offline_server_properties(host, endpoints); } unknown_server_properties(host, endpoints) } fn update_server_info_cache(cache: Option<&Mutex>, host: &str, info: &ServerProperties) { let Some(cache) = cache else { return; }; let mut c = match cache.lock() { Ok(cache) => cache, Err(poisoned) => { warn!("peer {host} server_info cache mutex poisoned"); poisoned.into_inner() } }; if c.server_failures >= CONSECUTIVE_FAILURE_THRESHOLD { info!( event = "peer_recovered_online", peer = host, consecutive_failures = c.server_failures, "peer server_info probe succeeded again; peer is back online for admin/console reporting" ); } c.last_server_info = Some(info.clone()); c.last_server_success = Some(SystemTime::now()); c.server_failures = 0; } /// Whether a cached server_info snapshot is recent enough to still report as /// `online` on a probe failure. A missing timestamp means no age information is /// available (e.g. a snapshot set without going through the success path in a /// test); such a snapshot is treated as fresh to preserve the prior behavior, /// while a clock that went backwards is treated as stale. See P2 in /// rustfs/backlog#1049. fn cached_snapshot_is_fresh(last_success: Option) -> bool { match last_success { Some(at) => at.elapsed().map(|age| age < SERVER_INFO_CACHE_MAX_AGE).unwrap_or(false), None => true, } } /// A member that could not be probed this cycle and is not confirmed down. /// Carries the endpoint's drives (marked `unknown`) so the pool's drive totals /// stay balanced instead of the member's drives vanishing from the summary. fn unknown_server_properties(host: &str, endpoints: &EndpointServerPools) -> ServerProperties { ServerProperties { endpoint: host.to_string(), state: ItemState::Unknown.to_string().to_owned(), disks: synthesized_disks(host, endpoints, ItemState::Unknown), ..Default::default() } } fn offline_server_properties(host: &str, endpoints: &EndpointServerPools) -> ServerProperties { ServerProperties { uptime: runtime_sources::boot_uptime_secs(), version: get_commit_id(), endpoint: host.to_string(), state: ItemState::Offline.to_string().to_owned(), disks: synthesized_disks(host, endpoints, ItemState::Offline), ..Default::default() } } /// A member whose admin RPC is unreachable but whose drives are still online. /// Carries the per-drive health observed from the local heartbeat so the drives /// land in the real online/offline buckets while the member reads as degraded. fn degraded_server_properties(host: &str, disks: &[rustfs_madmin::Disk]) -> ServerProperties { ServerProperties { uptime: runtime_sources::boot_uptime_secs(), version: get_commit_id(), endpoint: host.to_string(), state: ItemState::Degraded.to_string().to_owned(), disks: disks.to_vec(), ..Default::default() } } /// Enumerate the drives a host owns from the pool topology, tagged with the /// given member state. Used to synthesize drive entries for a member whose /// properties RPC could not be answered, so summary counters stay complete. fn synthesized_disks(host: &str, endpoints: &EndpointServerPools, state: ItemState) -> Vec { let mut disks = Vec::new(); for pool in endpoints.as_ref() { for ep in pool.endpoints.as_ref() { if (host.is_empty() && ep.is_local) || endpoint_host_matches(host, &ep.host_port()) { disks.push(rustfs_madmin::Disk { endpoint: ep.to_string(), state: state.to_string().to_owned(), pool_index: ep.pool_idx, set_index: ep.set_idx, disk_index: ep.disk_idx, ..Default::default() }); } } } disks } /// Whether `peer_host` refers to the same node as an endpoint whose /// `host_port()` is `ep_host_port`. /// /// `PeerRestClient::host` is an `XHost`, which resolves names to an address on /// construction (`hosts_sorted` -> `XHost::try_from` -> `to_socket_addrs`), so /// `peer_host` is the resolved `IP:port`. An endpoint's `host_port()`, however, /// is `url.host():port` — still the raw `hostname:port` on hostname-based /// deployments. A plain string compare therefore misses on hostname clusters, /// leaving the synthesized/degraded drive list empty and `unknownDisks` at 0 /// (rustfs/rustfs#4607 follow-up). Compare directly first (fast path / IP /// deployments), then canonicalize the endpoint side through the same `XHost` /// resolution and compare again. fn endpoint_host_matches(peer_host: &str, ep_host_port: &str) -> bool { if peer_host == ep_host_port { return true; } XHost::try_from(ep_host_port.to_string()) .map(|resolved| resolved.to_string() == peer_host) .unwrap_or(false) } fn aggregate_notification_failures(operation: &str, failures: Vec) -> Result<()> { if failures.is_empty() { return Ok(()); } Err(Error::other(format!( "{operation} encountered {} failure(s): {}", failures.len(), failures.join(" | ") ))) } #[cfg(test)] mod tests { use super::*; fn build_props(endpoint: &str) -> ServerProperties { ServerProperties { endpoint: endpoint.to_string(), ..Default::default() } } #[tokio::test] async fn call_peer_with_timeout_returns_value_when_fast() { let result = call_peer_with_timeout( Duration::from_millis(50), "peer-1", || async { Ok::<_, Error>(build_props("fast")) }, || build_props("fallback"), ) .await; assert_eq!(result.endpoint, "fast"); } #[tokio::test] async fn call_peer_with_timeout_uses_fallback_on_error() { let result = call_peer_with_timeout( Duration::from_millis(50), "peer-2", || async { Err::(Error::other("boom")) }, || build_props("fallback"), ) .await; assert_eq!(result.endpoint, "fallback"); } #[tokio::test] async fn call_peer_with_timeout_uses_fallback_on_timeout() { let result = call_peer_with_timeout( Duration::from_millis(5), "peer-3", std::future::pending::>, || build_props("fallback"), ) .await; assert_eq!(result.endpoint, "fallback"); } #[test] fn aggregate_notification_failures_returns_ok_when_empty() { assert!(aggregate_notification_failures("stop_rebalance", Vec::new()).is_ok()); } #[test] fn aggregate_notification_failures_returns_joined_error_when_non_empty() { let err = aggregate_notification_failures( "load_rebalance_meta", vec!["peer-1 failed".to_string(), "local save failed".to_string()], ) .expect_err("non-empty failures should return error"); let msg = err.to_string(); assert!(msg.contains("load_rebalance_meta")); assert!(msg.contains("2 failure(s)")); assert!(msg.contains("peer-1 failed")); assert!(msg.contains("local save failed")); } #[test] fn peer_client_for_grid_host_matches_exact_grid_host() { let sys = NotificationSys { peer_clients: Vec::new(), all_peer_clients: vec![Some(PeerRestClient::new( "127.0.0.1:9000".to_string().try_into().expect("peer host should parse"), "http://127.0.0.1:9000".to_string(), ))], peer_admin_caches: Vec::new(), }; let client = sys .peer_client_for_grid_host("http://127.0.0.1:9000") .expect("matching grid host should return peer client"); assert_eq!(client.grid_host, "http://127.0.0.1:9000"); assert!(sys.peer_client_for_grid_host("http://node-b:9000").is_none()); } #[test] fn load_rebalance_meta_aggregate_failures_return_error() { let err = aggregate_notification_failures( "load_rebalance_meta(start=true)", vec!["peer[0] load_rebalance_meta failed: peer is not reachable".to_string()], ) .expect_err("load_rebalance_meta peer failures must be returned"); let msg = err.to_string(); assert!(msg.contains("load_rebalance_meta(start=true)")); assert!(msg.contains("1 failure(s)")); assert!(msg.contains("peer[0]")); } #[test] fn stop_rebalance_aggregate_failures_return_error() { let err = aggregate_notification_failures( "stop_rebalance", vec!["peer[0] stop_rebalance failed: peer is not reachable".to_string()], ) .expect_err("stop_rebalance peer failures must be returned"); let msg = err.to_string(); assert!(msg.contains("stop_rebalance")); assert!(msg.contains("1 failure(s)")); assert!(msg.contains("peer[0]")); } #[tokio::test] async fn reload_pool_meta_reports_unreachable_peers() { let sys = NotificationSys { peer_clients: vec![None], all_peer_clients: Vec::new(), peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())], }; let err = sys .reload_pool_meta() .await .expect_err("unreachable peers should fail pool metadata reload"); let msg = err.to_string(); assert!(msg.contains("reload_pool_meta")); assert!(msg.contains("1 failure(s)")); assert!(msg.contains("peer[0]")); } #[tokio::test] async fn scanner_activity_probe_reports_unreachable_peers() { let sys = NotificationSys { peer_clients: vec![None], all_peer_clients: vec![None, None], peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())], }; let err = sys .scanner_activity_snapshots() .await .expect_err("unreachable peers must disable scanner idle backoff"); assert!(err.to_string().contains("scanner activity peer[0] is unreachable")); } #[tokio::test] async fn scanner_activity_probe_rejects_an_empty_peer_set() { let sys = NotificationSys { peer_clients: Vec::new(), all_peer_clients: Vec::new(), peer_admin_caches: Vec::new(), }; let err = sys .scanner_activity_snapshots() .await .expect_err("a missing peer set must disable scanner idle backoff"); assert!(err.to_string().contains("no remote peers")); } #[tokio::test] async fn scanner_activity_probe_rejects_an_incomplete_peer_topology() { let client = PeerRestClient::new( "127.0.0.1:9000".to_string().try_into().expect("peer host should parse"), "http://127.0.0.1:9000".to_string(), ); let sys = NotificationSys { peer_clients: vec![Some(client)], all_peer_clients: vec![None], peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())], }; let err = sys .scanner_activity_snapshots() .await .expect_err("an incomplete peer topology must disable scanner idle backoff"); assert!(err.to_string().contains("peer topology is incomplete")); } #[tokio::test] async fn scanner_activity_probe_times_out() { let err = scanner_activity_with_timeout( Duration::from_millis(5), "peer-1", std::future::pending::>(), ) .await .expect_err("a stalled peer must not block scanner scheduling"); assert!(err.to_string().contains("timed out")); assert!(err.to_string().contains("peer-1")); } #[tokio::test] async fn load_bucket_metadata_reports_unreachable_peers() { let sys = NotificationSys { peer_clients: vec![None], all_peer_clients: Vec::new(), peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())], }; let err = sys .load_bucket_metadata("bucket-a") .await .expect_err("unreachable peers should fail bucket metadata reload"); let msg = err.to_string(); assert!(msg.contains("load_bucket_metadata(bucket-a)")); assert!(msg.contains("1 failure(s)")); assert!(msg.contains("peer[0]")); } #[tokio::test] async fn load_transition_tier_config_reports_unreachable_peers() { let sys = NotificationSys { peer_clients: vec![None], all_peer_clients: Vec::new(), peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())], }; let results = sys.load_transition_tier_config().await; assert_eq!(results.len(), 1); assert!(results[0].host.is_empty()); assert!(results[0].err.is_some()); assert!(results[0].err.as_ref().unwrap().to_string().contains("peer is not reachable")); } #[tokio::test] async fn tier_mutation_fanout_reports_unreachable_peers_fail_closed() { let sys = NotificationSys { peer_clients: vec![None], all_peer_clients: Vec::new(), peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())], }; let mutation_id = Uuid::from_u128(1); let prepare = sys.prepare_tier_mutation(mutation_id, Bytes::from_static(b"prepare")).await; assert_eq!(prepare.len(), 1); assert!(prepare[0].host.is_empty()); assert!( prepare[0] .err .as_ref() .expect("unreachable prepare peer should carry an error") .to_string() .contains("peer is not reachable") ); let commit = sys.commit_tier_mutation(mutation_id, Bytes::from_static(b"commit")).await; assert_eq!(commit.len(), 1); assert!(commit[0].err.is_some()); let abort = sys.abort_tier_mutation(mutation_id).await; assert_eq!(abort.len(), 1); assert!(abort[0].err.is_some()); } // --- Tests for handle_peer_failure / handle_server_info_failure caching --- #[test] fn handle_peer_failure_first_failure_returns_none_when_no_cache() { let cache = Mutex::new(PeerAdminCache::new()); let endpoints = EndpointServerPools::default(); let result = handle_peer_failure(Some(&cache), "peer-1", &endpoints); assert!(result.is_none()); assert_eq!(cache.lock().unwrap().storage_failures, 1); } #[test] fn handle_peer_failure_returns_cached_data_on_single_failure() { let cached_info = StorageInfo { disks: vec![rustfs_madmin::Disk { endpoint: "disk-0".to_string(), state: "ok".to_string(), ..Default::default() }], ..Default::default() }; let cache = Mutex::new(PeerAdminCache { last_storage_info: Some(cached_info), last_server_info: None, storage_failures: 0, server_failures: 0, last_server_success: None, }); let endpoints = EndpointServerPools::default(); // First failure: should return cached data let result = handle_peer_failure(Some(&cache), "peer-1", &endpoints); let info = result.unwrap(); assert_eq!(info.disks.len(), 1); assert_eq!(info.disks[0].state, "ok"); assert_eq!(cache.lock().unwrap().storage_failures, 1); } #[test] fn normalize_and_cache_peer_storage_info_marks_disks_remote() { let cache = Mutex::new(PeerAdminCache::new()); let mut info = StorageInfo { disks: vec![ rustfs_madmin::Disk { endpoint: "http://node2:9000/media/rustfs-01".to_string(), drive_path: "/media/rustfs-01".to_string(), local: true, ..Default::default() }, rustfs_madmin::Disk { endpoint: "http://node3:9000/media/rustfs-01".to_string(), drive_path: "/media/rustfs-01".to_string(), local: true, ..Default::default() }, rustfs_madmin::Disk { endpoint: "http://node4:9000/media/rustfs-01".to_string(), drive_path: "/media/rustfs-01".to_string(), local: true, ..Default::default() }, ], ..Default::default() }; normalize_and_cache_peer_storage_info(Some(&cache), "peer-1", &mut info); assert!(info.disks.iter().all(|disk| !disk.local)); let cached = cache.lock().expect("peer cache must remain available"); assert!( cached .last_storage_info .as_ref() .expect("successful peer response must be cached") .disks .iter() .all(|disk| !disk.local) ); drop(cached); let degraded = handle_peer_failure(Some(&cache), "peer-1", &EndpointServerPools::default()) .expect("first peer failure must return the cached snapshot"); assert!(degraded.disks.iter().all(|disk| !disk.local)); } #[test] fn handle_peer_failure_returns_offline_after_threshold_exceeded() { let cached_info = StorageInfo { disks: vec![rustfs_madmin::Disk { endpoint: "disk-0".to_string(), state: "ok".to_string(), ..Default::default() }], ..Default::default() }; let cache = Mutex::new(PeerAdminCache { last_storage_info: Some(cached_info), last_server_info: None, storage_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1, server_failures: 0, last_server_success: None, }); let endpoints = EndpointServerPools::default(); // This failure pushes us to the threshold => offline let result = handle_peer_failure(Some(&cache), "peer-1", &endpoints); assert!(result.is_some()); assert_eq!(cache.lock().unwrap().storage_failures, CONSECUTIVE_FAILURE_THRESHOLD); } #[test] fn handle_server_info_failure_returns_cached_on_single_failure() { let cached_props = ServerProperties { endpoint: "peer-1".to_string(), state: "online".to_string(), ..Default::default() }; let cache = Mutex::new(PeerAdminCache { last_storage_info: None, last_server_info: Some(cached_props), storage_failures: 0, server_failures: 0, last_server_success: None, }); let endpoints = EndpointServerPools::default(); let result = handle_server_info_failure(Some(&cache), "peer-1", &endpoints, None); assert_eq!(result.endpoint, "peer-1"); assert_eq!(result.state, "online"); assert_eq!(cache.lock().unwrap().server_failures, 1); } #[test] fn handle_server_info_failure_does_not_serve_stale_cached_online() { // A single failure with a cached snapshot would normally return the // cached `online`, but if that snapshot is older than the max age we // must not keep reporting online — fall through to `unknown` instead of // masking a possibly-down peer (rustfs/backlog#1049 P2). let cached_props = ServerProperties { endpoint: "peer-1".to_string(), state: "online".to_string(), ..Default::default() }; let stale_at = SystemTime::now() .checked_sub(SERVER_INFO_CACHE_MAX_AGE + Duration::from_secs(1)) .expect("test clock underflow"); let cache = Mutex::new(PeerAdminCache { last_storage_info: None, last_server_info: Some(cached_props), storage_failures: 0, server_failures: 0, last_server_success: Some(stale_at), }); let endpoints = EndpointServerPools::default(); let result = handle_server_info_failure(Some(&cache), "peer-1", &endpoints, None); assert_eq!(result.state, ItemState::Unknown.to_string()); assert_eq!(cache.lock().unwrap().server_failures, 1); } #[test] fn cached_snapshot_freshness_respects_age_and_missing_timestamp() { assert!(cached_snapshot_is_fresh(None), "no timestamp is treated as fresh"); assert!(cached_snapshot_is_fresh(Some(SystemTime::now())), "a just-now success is fresh"); let stale = SystemTime::now() .checked_sub(SERVER_INFO_CACHE_MAX_AGE + Duration::from_secs(1)) .expect("test clock underflow"); assert!(!cached_snapshot_is_fresh(Some(stale)), "an old success is stale"); } #[test] fn endpoint_host_matches_direct_and_canonicalized() { // Direct match (IP deployment): peer host already equals host_port. assert!(endpoint_host_matches("10.0.0.12:9000", "10.0.0.12:9000")); // Different IPs must not match. assert!(!endpoint_host_matches("10.0.0.12:9000", "10.0.0.99:9000")); // Hostname deployment: `PeerRestClient::host` is the resolved `IP:port`, // the endpoint keeps the raw `hostname:port`. Resolve "localhost" the // same way `XHost` does (avoids depending on external DNS) and confirm // the canonical compare matches — the regression this fixes is the // synthesized/degraded drive list going empty on hostname clusters. let resolved = XHost::try_from("localhost:9000".to_string()) .expect("localhost should resolve") .to_string(); assert!( endpoint_host_matches(&resolved, "localhost:9000"), "resolved localhost ({resolved}) must match the hostname endpoint" ); // A resolved address that is not localhost must not match. assert!(!endpoint_host_matches("203.0.113.1:9000", "localhost:9000")); } #[test] fn handle_server_info_failure_returns_unknown_before_threshold_without_cache() { let cache = Mutex::new(PeerAdminCache::new()); let endpoints = EndpointServerPools::default(); let result = handle_server_info_failure(Some(&cache), "peer-1", &endpoints, None); assert_eq!(result.endpoint, "peer-1"); // A probe miss below the threshold is "unknown" (not confirmed down, // and not the misleading "initializing"): rustfs/backlog#1049. assert_eq!(result.state, ItemState::Unknown.to_string()); // The default (empty) pool has no topology entry for this host, so no // drives are synthesized here; the drive-synthesis and counter-balance // behavior is exercised by the get_online_offline_disks_stats tests in // admin_server_info. assert!(result.disks.is_empty()); assert_eq!(cache.lock().unwrap().server_failures, 1); } #[test] fn handle_server_info_failure_returns_offline_after_threshold() { let cached_props = ServerProperties { endpoint: "peer-1".to_string(), state: "online".to_string(), ..Default::default() }; let cache = Mutex::new(PeerAdminCache { last_storage_info: None, last_server_info: Some(cached_props), storage_failures: 0, server_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1, last_server_success: None, }); let endpoints = EndpointServerPools::default(); let result = handle_server_info_failure(Some(&cache), "peer-1", &endpoints, None); assert_eq!(result.state, ItemState::Offline.to_string()); assert_eq!(cache.lock().unwrap().server_failures, CONSECUTIVE_FAILURE_THRESHOLD); } #[test] fn handle_server_info_failure_returns_degraded_when_disks_online_past_threshold() { // Past the threshold but the peer's drives still answer the heartbeat: // the node is alive, only its admin RPC is stuck — report degraded (with // the real per-drive health), not offline (rustfs/backlog#1049 P0-B). let cache = Mutex::new(PeerAdminCache { last_storage_info: None, last_server_info: None, storage_failures: 0, server_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1, last_server_success: None, }); let endpoints = EndpointServerPools::default(); let health = PeerDiskHealth { any_online: true, disks: vec![rustfs_madmin::Disk { endpoint: "http://peer-1:9000/data".to_string(), state: "ok".to_string(), ..Default::default() }], }; let result = handle_server_info_failure(Some(&cache), "peer-1", &endpoints, Some(&health)); assert_eq!(result.state, ItemState::Degraded.to_string()); assert_eq!(result.disks.len(), 1); assert_eq!(result.disks[0].state, "ok"); assert_eq!(cache.lock().unwrap().server_failures, CONSECUTIVE_FAILURE_THRESHOLD); } #[test] fn handle_server_info_failure_stays_offline_when_disks_also_offline() { // Past the threshold and the heartbeat also reports the drives down: // this is a genuine offline, degraded must not mask it. let cache = Mutex::new(PeerAdminCache { last_storage_info: None, last_server_info: None, storage_failures: 0, server_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1, last_server_success: None, }); let endpoints = EndpointServerPools::default(); let health = PeerDiskHealth { any_online: false, disks: vec![rustfs_madmin::Disk { endpoint: "http://peer-1:9000/data".to_string(), state: ItemState::Offline.to_string().to_owned(), ..Default::default() }], }; let result = handle_server_info_failure(Some(&cache), "peer-1", &endpoints, Some(&health)); assert_eq!(result.state, ItemState::Offline.to_string()); } #[test] fn success_resets_failure_counters_independently() { let cache = Mutex::new(PeerAdminCache { last_storage_info: None, last_server_info: None, storage_failures: 2, server_failures: 2, last_server_success: None, }); { let mut c = cache.lock().unwrap(); c.last_storage_info = Some(StorageInfo::default()); c.storage_failures = 0; } let cache = cache.lock().unwrap(); assert_eq!(cache.storage_failures, 0); assert_eq!(cache.server_failures, 2); } #[test] fn storage_failures_do_not_affect_server_failures() { let cache = Mutex::new(PeerAdminCache { last_storage_info: Some(StorageInfo::default()), last_server_info: Some(ServerProperties { endpoint: "peer-1".to_string(), state: "online".to_string(), ..Default::default() }), storage_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1, server_failures: 0, last_server_success: None, }); let endpoints = EndpointServerPools::default(); let storage_result = handle_peer_failure(Some(&cache), "peer-1", &endpoints); assert!(storage_result.is_some()); let server_result = handle_server_info_failure(Some(&cache), "peer-1", &endpoints, None); assert_eq!(server_result.state, "online"); assert_eq!(cache.lock().unwrap().server_failures, 1); } #[test] fn poisoned_admin_cache_mutex_still_returns_fallbacks() { let storage_cache = Mutex::new(PeerAdminCache::new()); let server_cache = Mutex::new(PeerAdminCache::new()); let endpoints = EndpointServerPools::default(); let _ = std::panic::catch_unwind(|| { let _guard = storage_cache.lock().expect("test: poison storage cache mutex"); panic!("poison storage cache mutex"); }); let _ = std::panic::catch_unwind(|| { let _guard = server_cache.lock().expect("test: poison server cache mutex"); panic!("poison server cache mutex"); }); let storage_result = handle_peer_failure(Some(&storage_cache), "peer-1", &endpoints); assert!(storage_result.is_none()); let server_result = handle_server_info_failure(Some(&server_cache), "peer-1", &endpoints, None); assert_eq!(server_result.endpoint, "peer-1"); assert_eq!(server_result.state, ItemState::Unknown.to_string()); } #[test] fn poisoned_admin_cache_recovers_on_success_and_resets_failures() { let storage_cache = Mutex::new(PeerAdminCache { last_storage_info: None, last_server_info: None, storage_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1, server_failures: 0, last_server_success: None, }); let server_cache = Mutex::new(PeerAdminCache { last_storage_info: None, last_server_info: None, storage_failures: 0, server_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1, last_server_success: None, }); let endpoints = EndpointServerPools::default(); let _ = std::panic::catch_unwind(|| { let _guard = storage_cache.lock().expect("test: poison storage cache mutex"); panic!("poison storage cache mutex"); }); let _ = std::panic::catch_unwind(|| { let _guard = server_cache.lock().expect("test: poison server cache mutex"); panic!("poison server cache mutex"); }); normalize_and_cache_peer_storage_info( Some(&storage_cache), "peer-1", &mut StorageInfo { disks: vec![rustfs_madmin::Disk { endpoint: "disk-0".to_string(), state: "ok".to_string(), ..Default::default() }], ..Default::default() }, ); update_server_info_cache( Some(&server_cache), "peer-1", &ServerProperties { endpoint: "peer-1".to_string(), state: "online".to_string(), ..Default::default() }, ); let storage_result = handle_peer_failure(Some(&storage_cache), "peer-1", &endpoints); assert!(storage_result.is_some()); assert_eq!(storage_result.unwrap().disks[0].state, "ok"); let server_result = handle_server_info_failure(Some(&server_cache), "peer-1", &endpoints, None); assert_eq!(server_result.state, "online"); } }