// 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, TierConfigReloadOutcome}; 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::{BTreeMap, HashMap, hash_map::DefaultHasher}; use std::future::Future; use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant, SystemTime}; use tokio::time::{sleep, timeout}; use tokio_util::sync::CancellationToken; 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); const TIER_CONFIG_RELOAD_RETRY_BASE: Duration = Duration::from_millis(100); const TIER_CONFIG_RELOAD_RETRY_CAP: Duration = Duration::from_secs(5); const REMOTE_VERSION_STATE_PROBE_INTERVAL: Duration = Duration::from_secs(10); const REMOTE_VERSION_STATE_PROBE_TIMEOUT: Duration = Duration::from_secs(5); const REMOTE_VERSION_STATE_PROOF_TTL: Duration = Duration::from_secs(30); /// 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, } #[derive(Default)] struct TierConfigReloadWorkers { peers: HashMap, } enum TierConfigReloadFinish { Completed, Pending, } 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(); } #[derive(Clone)] struct RemoteVersionStateFleetProof { topology_fingerprint: String, peer_epochs: Arc>, expires_at: Instant, } impl RemoteVersionStateFleetProof { fn token(&self) -> RemoteVersionStateFleetProofToken { RemoteVersionStateFleetProofToken { topology_fingerprint: self.topology_fingerprint.clone(), peer_epochs: self.peer_epochs.clone(), } } } #[derive(Clone, PartialEq, Eq)] pub(crate) struct RemoteVersionStateFleetProofToken { topology_fingerprint: String, peer_epochs: Arc>, } #[derive(Default)] struct RemoteVersionStateFleetProofState { proof: Option, topology_conflict: bool, } static REMOTE_VERSION_STATE_FLEET_PROOF: OnceLock> = OnceLock::new(); static REMOTE_VERSION_STATE_PROBE_TOPOLOGY: OnceLock = OnceLock::new(); fn remote_version_state_fleet_proof_slot() -> &'static std::sync::RwLock { REMOTE_VERSION_STATE_FLEET_PROOF.get_or_init(|| std::sync::RwLock::new(RemoteVersionStateFleetProofState::default())) } fn replace_remote_version_state_fleet_proof(proof: Option) { replace_remote_version_state_fleet_proof_in(remote_version_state_fleet_proof_slot(), proof); } fn replace_remote_version_state_fleet_proof_in( slot: &std::sync::RwLock, proof: Option, ) { slot.write().unwrap_or_else(std::sync::PoisonError::into_inner).proof = proof; } fn publish_remote_version_state_probe_result( slot: &std::sync::RwLock, topology_fingerprint: &str, result: Result>, observed_at: Instant, ) -> Option { match result { Ok(peer_epochs) => { let mut state = slot.write().unwrap_or_else(std::sync::PoisonError::into_inner); let peer_epochs = state .proof .as_ref() .filter(|proof| proof.topology_fingerprint == topology_fingerprint && proof.peer_epochs.as_ref() == &peer_epochs) .map(|proof| Arc::clone(&proof.peer_epochs)) .unwrap_or_else(|| Arc::new(peer_epochs)); state.proof = Some(RemoteVersionStateFleetProof { topology_fingerprint: topology_fingerprint.to_string(), peer_epochs, expires_at: observed_at + REMOTE_VERSION_STATE_PROOF_TTL, }); None } Err(err) => { replace_remote_version_state_fleet_proof_in(slot, None); Some(err) } } } pub(crate) fn acquire_remote_version_state_fleet_proof() -> Option { let expected_topology = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get()?; let state = remote_version_state_fleet_proof_slot() .read() .unwrap_or_else(std::sync::PoisonError::into_inner); acquire_remote_version_state_fleet_proof_from(&state, expected_topology, Instant::now()) } fn acquire_remote_version_state_fleet_proof_from( state: &RemoteVersionStateFleetProofState, expected_topology: &str, now: Instant, ) -> Option { if state.topology_conflict || !remote_version_state_fleet_proof_valid_at(state.proof.as_ref(), expected_topology, now) { return None; } state.proof.as_ref().map(RemoteVersionStateFleetProof::token) } pub(crate) fn remote_version_state_fleet_proof_matches(proof: &RemoteVersionStateFleetProofToken) -> bool { let Some(expected_topology) = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get() else { return false; }; let state = remote_version_state_fleet_proof_slot() .read() .unwrap_or_else(std::sync::PoisonError::into_inner); if state.topology_conflict { return false; } state.proof.as_ref().is_some_and(|current| { current.topology_fingerprint == *expected_topology && current.topology_fingerprint == proof.topology_fingerprint && Arc::ptr_eq(¤t.peer_epochs, &proof.peer_epochs) && Instant::now() < current.expires_at }) } fn remote_version_state_fleet_proof_valid_at( proof: Option<&RemoteVersionStateFleetProof>, expected_topology: &str, now: Instant, ) -> bool { proof.is_some_and(|proof| proof.topology_fingerprint == expected_topology && now < proof.expires_at) } fn insert_remote_version_state_peer(peer_epochs: &mut BTreeMap, peer: String, epoch: Uuid) -> Result<()> { if epoch.is_nil() || peer_epochs.values().any(|existing| *existing == epoch) || peer_epochs.insert(peer, epoch).is_some() { return Err(Error::other("remote version state capability peer identity is invalid")); } Ok(()) } pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) { if REMOTE_VERSION_STATE_PROBE_TOPOLOGY.set(topology_fingerprint.clone()).is_err() { if REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get() != Some(&topology_fingerprint) { let mut state = remote_version_state_fleet_proof_slot() .write() .unwrap_or_else(std::sync::PoisonError::into_inner); state.topology_conflict = true; state.proof = None; } return; } tokio::spawn(async move { loop { let result = match get_global_notification_sys() { Some(notification_sys) => { match timeout( REMOTE_VERSION_STATE_PROBE_TIMEOUT, notification_sys.probe_remote_version_state_fleet(&topology_fingerprint), ) .await { Ok(result) => result, Err(_) => Err(Error::other("remote version state fleet capability probe timed out")), } } None => Err(Error::other("remote version state fleet capability notification system is unavailable")), }; let topology_conflict = remote_version_state_fleet_proof_slot() .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .topology_conflict; if topology_conflict { replace_remote_version_state_fleet_proof(None); } else if let Some(err) = publish_remote_version_state_probe_result( remote_version_state_fleet_proof_slot(), &topology_fingerprint, result, Instant::now(), ) { debug!(error = %err, "remote version state fleet capability probe failed closed"); } sleep(REMOTE_VERSION_STATE_PROBE_INTERVAL).await; } }); } 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_topology_hosts: Vec, peer_admin_caches: Vec>, tier_config_reload_workers: Arc>, } impl NotificationSys { pub async fn new(eps: EndpointServerPools) -> Self { let expected_remote_hosts = eps .peer_grid_host_slots_sorted() .into_iter() .filter_map(|(peer, _, is_local)| (!is_local).then_some(peer)) .collect::>(); let (peer_clients, all_peer_clients, peer_topology_hosts) = PeerRestClient::new_clients_with_topology(eps).await; let peer_topology_hosts = if peer_topology_hosts.is_empty() { expected_remote_hosts } else { peer_topology_hosts }; let peer_admin_caches = (0..peer_clients.len()).map(|_| Mutex::new(PeerAdminCache::new())).collect(); Self { peer_clients, all_peer_clients, peer_topology_hosts, peer_admin_caches, tier_config_reload_workers: Default::default(), } } async fn probe_remote_version_state_fleet(&self, topology_fingerprint: &str) -> Result> { if self.peer_clients.len() != self.peer_topology_hosts.len() { return Err(Error::other("remote version state capability fleet membership is incomplete")); } let probes = self.peer_clients.iter().map(|client| async { let client = client .as_ref() .ok_or_else(|| Error::other("remote version state capability peer is unreachable"))?; client.probe_remote_version_state(topology_fingerprint.to_string()).await }); let mut peer_epochs = BTreeMap::new(); for result in join_all(probes).await { let (peer, epoch) = result?; insert_remote_version_state_peer(&mut peer_epochs, peer, epoch)?; } Ok(peer_epochs) } } pub struct NotificationPeerErr { pub host: String, pub err: Option, } /// One peer's answer to a KMS configuration fingerprint probe. pub struct PeerKmsConfigFingerprint { pub host: String, /// `None` when the peer has no KMS configuration of its own, or could not /// be asked at all, in which case `err` carries the reason. pub fingerprint: Option, 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 } async fn signal_dynamic_config(&self, sub_sys: &str, dry_run: bool) -> 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, dry_run, 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 preflight_dynamic_config(&self, sub_sys: &str) -> Vec { self.signal_dynamic_config(sub_sys, true).await } pub async fn reload_dynamic_config(&self, sub_sys: &str) -> Vec { self.signal_dynamic_config(sub_sys, false).await } /// Ask every peer to re-read the cluster-persisted KMS configuration. /// /// Best-effort by contract: the caller has already switched locally, so a /// peer that fails is reported rather than rolled back. Peers built before /// the KMS subsystem existed reject the signal with an explicit error. pub async fn reload_kms_config(&self) -> Vec { self.reload_dynamic_config(crate::cluster::rpc::KMS_SIGNAL_SUBSYSTEM).await } /// Collect the KMS configuration fingerprint each peer is running. /// /// A peer whose build predates the KMS subsystem rejects the probe, so it /// is reported as an error rather than silently agreeing with this node. pub async fn kms_config_fingerprints(&self) -> Vec { let mut futures = Vec::with_capacity(self.peer_clients.len()); for client in self.peer_clients.iter() { futures.push(async move { let Some(client) = client else { return PeerKmsConfigFingerprint { host: String::new(), fingerprint: None, err: Some(Error::other("peer is not reachable")), }; }; match client.kms_config_fingerprint().await { Ok(fingerprint) => PeerKmsConfigFingerprint { host: client.host.to_string(), fingerprint, err: None, }, Err(e) => PeerKmsConfigFingerprint { host: client.host.to_string(), fingerprint: None, err: Some(e), }, } }); } 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 host = self .peer_topology_hosts .get(idx) .cloned() .or_else(|| client.as_ref().map(|client| client.host.to_string())) .unwrap_or_default(); futures.push(async move { let Some(client) = client else { return PeerServerInfoProbe { host, result: Err(PeerServerInfoProbeFailure::NoClient), }; }; // 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)) => { return PeerServerInfoProbe { host, result: Ok(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)) => PeerServerInfoProbe { host, result: Ok(info) }, Ok(Err(err)) => { warn!("peer {host} server_info failed after retry: {err}"); let health = peer_disk_health(&host).await; PeerServerInfoProbe { host, result: Err(PeerServerInfoProbeFailure::Rpc { health }), } } Err(_) => { warn!("peer {host} server_info timed out after retry ({peer_timeout:?})"); client.evict_connection().await; let health = peer_disk_health(&host).await; PeerServerInfoProbe { host, result: Err(PeerServerInfoProbeFailure::Rpc { health }), } } } }); } publish_server_info_probe_round(&self.peer_admin_caches, &endpoints, 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 acknowledge_scanner_dirty_usage(&self, acknowledgements: Vec<(String, String, u64)>) -> Result { let mut by_host = HashMap::with_capacity(acknowledgements.len()); for (host, instance_id, generation) in acknowledgements { if by_host.insert(host.clone(), (instance_id, generation)).is_some() { return Err(Error::other(format!("duplicate scanner dirty usage acknowledgement target: {host}"))); } } let clients = self .peer_clients .iter() .flatten() .map(|client| (client.grid_host.clone(), client.clone())) .collect::>(); let mut failures = Vec::new(); let mut futures = Vec::with_capacity(by_host.len()); for (host, (instance_id, generation)) in by_host { let Some(client) = clients.get(&host).cloned() else { failures.push(format!("peer {host} scanner dirty usage acknowledgement failed: peer is not reachable")); continue; }; futures.push(async move { let result = scanner_activity_with_timeout( SCANNER_ACTIVITY_PROBE_TIMEOUT, &host, client.acknowledge_scanner_dirty_usage(instance_id, generation), ) .await; (host, result) }); } aggregate_scanner_dirty_usage_acknowledgement_results(join_all(futures).await, failures) } 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 } /// Starts one immediate configuration reload worker per peer. Concurrent /// tier mutations share the existing worker for that peer. pub fn spawn_transition_tier_config_reload_workers(self: &Arc) { self.spawn_transition_tier_config_reload_workers_with_cancel_token(runtime_sources::background_services_cancel_token()); } fn spawn_transition_tier_config_reload_workers_with_cancel_token(self: &Arc, cancel_token: Option) { let Some(cancel_token) = cancel_token else { warn!( event = EVENT_NOTIFICATION_PEER_PROPAGATION, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_NOTIFICATION, action = "reload_transition_tier_config", result = "background_service_unavailable", "notification peer propagation" ); return; }; for (peer_index, client) in self.peer_clients.iter().enumerate() { let Some(client) = client.clone() else { warn!( event = EVENT_NOTIFICATION_PEER_PROPAGATION, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_NOTIFICATION, action = "reload_transition_tier_config", peer_index, result = "peer_unreachable", "notification peer propagation" ); continue; }; let host = client.grid_host.clone(); if !self.reserve_tier_config_reload_worker(&host) { debug!( event = EVENT_NOTIFICATION_PEER_PROPAGATION, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_NOTIFICATION, action = "reload_transition_tier_config", host, result = "coalesced", "notification peer propagation" ); continue; } let sys = Arc::clone(self); let cancel_token = cancel_token.clone(); tokio::spawn(async move { run_tier_config_reload_worker(sys, host, cancel_token, move || { let client = client.clone(); async move { client.load_transition_tier_config_single_attempt_outcome().await } }) .await; }); } } fn reserve_tier_config_reload_worker(&self, host: &str) -> bool { let mut workers = self .tier_config_reload_workers .lock() .expect("tier config reload worker state must not be poisoned"); match workers.peers.get_mut(host) { Some(pending) => { *pending = true; false } None => { workers.peers.insert(host.to_string(), false); true } } } fn take_tier_config_reload_pending(&self, host: &str) -> bool { let mut workers = self .tier_config_reload_workers .lock() .expect("tier config reload worker state must not be poisoned"); let Some(pending) = workers.peers.get_mut(host) else { return false; }; let pending_reload = *pending; *pending = false; pending_reload } fn finish_tier_config_reload_worker(&self, host: &str) -> TierConfigReloadFinish { let mut workers = self .tier_config_reload_workers .lock() .expect("tier config reload worker state must not be poisoned"); let Some(pending) = workers.peers.get_mut(host) else { return TierConfigReloadFinish::Completed; }; if *pending { *pending = false; return TierConfigReloadFinish::Pending; } workers.peers.remove(host); TierConfigReloadFinish::Completed } fn cancel_tier_config_reload_worker(&self, host: &str) { let mut workers = self .tier_config_reload_workers .lock() .expect("tier config reload worker state must not be poisoned"); workers.peers.remove(host); } fn tier_config_reload_worker_active(&self, host: &str) -> bool { self.tier_config_reload_workers .lock() .expect("tier config reload worker state must not be poisoned") .peers .contains_key(host) } 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 run_tier_config_reload_worker( sys: Arc, host: String, cancel_token: CancellationToken, mut reload: F, ) where F: FnMut() -> Fut, Fut: Future, { let mut retry_attempt = 0; loop { if cancel_token.is_cancelled() { sys.cancel_tier_config_reload_worker(&host); return; } let result = tokio::select! { _ = cancel_token.cancelled() => { sys.cancel_tier_config_reload_worker(&host); return; } result = reload() => result, }; match result { TierConfigReloadOutcome::Success => match sys.finish_tier_config_reload_worker(&host) { TierConfigReloadFinish::Completed => { debug!( event = EVENT_NOTIFICATION_PEER_PROPAGATION, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_NOTIFICATION, action = "reload_transition_tier_config", host, result = "success", "notification peer propagation" ); return; } TierConfigReloadFinish::Pending => retry_attempt = 0, }, TierConfigReloadOutcome::Terminal(err) => match sys.finish_tier_config_reload_worker(&host) { TierConfigReloadFinish::Completed => { // This peer keeps the previous tier configuration for good, so record // why. Dropping the error here hides the only evidence of a divergent // node behind an outcome label that cannot be acted on. warn!( event = EVENT_NOTIFICATION_PEER_PROPAGATION, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_NOTIFICATION, action = "reload_transition_tier_config", host, outcome = "terminal", error = ?err, "tier configuration reload stopped after a terminal outcome" ); return; } TierConfigReloadFinish::Pending => retry_attempt = 0, }, TierConfigReloadOutcome::TransientReconnect(_) | TierConfigReloadOutcome::TransientRetrySameChannel(_) => { let delay = tier_config_reload_retry_delay(retry_attempt); retry_attempt = retry_attempt.saturating_add(1); if sys.take_tier_config_reload_pending(&host) { retry_attempt = 0; continue; } if retry_attempt == 1 { warn!( event = EVENT_NOTIFICATION_PEER_PROPAGATION, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_NOTIFICATION, action = "reload_transition_tier_config", host, retry_attempt, retry_delay_ms = delay.as_millis(), outcome = "transient", "tier configuration reload failed; retrying" ); } else if retry_attempt.is_power_of_two() { debug!( event = EVENT_NOTIFICATION_PEER_PROPAGATION, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_NOTIFICATION, action = "reload_transition_tier_config", host, retry_attempt, retry_delay_ms = delay.as_millis(), outcome = "transient", "tier configuration reload retry failed" ); } tokio::select! { _ = cancel_token.cancelled() => { sys.cancel_tier_config_reload_worker(&host); return; } _ = sleep(delay) => {} } } } } } fn tier_config_reload_retry_delay(retry_attempt: u32) -> Duration { let multiplier = 1_u32 << retry_attempt.min(6); TIER_CONFIG_RELOAD_RETRY_BASE .checked_mul(multiplier) .unwrap_or(TIER_CONFIG_RELOAD_RETRY_CAP) .min(TIER_CONFIG_RELOAD_RETRY_CAP) } 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, } struct PeerServerInfoProbe { host: String, result: std::result::Result, } enum PeerServerInfoProbeFailure { Rpc { health: Option }, NoClient, } /// 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 publish_server_info_probe_round( caches: &[Mutex], endpoints: &EndpointServerPools, probes: Vec, ) -> Vec { probes .into_iter() .enumerate() .map(|(idx, probe)| { let cache = caches.get(idx); match probe.result { Ok(info) => { update_server_info_cache(cache, &probe.host, &info); info } Err(PeerServerInfoProbeFailure::Rpc { health }) => { handle_server_info_failure(cache, &probe.host, endpoints, health.as_ref()) } Err(PeerServerInfoProbeFailure::NoClient) => unknown_server_properties(&probe.host, endpoints), } }) .collect() } 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`. /// /// Current topology clients preserve the endpoint `hostname:port`, so the /// direct comparison is the normal path. The resolution fallback keeps /// compatibility with older or manually constructed clients whose `XHost` /// contains a resolved `IP:port` (rustfs/rustfs#4607 follow-up). 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(" | ") ))) } fn aggregate_scanner_dirty_usage_acknowledgement_results( results: Vec<(String, Result)>, mut failures: Vec, ) -> Result { let mut dirty_usage_pending = false; for (host, result) in results { match result { Ok(activity) => { dirty_usage_pending |= activity.dirty_usage_pending != Some(false); } Err(err) => failures.push(format!("peer {host} scanner dirty usage acknowledgement failed: {err}")), } } aggregate_notification_failures("acknowledge_scanner_dirty_usage", failures)?; Ok(dirty_usage_pending) } #[cfg(test)] mod tests { use super::*; #[test] fn remote_version_state_fleet_proof_rejects_stale_or_mismatched_membership() { let now = Instant::now(); let mut peer_epochs = BTreeMap::new(); peer_epochs.insert("peer-a".to_string(), Uuid::new_v4()); let proof = RemoteVersionStateFleetProof { topology_fingerprint: "topology-a".to_string(), peer_epochs: Arc::new(peer_epochs), expires_at: now + Duration::from_secs(1), }; assert!(remote_version_state_fleet_proof_valid_at(Some(&proof), "topology-a", now)); assert!(!remote_version_state_fleet_proof_valid_at(Some(&proof), "topology-b", now)); assert!(!remote_version_state_fleet_proof_valid_at(Some(&proof), "topology-a", proof.expires_at)); assert!(!remote_version_state_fleet_proof_valid_at(None, "topology-a", now)); } #[test] fn remote_version_state_fleet_proof_rejects_nil_process_epoch() { let mut peer_epochs = BTreeMap::new(); assert!(insert_remote_version_state_peer(&mut peer_epochs, "peer-a".to_string(), Uuid::nil()).is_err()); assert!(peer_epochs.is_empty()); } #[test] fn remote_version_state_fleet_proof_accepts_single_node_membership() { let now = Instant::now(); let proof = RemoteVersionStateFleetProof { topology_fingerprint: "topology-a".to_string(), peer_epochs: Arc::new(BTreeMap::new()), expires_at: now + Duration::from_secs(1), }; assert!(remote_version_state_fleet_proof_valid_at(Some(&proof), "topology-a", now)); } #[test] fn remote_version_state_fleet_proof_token_changes_with_process_epoch() { let now = Instant::now(); let proof = RemoteVersionStateFleetProof { topology_fingerprint: "topology-a".to_string(), peer_epochs: Arc::new(BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())])), expires_at: now + Duration::from_secs(1), }; let captured = proof.token(); let restarted = RemoteVersionStateFleetProof { topology_fingerprint: proof.topology_fingerprint.clone(), peer_epochs: Arc::new(BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())])), expires_at: proof.expires_at, }; assert!(captured != restarted.token()); } #[test] fn remote_version_state_fleet_proof_renewal_preserves_only_same_epoch_token() { let slot = std::sync::RwLock::new(RemoteVersionStateFleetProofState::default()); let now = Instant::now(); let epoch = Uuid::new_v4(); let peers = BTreeMap::from([("peer-a".to_string(), epoch)]); assert!(publish_remote_version_state_probe_result(&slot, "topology-a", Ok(peers.clone()), now).is_none()); let original = slot .read() .expect("proof slot should not poison") .proof .as_ref() .expect("successful probe should publish proof") .token(); assert!( publish_remote_version_state_probe_result(&slot, "topology-a", Ok(peers), now + Duration::from_millis(1)).is_none() ); let renewed = slot .read() .expect("proof slot should not poison") .proof .as_ref() .expect("renewal should retain proof") .token(); assert!(Arc::ptr_eq(&original.peer_epochs, &renewed.peer_epochs)); let restarted = BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())]); assert!( publish_remote_version_state_probe_result(&slot, "topology-a", Ok(restarted), now + Duration::from_millis(2)) .is_none() ); let replaced = slot .read() .expect("proof slot should not poison") .proof .as_ref() .expect("restarted peer should publish a new proof") .token(); assert!(!Arc::ptr_eq(&original.peer_epochs, &replaced.peer_epochs)); } #[test] fn remote_version_state_fleet_proof_conflict_revokes_atomic_snapshot() { let now = Instant::now(); let mut state = RemoteVersionStateFleetProofState { proof: Some(RemoteVersionStateFleetProof { topology_fingerprint: "topology-a".to_string(), peer_epochs: Arc::new(BTreeMap::new()), expires_at: now + Duration::from_secs(1), }), topology_conflict: false, }; assert!(acquire_remote_version_state_fleet_proof_from(&state, "topology-a", now).is_some()); state.topology_conflict = true; assert!(acquire_remote_version_state_fleet_proof_from(&state, "topology-a", now).is_none()); } #[test] fn remote_version_state_fleet_probe_rejects_duplicate_member_or_process_epoch() { let epoch = Uuid::new_v4(); let mut peer_epochs = BTreeMap::new(); insert_remote_version_state_peer(&mut peer_epochs, "node-a:9000".to_string(), epoch) .expect("first member should be admitted"); assert!(insert_remote_version_state_peer(&mut peer_epochs, "node-b:9000".to_string(), epoch).is_err()); assert!(insert_remote_version_state_peer(&mut peer_epochs, "node-a:9000".to_string(), Uuid::new_v4()).is_err()); assert!(insert_remote_version_state_peer(&mut peer_epochs, "node-c:9000".to_string(), Uuid::nil()).is_err()); } #[test] fn remote_version_state_fleet_probe_failure_revokes_previous_proof() { let slot = std::sync::RwLock::new(RemoteVersionStateFleetProofState::default()); let now = Instant::now(); let peer_epochs = BTreeMap::from([("node-a:9000".to_string(), Uuid::new_v4())]); assert!(publish_remote_version_state_probe_result(&slot, "topology-a", Ok(peer_epochs), now).is_none()); assert!(slot.read().expect("proof slot should not poison").proof.is_some()); assert!( publish_remote_version_state_probe_result(&slot, "topology-a", Err(Error::other("peer unavailable")), now,).is_some() ); assert!(slot.read().expect("proof slot should not poison").proof.is_none()); let peer_epochs = BTreeMap::from([("node-a:9000".to_string(), Uuid::new_v4())]); assert!(publish_remote_version_state_probe_result(&slot, "topology-a", Ok(peer_epochs), now).is_none()); assert!(slot.read().expect("proof slot should not poison").proof.is_some()); } #[tokio::test] async fn remote_version_state_fleet_probe_rejects_unreachable_member() { let notification_sys = NotificationSys { peer_clients: vec![None], all_peer_clients: vec![None, None], peer_topology_hosts: vec!["peer-a".to_string()], peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())], tier_config_reload_workers: Default::default(), }; let err = notification_sys .probe_remote_version_state_fleet("topology-a") .await .expect_err("an unreachable configured member must fail the fleet proof"); assert!(err.to_string().contains("unreachable")); } #[tokio::test] async fn remote_version_state_fleet_probe_rejects_missing_member_slot() { let notification_sys = NotificationSys { peer_clients: Vec::new(), all_peer_clients: vec![None], peer_topology_hosts: vec!["peer-a".to_string()], peer_admin_caches: Vec::new(), tier_config_reload_workers: Default::default(), }; let err = notification_sys .probe_remote_version_state_fleet("topology-a") .await .expect_err("a missing configured member slot must fail the fleet proof"); assert!(err.to_string().contains("incomplete")); } 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_topology_hosts: Vec::new(), peer_admin_caches: Vec::new(), tier_config_reload_workers: Default::default(), }; 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_topology_hosts: vec!["node-a:9000".to_string()], peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())], tier_config_reload_workers: Default::default(), }; 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_topology_hosts: vec!["node-a:9000".to_string()], peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())], tier_config_reload_workers: Default::default(), }; 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_topology_hosts: Vec::new(), peer_admin_caches: Vec::new(), tier_config_reload_workers: Default::default(), }; 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_topology_hosts: vec!["127.0.0.1:9000".to_string()], peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())], tier_config_reload_workers: Default::default(), }; 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 server_info_no_client_slot_uses_topology_host_without_counting_rpc_failure() { let sys = NotificationSys { peer_clients: vec![None], all_peer_clients: vec![None, None], peer_topology_hosts: vec!["node-a:9000".to_string()], peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())], tier_config_reload_workers: Default::default(), }; let servers = sys.server_info().await; assert_eq!(servers.len(), 1); assert_eq!(servers[0].endpoint, "node-a:9000"); assert_eq!(servers[0].state, ItemState::Unknown.to_string()); let cache = sys.peer_admin_caches[0].lock().expect("cache mutex should not be poisoned"); assert_eq!(cache.server_failures, 0, "construction-only missing slots are not failed RPC attempts"); assert!(cache.last_server_info.is_none()); } #[test] fn server_info_failure_cache_stays_aligned_with_topology_slot() { let cache_a = Mutex::new(PeerAdminCache { last_server_info: Some(build_props("cached-a")), last_server_success: Some(SystemTime::now()), server_failures: 1, storage_failures: 0, last_storage_info: None, }); let cache_b = Mutex::new(PeerAdminCache { last_server_info: Some(build_props("cached-b")), last_server_success: Some(SystemTime::now()), server_failures: 1, storage_failures: 0, last_storage_info: None, }); let caches = [cache_a, cache_b]; let endpoints = EndpointServerPools::from(Vec::new()); let rendered = handle_server_info_failure(Some(&caches[1]), "node-b:9000", &endpoints, None); assert_eq!(rendered.endpoint, "cached-b"); assert_eq!(caches[0].lock().expect("cache mutex should not be poisoned").server_failures, 1); assert_eq!(caches[1].lock().expect("cache mutex should not be poisoned").server_failures, 2); } #[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 scanner_dirty_usage_acknowledgement_rejects_missing_and_duplicate_targets() { let sys = NotificationSys { peer_clients: Vec::new(), all_peer_clients: Vec::new(), peer_admin_caches: Vec::new(), tier_config_reload_workers: Default::default(), peer_topology_hosts: Vec::new(), }; let missing = sys .acknowledge_scanner_dirty_usage(vec![("peer-1".to_string(), "0123456789abcdef0123456789abcdef".to_string(), 7)]) .await .expect_err("a missing acknowledgement target must remain pending"); assert!(missing.to_string().contains("peer is not reachable")); let duplicate = sys .acknowledge_scanner_dirty_usage(vec![ ("peer-1".to_string(), "0123456789abcdef0123456789abcdef".to_string(), 7), ("peer-1".to_string(), "0123456789abcdef0123456789abcdef".to_string(), 7), ]) .await .expect_err("duplicate acknowledgement targets must be rejected"); assert!( duplicate .to_string() .contains("duplicate scanner dirty usage acknowledgement target") ); } #[test] fn scanner_dirty_usage_acknowledgement_preserves_newer_pending_work() { let activity = |dirty_usage_pending| ScannerPeerActivity { instance_id: "0123456789abcdef0123456789abcdef".to_string(), namespace_generation: 1, maintenance_generation: 1, protocol_version: crate::storage_api_contracts::internode::SCANNER_ACTIVITY_PROTOCOL_VERSION, topology_digest: Some([0; 32]), data_movement_active: Some(false), dirty_usage_generation: Some(2), dirty_usage_pending, }; let pending = aggregate_scanner_dirty_usage_acknowledgement_results( vec![ ("peer-1".to_string(), Ok(activity(Some(false)))), ("peer-2".to_string(), Ok(activity(Some(true)))), ], Vec::new(), ) .expect("successful acknowledgements should return their pending state"); assert!(pending, "new dirty usage reported by an acknowledged peer must remain pending"); let cleared = aggregate_scanner_dirty_usage_acknowledgement_results( vec![("peer-1".to_string(), Ok(activity(Some(false))))], Vec::new(), ) .expect("a cleared acknowledgement should succeed"); assert!(!cleared, "an explicitly cleared peer must not remain pending"); let unknown = aggregate_scanner_dirty_usage_acknowledgement_results(vec![("peer-1".to_string(), Ok(activity(None)))], Vec::new()) .expect("an acknowledgement without a pending field should remain retryable"); assert!(unknown, "a peer that cannot prove its dirty state is clear must remain pending"); let err = aggregate_scanner_dirty_usage_acknowledgement_results( vec![("peer-1".to_string(), Err(Error::other("injected acknowledgement failure")))], Vec::new(), ) .expect_err("a reachable peer acknowledgement failure must be reported"); assert!(err.to_string().contains("peer-1")); assert!(err.to_string().contains("injected acknowledgement failure")); } #[tokio::test] async fn load_bucket_metadata_reports_unreachable_peers() { let sys = NotificationSys { peer_clients: vec![None], all_peer_clients: Vec::new(), peer_topology_hosts: vec!["node-a:9000".to_string()], peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())], tier_config_reload_workers: Default::default(), }; 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]")); } #[test] fn tier_config_reload_retry_delay_is_exponentially_capped() { assert_eq!(tier_config_reload_retry_delay(0), Duration::from_millis(100)); assert_eq!(tier_config_reload_retry_delay(1), Duration::from_millis(200)); assert_eq!(tier_config_reload_retry_delay(5), Duration::from_millis(3200)); assert_eq!(tier_config_reload_retry_delay(6), TIER_CONFIG_RELOAD_RETRY_CAP); assert_eq!(tier_config_reload_retry_delay(u32::MAX), TIER_CONFIG_RELOAD_RETRY_CAP); } #[tokio::test] async fn tier_config_reload_worker_retries_only_network_failures() { let sys = Arc::new(NotificationSys { peer_clients: Vec::new(), all_peer_clients: Vec::new(), peer_topology_hosts: Vec::new(), peer_admin_caches: Vec::new(), tier_config_reload_workers: Default::default(), }); assert!(sys.reserve_tier_config_reload_worker("node-a:9000")); let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let calls_for_reload = Arc::clone(&calls); run_tier_config_reload_worker(Arc::clone(&sys), "node-a:9000".to_string(), CancellationToken::new(), move || { let attempt = calls_for_reload.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async move { if attempt == 0 { TierConfigReloadOutcome::TransientReconnect(Error::other("connection refused")) } else { TierConfigReloadOutcome::Success } } }) .await; assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2); assert!(!sys.tier_config_reload_worker_active("node-a:9000")); } #[tokio::test] async fn tier_config_reload_worker_converges_after_readiness_unknown() { let sys = Arc::new(NotificationSys { peer_clients: Vec::new(), all_peer_clients: Vec::new(), peer_topology_hosts: Vec::new(), peer_admin_caches: Vec::new(), tier_config_reload_workers: Default::default(), }); assert!(sys.reserve_tier_config_reload_worker("node-a:9000")); let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let calls_for_reload = Arc::clone(&calls); run_tier_config_reload_worker(Arc::clone(&sys), "node-a:9000".to_string(), CancellationToken::new(), move || { let attempt = calls_for_reload.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async move { if attempt == 0 { TierConfigReloadOutcome::TransientRetrySameChannel(Error::other("Service was not ready: test client")) } else { TierConfigReloadOutcome::Success } } }) .await; assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2); assert!(!sys.tier_config_reload_worker_active("node-a:9000")); } #[tokio::test] async fn tier_config_reload_worker_stops_on_terminal_failure() { let sys = Arc::new(NotificationSys { peer_clients: Vec::new(), all_peer_clients: Vec::new(), peer_topology_hosts: Vec::new(), peer_admin_caches: Vec::new(), tier_config_reload_workers: Default::default(), }); assert!(sys.reserve_tier_config_reload_worker("node-a:9000")); let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let calls_for_reload = Arc::clone(&calls); run_tier_config_reload_worker(Arc::clone(&sys), "node-a:9000".to_string(), CancellationToken::new(), move || { calls_for_reload.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { TierConfigReloadOutcome::Terminal(Error::NotImplemented) } }) .await; assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); assert!(!sys.tier_config_reload_worker_active("node-a:9000")); } #[tokio::test] async fn tier_config_reload_worker_reloads_once_after_success_with_pending_mutation() { let sys = Arc::new(NotificationSys { peer_clients: Vec::new(), all_peer_clients: Vec::new(), peer_topology_hosts: Vec::new(), peer_admin_caches: Vec::new(), tier_config_reload_workers: Default::default(), }); assert!(sys.reserve_tier_config_reload_worker("node-a:9000")); let sys_for_reload = Arc::clone(&sys); let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let calls_for_reload = Arc::clone(&calls); run_tier_config_reload_worker(Arc::clone(&sys), "node-a:9000".to_string(), CancellationToken::new(), move || { let attempt = calls_for_reload.fetch_add(1, std::sync::atomic::Ordering::SeqCst); let sys = Arc::clone(&sys_for_reload); async move { if attempt == 0 { assert!(!sys.reserve_tier_config_reload_worker("node-a:9000")); TierConfigReloadOutcome::Success } else { TierConfigReloadOutcome::Success } } }) .await; assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2); assert!(!sys.tier_config_reload_worker_active("node-a:9000")); } #[test] fn tier_config_reload_none_peer_does_not_start_a_worker() { let sys = Arc::new(NotificationSys { peer_clients: vec![None], all_peer_clients: Vec::new(), peer_topology_hosts: vec!["node-a:9000".to_string()], peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())], tier_config_reload_workers: Default::default(), }); sys.spawn_transition_tier_config_reload_workers_with_cancel_token(Some(CancellationToken::new())); assert!( sys.tier_config_reload_workers .lock() .expect("tier config reload worker state must not be poisoned") .peers .is_empty() ); } #[test] fn tier_config_reload_without_background_token_does_not_reserve_a_worker() { 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 = Arc::new(NotificationSys { peer_clients: vec![Some(client)], all_peer_clients: Vec::new(), peer_topology_hosts: vec!["127.0.0.1:9000".to_string()], peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())], tier_config_reload_workers: Default::default(), }); sys.spawn_transition_tier_config_reload_workers_with_cancel_token(None); assert!( sys.tier_config_reload_workers .lock() .expect("tier config reload worker state must not be poisoned") .peers .is_empty() ); } #[tokio::test] async fn tier_config_reload_cancellation_during_transient_backoff_releases_state() { let sys = Arc::new(NotificationSys { peer_clients: Vec::new(), all_peer_clients: Vec::new(), peer_topology_hosts: Vec::new(), peer_admin_caches: Vec::new(), tier_config_reload_workers: Default::default(), }); assert!(sys.reserve_tier_config_reload_worker("node-a:9000")); let cancel_token = CancellationToken::new(); let cancel_for_reload = cancel_token.clone(); let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let calls_for_reload = Arc::clone(&calls); run_tier_config_reload_worker(Arc::clone(&sys), "node-a:9000".to_string(), cancel_token, move || { let attempt = calls_for_reload.fetch_add(1, std::sync::atomic::Ordering::SeqCst); let cancel_token = cancel_for_reload.clone(); async move { if attempt == 0 { cancel_token.cancel(); } TierConfigReloadOutcome::TransientReconnect(Error::other("connection refused")) } }) .await; assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); assert!(!sys.tier_config_reload_worker_active("node-a:9000")); } #[tokio::test] async fn load_transition_tier_config_reports_unreachable_peers() { let sys = NotificationSys { peer_clients: vec![None], all_peer_clients: Vec::new(), peer_topology_hosts: vec!["node-a:9000".to_string()], peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())], tier_config_reload_workers: Default::default(), }; 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_topology_hosts: vec!["node-a:9000".to_string()], peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())], tier_config_reload_workers: Default::default(), }; 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 server_info_probe_round_commits_failures_only_when_published() { let caches = vec![Mutex::new(PeerAdminCache::new())]; let endpoints = EndpointServerPools::default(); let probes = vec![PeerServerInfoProbe { host: "peer-1".to_string(), result: Err(PeerServerInfoProbeFailure::Rpc { health: None }), }]; assert_eq!( caches[0] .lock() .expect("peer cache should lock before publish") .server_failures, 0 ); let replies = publish_server_info_probe_round(&caches, &endpoints, probes); assert_eq!(replies.len(), 1); assert_eq!(replies[0].endpoint, "peer-1"); assert_eq!(replies[0].state, ItemState::Unknown.to_string()); assert_eq!( caches[0] .lock() .expect("peer cache should lock after publish") .server_failures, 1 ); } #[test] fn server_info_probe_round_does_not_count_no_client_slots_as_rpc_failures() { let caches = vec![Mutex::new(PeerAdminCache::new())]; let endpoints = EndpointServerPools::default(); let probes = vec![PeerServerInfoProbe { host: "node-a:9000".to_string(), result: Err(PeerServerInfoProbeFailure::NoClient), }]; let replies = publish_server_info_probe_round(&caches, &endpoints, probes); assert_eq!(replies.len(), 1); assert_eq!(replies[0].endpoint, "node-a:9000"); assert_eq!(replies[0].state, ItemState::Unknown.to_string()); assert_eq!( caches[0] .lock() .expect("peer cache should lock after no-client publish") .server_failures, 0 ); } #[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"); } }