//! Process-wide liveness cache for HA / RouterOS entity states. //! //! The dashboard's fleet probe runs every 30 s. Without a cache, every tick //! issues one HA `GET /api/states/...` per node/extra and — worse — a full //! RouterOS API login per switch. A dead switch must not be hammered, and a //! live one must not be asked to authenticate every 30 s. The cache keeps a //! state for a TTL (HA: 120 s, RouterOS: 300 s); a power action invalidates it //! so the next probe sees the real result immediately. use std::collections::HashMap; use std::sync::{LazyLock, Mutex}; use std::time::{Duration, Instant}; #[derive(Clone, Copy)] struct Entry { value: Option, checked: Instant, ttl: Duration, } static CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); fn lock_cache() -> std::sync::MutexGuard<'static, HashMap> { CACHE .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } /// Fresh cached state (`true`/`false`) or `None` when absent/expired. pub fn fresh(key: &str) -> Option> { let mut cache = lock_cache(); match cache.get(key) { Some(e) if e.checked.elapsed() < e.ttl => Some(e.value), _ => { cache.remove(key); None } } } /// Store a state for `ttl`. pub fn store(key: &str, value: Option, ttl: Duration) { let mut cache = lock_cache(); cache.insert( key.to_string(), Entry { value, checked: Instant::now(), ttl, }, ); // Keep the map bounded by dropping expired entries on write. cache.retain(|_, e| e.checked.elapsed() < e.ttl); } /// Drop one cached entity (e.g. after a HA/ RouterOS toggle). pub fn invalidate(key: &str) { lock_cache().remove(key); } /// Drop every entity (after a power flow; the dashboard's next tick re-probes). pub fn invalidate_all() { lock_cache().clear(); } #[cfg(test)] mod tests { use super::*; #[test] fn fresh_store_and_expire() { store("ha:switch.x", Some(true), Duration::from_secs(2)); assert_eq!(fresh("ha:switch.x"), Some(Some(true))); // A zero TTL means "do not trust this entry". store("ha:switch.y", Some(false), Duration::ZERO); assert_eq!(fresh("ha:switch.y"), None); } #[test] fn invalidation_forces_reprobe() { store( "mikrotik:sw:10.0.0.1", Some(false), Duration::from_secs(300), ); assert_eq!(fresh("mikrotik:sw:10.0.0.1"), Some(Some(false))); invalidate("mikrotik:sw:10.0.0.1"); assert_eq!(fresh("mikrotik:sw:10.0.0.1"), None); store("ha:switch.z", Some(true), Duration::from_secs(120)); invalidate_all(); assert_eq!(fresh("ha:switch.z"), None); } }