Skip to main content

dynomite/cluster/
gossip.rs

1//! Gossip state machine and seed-list bookkeeping.
2//!
3//! Gossip runs on a fixed interval: it queries the seeds provider,
4//! parses returned `host:port:rack:dc:tokens|...` blobs, and
5//! reconciles the resulting nodes against the per-DC / per-rack
6//! tables. Nodes are added when absent, replaced when their IP
7//! changes, and gossip-updated when only the timestamp / state
8//! moves. Once per round, it forwards either a `GOSSIP_SYN` (if
9//! joining) or the local state digest (if normal) to a randomly
10//! chosen peer.
11//!
12//! This module holds the data shape, the seed-list parser, and a
13//! deterministic state machine that the dispatcher / a tokio
14//! periodic task drives. The actual outbound dnode framing of
15//! `GOSSIP_SYN` lives in [`crate::proto::dnode`]; the cluster
16//! layer composes the two.
17//!
18//! # Examples
19//!
20//! ```
21//! use dynomite::cluster::gossip::{parse_seed_node, SeedRecord};
22//! let r = parse_seed_node("10.0.0.1:8101:rackA:dcX:1383429731").unwrap();
23//! assert_eq!(r.host, "10.0.0.1");
24//! assert_eq!(r.port, 8101);
25//! assert_eq!(r.dc, "dcX");
26//! assert_eq!(r.rack, "rackA");
27//! assert_eq!(r.tokens.len(), 1);
28//! ```
29
30use 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
40/// Default gossip period (ms) - mirrors `CONF_DEFAULT_GOS_INTERVAL`
41/// (1000 ms).
42pub const DEFAULT_GOSSIP_INTERVAL_MS: u64 = 1_000;
43
44/// Default seeds-check interval (`SEEDS_CHECK_INTERVAL`, 30s).
45pub const DEFAULT_SEEDS_CHECK_INTERVAL_MS: u64 = 30_000;
46
47/// Static configuration consumed by the gossip task.
48#[derive(Clone, Debug)]
49pub struct GossipConfig {
50    /// Whether gossip is enabled.
51    pub enabled: bool,
52    /// Gossip period.
53    pub interval: Duration,
54    /// Seeds-check period: the seeds provider is queried at most
55    /// once per this interval.
56    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/// Parsed view of one entry from a seeds-provider blob.
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct SeedRecord {
72    /// Hostname or IP.
73    pub host: String,
74    /// TCP port.
75    pub port: u16,
76    /// Rack name.
77    pub rack: String,
78    /// Datacenter name.
79    pub dc: String,
80    /// Token list.
81    pub tokens: Vec<DynToken>,
82}
83
84/// In-memory record of a node observed via gossip. Sits next to
85/// [`crate::cluster::peer::Peer`]; the gossip task keeps a
86/// dedicated table because the two records are kept separate
87/// (`gossip_node` vs `node`).
88#[derive(Clone, Debug)]
89pub struct GossipNode {
90    /// Datacenter.
91    pub dc: String,
92    /// Rack.
93    pub rack: String,
94    /// Hostname or IP.
95    pub host: String,
96    /// TCP port.
97    pub port: u16,
98    /// Token list.
99    pub tokens: Vec<DynToken>,
100    /// Lifecycle state.
101    pub state: PeerState,
102    /// Epoch-seconds timestamp of the last update.
103    pub ts_secs: u64,
104    /// True for the local node.
105    pub is_local: bool,
106}
107
108/// Live gossip state.
109///
110/// A simple `HashMap` keyed on `(dc, rack, primary token bytes)`
111/// provides the per-rack token-to-node lookup. A second map keyed
112/// on `(dc, rack, host)` provides
113/// the per-rack name lookup used to detect IP
114/// replacement.
115#[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    /// Empty state.
124    ///
125    /// # Examples
126    ///
127    /// ```
128    /// use dynomite::cluster::gossip::GossipState;
129    /// let s = GossipState::new();
130    /// assert_eq!(s.node_count(), 0);
131    /// ```
132    #[must_use]
133    pub fn new() -> Self {
134        Self::default()
135    }
136
137    /// Number of distinct gossip nodes tracked.
138    #[must_use]
139    pub fn node_count(&self) -> usize {
140        self.node_count
141    }
142
143    /// Step result of [`GossipState::add_or_update`].
144    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    /// Add or update a [`GossipNode`].
158    ///
159    /// The add-if-absent state machine:
160    ///
161    /// * brand-new (dc, rack, token) -> insert.
162    /// * known token but new host -> replace IP and re-index.
163    /// * known token + known host -> update timestamp / state if
164    ///   the supplied `ts_secs` is newer than the stored value.
165    ///
166    /// Returns the [`GossipStep`] that classifies the change for
167    /// the caller (handy in tests).
168    ///
169    /// # Examples
170    ///
171    /// ```
172    /// use dynomite::cluster::gossip::{GossipNode, GossipState, GossipStep};
173    /// use dynomite::cluster::peer::PeerState;
174    /// use dynomite::hashkit::DynToken;
175    /// let mut s = GossipState::new();
176    /// let n = GossipNode {
177    ///     dc: "d".into(), rack: "r".into(), host: "h".into(), port: 1,
178    ///     tokens: vec![DynToken::from_u32(7)], state: PeerState::Normal,
179    ///     ts_secs: 1, is_local: false,
180    /// };
181    /// assert_eq!(s.add_or_update(n.clone()), GossipStep::Added);
182    /// assert_eq!(s.add_or_update(n), GossipStep::Unchanged);
183    /// ```
184    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                // Replace IP.
201                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    /// Iterate over the live gossip nodes.
216    pub fn nodes(&self) -> impl Iterator<Item = &GossipNode> + '_ {
217        self.by_token.values()
218    }
219
220    /// Apply the failure detector to every non-local node.
221    ///
222    /// Mirrors `gossip_failure_detector`: a node whose
223    /// `now_secs - ts_secs` exceeds `(interval_ms / 1000) * 40`
224    /// is marked [`PeerState::Down`].
225    ///
226    /// # Examples
227    ///
228    /// ```
229    /// use dynomite::cluster::gossip::{GossipNode, GossipState};
230    /// use dynomite::cluster::peer::PeerState;
231    /// use dynomite::hashkit::DynToken;
232    /// let mut s = GossipState::new();
233    /// s.add_or_update(GossipNode {
234    ///     dc: "d".into(), rack: "r".into(), host: "h".into(), port: 1,
235    ///     tokens: vec![DynToken::from_u32(7)], state: PeerState::Normal,
236    ///     ts_secs: 0, is_local: false,
237    /// });
238    /// s.run_failure_detector(100, 1000);
239    /// assert_eq!(s.nodes().next().unwrap().state, PeerState::Down);
240    /// ```
241    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        // Mirror by_name.
252        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/// Outcome of [`GossipState::add_or_update`].
264#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
265pub enum GossipStep {
266    /// Node is brand new.
267    Added,
268    /// Same node, same host, newer state arrived.
269    StateChanged,
270    /// Same node, same host, only the timestamp moved forward.
271    TimestampUpdated,
272    /// Same token but different host: IP replacement.
273    Replaced,
274    /// Stale or duplicate ack.
275    Unchanged,
276}
277
278/// Parse one `host:port:rack:dc:tokens` seed string.
279///
280/// The token
281/// list may be a single big-int or a comma-separated list.
282///
283/// # Examples
284///
285/// ```
286/// use dynomite::cluster::gossip::parse_seed_node;
287/// assert!(parse_seed_node("h:1:r:d:1,2,3").is_ok());
288/// assert!(parse_seed_node("h:1:r:d").is_err());
289/// ```
290pub 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    // The seed string is split from the right, so
296    // tokens get the rightmost field. To preserve that with hosts
297    // that may contain colons (rare; typically IPv4), we instead
298    // rsplit:
299    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
337/// Parse a multi-entry seeds blob (entries separated by `|`).
338///
339/// # Examples
340///
341/// ```
342/// use dynomite::cluster::gossip::parse_seed_blob;
343/// let v = parse_seed_blob("h1:8101:r:d:1|h2:8101:r:d:2").unwrap();
344/// assert_eq!(v.len(), 2);
345/// ```
346pub 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/// Authoritative owner of [`PeerState`] transitions for the
358/// gossip plane.
359///
360/// The handler holds an `Arc<ServerPool>` and feeds the
361/// per-peer phi-accrual failure detectors as gossip frames
362/// arrive. A periodic tick re-evaluates phi for every non-local
363/// peer and toggles `PeerState` between `Normal` and `Down` based
364/// on the configured threshold:
365///
366/// * a peer is `Normal` once at least one heartbeat has been
367///   recorded AND `phi(now) <= threshold`,
368/// * a peer is `Down` when no heartbeat has ever been recorded
369///   OR `phi(now) > threshold`.
370///
371/// The handler is the single place that mutates `peer.state`
372/// once gossip is wired; the supervisor loop that owns the TCP
373/// link no longer publishes peer-state transitions of its own.
374///
375/// # Examples
376///
377/// ```
378/// use std::sync::Arc;
379/// use dynomite::cluster::gossip::GossipHandler;
380/// use dynomite::cluster::peer::{Peer, PeerEndpoint};
381/// use dynomite::cluster::pool::{PoolConfig, ServerPool};
382/// use dynomite::hashkit::DynToken;
383///
384/// let cfg = PoolConfig::default();
385/// let local = Peer::new(
386///     0, PeerEndpoint::tcp("h".into(), 1), "r".into(), "d".into(),
387///     vec![DynToken::from_u32(0)], true, true, false,
388/// );
389/// let pool = Arc::new(ServerPool::new(cfg, vec![local]));
390/// let handler = GossipHandler::new(pool);
391/// assert!((handler.threshold() - 8.0).abs() < f64::EPSILON);
392/// ```
393#[derive(Debug)]
394pub struct GossipHandler {
395    pool: Arc<ServerPool>,
396    threshold: f64,
397    interval: Duration,
398    /// Optional failure-cause metrics handle. When wired,
399    /// every peer-state transition observed by
400    /// [`Self::evaluate`] increments the matching
401    /// `peer_state_transitions_total` counter and updates the
402    /// `peer_state_current` and `gossip_phi_score` gauges.
403    failure_metrics: Option<Arc<crate::stats::FailureMetrics>>,
404    /// Optional structured-event publisher. When wired, every
405    /// peer-state transition observed by [`Self::evaluate`] or
406    /// [`Self::record_heartbeat_pname`] /
407    /// [`Self::record_heartbeat_idx`] surfaces a
408    /// [`ClusterEvent::PeerUp`] / [`ClusterEvent::PeerDown`]
409    /// payload on the manager's broadcast.
410    events: Option<Arc<EventManager>>,
411}
412
413impl GossipHandler {
414    /// Build a fresh handler over `pool` using the default
415    /// phi-accrual threshold ([`crate::cluster::failure_detector::DEFAULT_THRESHOLD`]).
416    #[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    /// Attach a [`crate::stats::FailureMetrics`] handle.
428    ///
429    /// When set, [`Self::evaluate`] emits a
430    /// `peer_state_transitions_total` counter tick and a
431    /// `peer_state_current` gauge update for every transition
432    /// it applies, plus a `gossip_phi_score` gauge update for
433    /// every non-local peer regardless of whether its state
434    /// changed. Default behaviour is unchanged when no metrics
435    /// handle is supplied.
436    #[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    /// Attach an [`EventManager`] handle.
443    ///
444    /// When set, every peer-state transition the handler
445    /// applies surfaces a [`ClusterEvent::PeerUp`] or
446    /// [`ClusterEvent::PeerDown`] payload on the manager's
447    /// broadcast. Default behaviour is unchanged when no event
448    /// manager is supplied.
449    #[must_use]
450    pub fn with_events(mut self, events: Arc<EventManager>) -> Self {
451        self.events = Some(events);
452        self
453    }
454
455    /// Borrow the installed event manager, if any.
456    #[must_use]
457    pub fn events(&self) -> Option<&Arc<EventManager>> {
458        self.events.as_ref()
459    }
460
461    /// Override the phi threshold (default 8.0).
462    #[must_use]
463    pub fn with_threshold(mut self, threshold: f64) -> Self {
464        self.threshold = threshold;
465        self
466    }
467
468    /// Override the gossip interval used by the periodic tick
469    /// when the handler is driven by the binary's run loop. The
470    /// in-process tests do not depend on this value.
471    #[must_use]
472    pub fn with_interval(mut self, interval: Duration) -> Self {
473        self.interval = interval;
474        self
475    }
476
477    /// Phi threshold the handler is configured with.
478    #[must_use]
479    pub fn threshold(&self) -> f64 {
480        self.threshold
481    }
482
483    /// Configured gossip interval.
484    #[must_use]
485    pub fn interval(&self) -> Duration {
486        self.interval
487    }
488
489    /// Borrow the underlying pool.
490    #[must_use]
491    pub fn pool(&self) -> &Arc<ServerPool> {
492        &self.pool
493    }
494
495    /// Record an inbound gossip heartbeat from the peer
496    /// identified by `pname` (a `host:port` string matching the
497    /// peer's [`crate::cluster::peer::PeerEndpoint::pname`]).
498    ///
499    /// Mutates the peer's failure detector and immediately
500    /// promotes the peer's state to [`PeerState::Normal`] when
501    /// `phi(now)` is below the threshold; this gives gossip a
502    /// snappy first-contact transition without waiting for the
503    /// next periodic tick.
504    ///
505    /// Unknown pnames are ignored.
506    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    /// Record an inbound gossip heartbeat against a known peer
541    /// index. Used by tests and by callers that already resolved
542    /// the originating peer.
543    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    /// Walk every non-local peer and reconcile its `PeerState`
574    /// with the failure detector's current view of `phi(now)`.
575    /// Returns the list of `(peer_idx, new_state)` transitions
576    /// the call applied (handy in tests).
577    ///
578    /// This is the failure-detector tick the binary runs on a
579    /// periodic timer. Calling it never panics and it never
580    /// blocks on I/O.
581    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    /// Mark the peer identified by `pname` as [`PeerState::Down`]
631    /// without consulting the failure detector. Used by the
632    /// gossip-shutdown path so the dispatcher can short-circuit
633    /// routing to a peer that announced its own departure.
634    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    /// Reset the per-peer failure detector. Used when a peer is
666    /// removed and re-added so historical jitter does not bias
667    /// the new suspicion value.
668    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); // delta = 40s, now > 40s
758        assert_eq!(s.nodes().next().unwrap().state, PeerState::Down);
759    }
760
761    /// Construction helper for the `GossipHandler` test suite.
762    /// The handler operates on a real `ServerPool`, so each test
763    /// builds a small two-peer pool (one local, one remote).
764    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        // After the first received heartbeat the remote peer is
818        // promoted out of the initial `Down` state.
819        assert_eq!(remote_state(&pool), PeerState::Normal);
820    }
821
822    #[test]
823    fn handler_steady_heartbeats_keep_peer_normal() {
824        // Drive 100 heartbeats at 1s intervals; phi must stay
825        // below 1.0 throughout and the peer must remain `Normal`.
826        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        // Stop heartbeats; advance the clock 60s; assert the
852        // periodic evaluation transitions the peer to `Down`.
853        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        // Advance 60 seconds past the last heartbeat with no new
861        // gossip; phi crosses the default threshold of 8.0.
862        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        // A peer we have never heard from stays `Down`.
871        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    /// `evaluate` toggles a peer Normal->Down once gossip
900    /// quiesces. The wired `FailureMetrics` accumulator must
901    /// see exactly one `(from=Normal, to=Down)` transition
902    /// counter tick and the matching `peer_state_current`
903    /// gauge entry.
904    #[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        // Drive 100 heartbeats so the peer is firmly `Normal`.
911        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        // There should be exactly one Down->Normal flip from
924        // the very first heartbeat.
925        assert_eq!(
926            normal_count, 1,
927            "got transitions: {:?}",
928            mid_snap.peer_state_transitions
929        );
930
931        // Now stop heartbeats and skip 60 seconds of wall
932        // time. evaluate should flip the peer to Down once.
933        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        // The current-state gauge follows the latest
947        // observation.
948        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        // Phi gauge must be populated for the remote peer.
958        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    /// Simulate a peer flap (Normal -> Down -> Normal) and
971    /// confirm:
972    ///
973    /// * the transitions counter records exactly one
974    ///   Normal->Down and one Down->Normal entry,
975    /// * the dwell histogram captures at least one observation
976    ///   for both the Normal and Down state buckets,
977    /// * the threshold gauge is populated alongside the phi
978    ///   score so the operator can read both side by side.
979    #[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        // Phase 1: 100 steady heartbeats establish Normal.
988        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        // Phase 2: stop heartbeats; the next evaluate flips to
994        // Down.
995        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        // Phase 3: heartbeats resume; the next inbound message
999        // promotes the peer back to Normal (the snappy-promote
1000        // path inside `record_heartbeat_pname`).
1001        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        // Exactly one Normal -> Down and one Down -> Normal
1006        // since the start of the flap.
1007        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        // Dwell histogram must hold at least one observation
1029        // in both Normal and Down state rows.
1030        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        // The +Inf bucket equals the per-state observation
1046        // count for both rows.
1047        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        // Threshold gauge must be populated for the remote
1054        // peer.
1055        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}