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}