1use std::collections::HashMap;
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
30pub enum HealthTier {
31 Healthy = 0,
33 Degraded = 1,
35 Unhealthy = 2,
37 Dead = 3,
39}
40
41#[derive(Clone, Debug)]
47pub struct HeartbeatRecord {
48 pub peer_id: String,
50 pub last_seen_tick: u64,
52 pub consecutive_misses: u32,
54 pub total_heartbeats: u64,
56 pub total_misses: u64,
58}
59
60impl HeartbeatRecord {
61 fn new(peer_id: &str) -> Self {
63 Self {
64 peer_id: peer_id.to_owned(),
65 last_seen_tick: 0,
66 consecutive_misses: 0,
67 total_heartbeats: 0,
68 total_misses: 0,
69 }
70 }
71
72 pub fn miss_rate(&self) -> f64 {
76 let total = self.total_heartbeats + self.total_misses;
77 if total == 0 {
78 0.0
79 } else {
80 self.total_misses as f64 / total as f64
81 }
82 }
83
84 pub fn health_score(&self) -> f64 {
88 1.0 - self.miss_rate()
89 }
90}
91
92#[derive(Clone, Debug)]
98pub struct PeerHealthCheckerConfig {
99 pub heartbeat_interval_ticks: u64,
101 pub degraded_miss_threshold: u32,
103 pub unhealthy_miss_threshold: u32,
105 pub dead_miss_threshold: u32,
107}
108
109impl Default for PeerHealthCheckerConfig {
110 fn default() -> Self {
111 Self {
112 heartbeat_interval_ticks: 30,
113 degraded_miss_threshold: 2,
114 unhealthy_miss_threshold: 5,
115 dead_miss_threshold: 10,
116 }
117 }
118}
119
120#[derive(Clone, Debug)]
126pub struct HealthCheckerStats {
127 pub total_peers: usize,
129 pub by_tier: HashMap<HealthTier, usize>,
131 pub total_heartbeats_received: u64,
133 pub total_misses_recorded: u64,
135}
136
137pub struct PeerHealthChecker {
149 pub records: HashMap<String, HeartbeatRecord>,
151 pub config: PeerHealthCheckerConfig,
153}
154
155impl PeerHealthChecker {
156 pub fn new(config: PeerHealthCheckerConfig) -> Self {
158 Self {
159 records: HashMap::new(),
160 config,
161 }
162 }
163
164 pub fn record_heartbeat(&mut self, peer_id: &str, current_tick: u64) {
171 let record = self
172 .records
173 .entry(peer_id.to_owned())
174 .or_insert_with(|| HeartbeatRecord::new(peer_id));
175 record.last_seen_tick = current_tick;
176 record.consecutive_misses = 0;
177 record.total_heartbeats += 1;
178 }
179
180 pub fn record_miss(&mut self, peer_id: &str) {
185 let record = self
186 .records
187 .entry(peer_id.to_owned())
188 .or_insert_with(|| HeartbeatRecord::new(peer_id));
189 record.consecutive_misses += 1;
190 record.total_misses += 1;
191 }
192
193 pub fn tier(&self, peer_id: &str) -> HealthTier {
198 match self.records.get(peer_id) {
199 None => HealthTier::Dead,
200 Some(rec) => {
201 if rec.consecutive_misses >= self.config.dead_miss_threshold {
202 HealthTier::Dead
203 } else if rec.consecutive_misses >= self.config.unhealthy_miss_threshold {
204 HealthTier::Unhealthy
205 } else if rec.consecutive_misses >= self.config.degraded_miss_threshold {
206 HealthTier::Degraded
207 } else {
208 HealthTier::Healthy
209 }
210 }
211 }
212 }
213
214 pub fn tick_check(&mut self, current_tick: u64) -> Vec<String> {
220 let interval = self.config.heartbeat_interval_ticks;
221
222 let stale: Vec<String> = self
224 .records
225 .values()
226 .filter(|rec| current_tick.saturating_sub(rec.last_seen_tick) > interval)
227 .map(|rec| rec.peer_id.clone())
228 .collect();
229
230 for peer_id in &stale {
231 if let Some(rec) = self.records.get_mut(peer_id) {
233 rec.consecutive_misses += 1;
234 rec.total_misses += 1;
235 }
236 }
237
238 let mut sorted = stale;
239 sorted.sort_unstable();
240 sorted
241 }
242
243 pub fn remove_peer(&mut self, peer_id: &str) -> bool {
247 self.records.remove(peer_id).is_some()
248 }
249
250 pub fn health_score(&self, peer_id: &str) -> f64 {
253 self.records
254 .get(peer_id)
255 .map(|rec| rec.health_score())
256 .unwrap_or(0.0)
257 }
258
259 pub fn healthy_peers(&self) -> Vec<&str> {
262 let mut peers: Vec<&str> = self
263 .records
264 .keys()
265 .filter(|id| self.tier(id) == HealthTier::Healthy)
266 .map(|s| s.as_str())
267 .collect();
268 peers.sort_unstable();
269 peers
270 }
271
272 pub fn stats(&self) -> HealthCheckerStats {
274 let mut by_tier: HashMap<HealthTier, usize> = HashMap::new();
275 let mut total_heartbeats_received: u64 = 0;
276 let mut total_misses_recorded: u64 = 0;
277
278 for rec in self.records.values() {
279 *by_tier.entry(self.tier(&rec.peer_id)).or_insert(0) += 1;
280 total_heartbeats_received += rec.total_heartbeats;
281 total_misses_recorded += rec.total_misses;
282 }
283
284 HealthCheckerStats {
285 total_peers: self.records.len(),
286 by_tier,
287 total_heartbeats_received,
288 total_misses_recorded,
289 }
290 }
291}
292
293#[cfg(test)]
298mod tests {
299 use super::*;
300
301 fn default_checker() -> PeerHealthChecker {
302 PeerHealthChecker::new(PeerHealthCheckerConfig::default())
303 }
304
305 #[test]
308 fn miss_rate_zero_when_no_data() {
309 let rec = HeartbeatRecord::new("p");
310 assert_eq!(rec.miss_rate(), 0.0);
311 }
312
313 #[test]
314 fn miss_rate_calculation() {
315 let mut rec = HeartbeatRecord::new("p");
316 rec.total_heartbeats = 3;
317 rec.total_misses = 1;
318 let diff = (rec.miss_rate() - 0.25_f64).abs();
320 assert!(
321 diff < 1e-10,
322 "miss_rate should be 0.25, got {}",
323 rec.miss_rate()
324 );
325 }
326
327 #[test]
328 fn health_score_equals_one_minus_miss_rate() {
329 let mut rec = HeartbeatRecord::new("p");
330 rec.total_heartbeats = 3;
331 rec.total_misses = 1;
332 let expected = 1.0 - rec.miss_rate();
333 let diff = (rec.health_score() - expected).abs();
334 assert!(diff < 1e-10);
335 }
336
337 #[test]
338 fn health_score_is_one_when_no_misses() {
339 let mut rec = HeartbeatRecord::new("p");
340 rec.total_heartbeats = 10;
341 let diff = (rec.health_score() - 1.0_f64).abs();
342 assert!(diff < 1e-10);
343 }
344
345 #[test]
346 fn health_score_is_one_when_no_data() {
347 let rec = HeartbeatRecord::new("p");
348 let diff = (rec.health_score() - 1.0_f64).abs();
349 assert!(
350 diff < 1e-10,
351 "empty record should have score 1.0, got {}",
352 rec.health_score()
353 );
354 }
355
356 #[test]
359 fn record_heartbeat_creates_record() {
360 let mut checker = default_checker();
361 checker.record_heartbeat("peer-1", 50);
362 assert!(checker.records.contains_key("peer-1"));
363 }
364
365 #[test]
366 fn record_heartbeat_updates_last_seen_tick() {
367 let mut checker = default_checker();
368 checker.record_heartbeat("peer-1", 42);
369 assert_eq!(checker.records["peer-1"].last_seen_tick, 42);
370 }
371
372 #[test]
373 fn record_heartbeat_resets_consecutive_misses() {
374 let mut checker = default_checker();
375 checker.record_miss("peer-1");
376 checker.record_miss("peer-1");
377 checker.record_heartbeat("peer-1", 100);
378 assert_eq!(checker.records["peer-1"].consecutive_misses, 0);
379 }
380
381 #[test]
382 fn record_heartbeat_increments_total_heartbeats() {
383 let mut checker = default_checker();
384 checker.record_heartbeat("peer-1", 10);
385 checker.record_heartbeat("peer-1", 20);
386 assert_eq!(checker.records["peer-1"].total_heartbeats, 2);
387 }
388
389 #[test]
392 fn record_miss_creates_record() {
393 let mut checker = default_checker();
394 checker.record_miss("peer-2");
395 assert!(checker.records.contains_key("peer-2"));
396 }
397
398 #[test]
399 fn record_miss_increments_consecutive_misses() {
400 let mut checker = default_checker();
401 checker.record_miss("peer-2");
402 checker.record_miss("peer-2");
403 assert_eq!(checker.records["peer-2"].consecutive_misses, 2);
404 }
405
406 #[test]
407 fn record_miss_increments_total_misses() {
408 let mut checker = default_checker();
409 checker.record_miss("peer-2");
410 checker.record_miss("peer-2");
411 assert_eq!(checker.records["peer-2"].total_misses, 2);
412 }
413
414 #[test]
417 fn tier_dead_for_missing_peer() {
418 let checker = default_checker();
419 assert_eq!(checker.tier("unknown"), HealthTier::Dead);
420 }
421
422 #[test]
423 fn tier_healthy_on_zero_misses() {
424 let mut checker = default_checker();
425 checker.record_heartbeat("peer-1", 10);
426 assert_eq!(checker.tier("peer-1"), HealthTier::Healthy);
427 }
428
429 #[test]
430 fn tier_degraded_at_threshold() {
431 let mut checker = default_checker();
432 checker.record_heartbeat("peer-1", 10);
433 checker.record_miss("peer-1");
435 checker.record_miss("peer-1");
436 assert_eq!(checker.tier("peer-1"), HealthTier::Degraded);
437 }
438
439 #[test]
440 fn tier_unhealthy_at_threshold() {
441 let mut checker = default_checker();
442 checker.record_heartbeat("peer-1", 10);
443 for _ in 0..5 {
445 checker.record_miss("peer-1");
446 }
447 assert_eq!(checker.tier("peer-1"), HealthTier::Unhealthy);
448 }
449
450 #[test]
451 fn tier_dead_at_threshold() {
452 let mut checker = default_checker();
453 checker.record_heartbeat("peer-1", 10);
454 for _ in 0..10 {
456 checker.record_miss("peer-1");
457 }
458 assert_eq!(checker.tier("peer-1"), HealthTier::Dead);
459 }
460
461 #[test]
462 fn tier_healthy_after_heartbeat_resets_misses() {
463 let mut checker = default_checker();
464 for _ in 0..8 {
465 checker.record_miss("peer-1");
466 }
467 checker.record_heartbeat("peer-1", 500);
468 assert_eq!(checker.tier("peer-1"), HealthTier::Healthy);
469 }
470
471 #[test]
474 fn tick_check_detects_stale_peers() {
475 let mut checker = default_checker();
476 checker.record_heartbeat("peer-a", 0);
477 let missed = checker.tick_check(31);
479 assert!(missed.contains(&"peer-a".to_owned()));
480 }
481
482 #[test]
483 fn tick_check_does_not_flag_recent_peers() {
484 let mut checker = default_checker();
485 checker.record_heartbeat("peer-b", 20);
486 let missed = checker.tick_check(30);
488 assert!(!missed.contains(&"peer-b".to_owned()));
489 }
490
491 #[test]
492 fn tick_check_returns_sorted_alphabetically() {
493 let mut checker = default_checker();
494 checker.record_heartbeat("peer-z", 0);
495 checker.record_heartbeat("peer-a", 0);
496 checker.record_heartbeat("peer-m", 0);
497 let missed = checker.tick_check(100);
498 let mut expected = missed.clone();
499 expected.sort();
500 assert_eq!(missed, expected);
501 }
502
503 #[test]
504 fn tick_check_increments_consecutive_misses_for_stale_peer() {
505 let mut checker = default_checker();
506 checker.record_heartbeat("peer-c", 0);
507 checker.tick_check(100);
508 assert!(checker.records["peer-c"].consecutive_misses > 0);
509 }
510
511 #[test]
512 fn tick_check_returns_empty_when_no_peers_are_stale() {
513 let mut checker = default_checker();
514 checker.record_heartbeat("peer-d", 100);
515 let missed = checker.tick_check(110);
516 assert!(missed.is_empty());
517 }
518
519 #[test]
522 fn remove_peer_returns_true_when_present() {
523 let mut checker = default_checker();
524 checker.record_heartbeat("peer-1", 10);
525 assert!(checker.remove_peer("peer-1"));
526 }
527
528 #[test]
529 fn remove_peer_returns_false_when_absent() {
530 let mut checker = default_checker();
531 assert!(!checker.remove_peer("ghost"));
532 }
533
534 #[test]
535 fn remove_peer_actually_removes_record() {
536 let mut checker = default_checker();
537 checker.record_heartbeat("peer-1", 10);
538 checker.remove_peer("peer-1");
539 assert!(!checker.records.contains_key("peer-1"));
540 }
541
542 #[test]
545 fn health_score_returns_zero_for_unknown_peer() {
546 let checker = default_checker();
547 let diff = checker.health_score("unknown").abs();
548 assert!(diff < 1e-10);
549 }
550
551 #[test]
552 fn health_score_returns_record_score() {
553 let mut checker = default_checker();
554 checker.record_heartbeat("peer-1", 10);
555 checker.record_heartbeat("peer-1", 20);
556 checker.record_miss("peer-1");
557 let score = checker.health_score("peer-1");
559 let expected = checker.records["peer-1"].health_score();
560 let diff = (score - expected).abs();
561 assert!(diff < 1e-10);
562 }
563
564 #[test]
567 fn healthy_peers_sorted_alphabetically() {
568 let mut checker = default_checker();
569 checker.record_heartbeat("peer-z", 10);
570 checker.record_heartbeat("peer-a", 10);
571 checker.record_heartbeat("peer-m", 10);
572 let peers = checker.healthy_peers();
573 let mut expected = peers.clone();
574 expected.sort_unstable();
575 assert_eq!(peers, expected);
576 }
577
578 #[test]
579 fn healthy_peers_excludes_degraded_peers() {
580 let mut checker = default_checker();
581 checker.record_heartbeat("peer-good", 10);
582 checker.record_heartbeat("peer-bad", 10);
583 checker.record_miss("peer-bad");
584 checker.record_miss("peer-bad");
585 let peers = checker.healthy_peers();
586 assert!(peers.contains(&"peer-good"));
587 assert!(!peers.contains(&"peer-bad"));
588 }
589
590 #[test]
593 fn stats_total_peers_count() {
594 let mut checker = default_checker();
595 checker.record_heartbeat("p1", 1);
596 checker.record_heartbeat("p2", 1);
597 let s = checker.stats();
598 assert_eq!(s.total_peers, 2);
599 }
600
601 #[test]
602 fn stats_by_tier_counts_healthy_and_degraded() {
603 let mut checker = default_checker();
604 checker.record_heartbeat("healthy", 10);
605 checker.record_heartbeat("degraded", 10);
606 checker.record_miss("degraded");
607 checker.record_miss("degraded");
608 let s = checker.stats();
609 assert_eq!(s.by_tier.get(&HealthTier::Healthy).copied().unwrap_or(0), 1);
610 assert_eq!(
611 s.by_tier.get(&HealthTier::Degraded).copied().unwrap_or(0),
612 1
613 );
614 }
615
616 #[test]
617 fn stats_total_heartbeats_received() {
618 let mut checker = default_checker();
619 checker.record_heartbeat("p1", 1);
620 checker.record_heartbeat("p1", 2);
621 checker.record_heartbeat("p2", 1);
622 let s = checker.stats();
623 assert_eq!(s.total_heartbeats_received, 3);
624 }
625
626 #[test]
627 fn stats_total_misses_recorded() {
628 let mut checker = default_checker();
629 checker.record_heartbeat("p1", 1);
630 checker.record_miss("p1");
631 checker.record_miss("p1");
632 checker.record_miss("p2");
633 let s = checker.stats();
634 assert_eq!(s.total_misses_recorded, 3);
635 }
636
637 #[test]
638 fn stats_by_tier_dead_count() {
639 let mut checker = default_checker();
640 checker.record_heartbeat("p1", 10);
641 for _ in 0..10 {
642 checker.record_miss("p1");
643 }
644 let s = checker.stats();
645 assert_eq!(s.by_tier.get(&HealthTier::Dead).copied().unwrap_or(0), 1);
646 }
647}