Skip to content

Commit 330e49a

Browse files
committed
feat(cluster): add SWIM failure detection and membership protocol
Implement a SWIM (Scalable Weakly-consistent Infection-style Membership) failure detector for the cluster layer. The protocol provides probabilistic membership convergence with O(log n) dissemination, replacing the need for a central membership oracle. Includes: - Incarnation counter for detecting stale membership state after restarts - Member records and state machine (Alive/Suspect/Dead/Left) - MembershipList with merge semantics for gossip convergence - Wire message format and QUIC-framed codec for probe/ack/indirect-probe - Configuration surface (probe interval, suspicion timeout, fanout) - NodeId MessagePack derive to support membership wire encoding
1 parent d75d795 commit 330e49a

16 files changed

Lines changed: 1806 additions & 0 deletions

File tree

nodedb-cluster/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ pub mod rebalance_scheduler;
3131
pub mod routing;
3232
pub mod rpc_codec;
3333
pub mod shard_split;
34+
pub mod swim;
3435
pub mod topology;
3536
pub mod transport;
3637
pub mod vshard_handler;
@@ -77,3 +78,4 @@ pub use lifecycle::{
7778
pub use rdma_transport::{RdmaConfig, RdmaTransport};
7879
pub use rebalance_scheduler::{NodeMetrics, RebalanceScheduler, RebalanceTrigger, SchedulerConfig};
7980
pub use shard_split::{SplitPlan, SplitStrategy, plan_graph_split, plan_vector_split};
81+
pub use swim::{Incarnation, Member, MemberState, MembershipList, SwimConfig, SwimError};

nodedb-cluster/src/swim/config.rs

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
//! SWIM protocol configuration.
2+
//!
3+
//! Tunable parameters that govern failure-detection latency, bandwidth, and
4+
//! false-positive rate. Defaults follow the Lifeguard recommendations for
5+
//! a ≤ 256-node cluster and are safe for production without tuning.
6+
7+
use std::time::Duration;
8+
9+
use super::error::SwimError;
10+
use super::incarnation::Incarnation;
11+
12+
/// Configuration for the SWIM failure detector.
13+
///
14+
/// All fields are validated at construction time via [`SwimConfig::validate`];
15+
/// an invalid config is a programmer error and returns a typed
16+
/// [`SwimError::InvalidConfig`] rather than panicking.
17+
#[derive(Debug, Clone)]
18+
pub struct SwimConfig {
19+
/// Time between probe rounds (T' in the SWIM paper). One randomly-chosen
20+
/// alive peer is pinged per interval.
21+
pub probe_interval: Duration,
22+
23+
/// Round-trip deadline for a direct ping before falling back to k
24+
/// indirect pings. Must be strictly less than `probe_interval`.
25+
pub probe_timeout: Duration,
26+
27+
/// Number of indirect probe helpers (`k` in the paper).
28+
pub indirect_probes: u8,
29+
30+
/// Multiplier on `probe_interval` used to compute the suspicion timeout
31+
/// before a `Suspect` member is declared `Dead`. Lifeguard §3.1.
32+
pub suspicion_mult: u8,
33+
34+
/// Minimum value for the suspicion timeout; protects small clusters from
35+
/// sub-second suspicion windows. The effective timeout is
36+
/// `max(min_suspicion, suspicion_mult * log2(n) * probe_interval)`.
37+
pub min_suspicion: Duration,
38+
39+
/// Seed incarnation for a freshly-booted local node. Always `0` in
40+
/// production; exposed for deterministic unit tests.
41+
pub initial_incarnation: Incarnation,
42+
}
43+
44+
impl SwimConfig {
45+
/// Production defaults from Lifeguard, tuned for a ≤ 256-node cluster.
46+
pub fn production() -> Self {
47+
Self {
48+
probe_interval: Duration::from_millis(1000),
49+
probe_timeout: Duration::from_millis(500),
50+
indirect_probes: 3,
51+
suspicion_mult: 4,
52+
min_suspicion: Duration::from_secs(2),
53+
initial_incarnation: Incarnation::ZERO,
54+
}
55+
}
56+
57+
/// Validate the configuration. Returns `InvalidConfig` if any invariant
58+
/// fails. Callers should treat validation failure as a fatal startup
59+
/// error — SWIM cannot run with incoherent timing parameters.
60+
pub fn validate(&self) -> Result<(), SwimError> {
61+
if self.probe_interval.is_zero() {
62+
return Err(SwimError::InvalidConfig {
63+
field: "probe_interval",
64+
reason: "must be non-zero",
65+
});
66+
}
67+
if self.probe_timeout >= self.probe_interval {
68+
return Err(SwimError::InvalidConfig {
69+
field: "probe_timeout",
70+
reason: "must be strictly less than probe_interval",
71+
});
72+
}
73+
if self.indirect_probes == 0 {
74+
return Err(SwimError::InvalidConfig {
75+
field: "indirect_probes",
76+
reason: "must be at least 1",
77+
});
78+
}
79+
if self.suspicion_mult == 0 {
80+
return Err(SwimError::InvalidConfig {
81+
field: "suspicion_mult",
82+
reason: "must be at least 1",
83+
});
84+
}
85+
if self.min_suspicion.is_zero() {
86+
return Err(SwimError::InvalidConfig {
87+
field: "min_suspicion",
88+
reason: "must be non-zero",
89+
});
90+
}
91+
Ok(())
92+
}
93+
}
94+
95+
impl Default for SwimConfig {
96+
fn default() -> Self {
97+
Self::production()
98+
}
99+
}
100+
101+
#[cfg(test)]
102+
mod tests {
103+
use super::*;
104+
105+
#[test]
106+
fn production_defaults_are_valid() {
107+
SwimConfig::production().validate().expect("valid");
108+
}
109+
110+
#[test]
111+
fn zero_probe_interval_rejected() {
112+
let mut cfg = SwimConfig::production();
113+
cfg.probe_interval = Duration::ZERO;
114+
assert!(matches!(
115+
cfg.validate(),
116+
Err(SwimError::InvalidConfig {
117+
field: "probe_interval",
118+
..
119+
})
120+
));
121+
}
122+
123+
#[test]
124+
fn probe_timeout_must_be_less_than_interval() {
125+
let mut cfg = SwimConfig::production();
126+
cfg.probe_timeout = cfg.probe_interval;
127+
assert!(matches!(
128+
cfg.validate(),
129+
Err(SwimError::InvalidConfig {
130+
field: "probe_timeout",
131+
..
132+
})
133+
));
134+
}
135+
136+
#[test]
137+
fn zero_indirect_probes_rejected() {
138+
let mut cfg = SwimConfig::production();
139+
cfg.indirect_probes = 0;
140+
assert!(matches!(
141+
cfg.validate(),
142+
Err(SwimError::InvalidConfig {
143+
field: "indirect_probes",
144+
..
145+
})
146+
));
147+
}
148+
149+
#[test]
150+
fn zero_suspicion_mult_rejected() {
151+
let mut cfg = SwimConfig::production();
152+
cfg.suspicion_mult = 0;
153+
assert!(matches!(
154+
cfg.validate(),
155+
Err(SwimError::InvalidConfig {
156+
field: "suspicion_mult",
157+
..
158+
})
159+
));
160+
}
161+
162+
#[test]
163+
fn zero_min_suspicion_rejected() {
164+
let mut cfg = SwimConfig::production();
165+
cfg.min_suspicion = Duration::ZERO;
166+
assert!(matches!(
167+
cfg.validate(),
168+
Err(SwimError::InvalidConfig {
169+
field: "min_suspicion",
170+
..
171+
})
172+
));
173+
}
174+
}

nodedb-cluster/src/swim/error.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
//! Typed error variants for the SWIM subsystem.
2+
//!
3+
//! `SwimError` is the single error type returned by every public function
4+
//! in `nodedb_cluster::swim`. It is wired into the cluster-wide
5+
//! [`ClusterError`] enum via a `From` impl in `crate::error`, which in turn
6+
//! bridges to `nodedb_types::NodeDbError` at the public API boundary.
7+
8+
use thiserror::Error;
9+
10+
use nodedb_types::NodeId;
11+
12+
use super::incarnation::Incarnation;
13+
use super::member::MemberState;
14+
15+
/// Errors produced by the SWIM failure detector and membership layer.
16+
#[derive(Debug, Error)]
17+
pub enum SwimError {
18+
/// A message or update referenced a node id not present in the
19+
/// membership list. This is non-fatal — the detector will request a
20+
/// full sync from the sender.
21+
#[error("swim: unknown member {node_id}")]
22+
UnknownMember { node_id: NodeId },
23+
24+
/// Received update carries an incarnation strictly older than the
25+
/// locally recorded value, so the update is refuted.
26+
#[error("swim: stale incarnation for {node_id}: received {received:?} <= local {local:?}")]
27+
StaleIncarnation {
28+
node_id: NodeId,
29+
received: Incarnation,
30+
local: Incarnation,
31+
},
32+
33+
/// Received a `Suspect` update targeting the local node. The failure
34+
/// detector must bump its own incarnation and broadcast an `Alive`
35+
/// refutation. Callers treat this as a signal, not a fatal error.
36+
#[error("swim: local node suspected at incarnation {incarnation:?}")]
37+
SelfSuspected { incarnation: Incarnation },
38+
39+
/// A state transition violated the SWIM state machine (e.g. attempting
40+
/// to move a `Left` member back to `Alive`). Always a bug.
41+
#[error("swim: invalid state transition {from:?} -> {to:?}")]
42+
InvalidTransition { from: MemberState, to: MemberState },
43+
44+
/// Configuration validation failed. Returned by [`super::SwimConfig::validate`].
45+
#[error("swim: invalid config field {field}: {reason}")]
46+
InvalidConfig {
47+
field: &'static str,
48+
reason: &'static str,
49+
},
50+
51+
/// zerompk failed to serialize a `SwimMessage`. In practice this is
52+
/// infallible for the current message schema — the variant exists so
53+
/// future additions to the wire format cannot silently panic.
54+
#[error("swim: encode failure: {detail}")]
55+
Encode { detail: String },
56+
57+
/// zerompk failed to parse incoming bytes as a `SwimMessage`. Common
58+
/// causes: truncated datagram, version skew, random UDP noise.
59+
#[error("swim: decode failure: {detail}")]
60+
Decode { detail: String },
61+
}
62+
63+
impl From<SwimError> for crate::error::ClusterError {
64+
fn from(err: SwimError) -> Self {
65+
crate::error::ClusterError::Transport {
66+
detail: err.to_string(),
67+
}
68+
}
69+
}
70+
71+
#[cfg(test)]
72+
mod tests {
73+
use super::*;
74+
75+
#[test]
76+
fn display_contains_context() {
77+
let err = SwimError::StaleIncarnation {
78+
node_id: NodeId::new("n1"),
79+
received: Incarnation::new(3),
80+
local: Incarnation::new(5),
81+
};
82+
let msg = err.to_string();
83+
assert!(msg.contains("n1"));
84+
assert!(msg.contains('3'));
85+
assert!(msg.contains('5'));
86+
}
87+
88+
#[test]
89+
fn invalid_config_display() {
90+
let err = SwimError::InvalidConfig {
91+
field: "probe_timeout",
92+
reason: "must be strictly less than probe_interval",
93+
};
94+
assert!(err.to_string().contains("probe_timeout"));
95+
}
96+
97+
#[test]
98+
fn bridges_to_cluster_error() {
99+
let err: crate::error::ClusterError = SwimError::UnknownMember {
100+
node_id: NodeId::new("n42"),
101+
}
102+
.into();
103+
assert!(matches!(err, crate::error::ClusterError::Transport { .. }));
104+
}
105+
}

0 commit comments

Comments
 (0)