1use std::collections::HashMap;
8use std::sync::Arc;
9use std::time::Duration;
10
11use serde::{Deserialize, Serialize};
12
13use crate::mesh_clock::{Clock, MonoTime, RealClock};
14
15#[derive(Debug, Clone)]
17pub struct HeartbeatConfig {
18 pub probe_interval: Duration,
20 pub ping_timeout: Duration,
22 pub indirect_timeout: Duration,
24 pub indirect_witnesses: usize,
26 pub suspect_timeout: Duration,
28}
29
30impl Default for HeartbeatConfig {
31 fn default() -> Self {
32 Self {
33 probe_interval: Duration::from_secs(1),
34 ping_timeout: Duration::from_millis(500),
35 indirect_timeout: Duration::from_secs(1),
36 indirect_witnesses: 3,
37 suspect_timeout: Duration::from_secs(5),
38 }
39 }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct PingRequest {
45 pub from_node: String,
47 pub sequence: u64,
49 pub indirect: bool,
51 pub on_behalf_of: Option<String>,
53 #[serde(default)]
55 pub mesh_time_us: u64,
56 #[serde(default)]
58 pub clock_source: ClockSource,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct PingResponse {
64 pub from_node: String,
66 pub sequence: u64,
68 pub process_count: u32,
70 pub service_count: u32,
72 #[serde(default)]
74 pub mesh_time_us: u64,
75 #[serde(default)]
77 pub clock_source: ClockSource,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85#[derive(Default)]
86pub enum ClockSource {
87 #[default]
89 Local = 0,
90 Mesh = 1,
92 Ntp = 2,
94 Tsf = 3,
96 Gps = 4,
98}
99
100#[derive(Debug, Clone)]
102pub struct MeshClockSync {
103 pub offset_us: i64,
105 smoothed_offset: f64,
107 pub authority_id: Option<String>,
109 pub authority_source: ClockSource,
111 pub local_source: ClockSource,
113 pub sync_count: u64,
115 pub uncertainty_us: u64,
117}
118
119impl MeshClockSync {
120 pub fn new(local_source: ClockSource) -> Self {
122 Self {
123 offset_us: 0,
124 smoothed_offset: 0.0,
125 authority_id: None,
126 authority_source: ClockSource::Local,
127 local_source,
128 sync_count: 0,
129 uncertainty_us: 1_000_000, }
131 }
132
133 pub fn mesh_time_us(&self) -> u64 {
138 self.mesh_time_us_with(&RealClock)
139 }
140
141 pub fn mesh_time_us_with(&self, clock: &dyn Clock) -> u64 {
143 let local = clock.unix_time_us();
144 (local as i64 + self.offset_us) as u64
145 }
146
147 pub fn process_sync(
152 &mut self,
153 sender_id: &str,
154 sender_time_us: u64,
155 sender_source: ClockSource,
156 local_receive_time_us: u64,
157 ) {
158 if sender_source < self.local_source && self.sync_count > 0 {
160 return;
161 }
162
163 if sender_source > self.authority_source
165 || self.authority_id.is_none()
166 || self.authority_id.as_deref() == Some(sender_id)
167 {
168 let raw_offset = sender_time_us as i64 - local_receive_time_us as i64;
169
170 if self.sync_count > 5 && (raw_offset - self.offset_us).unsigned_abs() > 100_000 {
172 return;
173 }
174
175 let alpha = if self.sync_count < 5 { 0.5 } else { 0.1 };
177 self.smoothed_offset = (1.0 - alpha) * self.smoothed_offset + alpha * raw_offset as f64;
178 self.offset_us = self.smoothed_offset as i64;
179
180 self.authority_id = Some(sender_id.to_string());
181 self.authority_source = sender_source;
182 self.sync_count += 1;
183
184 self.uncertainty_us = match self.sync_count {
186 0..=5 => 10_000, 6..=20 => 1_000, 21..=100 => 100, _ => 50, };
191 }
192 }
193
194 pub fn is_authority(&self, our_node_id: &str) -> bool {
196 self.authority_id.as_deref() == Some(our_node_id) || self.authority_id.is_none()
197 }
198
199 pub fn is_better_source(&self, other: ClockSource) -> bool {
201 self.local_source > other
202 }
203}
204
205pub fn system_time_us() -> u64 {
210 RealClock.unix_time_us()
211}
212
213#[non_exhaustive]
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum HeartbeatState {
217 Alive,
219 Suspect,
221 Dead,
223}
224
225#[derive(Debug, Clone)]
227pub struct PeerHeartbeat {
228 pub state: HeartbeatState,
230 pub last_seen: MonoTime,
232 pub suspect_since: Option<MonoTime>,
234 pub missed_count: u32,
236 pub last_ping_seq: u64,
238}
239
240impl PeerHeartbeat {
241 pub fn new(clock: &dyn Clock) -> Self {
243 Self {
244 state: HeartbeatState::Alive,
245 last_seen: clock.now(),
246 suspect_since: None,
247 missed_count: 0,
248 last_ping_seq: 0,
249 }
250 }
251
252 pub fn record_alive(&mut self, clock: &dyn Clock) {
254 self.state = HeartbeatState::Alive;
255 self.last_seen = clock.now();
256 self.suspect_since = None;
257 self.missed_count = 0;
258 }
259
260 pub fn record_miss(&mut self, clock: &dyn Clock, config: &HeartbeatConfig) {
262 self.missed_count += 1;
263 match self.state {
264 HeartbeatState::Alive => {
265 self.state = HeartbeatState::Suspect;
266 self.suspect_since = Some(clock.now());
267 }
268 HeartbeatState::Suspect => {
269 if let Some(since) = self.suspect_since {
275 let elapsed = clock.now().elapsed_since(since);
276 if elapsed >= config.suspect_timeout {
277 self.state = HeartbeatState::Dead;
278 }
279 }
280 }
281 HeartbeatState::Dead => {} }
283 }
284
285 pub fn is_unreachable(&self, clock: &dyn Clock, config: &HeartbeatConfig) -> bool {
287 self.state == HeartbeatState::Dead
288 || clock.now().elapsed_since(self.last_seen) > config.suspect_timeout * 3
289 }
290}
291
292impl Default for PeerHeartbeat {
293 fn default() -> Self {
294 Self::new(&RealClock)
295 }
296}
297
298pub struct HeartbeatTracker {
300 config: HeartbeatConfig,
301 peers: HashMap<String, PeerHeartbeat>,
302 next_sequence: u64,
303 clock: Arc<dyn Clock>,
305}
306
307impl HeartbeatTracker {
308 pub fn new(config: HeartbeatConfig) -> Self {
310 Self::with_clock(config, RealClock::shared())
311 }
312
313 pub fn with_clock(config: HeartbeatConfig, clock: Arc<dyn Clock>) -> Self {
315 Self {
316 config,
317 peers: HashMap::new(),
318 next_sequence: 1,
319 clock,
320 }
321 }
322
323 pub fn clock(&self) -> &Arc<dyn Clock> {
325 &self.clock
326 }
327
328 pub fn add_peer(&mut self, node_id: String) {
330 self.peers
331 .entry(node_id)
332 .or_insert_with(|| PeerHeartbeat::new(self.clock.as_ref()));
333 }
334
335 pub fn remove_peer(&mut self, node_id: &str) {
337 self.peers.remove(node_id);
338 }
339
340 pub fn record_alive(&mut self, node_id: &str) {
342 if let Some(peer) = self.peers.get_mut(node_id) {
343 peer.record_alive(self.clock.as_ref());
344 }
345 }
346
347 pub fn record_miss(&mut self, node_id: &str) {
349 if let Some(peer) = self.peers.get_mut(node_id) {
350 peer.record_miss(self.clock.as_ref(), &self.config);
351 }
352 }
353
354 pub fn next_sequence(&mut self) -> u64 {
356 let seq = self.next_sequence;
357 self.next_sequence += 1;
358 seq
359 }
360
361 pub fn suspect_peers(&self) -> Vec<&str> {
363 self.peers
364 .iter()
365 .filter(|(_, p)| p.state == HeartbeatState::Suspect)
366 .map(|(id, _)| id.as_str())
367 .collect()
368 }
369
370 pub fn dead_peers(&self) -> Vec<&str> {
372 self.peers
373 .iter()
374 .filter(|(_, p)| p.state == HeartbeatState::Dead)
375 .map(|(id, _)| id.as_str())
376 .collect()
377 }
378
379 pub fn peer_count(&self) -> usize {
381 self.peers.len()
382 }
383
384 pub fn peer_state(&self, node_id: &str) -> Option<HeartbeatState> {
386 self.peers.get(node_id).map(|p| p.state)
387 }
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize)]
392pub struct PeerMetrics {
393 pub node_id: String,
395 pub rtt_ms: f64,
397 pub messages_sent: u64,
399 pub messages_received: u64,
401 pub bytes_sent: u64,
403 pub bytes_received: u64,
405 pub errors: u64,
407 pub last_updated: u64,
409}
410
411impl PeerMetrics {
412 pub fn new(node_id: String) -> Self {
414 Self {
415 node_id,
416 rtt_ms: 0.0,
417 messages_sent: 0,
418 messages_received: 0,
419 bytes_sent: 0,
420 bytes_received: 0,
421 errors: 0,
422 last_updated: 0,
423 }
424 }
425
426 pub fn affinity_score(&self) -> f64 {
428 let error_rate = if self.messages_sent > 0 {
429 self.errors as f64 / self.messages_sent as f64
430 } else {
431 0.0
432 };
433 self.rtt_ms + error_rate * 1000.0
434 }
435
436 pub fn record_send(&mut self, bytes: u64) {
438 self.messages_sent += 1;
439 self.bytes_sent += bytes;
440 }
441
442 pub fn record_recv(&mut self, bytes: u64) {
444 self.messages_received += 1;
445 self.bytes_received += bytes;
446 }
447
448 pub fn record_error(&mut self) {
450 self.errors += 1;
451 }
452
453 pub fn update_rtt(&mut self, rtt_ms: f64) {
455 self.rtt_ms = self.rtt_ms * 0.8 + rtt_ms * 0.2;
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462 use crate::mesh_clock::MockClock;
463
464 #[test]
465 fn ping_request_serde_roundtrip() {
466 let req = PingRequest {
467 from_node: "node-a".to_string(),
468 sequence: 42,
469 indirect: true,
470 on_behalf_of: Some("node-b".to_string()),
471 mesh_time_us: system_time_us(),
472 clock_source: ClockSource::Ntp,
473 };
474 let json = serde_json::to_string(&req).unwrap();
475 let restored: PingRequest = serde_json::from_str(&json).unwrap();
476 assert_eq!(restored.from_node, "node-a");
477 assert_eq!(restored.sequence, 42);
478 assert!(restored.indirect);
479 assert_eq!(restored.on_behalf_of, Some("node-b".to_string()));
480 }
481
482 #[test]
483 fn ping_response_serde_roundtrip() {
484 let resp = PingResponse {
485 from_node: "node-b".to_string(),
486 sequence: 42,
487 process_count: 5,
488 service_count: 3,
489 mesh_time_us: system_time_us(),
490 clock_source: ClockSource::Local,
491 };
492 let json = serde_json::to_string(&resp).unwrap();
493 let restored: PingResponse = serde_json::from_str(&json).unwrap();
494 assert_eq!(restored.from_node, "node-b");
495 assert_eq!(restored.sequence, 42);
496 assert_eq!(restored.process_count, 5);
497 assert_eq!(restored.service_count, 3);
498 }
499
500 #[test]
501 fn heartbeat_config_defaults() {
502 let config = HeartbeatConfig::default();
503 assert_eq!(config.probe_interval, Duration::from_secs(1));
504 assert_eq!(config.ping_timeout, Duration::from_millis(500));
505 assert_eq!(config.indirect_timeout, Duration::from_secs(1));
506 assert_eq!(config.indirect_witnesses, 3);
507 assert_eq!(config.suspect_timeout, Duration::from_secs(5));
508 }
509
510 #[test]
511 fn peer_heartbeat_alive_to_suspect() {
512 let clock = MockClock::new();
513 let config = HeartbeatConfig::default();
514 let mut peer = PeerHeartbeat::new(&clock);
515 assert_eq!(peer.state, HeartbeatState::Alive);
516
517 peer.record_miss(&clock, &config);
518 assert_eq!(peer.state, HeartbeatState::Suspect);
519 assert_eq!(peer.missed_count, 1);
520 assert!(peer.suspect_since.is_some());
521 }
522
523 #[test]
524 fn peer_heartbeat_suspect_to_dead() {
525 let clock = MockClock::new();
527 let config = HeartbeatConfig {
528 suspect_timeout: Duration::from_secs(0),
529 ..HeartbeatConfig::default()
530 };
531 let mut peer = PeerHeartbeat::new(&clock);
532
533 peer.record_miss(&clock, &config);
535 assert_eq!(peer.state, HeartbeatState::Suspect);
536
537 peer.record_miss(&clock, &config);
539 assert_eq!(peer.state, HeartbeatState::Dead);
540 }
541
542 #[test]
543 fn record_alive_resets_state() {
544 let clock = MockClock::new();
545 let config = HeartbeatConfig::default();
546 let mut peer = PeerHeartbeat::new(&clock);
547
548 peer.record_miss(&clock, &config);
549 assert_eq!(peer.state, HeartbeatState::Suspect);
550
551 peer.record_alive(&clock);
552 assert_eq!(peer.state, HeartbeatState::Alive);
553 assert_eq!(peer.missed_count, 0);
554 assert!(peer.suspect_since.is_none());
555 }
556
557 #[test]
558 fn mock_clock_deterministically_advances_heartbeat_ticks() {
559 let clock = MockClock::new();
561 let config = HeartbeatConfig {
562 suspect_timeout: Duration::from_secs(5),
563 ..HeartbeatConfig::default()
564 };
565 let mut tracker = HeartbeatTracker::with_clock(config, Arc::new(clock.clone()));
566 tracker.add_peer("peer-a".into());
567
568 tracker.record_miss("peer-a");
569 assert_eq!(
570 tracker.peer_state("peer-a"),
571 Some(HeartbeatState::Suspect)
572 );
573
574 clock.advance(Duration::from_secs(4));
576 tracker.record_miss("peer-a");
577 assert_eq!(
578 tracker.peer_state("peer-a"),
579 Some(HeartbeatState::Suspect)
580 );
581
582 clock.advance(Duration::from_secs(2));
584 tracker.record_miss("peer-a");
585 assert_eq!(tracker.peer_state("peer-a"), Some(HeartbeatState::Dead));
586 assert!(tracker.dead_peers().contains(&"peer-a"));
587 }
588
589 #[test]
590 fn tracker_add_remove_peers() {
591 let mut tracker = HeartbeatTracker::new(HeartbeatConfig::default());
592 tracker.add_peer("node-a".to_string());
593 tracker.add_peer("node-b".to_string());
594 assert_eq!(tracker.peer_count(), 2);
595
596 tracker.remove_peer("node-a");
597 assert_eq!(tracker.peer_count(), 1);
598 assert!(tracker.peer_state("node-a").is_none());
599 assert_eq!(tracker.peer_state("node-b"), Some(HeartbeatState::Alive));
600 }
601
602 #[test]
603 fn tracker_suspect_and_dead_peers() {
604 let config = HeartbeatConfig {
605 suspect_timeout: Duration::from_secs(0),
606 ..HeartbeatConfig::default()
607 };
608 let mut tracker = HeartbeatTracker::new(config);
609 tracker.add_peer("node-a".to_string());
610 tracker.add_peer("node-b".to_string());
611 tracker.add_peer("node-c".to_string());
612
613 tracker.record_miss("node-a");
615 tracker.record_miss("node-b");
617 tracker.record_miss("node-b");
618 let suspects = tracker.suspect_peers();
621 assert_eq!(suspects.len(), 1);
622 assert!(suspects.contains(&"node-a"));
623
624 let dead = tracker.dead_peers();
625 assert_eq!(dead.len(), 1);
626 assert!(dead.contains(&"node-b"));
627 }
628
629 #[test]
632 fn peer_metrics_affinity_score_zero_errors() {
633 let mut m = PeerMetrics::new("n1".into());
634 m.rtt_ms = 10.0;
635 m.messages_sent = 100;
636 m.errors = 0;
637 assert!((m.affinity_score() - 10.0).abs() < f64::EPSILON);
639 }
640
641 #[test]
642 fn peer_metrics_affinity_score_with_errors() {
643 let mut m = PeerMetrics::new("n1".into());
644 m.rtt_ms = 5.0;
645 m.messages_sent = 100;
646 m.errors = 10;
647 assert!((m.affinity_score() - 105.0).abs() < f64::EPSILON);
649 }
650
651 #[test]
652 fn peer_metrics_affinity_no_messages() {
653 let m = PeerMetrics::new("n1".into());
654 assert!((m.affinity_score() - 0.0).abs() < f64::EPSILON);
656 }
657
658 #[test]
659 fn peer_metrics_record_send_recv() {
660 let mut m = PeerMetrics::new("n1".into());
661 m.record_send(100);
662 m.record_send(200);
663 assert_eq!(m.messages_sent, 2);
664 assert_eq!(m.bytes_sent, 300);
665
666 m.record_recv(50);
667 assert_eq!(m.messages_received, 1);
668 assert_eq!(m.bytes_received, 50);
669 }
670
671 #[test]
672 fn peer_metrics_record_error() {
673 let mut m = PeerMetrics::new("n1".into());
674 m.record_error();
675 m.record_error();
676 assert_eq!(m.errors, 2);
677 }
678
679 #[test]
680 fn peer_metrics_update_rtt_ema() {
681 let mut m = PeerMetrics::new("n1".into());
682 m.rtt_ms = 100.0;
683 m.update_rtt(50.0);
684 assert!((m.rtt_ms - 90.0).abs() < f64::EPSILON);
686 }
687
688 #[test]
689 fn peer_metrics_serde_roundtrip() {
690 let mut m = PeerMetrics::new("node-x".into());
691 m.rtt_ms = 15.5;
692 m.messages_sent = 42;
693 let json = serde_json::to_string(&m).unwrap();
694 let restored: PeerMetrics = serde_json::from_str(&json).unwrap();
695 assert_eq!(restored.node_id, "node-x");
696 assert!((restored.rtt_ms - 15.5).abs() < f64::EPSILON);
697 assert_eq!(restored.messages_sent, 42);
698 }
699
700 #[test]
701 fn next_sequence_increments() {
702 let mut tracker = HeartbeatTracker::new(HeartbeatConfig::default());
703 assert_eq!(tracker.next_sequence(), 1);
704 assert_eq!(tracker.next_sequence(), 2);
705 assert_eq!(tracker.next_sequence(), 3);
706 }
707
708 #[test]
711 fn clock_source_ordering() {
712 assert!(ClockSource::Gps > ClockSource::Ntp);
713 assert!(ClockSource::Ntp > ClockSource::Mesh);
714 assert!(ClockSource::Mesh > ClockSource::Local);
715 assert!(ClockSource::Tsf > ClockSource::Ntp);
716 }
717
718 #[test]
719 fn mesh_clock_sync_initial_state() {
720 let sync = MeshClockSync::new(ClockSource::Local);
721 assert_eq!(sync.offset_us, 0);
722 assert_eq!(sync.sync_count, 0);
723 assert!(sync.authority_id.is_none());
724 assert_eq!(sync.uncertainty_us, 1_000_000);
725 }
726
727 #[test]
728 fn mesh_clock_sync_from_ntp_authority() {
729 let mut sync = MeshClockSync::new(ClockSource::Local);
730 let now = system_time_us();
731
732 let authority_time = now + 5_000;
734 sync.process_sync("authority-1", authority_time, ClockSource::Ntp, now);
735
736 assert_eq!(sync.authority_id.as_deref(), Some("authority-1"));
737 assert_eq!(sync.authority_source, ClockSource::Ntp);
738 assert_eq!(sync.sync_count, 1);
739 assert!((sync.offset_us - 2_500).unsigned_abs() < 1_000);
741 }
742
743 #[test]
744 fn mesh_clock_sync_ignores_worse_source() {
745 let mut sync = MeshClockSync::new(ClockSource::Ntp);
746 let now = system_time_us();
747
748 for i in 0..10 {
750 sync.process_sync("peer", now + i * 100, ClockSource::Local, now + i * 100);
751 }
752 let offset_before = sync.offset_us;
754 sync.process_sync("peer", now + 50_000, ClockSource::Local, now);
755 assert_eq!(sync.offset_us, offset_before);
756 }
757
758 #[test]
759 fn mesh_clock_sync_smooths_jitter() {
760 let mut sync = MeshClockSync::new(ClockSource::Local);
761 let now = system_time_us();
762
763 for i in 0..20u64 {
765 let jitter = (i % 3) as i64 * 50 - 50; let authority_time = (now as i64 + 1_000 + jitter) as u64;
767 sync.process_sync("auth", authority_time, ClockSource::Ntp, now);
768 }
769
770 assert!((sync.offset_us - 1_000).unsigned_abs() < 500);
772 assert!(sync.uncertainty_us < 10_000);
774 }
775
776 #[test]
777 fn system_time_us_returns_plausible_value() {
778 let t = system_time_us();
779 assert!(t > 1_577_836_800_000_000);
781 assert!(t < 2_524_608_000_000_000);
783 }
784}