use std::collections::HashMap;
use std::time::{Duration, Instant};
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use tracing::debug;
const DEFAULT_SUSPECT_TIMEOUT: Duration = Duration::from_secs(30);
#[inline]
fn classify_status(elapsed: Duration, config: &HeartbeatConfig) -> Status {
if elapsed >= config.offline_after {
Status::Offline
} else if elapsed >= config.suspect_after {
Status::Suspect
} else {
Status::Online
}
}
const DEFAULT_OFFLINE_TIMEOUT: Duration = Duration::from_secs(90);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Status {
Online,
Suspect,
Offline,
}
impl std::fmt::Display for Status {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Online => write!(f, "online"),
Self::Suspect => write!(f, "suspect"),
Self::Offline => write!(f, "offline"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GpuTelemetry {
pub utilization_pct: f32,
pub memory_used_mb: u64,
pub memory_total_mb: u64,
pub temperature_c: Option<f32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[must_use]
pub struct FleetStats {
pub total_nodes: usize,
pub online: usize,
pub suspect: usize,
pub offline: usize,
pub total_gpus: usize,
pub total_vram_mb: u64,
pub available_vram_mb: u64,
}
#[derive(Debug, Clone)]
pub struct EvictionPolicy {
pub offline_cycles: u32,
pub eviction_tx: Option<tokio::sync::mpsc::UnboundedSender<String>>,
}
#[derive(Debug, Clone)]
pub struct HeartbeatConfig {
pub suspect_after: Duration,
pub offline_after: Duration,
pub eviction_policy: Option<EvictionPolicy>,
}
impl Default for HeartbeatConfig {
fn default() -> Self {
Self {
suspect_after: DEFAULT_SUSPECT_TIMEOUT,
offline_after: DEFAULT_OFFLINE_TIMEOUT,
eviction_policy: None,
}
}
}
#[derive(Debug, Clone)]
pub struct NodeState {
pub status: Status,
pub last_seen: Instant,
pub metadata: serde_json::Value,
}
pub struct HeartbeatTracker {
nodes: HashMap<String, NodeState>,
config: HeartbeatConfig,
}
impl HeartbeatTracker {
pub fn new(config: HeartbeatConfig) -> Self {
Self {
nodes: HashMap::new(),
config,
}
}
pub fn register(&mut self, id: impl Into<String>, metadata: serde_json::Value) {
let id = id.into();
debug!(node = %id, "heartbeat: registered");
self.nodes.insert(
id,
NodeState {
status: Status::Online,
last_seen: Instant::now(),
metadata,
},
);
}
#[must_use]
pub fn heartbeat(&mut self, id: &str) -> bool {
if let Some(node) = self.nodes.get_mut(id) {
node.status = Status::Online;
node.last_seen = Instant::now();
true
} else {
false
}
}
#[must_use]
pub fn heartbeat_with_metadata(&mut self, id: &str, metadata: serde_json::Value) -> bool {
if let Some(node) = self.nodes.get_mut(id) {
node.status = Status::Online;
node.last_seen = Instant::now();
node.metadata = metadata;
true
} else {
false
}
}
pub fn deregister(&mut self, id: &str) -> bool {
self.nodes.remove(id).is_some()
}
pub fn update_statuses(&mut self) -> Vec<(String, Status)> {
let now = Instant::now();
let mut transitions = Vec::new();
for (id, node) in &mut self.nodes {
let elapsed = now.duration_since(node.last_seen);
let prev = node.status;
node.status = classify_status(elapsed, &self.config);
if node.status != prev {
debug!(node = %id, from = ?prev, to = ?node.status, "heartbeat: transition");
transitions.push((id.clone(), node.status));
}
}
transitions
}
#[must_use]
pub fn list_by_status(&self, status: Status) -> Vec<(&str, &NodeState)> {
self.nodes
.iter()
.filter(|(_, n)| n.status == status)
.map(|(id, n)| (id.as_str(), n))
.collect()
}
#[must_use]
pub fn online(&self) -> Vec<(&str, &NodeState)> {
self.list_by_status(Status::Online)
}
#[inline]
#[must_use]
pub fn get(&self, id: &str) -> Option<&NodeState> {
self.nodes.get(id)
}
#[inline]
#[must_use]
pub fn len(&self) -> usize {
self.nodes.len()
}
#[inline]
#[must_use]
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
}
impl Default for HeartbeatTracker {
fn default() -> Self {
Self::new(HeartbeatConfig::default())
}
}
#[derive(Debug, Clone)]
struct NodeStateExt {
status: Status,
last_seen: Instant,
metadata: serde_json::Value,
gpu_telemetry: Option<Vec<GpuTelemetry>>,
offline_cycles: u32,
}
impl NodeStateExt {
fn to_node_state(&self) -> NodeState {
NodeState {
status: self.status,
last_seen: self.last_seen,
metadata: self.metadata.clone(),
}
}
}
pub struct ConcurrentHeartbeatTracker {
nodes: DashMap<String, NodeStateExt>,
config: HeartbeatConfig,
}
impl ConcurrentHeartbeatTracker {
pub fn new(config: HeartbeatConfig) -> Self {
Self {
nodes: DashMap::new(),
config,
}
}
pub fn register(&self, id: impl Into<String>, metadata: serde_json::Value) {
let id = id.into();
debug!(node = %id, "heartbeat: registered");
self.nodes.insert(
id,
NodeStateExt {
status: Status::Online,
last_seen: Instant::now(),
metadata,
gpu_telemetry: None,
offline_cycles: 0,
},
);
}
pub fn register_with_telemetry(
&self,
id: impl Into<String>,
metadata: serde_json::Value,
gpu_telemetry: Vec<GpuTelemetry>,
) {
let id = id.into();
debug!(node = %id, gpus = gpu_telemetry.len(), "heartbeat: registered with telemetry");
self.nodes.insert(
id,
NodeStateExt {
status: Status::Online,
last_seen: Instant::now(),
metadata,
gpu_telemetry: Some(gpu_telemetry),
offline_cycles: 0,
},
);
}
#[must_use]
pub fn heartbeat(&self, id: &str) -> bool {
if let Some(mut node) = self.nodes.get_mut(id) {
node.status = Status::Online;
node.last_seen = Instant::now();
node.offline_cycles = 0;
true
} else {
false
}
}
#[must_use]
pub fn heartbeat_with_metadata(&self, id: &str, metadata: serde_json::Value) -> bool {
if let Some(mut node) = self.nodes.get_mut(id) {
node.status = Status::Online;
node.last_seen = Instant::now();
node.metadata = metadata;
node.offline_cycles = 0;
true
} else {
false
}
}
#[must_use]
pub fn heartbeat_with_telemetry(&self, id: &str, gpu_telemetry: Vec<GpuTelemetry>) -> bool {
if let Some(mut node) = self.nodes.get_mut(id) {
node.status = Status::Online;
node.last_seen = Instant::now();
node.gpu_telemetry = Some(gpu_telemetry);
node.offline_cycles = 0;
true
} else {
false
}
}
pub fn deregister(&self, id: &str) -> bool {
self.nodes.remove(id).is_some()
}
pub fn update_statuses(&self) -> Vec<(String, Status)> {
let now = Instant::now();
let mut transitions = Vec::new();
let mut evict_ids = Vec::new();
for mut entry in self.nodes.iter_mut() {
let elapsed = now.duration_since(entry.last_seen);
let prev = entry.status;
entry.status = classify_status(elapsed, &self.config);
if entry.status == Status::Offline {
entry.offline_cycles += 1;
} else {
entry.offline_cycles = 0;
}
if entry.status != prev {
debug!(node = %entry.key(), from = ?prev, to = ?entry.status, "heartbeat: transition");
transitions.push((entry.key().clone(), entry.status));
}
if self
.config
.eviction_policy
.as_ref()
.is_some_and(|p| entry.offline_cycles >= p.offline_cycles)
{
evict_ids.push(entry.key().clone());
}
}
for id in evict_ids {
self.nodes.remove(&id);
debug!(node = %id, "heartbeat: evicted");
if let Some(tx) = self
.config
.eviction_policy
.as_ref()
.and_then(|p| p.eviction_tx.as_ref())
&& tx.send(id).is_err()
{
debug!("heartbeat: eviction channel closed");
}
}
transitions
}
#[must_use]
pub fn list_by_status(&self, status: Status) -> Vec<(String, NodeState)> {
self.nodes
.iter()
.filter(|entry| entry.value().status == status)
.map(|entry| (entry.key().clone(), entry.value().to_node_state()))
.collect()
}
#[must_use]
pub fn online(&self) -> Vec<(String, NodeState)> {
self.list_by_status(Status::Online)
}
#[must_use]
pub fn get(&self, id: &str) -> Option<NodeState> {
self.nodes
.get(id)
.map(|entry| entry.value().to_node_state())
}
#[must_use]
pub fn get_gpu_telemetry(&self, id: &str) -> Option<Vec<GpuTelemetry>> {
self.nodes
.get(id)
.and_then(|entry| entry.value().gpu_telemetry.clone())
}
#[inline]
#[must_use]
pub fn len(&self) -> usize {
self.nodes.len()
}
#[inline]
#[must_use]
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
pub fn fleet_stats(&self) -> FleetStats {
let mut stats = FleetStats::default();
for entry in self.nodes.iter() {
stats.total_nodes += 1;
match entry.status {
Status::Online => stats.online += 1,
Status::Suspect => stats.suspect += 1,
Status::Offline => stats.offline += 1,
}
if let Some(ref gpus) = entry.gpu_telemetry {
stats.total_gpus += gpus.len();
for gpu in gpus {
stats.total_vram_mb += gpu.memory_total_mb;
stats.available_vram_mb +=
gpu.memory_total_mb.saturating_sub(gpu.memory_used_mb);
}
}
}
stats
}
}
impl Default for ConcurrentHeartbeatTracker {
fn default() -> Self {
Self::new(HeartbeatConfig::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn register_and_heartbeat() {
let mut tracker = HeartbeatTracker::default();
tracker.register("n1", serde_json::json!({"gpu": true}));
assert_eq!(tracker.len(), 1);
assert_eq!(tracker.get("n1").unwrap().status, Status::Online);
assert!(tracker.heartbeat("n1"));
assert!(!tracker.heartbeat("unknown"));
}
#[test]
fn deregister() {
let mut tracker = HeartbeatTracker::default();
tracker.register("n1", serde_json::Value::Null);
assert!(tracker.deregister("n1"));
assert!(!tracker.deregister("n1"));
assert!(tracker.is_empty());
}
#[test]
fn status_transitions() {
let config = HeartbeatConfig {
suspect_after: Duration::from_millis(50),
offline_after: Duration::from_millis(500),
eviction_policy: None,
};
let mut tracker = HeartbeatTracker::new(config);
tracker.register("n1", serde_json::Value::Null);
let changes = tracker.update_statuses();
assert!(changes.is_empty());
std::thread::sleep(Duration::from_millis(80));
let changes = tracker.update_statuses();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0], ("n1".to_string(), Status::Suspect));
let _ = tracker.heartbeat("n1");
assert_eq!(tracker.get("n1").unwrap().status, Status::Online);
}
#[test]
fn list_by_status() {
let mut tracker = HeartbeatTracker::default();
tracker.register("n1", serde_json::Value::Null);
tracker.register("n2", serde_json::Value::Null);
let online = tracker.online();
assert_eq!(online.len(), 2);
}
#[test]
fn heartbeat_with_metadata_updates() {
let mut tracker = HeartbeatTracker::default();
tracker.register("n1", serde_json::json!({"v": 1}));
assert!(tracker.heartbeat_with_metadata("n1", serde_json::json!({"v": 2})));
assert_eq!(tracker.get("n1").unwrap().metadata["v"], 2);
assert!(!tracker.heartbeat_with_metadata("unknown", serde_json::Value::Null));
}
#[test]
fn concurrent_register_and_heartbeat() {
let tracker = ConcurrentHeartbeatTracker::default();
tracker.register("n1", serde_json::json!({"gpu": true}));
assert_eq!(tracker.len(), 1);
assert_eq!(tracker.get("n1").unwrap().status, Status::Online);
assert!(tracker.heartbeat("n1"));
assert!(!tracker.heartbeat("unknown"));
}
#[test]
fn concurrent_deregister() {
let tracker = ConcurrentHeartbeatTracker::default();
tracker.register("n1", serde_json::Value::Null);
assert!(tracker.deregister("n1"));
assert!(!tracker.deregister("n1"));
assert!(tracker.is_empty());
}
#[test]
fn concurrent_status_transitions() {
let config = HeartbeatConfig {
suspect_after: Duration::from_millis(50),
offline_after: Duration::from_millis(500),
eviction_policy: None,
};
let tracker = ConcurrentHeartbeatTracker::new(config);
tracker.register("n1", serde_json::Value::Null);
let changes = tracker.update_statuses();
assert!(changes.is_empty());
std::thread::sleep(Duration::from_millis(80));
let changes = tracker.update_statuses();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0], ("n1".to_string(), Status::Suspect));
let _ = tracker.heartbeat("n1");
assert_eq!(tracker.get("n1").unwrap().status, Status::Online);
}
#[test]
fn concurrent_list_by_status() {
let tracker = ConcurrentHeartbeatTracker::default();
tracker.register("n1", serde_json::Value::Null);
tracker.register("n2", serde_json::Value::Null);
let online = tracker.online();
assert_eq!(online.len(), 2);
}
#[test]
fn concurrent_heartbeat_with_metadata() {
let tracker = ConcurrentHeartbeatTracker::default();
tracker.register("n1", serde_json::json!({"v": 1}));
assert!(tracker.heartbeat_with_metadata("n1", serde_json::json!({"v": 2})));
assert_eq!(tracker.get("n1").unwrap().metadata["v"], 2);
}
#[test]
fn concurrent_multi_thread_heartbeat() {
use std::sync::Arc;
use std::thread;
let tracker = Arc::new(ConcurrentHeartbeatTracker::default());
for i in 0..8 {
tracker.register(format!("n-{i}"), serde_json::Value::Null);
}
let mut handles = Vec::new();
for i in 0..8 {
let t = tracker.clone();
handles.push(thread::spawn(move || {
let id = format!("n-{i}");
for _ in 0..100 {
let _ = t.heartbeat(&id);
}
}));
}
for h in handles {
h.join().unwrap();
}
assert_eq!(tracker.len(), 8);
assert_eq!(tracker.online().len(), 8);
}
fn sample_gpu() -> GpuTelemetry {
GpuTelemetry {
utilization_pct: 75.0,
memory_used_mb: 6000,
memory_total_mb: 8000,
temperature_c: Some(65.0),
}
}
#[test]
fn register_with_telemetry() {
let tracker = ConcurrentHeartbeatTracker::default();
tracker.register_with_telemetry("gpu-node", serde_json::Value::Null, vec![sample_gpu()]);
let tel = tracker.get_gpu_telemetry("gpu-node").unwrap();
assert_eq!(tel.len(), 1);
assert_eq!(tel[0].memory_total_mb, 8000);
}
#[test]
fn heartbeat_with_telemetry_updates() {
let tracker = ConcurrentHeartbeatTracker::default();
tracker.register("gpu-node", serde_json::Value::Null);
let new_gpu = GpuTelemetry {
utilization_pct: 50.0,
memory_used_mb: 4000,
memory_total_mb: 16000,
temperature_c: None,
};
assert!(tracker.heartbeat_with_telemetry("gpu-node", vec![new_gpu]));
assert!(!tracker.heartbeat_with_telemetry("unknown", vec![]));
let tel = tracker.get_gpu_telemetry("gpu-node").unwrap();
assert_eq!(tel[0].memory_total_mb, 16000);
}
#[test]
fn fleet_stats_aggregation() {
let config = HeartbeatConfig {
suspect_after: Duration::from_millis(50),
offline_after: Duration::from_millis(500),
eviction_policy: None,
};
let tracker = ConcurrentHeartbeatTracker::new(config);
tracker.register_with_telemetry(
"n1",
serde_json::Value::Null,
vec![
GpuTelemetry {
utilization_pct: 80.0,
memory_used_mb: 6000,
memory_total_mb: 8000,
temperature_c: Some(70.0),
},
GpuTelemetry {
utilization_pct: 20.0,
memory_used_mb: 2000,
memory_total_mb: 8000,
temperature_c: Some(45.0),
},
],
);
tracker.register("n2", serde_json::Value::Null);
let stats = tracker.fleet_stats();
assert_eq!(stats.total_nodes, 2);
assert_eq!(stats.online, 2);
assert_eq!(stats.total_gpus, 2);
assert_eq!(stats.total_vram_mb, 16000);
assert_eq!(stats.available_vram_mb, 8000);
std::thread::sleep(Duration::from_millis(80));
let _ = tracker.heartbeat("n1"); tracker.update_statuses();
let stats = tracker.fleet_stats();
assert_eq!(stats.online, 1);
assert_eq!(stats.suspect, 1);
}
#[test]
fn eviction_policy() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let config = HeartbeatConfig {
suspect_after: Duration::from_millis(30),
offline_after: Duration::from_millis(60),
eviction_policy: Some(EvictionPolicy {
offline_cycles: 2,
eviction_tx: Some(tx),
}),
};
let tracker = ConcurrentHeartbeatTracker::new(config);
tracker.register("n1", serde_json::Value::Null);
std::thread::sleep(Duration::from_millis(80));
tracker.update_statuses(); assert_eq!(tracker.len(), 1);
tracker.update_statuses(); assert_eq!(tracker.len(), 0);
let evicted = rx.try_recv().unwrap();
assert_eq!(evicted, "n1");
}
#[test]
fn eviction_resets_on_heartbeat() {
let config = HeartbeatConfig {
suspect_after: Duration::from_millis(30),
offline_after: Duration::from_millis(60),
eviction_policy: Some(EvictionPolicy {
offline_cycles: 3,
eviction_tx: None,
}),
};
let tracker = ConcurrentHeartbeatTracker::new(config);
tracker.register("n1", serde_json::Value::Null);
std::thread::sleep(Duration::from_millis(80));
tracker.update_statuses(); tracker.update_statuses();
let _ = tracker.heartbeat("n1");
std::thread::sleep(Duration::from_millis(80));
tracker.update_statuses(); assert_eq!(tracker.len(), 1); }
#[test]
fn gpu_telemetry_serialization() {
let gpu = sample_gpu();
let json = serde_json::to_string(&gpu).unwrap();
let parsed: GpuTelemetry = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.utilization_pct, 75.0);
assert_eq!(parsed.memory_used_mb, 6000);
}
#[test]
fn fleet_stats_empty() {
let tracker = ConcurrentHeartbeatTracker::default();
let stats = tracker.fleet_stats();
assert_eq!(stats.total_nodes, 0);
assert_eq!(stats.total_gpus, 0);
}
}