1use std::collections::HashMap;
31use std::sync::Arc;
32use std::time::{Duration, Instant};
33
34use crate::cluster::failure_detector::DEFAULT_THRESHOLD;
35use crate::cluster::peer::PeerState;
36use crate::cluster::pool::ServerPool;
37use crate::events::{ClusterEvent, EventManager};
38use crate::hashkit::{token::parse_token, DynToken};
39
40pub const DEFAULT_GOSSIP_INTERVAL_MS: u64 = 1_000;
43
44pub const DEFAULT_SEEDS_CHECK_INTERVAL_MS: u64 = 30_000;
46
47#[derive(Clone, Debug)]
49pub struct GossipConfig {
50 pub enabled: bool,
52 pub interval: Duration,
54 pub seeds_check_interval: Duration,
57}
58
59impl Default for GossipConfig {
60 fn default() -> Self {
61 Self {
62 enabled: false,
63 interval: Duration::from_millis(DEFAULT_GOSSIP_INTERVAL_MS),
64 seeds_check_interval: Duration::from_millis(DEFAULT_SEEDS_CHECK_INTERVAL_MS),
65 }
66 }
67}
68
69#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct SeedRecord {
72 pub host: String,
74 pub port: u16,
76 pub rack: String,
78 pub dc: String,
80 pub tokens: Vec<DynToken>,
82}
83
84#[derive(Clone, Debug)]
89pub struct GossipNode {
90 pub dc: String,
92 pub rack: String,
94 pub host: String,
96 pub port: u16,
98 pub tokens: Vec<DynToken>,
100 pub state: PeerState,
102 pub ts_secs: u64,
104 pub is_local: bool,
106}
107
108#[derive(Clone, Debug, Default)]
116pub struct GossipState {
117 by_token: HashMap<(String, String, String), GossipNode>,
118 by_name: HashMap<(String, String, String), GossipNode>,
119 node_count: usize,
120}
121
122impl GossipState {
123 #[must_use]
133 pub fn new() -> Self {
134 Self::default()
135 }
136
137 #[must_use]
139 pub fn node_count(&self) -> usize {
140 self.node_count
141 }
142
143 fn token_key(node: &GossipNode) -> (String, String, String) {
145 let primary = node
146 .tokens
147 .first()
148 .map(|t| format!("{}", t.get_int()))
149 .unwrap_or_default();
150 (node.dc.clone(), node.rack.clone(), primary)
151 }
152
153 fn name_key(node: &GossipNode) -> (String, String, String) {
154 (node.dc.clone(), node.rack.clone(), node.host.clone())
155 }
156
157 pub fn add_or_update(&mut self, node: GossipNode) -> GossipStep {
185 let token_key = Self::token_key(&node);
186 let name_key = Self::name_key(&node);
187 if let Some(existing) = self.by_token.get_mut(&token_key) {
188 if existing.host == node.host {
189 if node.ts_secs > existing.ts_secs {
190 let changed = existing.state != node.state;
191 existing.state = node.state;
192 existing.ts_secs = node.ts_secs;
193 if changed {
194 return GossipStep::StateChanged;
195 }
196 return GossipStep::TimestampUpdated;
197 }
198 GossipStep::Unchanged
199 } else {
200 let old_name_key = Self::name_key(existing);
202 self.by_name.remove(&old_name_key);
203 *existing = node.clone();
204 self.by_name.insert(name_key, node);
205 GossipStep::Replaced
206 }
207 } else {
208 self.by_token.insert(token_key, node.clone());
209 self.by_name.insert(name_key, node);
210 self.node_count += 1;
211 GossipStep::Added
212 }
213 }
214
215 pub fn nodes(&self) -> impl Iterator<Item = &GossipNode> + '_ {
217 self.by_token.values()
218 }
219
220 pub fn run_failure_detector(&mut self, now_secs: u64, interval_ms: u64) {
242 let delta_secs = (interval_ms / 1000).saturating_mul(40);
243 for node in self.by_token.values_mut() {
244 if node.is_local {
245 continue;
246 }
247 if now_secs.saturating_sub(node.ts_secs) > delta_secs {
248 node.state = PeerState::Down;
249 }
250 }
251 for node in self.by_name.values_mut() {
253 if node.is_local {
254 continue;
255 }
256 if now_secs.saturating_sub(node.ts_secs) > delta_secs {
257 node.state = PeerState::Down;
258 }
259 }
260 }
261}
262
263#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
265pub enum GossipStep {
266 Added,
268 StateChanged,
270 TimestampUpdated,
272 Replaced,
274 Unchanged,
276}
277
278pub fn parse_seed_node(raw: &str) -> Result<SeedRecord, String> {
291 let parts: Vec<&str> = raw.splitn(5, ':').collect();
292 if parts.len() != 5 {
293 return Err(format!("malformed seed entry '{raw}'"));
294 }
295 let mut iter = raw.rsplitn(5, ':');
300 let tokens_str = iter.next().ok_or("missing tokens")?;
301 let dc = iter.next().ok_or("missing dc")?;
302 let rack = iter.next().ok_or("missing rack")?;
303 let port_str = iter.next().ok_or("missing port")?;
304 let host = iter.next().ok_or("missing host")?;
305 if host.is_empty() {
306 return Err(format!("empty host in '{raw}'"));
307 }
308 if rack.is_empty() {
309 return Err(format!("empty rack in '{raw}'"));
310 }
311 if dc.is_empty() {
312 return Err(format!("empty dc in '{raw}'"));
313 }
314 let port: u16 = port_str
315 .parse()
316 .map_err(|e| format!("bad port '{port_str}': {e}"))?;
317 if port == 0 {
318 return Err(format!("zero port in '{raw}'"));
319 }
320 if tokens_str.is_empty() {
321 return Err(format!("empty tokens in '{raw}'"));
322 }
323 let mut tokens = Vec::new();
324 for t in tokens_str.split(',') {
325 let parsed = parse_token(t.as_bytes()).map_err(|e| format!("bad token '{t}': {e}"))?;
326 tokens.push(parsed);
327 }
328 Ok(SeedRecord {
329 host: host.to_string(),
330 port,
331 rack: rack.to_string(),
332 dc: dc.to_string(),
333 tokens,
334 })
335}
336
337pub fn parse_seed_blob(raw: &str) -> Result<Vec<SeedRecord>, String> {
347 let mut out = Vec::new();
348 for piece in raw.split('|') {
349 if piece.is_empty() {
350 continue;
351 }
352 out.push(parse_seed_node(piece)?);
353 }
354 Ok(out)
355}
356
357#[derive(Debug)]
394pub struct GossipHandler {
395 pool: Arc<ServerPool>,
396 threshold: f64,
397 interval: Duration,
398 failure_metrics: Option<Arc<crate::stats::FailureMetrics>>,
404 events: Option<Arc<EventManager>>,
411}
412
413impl GossipHandler {
414 #[must_use]
417 pub fn new(pool: Arc<ServerPool>) -> Self {
418 Self {
419 pool,
420 threshold: DEFAULT_THRESHOLD,
421 interval: Duration::from_millis(DEFAULT_GOSSIP_INTERVAL_MS),
422 failure_metrics: None,
423 events: None,
424 }
425 }
426
427 #[must_use]
437 pub fn with_failure_metrics(mut self, metrics: Arc<crate::stats::FailureMetrics>) -> Self {
438 self.failure_metrics = Some(metrics);
439 self
440 }
441
442 #[must_use]
450 pub fn with_events(mut self, events: Arc<EventManager>) -> Self {
451 self.events = Some(events);
452 self
453 }
454
455 #[must_use]
457 pub fn events(&self) -> Option<&Arc<EventManager>> {
458 self.events.as_ref()
459 }
460
461 #[must_use]
463 pub fn with_threshold(mut self, threshold: f64) -> Self {
464 self.threshold = threshold;
465 self
466 }
467
468 #[must_use]
472 pub fn with_interval(mut self, interval: Duration) -> Self {
473 self.interval = interval;
474 self
475 }
476
477 #[must_use]
479 pub fn threshold(&self) -> f64 {
480 self.threshold
481 }
482
483 #[must_use]
485 pub fn interval(&self) -> Duration {
486 self.interval
487 }
488
489 #[must_use]
491 pub fn pool(&self) -> &Arc<ServerPool> {
492 &self.pool
493 }
494
495 pub fn record_heartbeat_pname(&self, pname: &str, now: Instant) {
507 let mut peers = self.pool.peers().write();
508 for p in peers.iter_mut() {
509 if p.is_local() {
510 continue;
511 }
512 if p.endpoint().pname() == pname {
513 p.failure_detector_mut().record_heartbeat(now);
514 if p.failure_detector().phi(now) <= self.threshold && p.state() != PeerState::Normal
515 {
516 let prev = p.state();
517 p.set_state(PeerState::Normal, now_secs_wall());
518 if let Some(m) = self.failure_metrics.as_ref() {
519 m.record_peer_state_transition(
520 p.idx(),
521 p.dc(),
522 p.rack(),
523 prev,
524 PeerState::Normal,
525 );
526 }
527 if let Some(ev) = self.events.as_ref() {
528 ev.publish(ClusterEvent::PeerUp {
529 peer_id: p.idx(),
530 dc: p.dc().to_string(),
531 ts: std::time::SystemTime::now(),
532 });
533 }
534 }
535 return;
536 }
537 }
538 }
539
540 pub fn record_heartbeat_idx(&self, peer_idx: u32, now: Instant) {
544 let mut peers = self.pool.peers().write();
545 if let Some(p) = peers.iter_mut().find(|p| p.idx() == peer_idx) {
546 if p.is_local() {
547 return;
548 }
549 p.failure_detector_mut().record_heartbeat(now);
550 if p.failure_detector().phi(now) <= self.threshold && p.state() != PeerState::Normal {
551 let prev = p.state();
552 p.set_state(PeerState::Normal, now_secs_wall());
553 if let Some(m) = self.failure_metrics.as_ref() {
554 m.record_peer_state_transition(
555 p.idx(),
556 p.dc(),
557 p.rack(),
558 prev,
559 PeerState::Normal,
560 );
561 }
562 if let Some(ev) = self.events.as_ref() {
563 ev.publish(ClusterEvent::PeerUp {
564 peer_id: p.idx(),
565 dc: p.dc().to_string(),
566 ts: std::time::SystemTime::now(),
567 });
568 }
569 }
570 }
571 }
572
573 pub fn evaluate(&self, now: Instant) -> Vec<(u32, PeerState)> {
582 let mut peers = self.pool.peers().write();
583 let mut transitions = Vec::new();
584 for p in peers.iter_mut() {
585 if p.is_local() {
586 continue;
587 }
588 let phi = p.failure_detector().phi(now);
589 if let Some(m) = self.failure_metrics.as_ref() {
590 m.observe_phi(p.idx(), p.dc(), p.rack(), phi);
591 m.observe_threshold(p.idx(), p.dc(), p.rack(), self.threshold);
592 }
593 let target = if p.failure_detector().last_heartbeat().is_some() && phi <= self.threshold
594 {
595 PeerState::Normal
596 } else {
597 PeerState::Down
598 };
599 let prev = p.state();
600 if prev != target {
601 p.set_state(target, now_secs_wall());
602 transitions.push((p.idx(), target));
603 if let Some(m) = self.failure_metrics.as_ref() {
604 m.record_peer_state_transition_at(p.idx(), p.dc(), p.rack(), prev, target, now);
605 }
606 if let Some(ev) = self.events.as_ref() {
607 let ts = std::time::SystemTime::now();
608 match target {
609 PeerState::Normal => ev.publish(ClusterEvent::PeerUp {
610 peer_id: p.idx(),
611 dc: p.dc().to_string(),
612 ts,
613 }),
614 PeerState::Down => ev.publish(ClusterEvent::PeerDown {
615 peer_id: p.idx(),
616 dc: p.dc().to_string(),
617 phi,
618 ts,
619 }),
620 _ => {}
621 }
622 }
623 } else if let Some(m) = self.failure_metrics.as_ref() {
624 m.observe_peer_state(p.idx(), p.dc(), p.rack(), target);
625 }
626 }
627 transitions
628 }
629
630 pub fn mark_down_pname(&self, pname: &str) {
635 let mut peers = self.pool.peers().write();
636 for p in peers.iter_mut() {
637 if p.is_local() {
638 continue;
639 }
640 if p.endpoint().pname() == pname && p.state() != PeerState::Down {
641 let prev = p.state();
642 p.set_state(PeerState::Down, now_secs_wall());
643 if let Some(m) = self.failure_metrics.as_ref() {
644 m.record_peer_state_transition(
645 p.idx(),
646 p.dc(),
647 p.rack(),
648 prev,
649 PeerState::Down,
650 );
651 }
652 if let Some(ev) = self.events.as_ref() {
653 ev.publish(ClusterEvent::PeerDown {
654 peer_id: p.idx(),
655 dc: p.dc().to_string(),
656 phi: p.failure_detector().phi(Instant::now()),
657 ts: std::time::SystemTime::now(),
658 });
659 }
660 return;
661 }
662 }
663 }
664
665 pub fn reset_detector(&self, peer_idx: u32) {
669 let mut peers = self.pool.peers().write();
670 if let Some(p) = peers.iter_mut().find(|p| p.idx() == peer_idx) {
671 p.failure_detector_mut().reset();
672 }
673 }
674}
675
676fn now_secs_wall() -> u64 {
677 std::time::SystemTime::now()
678 .duration_since(std::time::UNIX_EPOCH)
679 .map_or(0, |d| d.as_secs())
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685
686 fn node(dc: &str, rack: &str, host: &str, tok: u32, ts: u64) -> GossipNode {
687 GossipNode {
688 dc: dc.into(),
689 rack: rack.into(),
690 host: host.into(),
691 port: 8101,
692 tokens: vec![DynToken::from_u32(tok)],
693 state: PeerState::Normal,
694 ts_secs: ts,
695 is_local: false,
696 }
697 }
698
699 #[test]
700 fn add_then_update_state() {
701 let mut s = GossipState::new();
702 assert_eq!(
703 s.add_or_update(node("d", "r", "h", 7, 1)),
704 GossipStep::Added
705 );
706 let mut n2 = node("d", "r", "h", 7, 2);
707 n2.state = PeerState::Down;
708 assert_eq!(s.add_or_update(n2), GossipStep::StateChanged);
709 }
710
711 #[test]
712 fn ip_replacement() {
713 let mut s = GossipState::new();
714 s.add_or_update(node("d", "r", "h1", 7, 1));
715 let n2 = node("d", "r", "h2", 7, 2);
716 assert_eq!(s.add_or_update(n2), GossipStep::Replaced);
717 }
718
719 #[test]
720 fn stale_update_ignored() {
721 let mut s = GossipState::new();
722 s.add_or_update(node("d", "r", "h", 7, 5));
723 let stale = node("d", "r", "h", 7, 1);
724 assert_eq!(s.add_or_update(stale), GossipStep::Unchanged);
725 }
726
727 #[test]
728 fn parse_one_seed() {
729 let r = parse_seed_node("10.0.0.1:8101:rA:dc1:1383429731").unwrap();
730 assert_eq!(r.host, "10.0.0.1");
731 assert_eq!(r.port, 8101);
732 assert_eq!(r.rack, "rA");
733 assert_eq!(r.dc, "dc1");
734 }
735
736 #[test]
737 fn parse_multi_token_seed() {
738 let r = parse_seed_node("h:1:r:d:1,2,3").unwrap();
739 assert_eq!(r.tokens.len(), 3);
740 }
741
742 #[test]
743 fn parse_blob_with_pipe() {
744 let v = parse_seed_blob("h1:1:r:d:1|h2:2:r:d:2").unwrap();
745 assert_eq!(v.len(), 2);
746 }
747
748 #[test]
749 fn parse_seed_rejects_short() {
750 assert!(parse_seed_node("h:1:r:d").is_err());
751 }
752
753 #[test]
754 fn failure_detector_ages_node_to_down() {
755 let mut s = GossipState::new();
756 s.add_or_update(node("d", "r", "h", 7, 0));
757 s.run_failure_detector(1000, 1000); assert_eq!(s.nodes().next().unwrap().state, PeerState::Down);
759 }
760
761 mod handler_helpers {
765 use std::sync::Arc;
766
767 use crate::cluster::peer::{Peer, PeerEndpoint};
768 use crate::cluster::pool::{PoolConfig, ServerPool};
769 use crate::hashkit::DynToken;
770
771 pub fn pool() -> Arc<ServerPool> {
772 let cfg = PoolConfig {
773 dc: "dc1".into(),
774 rack: "r1".into(),
775 enable_gossip: true,
776 ..PoolConfig::default()
777 };
778 let local = Peer::new(
779 0,
780 PeerEndpoint::tcp("127.0.0.1".into(), 8101),
781 "r1".into(),
782 "dc1".into(),
783 vec![DynToken::from_u32(0)],
784 true,
785 true,
786 false,
787 );
788 let remote = Peer::new(
789 1,
790 PeerEndpoint::tcp("127.0.0.1".into(), 8102),
791 "r1".into(),
792 "dc1".into(),
793 vec![DynToken::from_u32(2_147_483_648)],
794 false,
795 true,
796 false,
797 );
798 Arc::new(ServerPool::new(cfg, vec![local, remote]))
799 }
800 }
801
802 fn remote_state(pool: &super::ServerPool) -> PeerState {
803 pool.peers()
804 .read()
805 .iter()
806 .find(|p| !p.is_local())
807 .map_or(PeerState::Unknown, super::super::peer::Peer::state)
808 }
809
810 #[test]
811 fn handler_first_heartbeat_promotes_to_normal() {
812 let pool = handler_helpers::pool();
813 let handler = GossipHandler::new(pool.clone());
814 let t0 = std::time::Instant::now();
815 assert_eq!(remote_state(&pool), PeerState::Down);
816 handler.record_heartbeat_pname("127.0.0.1:8102", t0);
817 assert_eq!(remote_state(&pool), PeerState::Normal);
820 }
821
822 #[test]
823 fn handler_steady_heartbeats_keep_peer_normal() {
824 let pool = handler_helpers::pool();
827 let handler = GossipHandler::new(pool.clone());
828 let t0 = std::time::Instant::now();
829 for i in 0..100 {
830 let now = t0 + std::time::Duration::from_secs(i);
831 handler.record_heartbeat_pname("127.0.0.1:8102", now);
832 handler.evaluate(now);
833 }
834 let after_last =
835 t0 + std::time::Duration::from_secs(99) + std::time::Duration::from_millis(10);
836 let phi = pool
837 .peers()
838 .read()
839 .iter()
840 .find(|p| !p.is_local())
841 .map_or(0.0, |p| p.failure_detector().phi(after_last));
842 assert!(
843 phi < 1.0,
844 "phi should be < 1.0 right after a heartbeat, got {phi}"
845 );
846 assert_eq!(remote_state(&pool), PeerState::Normal);
847 }
848
849 #[test]
850 fn handler_silence_transitions_peer_to_down() {
851 let pool = handler_helpers::pool();
854 let handler = GossipHandler::new(pool.clone());
855 let t0 = std::time::Instant::now();
856 for i in 0..100 {
857 let now = t0 + std::time::Duration::from_secs(i);
858 handler.record_heartbeat_pname("127.0.0.1:8102", now);
859 }
860 let later = t0 + std::time::Duration::from_secs(159);
863 let transitions = handler.evaluate(later);
864 assert_eq!(transitions, vec![(1, PeerState::Down)]);
865 assert_eq!(remote_state(&pool), PeerState::Down);
866 }
867
868 #[test]
869 fn handler_evaluate_no_data_keeps_peer_down() {
870 let pool = handler_helpers::pool();
872 let handler = GossipHandler::new(pool.clone());
873 let t0 = std::time::Instant::now();
874 let transitions = handler.evaluate(t0);
875 assert!(transitions.is_empty());
876 assert_eq!(remote_state(&pool), PeerState::Down);
877 }
878
879 #[test]
880 fn handler_unknown_pname_is_silent() {
881 let pool = handler_helpers::pool();
882 let handler = GossipHandler::new(pool.clone());
883 let t0 = std::time::Instant::now();
884 handler.record_heartbeat_pname("10.0.0.99:9999", t0);
885 assert_eq!(remote_state(&pool), PeerState::Down);
886 }
887
888 #[test]
889 fn handler_mark_down_overrides_normal() {
890 let pool = handler_helpers::pool();
891 let handler = GossipHandler::new(pool.clone());
892 let t0 = std::time::Instant::now();
893 handler.record_heartbeat_pname("127.0.0.1:8102", t0);
894 assert_eq!(remote_state(&pool), PeerState::Normal);
895 handler.mark_down_pname("127.0.0.1:8102");
896 assert_eq!(remote_state(&pool), PeerState::Down);
897 }
898
899 #[test]
905 fn handler_evaluate_records_normal_to_down_transition() {
906 let pool = handler_helpers::pool();
907 let metrics = std::sync::Arc::new(crate::stats::FailureMetrics::new());
908 let handler = GossipHandler::new(pool.clone()).with_failure_metrics(metrics.clone());
909 let t0 = std::time::Instant::now();
910 for i in 0..100 {
912 let now = t0 + std::time::Duration::from_secs(i);
913 handler.record_heartbeat_pname("127.0.0.1:8102", now);
914 handler.evaluate(now);
915 }
916 let mid_snap = metrics.snapshot();
917 let normal_count = mid_snap
918 .peer_state_transitions
919 .iter()
920 .filter(|t| t.to == PeerState::Normal)
921 .map(|t| t.count)
922 .sum::<u64>();
923 assert_eq!(
926 normal_count, 1,
927 "got transitions: {:?}",
928 mid_snap.peer_state_transitions
929 );
930
931 let later = t0 + std::time::Duration::from_secs(159);
934 let transitions = handler.evaluate(later);
935 assert_eq!(transitions, vec![(1, PeerState::Down)]);
936
937 let snap = metrics.snapshot();
938 let down_entry = snap
939 .peer_state_transitions
940 .iter()
941 .find(|t| t.from == PeerState::Normal && t.to == PeerState::Down)
942 .expect("normal->down transition should be recorded");
943 assert_eq!(down_entry.count, 1);
944 assert_eq!(down_entry.peer_idx, 1);
945
946 let current = snap
949 .peer_state_current
950 .iter()
951 .find(|c| c.peer_idx == 1)
952 .expect("peer_state_current entry should be present");
953 assert_eq!(current.state, PeerState::Down);
954 assert_eq!(current.dc, "dc1");
955 assert_eq!(current.rack, "r1");
956
957 let phi_entry = snap
959 .peer_phi
960 .iter()
961 .find(|p| p.peer_idx == 1)
962 .expect("gossip_phi_score gauge should be populated");
963 assert!(
964 phi_entry.phi >= 0.0,
965 "phi should be non-negative; got {}",
966 phi_entry.phi
967 );
968 }
969
970 #[test]
980 fn handler_flap_increments_transitions_and_records_dwell() {
981 let pool = handler_helpers::pool();
982 let metrics = std::sync::Arc::new(crate::stats::FailureMetrics::new());
983 let handler = GossipHandler::new(pool.clone())
984 .with_failure_metrics(metrics.clone())
985 .with_threshold(8.0);
986 let t0 = std::time::Instant::now();
987 for i in 0..100 {
989 let now = t0 + std::time::Duration::from_secs(i);
990 handler.record_heartbeat_pname("127.0.0.1:8102", now);
991 handler.evaluate(now);
992 }
993 let down_at = t0 + std::time::Duration::from_secs(160);
996 let trans1 = handler.evaluate(down_at);
997 assert_eq!(trans1, vec![(1, PeerState::Down)]);
998 let up_at = down_at + std::time::Duration::from_secs(5);
1002 handler.record_heartbeat_pname("127.0.0.1:8102", up_at);
1003
1004 let snap = metrics.snapshot();
1005 let n_to_d = snap
1008 .peer_state_transitions
1009 .iter()
1010 .find(|t| t.from == PeerState::Normal && t.to == PeerState::Down)
1011 .map_or(0, |t| t.count);
1012 let d_to_n = snap
1013 .peer_state_transitions
1014 .iter()
1015 .find(|t| t.from == PeerState::Down && t.to == PeerState::Normal)
1016 .map_or(0, |t| t.count);
1017 assert_eq!(
1018 n_to_d, 1,
1019 "expected exactly one Normal->Down transition, got: {:?}",
1020 snap.peer_state_transitions
1021 );
1022 assert_eq!(
1023 d_to_n, 2,
1024 "expected exactly two Down->Normal transitions (initial promote + flap recover), got: {:?}",
1025 snap.peer_state_transitions
1026 );
1027
1028 let normal_dwell = snap
1031 .peer_state_dwell
1032 .iter()
1033 .find(|e| e.state == PeerState::Normal)
1034 .expect("Normal dwell row missing");
1035 let down_dwell = snap
1036 .peer_state_dwell
1037 .iter()
1038 .find(|e| e.state == PeerState::Down)
1039 .expect("Down dwell row missing");
1040 assert!(
1041 normal_dwell.count >= 1,
1042 "Normal dwell row had no observations"
1043 );
1044 assert!(down_dwell.count >= 1, "Down dwell row had no observations");
1045 assert_eq!(
1048 *normal_dwell.bucket_counts.last().unwrap(),
1049 normal_dwell.count
1050 );
1051 assert_eq!(*down_dwell.bucket_counts.last().unwrap(), down_dwell.count);
1052
1053 let thr_entry = snap
1056 .peer_threshold
1057 .iter()
1058 .find(|t| t.peer_idx == 1)
1059 .expect("gossip_phi_threshold_observed gauge should be populated");
1060 assert!(
1061 (thr_entry.threshold - 8.0).abs() < 1e-6,
1062 "threshold gauge should mirror handler config (got {})",
1063 thr_entry.threshold
1064 );
1065 }
1066}