Skip to main content

dynomite/cluster/
swim.rs

1//! SWIM + Lifeguard membership and failure detection.
2//!
3//! This module is an opt-in alternative to the Dynamo-style gossip
4//! plus phi-accrual detector in [`crate::cluster::gossip`] and
5//! [`crate::cluster::failure_detector`]. It is selected by the
6//! pool's `membership: swim` directive; the default remains
7//! `gossip` so nothing changes for existing deployments. The
8//! phi-accrual path is left completely intact -- SWIM is additive.
9//!
10//! # Protocol
11//!
12//! SWIM (Das, Gupta, Motivala, DSN 2002) replaces all-to-all
13//! heartbeating with a randomised probe: every protocol period a
14//! node picks one random member and PINGs it. If no ack arrives
15//! within the round-trip window, the prober asks `k` other members
16//! to probe the target on its behalf (PING-REQ, "indirect probe").
17//! Only when both the direct and every indirect probe fail does the
18//! prober mark the target *suspect*. A suspect that is not refuted
19//! within a suspicion timeout is *confirmed* dead. Membership
20//! updates ride along ("piggyback") on the ordinary ping/ack
21//! traffic, so a change spreads infection-style in O(log N) periods
22//! with O(1) load per node per period.
23//!
24//! Incarnation numbers make suspicions refutable: every node owns a
25//! monotonically increasing incarnation for itself. When a node
26//! learns it is suspected, it re-broadcasts an *alive* update at a
27//! strictly higher incarnation, which overrides the suspicion on
28//! every other node. A dead node cannot refute, so a genuine death
29//! is confirmed; a merely-slow node that is still running does
30//! refute, so a transient slowdown does not produce a permanent
31//! false "down".
32//!
33//! # Lifeguard
34//!
35//! Lifeguard (Dadgar, Phillips, Currey, HashiCorp 2018) addresses
36//! the case where the *observer* is unhealthy rather than the
37//! target. Three mechanisms, all implemented here:
38//!
39//! * **Self-awareness (nack score, NS).** A node tracks how many of
40//!   its own recent probes failed to draw a response *and* were
41//!   later contradicted (the target turned out to be alive via an
42//!   indirect probe or a refutation). A high NS means "I am probably
43//!   the slow one." NS dilates the local probe interval and the
44//!   suspicion timeout, so a slow observer waits longer before
45//!   convicting anyone.
46//! * **Dogpile / buddy-system suspicion timeout.** The suspicion
47//!   timeout shrinks as independent suspect confirmations pile up:
48//!   the more nodes that independently suspect the same target, the
49//!   shorter the wait before confirming. One lone suspicion waits
50//!   the full (NS-dilated) timeout; a target suspected by many
51//!   confirms quickly.
52//! * **Buddy-system nack.** An indirect prober that could reach the
53//!   target but got no ack (a "nack") is evidence the *original*
54//!   prober is the problem, not the target; that feeds the nack
55//!   score above.
56//!
57//! # Two-layer design (DST discipline)
58//!
59//! The protocol logic lives entirely in [`SwimState`], a pure,
60//! synchronous, deterministic state machine. It takes explicit
61//! inputs (a probe outcome, an incoming membership update, a clock
62//! tick) and returns explicit outputs (the peer-state transitions to
63//! apply). It performs no I/O and holds no clock -- the caller
64//! supplies a logical time. This is exactly the shape the
65//! `model-tests` DST checker drives, and it is why the accuracy and
66//! completeness invariants can be model-checked deterministically.
67//!
68//! The I/O shell -- the tokio task that actually opens sockets,
69//! sends pings, waits for acks with a timeout, and calls into the
70//! state machine -- is [`SwimHandler`]. It mirrors
71//! [`crate::cluster::gossip::GossipHandler::evaluate`] by producing
72//! the same `Vec<(u32, PeerState)>` transition list the rest of the
73//! engine already consumes, so dispatch and hinted handoff are
74//! unchanged.
75//!
76//! # Examples
77//!
78//! ```
79//! use dynomite::cluster::swim::{SwimConfig, SwimState, ProbeResult, Status};
80//! use dynomite::cluster::peer::PeerState;
81//!
82//! // Three members: 0 (self), 1, 2. Node 2 is genuinely dead.
83//! let mut s = SwimState::new(0, 3, SwimConfig::default());
84//! // Period 1: probe node 2, no direct ack and no indirect ack.
85//! s.on_probe(1, 2, ProbeResult::Failed);
86//! // A suspicion immediately removes the peer from routing (maps to
87//! // Down) but is not yet a confirmed death.
88//! assert_eq!(s.member_state(2), PeerState::Down);
89//! assert!(matches!(s.status(2), Status::Suspect { .. }));
90//! // Let the suspicion timeout elapse with no refutation: the
91//! // member is confirmed dead. (It was already projected Down, so
92//! // no new PeerState transition is emitted.)
93//! s.tick(s.confirm_deadline(2).unwrap());
94//! assert_eq!(s.status(2), Status::Dead);
95//! ```
96
97use std::collections::BTreeMap;
98use std::sync::Arc;
99use std::time::{Duration, Instant};
100
101use parking_lot::Mutex;
102
103use crate::cluster::peer::PeerState;
104use crate::cluster::pool::ServerPool;
105
106/// Monotonic incarnation number for a member. A node bumps its own
107/// incarnation to refute a suspicion; the highest incarnation always
108/// wins a merge.
109pub type Incarnation = u64;
110
111/// Logical time, in protocol periods. The pure state machine never
112/// reads a wall clock; the caller (test harness or tokio shell)
113/// supplies a strictly non-decreasing tick count.
114pub type Tick = u64;
115
116/// The membership status a node holds for one member. This is the
117/// SWIM-internal status; it is projected onto the engine-wide
118/// [`PeerState`] by [`SwimState::member_state`].
119#[derive(Copy, Clone, Debug, Eq, PartialEq)]
120pub enum Status {
121    /// The member is believed reachable.
122    Alive,
123    /// The member missed a probe (direct and indirect) and is under
124    /// the suspicion timer. Carries the tick at which the suspicion
125    /// began and the count of independent suspectors seen so far
126    /// (the dogpile signal).
127    Suspect {
128        /// Tick at which the suspicion started.
129        since: Tick,
130        /// Number of independent suspectors observed (>= 1).
131        suspectors: u32,
132    },
133    /// The member is confirmed dead. Terminal unless the member
134    /// rejoins with a fresh incarnation.
135    Dead,
136}
137
138/// One member's record in the local view.
139#[derive(Copy, Clone, Debug, Eq, PartialEq)]
140pub struct Member {
141    /// Highest incarnation observed for this member.
142    pub incarnation: Incarnation,
143    /// Current believed status.
144    pub status: Status,
145}
146
147/// Outcome of one probe period against a target, as observed by the
148/// prober. This is the input the I/O shell feeds the state machine
149/// after a ping / ping-req round completes.
150#[derive(Copy, Clone, Debug, Eq, PartialEq)]
151pub enum ProbeResult {
152    /// The direct ping was acked (target is clearly alive).
153    Acked,
154    /// The direct ping timed out but an indirect (PING-REQ) probe
155    /// drew an ack: the target is alive; the prober's own path to
156    /// it is the problem. This is a Lifeguard "nack" against the
157    /// prober and raises its nack score.
158    IndirectAcked,
159    /// Neither the direct ping nor any indirect probe drew an ack.
160    Failed,
161}
162
163/// Tunables for the SWIM + Lifeguard state machine. The defaults
164/// mirror the Lifeguard paper's recommended shape (small `k`, a
165/// suspicion timeout that is a small multiple of the probe period,
166/// scaled logarithmically by cluster size).
167#[derive(Copy, Clone, Debug)]
168pub struct SwimConfig {
169    /// Number of indirect probers per period (`k`). Not consumed by
170    /// the pure state machine directly -- the I/O shell uses it to
171    /// pick fan-out -- but recorded here so the whole knob set lives
172    /// in one place.
173    pub indirect_probes: u32,
174    /// Base suspicion timeout, in protocol periods, before the
175    /// dogpile and nack-score adjustments. A lone suspicion on a
176    /// healthy observer waits this long before confirming.
177    pub suspicion_periods_base: Tick,
178    /// Multiplier applied per unit of nack score when dilating the
179    /// suspicion timeout: a slow observer (high NS) waits
180    /// `base * (1 + ns * dilation)` periods. Setting this to 0
181    /// disables Lifeguard timeout dilation (used by the negative
182    /// control in the DST model).
183    pub ns_dilation: Tick,
184    /// Maximum nack score. Caps how far a slow observer dilates.
185    pub ns_max: u32,
186    /// When false, a node cannot refute a suspicion by bumping its
187    /// incarnation. This is NOT a production knob -- it exists only
188    /// so the DST model's negative control can show that disabling
189    /// refutation makes a live-but-slow node get falsely and
190    /// permanently confirmed dead.
191    pub refutation_enabled: bool,
192}
193
194impl Default for SwimConfig {
195    fn default() -> Self {
196        Self {
197            indirect_probes: 3,
198            suspicion_periods_base: 4,
199            ns_dilation: 1,
200            ns_max: 8,
201            refutation_enabled: true,
202        }
203    }
204}
205
206/// A membership update as it rides on ping / ack traffic
207/// (infection-style dissemination). This is the unit the I/O shell
208/// piggybacks and the state machine merges.
209#[derive(Copy, Clone, Debug, Eq, PartialEq)]
210pub struct Update {
211    /// Which member the update is about.
212    pub member: usize,
213    /// The incarnation the sender holds for that member.
214    pub incarnation: Incarnation,
215    /// The status the sender believes.
216    pub status: Status,
217}
218
219/// The pure SWIM + Lifeguard state machine for one node.
220///
221/// Deterministic and I/O-free: every method takes a logical tick or
222/// an explicit event and returns the peer-state transitions to
223/// apply. See the module docs for the protocol and the two-layer
224/// rationale.
225#[derive(Clone, Debug)]
226pub struct SwimState {
227    /// This node's index into the member array.
228    me: usize,
229    /// This node's own incarnation (bumped to refute suspicions).
230    my_incarnation: Incarnation,
231    /// Per-member view, keyed by member index. `BTreeMap` keeps
232    /// iteration order deterministic for the model checker.
233    members: BTreeMap<usize, Member>,
234    /// Lifeguard nack score: how "slow" this observer believes it
235    /// is. Raised when its direct probe fails but an indirect one
236    /// succeeds; decayed on a clean direct ack.
237    nack_score: u32,
238    /// Tunables.
239    cfg: SwimConfig,
240}
241
242impl SwimState {
243    /// Build a state machine for node `me` in a group of `n`
244    /// members. Every other member starts [`Status::Alive`] at
245    /// incarnation 0 (the join-time optimistic assumption; a
246    /// genuinely dead member is discovered by the first failed
247    /// probe).
248    ///
249    /// # Panics
250    ///
251    /// Panics if `me >= n`; a node must be a member of its own group.
252    #[must_use]
253    pub fn new(me: usize, n: usize, cfg: SwimConfig) -> Self {
254        assert!(me < n, "self index {me} must be < group size {n}");
255        let mut members = BTreeMap::new();
256        for i in 0..n {
257            if i != me {
258                members.insert(
259                    i,
260                    Member {
261                        incarnation: 0,
262                        status: Status::Alive,
263                    },
264                );
265            }
266        }
267        Self {
268            me,
269            my_incarnation: 0,
270            members,
271            nack_score: 0,
272            cfg,
273        }
274    }
275
276    /// This node's own index.
277    #[must_use]
278    pub fn me(&self) -> usize {
279        self.me
280    }
281
282    /// This node's current incarnation.
283    #[must_use]
284    pub fn my_incarnation(&self) -> Incarnation {
285        self.my_incarnation
286    }
287
288    /// Current Lifeguard nack score (0 means "I believe I am
289    /// healthy").
290    #[must_use]
291    pub fn nack_score(&self) -> u32 {
292        self.nack_score
293    }
294
295    /// The internal SWIM status this node holds for `member`.
296    /// Returns [`Status::Alive`] for `me` (a node always considers
297    /// itself alive) and for any unknown index.
298    #[must_use]
299    pub fn status(&self, member: usize) -> Status {
300        if member == self.me {
301            return Status::Alive;
302        }
303        self.members
304            .get(&member)
305            .map_or(Status::Alive, |m| m.status)
306    }
307
308    /// The incarnation this node holds for `member`.
309    #[must_use]
310    pub fn incarnation(&self, member: usize) -> Incarnation {
311        if member == self.me {
312            return self.my_incarnation;
313        }
314        self.members.get(&member).map_or(0, |m| m.incarnation)
315    }
316
317    /// Project the internal SWIM status onto the engine-wide
318    /// [`PeerState`] the dispatcher consumes.
319    ///
320    /// * [`Status::Alive`] -> [`PeerState::Normal`]
321    /// * [`Status::Suspect`] -> [`PeerState::Down`] (a suspect is
322    ///   removed from routing immediately; refutation restores it,
323    ///   exactly as phi-accrual's transient down/up does)
324    /// * [`Status::Dead`] -> [`PeerState::Down`]
325    ///
326    /// Mapping suspect to `Down` is deliberate: routing to a
327    /// suspected-unreachable peer wastes a request, and a refutation
328    /// promotes it straight back to `Normal`. The DST accuracy
329    /// invariant is about the *confirmed-dead* status, not the
330    /// transient suspect window.
331    #[must_use]
332    pub fn member_state(&self, member: usize) -> PeerState {
333        match self.status(member) {
334            Status::Alive => PeerState::Normal,
335            Status::Suspect { .. } | Status::Dead => PeerState::Down,
336        }
337    }
338
339    /// Record the outcome of a probe period initiated by this node
340    /// against `target` at logical `tick`.
341    ///
342    /// * [`ProbeResult::Acked`] -> target confirmed alive; refresh
343    ///   its status and decay this node's nack score.
344    /// * [`ProbeResult::IndirectAcked`] -> target alive, but this
345    ///   node's direct path failed: raise the nack score (Lifeguard
346    ///   self-awareness) and keep the target alive.
347    /// * [`ProbeResult::Failed`] -> begin (or reinforce) suspicion
348    ///   of the target.
349    ///
350    /// Returns any peer-state transitions the caller should apply.
351    pub fn on_probe(
352        &mut self,
353        tick: Tick,
354        target: usize,
355        result: ProbeResult,
356    ) -> Vec<(u32, PeerState)> {
357        if target == self.me {
358            return Vec::new();
359        }
360        match result {
361            ProbeResult::Acked => {
362                self.nack_score = self.nack_score.saturating_sub(1);
363                self.mark_alive_local(tick, target)
364            }
365            ProbeResult::IndirectAcked => {
366                // The target is alive but our direct probe missed:
367                // we are the slow one. Raise nack score, keep target
368                // alive.
369                self.nack_score = (self.nack_score + 1).min(self.cfg.ns_max);
370                self.mark_alive_local(tick, target)
371            }
372            ProbeResult::Failed => self.begin_suspicion(tick, target),
373        }
374    }
375
376    /// Merge an incoming membership update that piggybacked on
377    /// ping / ack traffic. Returns any peer-state transitions.
378    ///
379    /// The merge rule is the SWIM precedence order:
380    ///
381    /// * A higher incarnation always wins.
382    /// * At equal incarnation, `Dead` beats `Suspect` beats `Alive`
383    ///   (a worse belief overrides a better one at the same
384    ///   incarnation, which is how a suspicion spreads).
385    /// * An update about *this* node that suspects or kills it is
386    ///   refuted: this node bumps its own incarnation above the
387    ///   update and the refutation (a fresh `Alive` at the higher
388    ///   incarnation) is what the caller disseminates. Refutation is
389    ///   skipped when [`SwimConfig::refutation_enabled`] is false
390    ///   (negative-control only).
391    pub fn on_update(&mut self, tick: Tick, update: Update) -> Vec<(u32, PeerState)> {
392        if update.member == self.me {
393            self.handle_update_about_self(update);
394            return Vec::new();
395        }
396        let entry = self.members.entry(update.member).or_insert(Member {
397            incarnation: 0,
398            status: Status::Alive,
399        });
400        let prev_state = status_to_peer_state(entry.status);
401        let take = match update.incarnation.cmp(&entry.incarnation) {
402            std::cmp::Ordering::Greater => true,
403            std::cmp::Ordering::Less => false,
404            std::cmp::Ordering::Equal => {
405                // Worse belief wins so suspicion spreads. A same-rank
406                // Suspect also "takes" so the dogpile suspector count
407                // can accumulate (merge_suspect sums the counts).
408                status_rank(update.status) > status_rank(entry.status)
409                    || matches!(
410                        (update.status, entry.status),
411                        (Status::Suspect { .. }, Status::Suspect { .. })
412                    )
413            }
414        };
415        if take {
416            // Preserve dogpile bookkeeping: if we and the sender both
417            // suspect the same target at the same incarnation, the
418            // suspector count grows, which shortens the confirm
419            // deadline.
420            let merged = merge_suspect(entry.status, update.status, tick);
421            entry.incarnation = update.incarnation;
422            entry.status = merged;
423        }
424        let new_state = status_to_peer_state(self.status(update.member));
425        transition(update.member, prev_state, new_state)
426    }
427
428    /// Advance logical time to `tick` and confirm any suspicions
429    /// whose (dogpile- and NS-adjusted) deadline has passed. Returns
430    /// the peer-state transitions produced (suspect -> dead is not a
431    /// [`PeerState`] change, since both map to `Down`; the list is
432    /// non-empty only when a member first crosses into `Down`).
433    pub fn tick(&mut self, tick: Tick) -> Vec<(u32, PeerState)> {
434        let mut transitions = Vec::new();
435        let members: Vec<usize> = self.members.keys().copied().collect();
436        for m in members {
437            let Status::Suspect { since, suspectors } = self.members[&m].status else {
438                continue;
439            };
440            if tick >= self.confirm_deadline_for(since, suspectors) {
441                let prev = status_to_peer_state(self.members[&m].status);
442                self.members.get_mut(&m).unwrap().status = Status::Dead;
443                let now = status_to_peer_state(Status::Dead);
444                transitions.extend(transition(m, prev, now));
445            }
446        }
447        transitions
448    }
449
450    /// The tick at which a suspicion of `member` will confirm dead,
451    /// given the current suspector count and this node's nack score.
452    /// Returns `None` if the member is not currently suspect.
453    #[must_use]
454    pub fn confirm_deadline(&self, member: usize) -> Option<Tick> {
455        match self.members.get(&member)?.status {
456            Status::Suspect { since, suspectors } => {
457                Some(self.confirm_deadline_for(since, suspectors))
458            }
459            _ => None,
460        }
461    }
462
463    /// Snapshot every member's projected [`PeerState`], for the I/O
464    /// shell to reconcile against the pool.
465    #[must_use]
466    pub fn snapshot(&self) -> Vec<(usize, PeerState)> {
467        self.members
468            .keys()
469            .map(|&m| (m, self.member_state(m)))
470            .collect()
471    }
472
473    // --- internals ---
474
475    /// Compute the confirm deadline from the suspicion start, the
476    /// dogpile suspector count, and this node's nack score.
477    ///
478    /// Base timeout is dilated by nack score (a slow observer waits
479    /// longer -- Lifeguard self-awareness) and shrunk by the number
480    /// of independent suspectors (dogpile: more suspectors -> faster
481    /// confirm). The deadline is never shorter than 1 period past
482    /// the suspicion start.
483    fn confirm_deadline_for(&self, since: Tick, suspectors: u32) -> Tick {
484        let dilated = self
485            .cfg
486            .suspicion_periods_base
487            .saturating_mul(1 + u64::from(self.nack_score) * self.cfg.ns_dilation);
488        // Dogpile: each extra independent suspector halves the
489        // remaining wait, floored at 1 period. `suspectors` is >= 1.
490        let shift = (suspectors.saturating_sub(1)).min(63);
491        let shrunk = (dilated >> shift).max(1);
492        since.saturating_add(shrunk)
493    }
494
495    /// Refresh a member to alive at the current known incarnation,
496    /// emitting a transition if it was previously non-routable.
497    fn mark_alive_local(&mut self, _tick: Tick, target: usize) -> Vec<(u32, PeerState)> {
498        let entry = self.members.entry(target).or_insert(Member {
499            incarnation: 0,
500            status: Status::Alive,
501        });
502        let prev = status_to_peer_state(entry.status);
503        // A local ack does not raise the incarnation (the target owns
504        // its incarnation); it only clears a local suspicion.
505        if !matches!(entry.status, Status::Dead) {
506            entry.status = Status::Alive;
507        }
508        let now = status_to_peer_state(self.status(target));
509        transition(target, prev, now)
510    }
511
512    /// Begin or reinforce suspicion of `target` after a fully failed
513    /// probe period.
514    fn begin_suspicion(&mut self, tick: Tick, target: usize) -> Vec<(u32, PeerState)> {
515        let entry = self.members.entry(target).or_insert(Member {
516            incarnation: 0,
517            status: Status::Alive,
518        });
519        let prev = status_to_peer_state(entry.status);
520        match entry.status {
521            Status::Alive => {
522                entry.status = Status::Suspect {
523                    since: tick,
524                    suspectors: 1,
525                };
526            }
527            Status::Suspect { since, suspectors } => {
528                entry.status = Status::Suspect {
529                    since,
530                    suspectors: suspectors.saturating_add(1),
531                };
532            }
533            Status::Dead => {}
534        }
535        let now = status_to_peer_state(self.status(target));
536        transition(target, prev, now)
537    }
538
539    /// Handle an update that names this node. If it suspects or
540    /// kills us and refutation is enabled, bump our incarnation
541    /// above the update so the fresh `Alive` overrides it everywhere.
542    fn handle_update_about_self(&mut self, update: Update) {
543        let suspects_us = matches!(update.status, Status::Suspect { .. } | Status::Dead);
544        if suspects_us && self.cfg.refutation_enabled && update.incarnation >= self.my_incarnation {
545            self.my_incarnation = update.incarnation + 1;
546        }
547    }
548}
549
550/// Rank for the equal-incarnation tie-break: worse beliefs win so
551/// suspicion spreads. Dead > Suspect > Alive.
552fn status_rank(s: Status) -> u8 {
553    match s {
554        Status::Alive => 0,
555        Status::Suspect { .. } => 1,
556        Status::Dead => 2,
557    }
558}
559
560/// Merge two statuses at the (already-decided winning) incarnation,
561/// combining dogpile suspector counts when both sides suspect.
562fn merge_suspect(existing: Status, incoming: Status, tick: Tick) -> Status {
563    match (existing, incoming) {
564        (
565            Status::Suspect {
566                since: s1,
567                suspectors: c1,
568            },
569            Status::Suspect { suspectors: c2, .. },
570        ) => Status::Suspect {
571            since: s1,
572            suspectors: c1.saturating_add(c2),
573        },
574        (_, Status::Suspect { suspectors, .. }) => Status::Suspect {
575            since: tick,
576            suspectors: suspectors.max(1),
577        },
578        (_, other) => other,
579    }
580}
581
582/// Project a raw [`Status`] to a [`PeerState`] without needing a
583/// `SwimState` (used inside the merge/transition helpers).
584fn status_to_peer_state(s: Status) -> PeerState {
585    match s {
586        Status::Alive => PeerState::Normal,
587        Status::Suspect { .. } | Status::Dead => PeerState::Down,
588    }
589}
590
591/// Emit a single transition when the projected state actually
592/// changed; empty otherwise. `member` is cast to the `u32` peer
593/// index the engine uses.
594#[allow(
595    clippy::cast_possible_truncation,
596    reason = "member count is bounded by the configured peer array, well under u32::MAX"
597)]
598fn transition(member: usize, prev: PeerState, now: PeerState) -> Vec<(u32, PeerState)> {
599    if prev == now {
600        Vec::new()
601    } else {
602        vec![(member as u32, now)]
603    }
604}
605
606/// The tokio-facing I/O shell around [`SwimState`].
607///
608/// It owns the pure state machine behind a `Mutex`, converts wall
609/// time into the logical protocol-period ticks the state machine
610/// speaks, and reconciles the machine's peer-state transitions onto
611/// the shared [`ServerPool`]. Its [`SwimHandler::evaluate`] returns
612/// the same `Vec<(u32, PeerState)>` shape as
613/// [`crate::cluster::gossip::GossipHandler::evaluate`], so the run
614/// loop and every downstream consumer (dispatch, hinted handoff) are
615/// identical regardless of which membership backend is selected.
616///
617/// The actual ping / ping-req socket traffic and the ack-timeout
618/// wait live in the run loop that calls [`SwimHandler::on_probe`] /
619/// [`SwimHandler::on_update`]; this shell deliberately holds no
620/// sockets so the protocol logic stays in the deterministically
621/// testable [`SwimState`].
622#[derive(Clone)]
623pub struct SwimHandler {
624    pool: Arc<ServerPool>,
625    state: Arc<Mutex<SwimState>>,
626    /// Wall-clock length of one protocol period. Wall time is
627    /// divided by this to derive the logical tick.
628    period: Duration,
629    /// Anchor for tick derivation (the handler's construction time).
630    epoch: Instant,
631}
632
633impl SwimHandler {
634    /// Build a handler for local node `me` over a `pool` of `n`
635    /// peers, using `cfg` tunables and a `period`-length protocol
636    /// interval.
637    #[must_use]
638    pub fn new(
639        pool: Arc<ServerPool>,
640        me: usize,
641        n: usize,
642        cfg: SwimConfig,
643        period: Duration,
644    ) -> Self {
645        Self {
646            pool,
647            state: Arc::new(Mutex::new(SwimState::new(me, n, cfg))),
648            period: if period.is_zero() {
649                Duration::from_millis(1)
650            } else {
651                period
652            },
653            epoch: Instant::now(),
654        }
655    }
656
657    /// Borrow the owning pool.
658    #[must_use]
659    pub fn pool(&self) -> &Arc<ServerPool> {
660        &self.pool
661    }
662
663    /// Convert a wall-clock instant into the logical protocol tick.
664    #[allow(
665        clippy::cast_possible_truncation,
666        reason = "period is >= 1ms so the tick count stays far below u64::MAX for any realistic uptime"
667    )]
668    fn tick_of(&self, now: Instant) -> Tick {
669        let elapsed = now.saturating_duration_since(self.epoch);
670        (elapsed.as_nanos() / self.period.as_nanos().max(1)) as Tick
671    }
672
673    /// Feed a completed probe outcome into the state machine and
674    /// reconcile the result onto the pool. Returns the applied
675    /// transitions.
676    pub fn on_probe(
677        &self,
678        now: Instant,
679        target: usize,
680        result: ProbeResult,
681    ) -> Vec<(u32, PeerState)> {
682        let tick = self.tick_of(now);
683        let t = self.state.lock().on_probe(tick, target, result);
684        self.apply(&t);
685        t
686    }
687
688    /// Merge a piggybacked membership update and reconcile.
689    pub fn on_update(&self, now: Instant, update: Update) -> Vec<(u32, PeerState)> {
690        let tick = self.tick_of(now);
691        let t = self.state.lock().on_update(tick, update);
692        self.apply(&t);
693        t
694    }
695
696    /// The periodic timer tick: advance logical time, confirm any
697    /// overdue suspicions, and reconcile. Mirrors
698    /// [`crate::cluster::gossip::GossipHandler::evaluate`].
699    pub fn evaluate(&self, now: Instant) -> Vec<(u32, PeerState)> {
700        let tick = self.tick_of(now);
701        let t = self.state.lock().tick(tick);
702        self.apply(&t);
703        t
704    }
705
706    /// Apply a batch of `(peer_idx, PeerState)` transitions onto the
707    /// pool's peer table, skipping the local node.
708    fn apply(&self, transitions: &[(u32, PeerState)]) {
709        if transitions.is_empty() {
710            return;
711        }
712        let mut peers = self.pool.peers().write();
713        for &(idx, state) in transitions {
714            if let Some(p) = peers.iter_mut().find(|p| p.idx() == idx && !p.is_local()) {
715                if p.state() != state {
716                    p.set_state(state, now_secs_wall());
717                }
718            }
719        }
720    }
721}
722
723/// Wall-clock epoch seconds, for stamping peer-state transitions.
724fn now_secs_wall() -> u64 {
725    std::time::SystemTime::now()
726        .duration_since(std::time::UNIX_EPOCH)
727        .map_or(0, |d| d.as_secs())
728}
729
730#[cfg(test)]
731mod tests {
732    use super::*;
733
734    #[test]
735    fn dead_node_is_confirmed_after_timeout() {
736        let mut s = SwimState::new(0, 3, SwimConfig::default());
737        // Probe node 2, fully failed -> suspect.
738        let t = s.on_probe(1, 2, ProbeResult::Failed);
739        assert_eq!(t, vec![(2, PeerState::Down)]);
740        assert!(matches!(s.status(2), Status::Suspect { .. }));
741        let deadline = s.confirm_deadline(2).unwrap();
742        // Before the deadline: still suspect, no new transition.
743        let none = s.tick(deadline - 1);
744        assert!(none.is_empty());
745        assert!(matches!(s.status(2), Status::Suspect { .. }));
746        // At the deadline: confirmed dead (already Down, so no
747        // PeerState change emitted).
748        s.tick(deadline);
749        assert_eq!(s.status(2), Status::Dead);
750    }
751
752    #[test]
753    fn refutation_clears_a_false_suspicion() {
754        // Node 0 suspects node 1; node 1 (alive) refutes with a
755        // higher incarnation. Node 0 must restore node 1 to alive.
756        let mut s0 = SwimState::new(0, 2, SwimConfig::default());
757        s0.on_probe(1, 1, ProbeResult::Failed);
758        assert_eq!(s0.member_state(1), PeerState::Down);
759
760        // Node 1 learns it is suspected and refutes.
761        let mut s1 = SwimState::new(1, 2, SwimConfig::default());
762        s1.on_update(
763            1,
764            Update {
765                member: 1,
766                incarnation: 0,
767                status: Status::Suspect {
768                    since: 1,
769                    suspectors: 1,
770                },
771            },
772        );
773        assert_eq!(s1.my_incarnation(), 1, "should bump to refute");
774
775        // The refutation reaches node 0.
776        let back = s0.on_update(
777            2,
778            Update {
779                member: 1,
780                incarnation: s1.my_incarnation(),
781                status: Status::Alive,
782            },
783        );
784        assert_eq!(back, vec![(1, PeerState::Normal)]);
785        assert_eq!(s0.member_state(1), PeerState::Normal);
786    }
787
788    #[test]
789    fn indirect_ack_raises_nack_score_and_keeps_target_alive() {
790        let mut s = SwimState::new(0, 3, SwimConfig::default());
791        s.on_probe(1, 1, ProbeResult::IndirectAcked);
792        assert_eq!(s.nack_score(), 1);
793        assert_eq!(s.member_state(1), PeerState::Normal);
794    }
795
796    #[test]
797    fn nack_score_dilates_suspicion_timeout() {
798        let cfg = SwimConfig::default();
799        let mut healthy = SwimState::new(0, 2, cfg);
800        let mut slow = SwimState::new(0, 2, cfg);
801        // Make `slow` believe it is slow.
802        for _ in 0..3 {
803            slow.on_probe(0, 1, ProbeResult::IndirectAcked);
804        }
805        assert!(slow.nack_score() > 0);
806        healthy.on_probe(1, 1, ProbeResult::Failed);
807        slow.on_probe(1, 1, ProbeResult::Failed);
808        let dh = healthy.confirm_deadline(1).unwrap();
809        let ds = slow.confirm_deadline(1).unwrap();
810        assert!(ds > dh, "slow observer must wait longer (ds={ds}, dh={dh})");
811    }
812
813    #[test]
814    fn dogpile_shortens_confirm_deadline() {
815        let cfg = SwimConfig::default();
816        let mut lone = SwimState::new(0, 3, cfg);
817        let mut piled = SwimState::new(0, 3, cfg);
818        lone.on_probe(1, 2, ProbeResult::Failed);
819        piled.on_probe(1, 2, ProbeResult::Failed);
820        // Piled: two more independent suspectors arrive via gossip.
821        for _ in 0..2 {
822            piled.on_update(
823                1,
824                Update {
825                    member: 2,
826                    incarnation: 0,
827                    status: Status::Suspect {
828                        since: 1,
829                        suspectors: 1,
830                    },
831                },
832            );
833        }
834        let dl = lone.confirm_deadline(2).unwrap();
835        let dp = piled.confirm_deadline(2).unwrap();
836        assert!(dp < dl, "dogpile must shorten confirm (dp={dp}, dl={dl})");
837    }
838
839    #[test]
840    fn higher_incarnation_wins_merge() {
841        let mut s = SwimState::new(0, 2, SwimConfig::default());
842        // Suspect node 1 at incarnation 0.
843        s.on_update(
844            1,
845            Update {
846                member: 1,
847                incarnation: 0,
848                status: Status::Suspect {
849                    since: 1,
850                    suspectors: 1,
851                },
852            },
853        );
854        assert_eq!(s.member_state(1), PeerState::Down);
855        // A higher-incarnation Alive wins.
856        s.on_update(
857            2,
858            Update {
859                member: 1,
860                incarnation: 1,
861                status: Status::Alive,
862            },
863        );
864        assert_eq!(s.member_state(1), PeerState::Normal);
865        // A stale lower-incarnation Suspect is ignored.
866        s.on_update(
867            3,
868            Update {
869                member: 1,
870                incarnation: 0,
871                status: Status::Dead,
872            },
873        );
874        assert_eq!(s.member_state(1), PeerState::Normal);
875    }
876
877    #[test]
878    fn refutation_disabled_lets_false_death_stick() {
879        let cfg = SwimConfig {
880            refutation_enabled: false,
881            ..SwimConfig::default()
882        };
883        let mut s1 = SwimState::new(1, 2, cfg);
884        // Node 1 is told it is suspected but cannot refute.
885        s1.on_update(
886            1,
887            Update {
888                member: 1,
889                incarnation: 0,
890                status: Status::Suspect {
891                    since: 1,
892                    suspectors: 1,
893                },
894            },
895        );
896        assert_eq!(s1.my_incarnation(), 0, "refutation disabled: no bump");
897    }
898
899    #[test]
900    fn self_is_always_alive() {
901        let s = SwimState::new(1, 3, SwimConfig::default());
902        assert_eq!(s.member_state(1), PeerState::Normal);
903        assert_eq!(s.status(1), Status::Alive);
904    }
905}