Skip to main content

atomr_cluster/
sbr.rs

1//! Split-brain resolvers.
2//!
3//! Five strategies are implemented matching :
4//! * KeepMajority
5//! * StaticQuorum
6//! * KeepOldest
7//! * KeepReferee
8//! * LeaseMajority
9
10use crate::member::{Member, MemberStatus};
11
12/// What the resolver recommends the cluster do with the given side.
13#[derive(Debug, Clone, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum DowningDecision {
16    DownUnreachable,
17    DownAll,
18    DownSelf,
19    Stay,
20}
21
22pub trait DowningStrategy: Send + Sync {
23    fn decide(&self, reachable: &[&Member], unreachable: &[&Member]) -> DowningDecision;
24}
25
26/// KeepMajority: the side with strictly more up members survives.
27#[derive(Debug, Clone, Copy, Default)]
28pub struct KeepMajorityStrategy;
29
30impl DowningStrategy for KeepMajorityStrategy {
31    fn decide(&self, r: &[&Member], u: &[&Member]) -> DowningDecision {
32        let up = |ms: &[&Member]| ms.iter().filter(|m| m.status == MemberStatus::Up).count();
33        let rn = up(r);
34        let un = up(u);
35        if rn > un {
36            DowningDecision::DownUnreachable
37        } else if rn < un {
38            DowningDecision::DownSelf
39        } else {
40            DowningDecision::DownAll
41        }
42    }
43}
44
45/// StaticQuorum: requires at least `quorum_size` reachable members to survive.
46#[derive(Debug, Clone, Copy)]
47pub struct StaticQuorumStrategy {
48    pub quorum_size: usize,
49}
50
51impl DowningStrategy for StaticQuorumStrategy {
52    fn decide(&self, r: &[&Member], _: &[&Member]) -> DowningDecision {
53        if r.len() >= self.quorum_size {
54            DowningDecision::DownUnreachable
55        } else {
56            DowningDecision::DownSelf
57        }
58    }
59}
60
61/// KeepOldest: the side containing the oldest (lowest `up_number`) up member survives.
62#[derive(Debug, Clone, Copy, Default)]
63pub struct KeepOldestStrategy {
64    pub down_if_alone: bool,
65}
66
67impl DowningStrategy for KeepOldestStrategy {
68    fn decide(&self, r: &[&Member], u: &[&Member]) -> DowningDecision {
69        fn oldest<'a>(ms: &[&'a Member]) -> Option<&'a Member> {
70            ms.iter().min_by_key(|m| m.up_number).copied()
71        }
72        let rolds = oldest(r);
73        let uolds = oldest(u);
74        match (rolds, uolds) {
75            (Some(ro), Some(uo)) => {
76                if ro.up_number <= uo.up_number {
77                    if r.len() == 1 && self.down_if_alone {
78                        DowningDecision::DownAll
79                    } else {
80                        DowningDecision::DownUnreachable
81                    }
82                } else {
83                    DowningDecision::DownSelf
84                }
85            }
86            (Some(_), None) => DowningDecision::DownUnreachable,
87            (None, Some(_)) => DowningDecision::DownSelf,
88            (None, None) => DowningDecision::Stay,
89        }
90    }
91}
92
93/// KeepReferee: the side containing the designated `referee` member survives.
94#[derive(Debug, Clone)]
95pub struct KeepReferee {
96    pub referee: String,
97    pub down_all_if_less_than: usize,
98}
99
100impl DowningStrategy for KeepReferee {
101    fn decide(&self, r: &[&Member], _u: &[&Member]) -> DowningDecision {
102        let has_referee = r.iter().any(|m| m.address.to_string() == self.referee);
103        if !has_referee {
104            return DowningDecision::DownSelf;
105        }
106        if r.len() < self.down_all_if_less_than {
107            DowningDecision::DownAll
108        } else {
109            DowningDecision::DownUnreachable
110        }
111    }
112}
113
114/// DownAll: unconditionally downs every member on both sides of the
115/// partition. Used when the operator prefers cluster-wide restart over
116/// any chance of split-brain (matches "down-all-when-unstable" in
117/// related industry SBR catalogs).
118///
119/// Returns [`DowningDecision::DownAll`] whenever there is any
120/// unreachable member; [`DowningDecision::Stay`] when the partition is
121/// healthy. The reachable/unreachable inputs are inspected only to
122/// distinguish those two cases.
123#[derive(Debug, Clone, Copy, Default)]
124pub struct DownAllStrategy;
125
126impl DowningStrategy for DownAllStrategy {
127    fn decide(&self, _r: &[&Member], u: &[&Member]) -> DowningDecision {
128        if u.is_empty() {
129            DowningDecision::Stay
130        } else {
131            DowningDecision::DownAll
132        }
133    }
134}
135
136/// LeaseMajority: majority decision gated by an external lease. In-memory
137/// simulation of whether a lease was acquired.
138#[derive(Debug, Clone, Copy, Default)]
139pub struct LeaseMajorityStrategy {
140    pub lease_acquired: bool,
141}
142
143impl DowningStrategy for LeaseMajorityStrategy {
144    fn decide(&self, r: &[&Member], u: &[&Member]) -> DowningDecision {
145        let m = KeepMajorityStrategy.decide(r, u);
146        match m {
147            DowningDecision::DownAll if self.lease_acquired => DowningDecision::DownUnreachable,
148            other => other,
149        }
150    }
151}
152
153/// RoleWeightedQuorum: weights survival by *role importance* rather than
154/// raw node count.
155///
156/// Each role carries a weight. A member's weight is the **max** weight
157/// among its roles (0 if it holds no weighted role). The reachable side
158/// survives (`DownUnreachable`) iff the sum of its Up members' weights is
159/// at least `min_quorum_weight`; otherwise this node steps down
160/// (`DownSelf`).
161///
162/// This lets a numerically larger partition that happens to lack the
163/// heavyweight (core) roles be the side that is downed — see the
164/// `role_weighted_*` tests.
165#[derive(Debug, Clone, Default)]
166pub struct RoleWeightedQuorum {
167    pub weights: std::collections::HashMap<String, u32>,
168    pub min_quorum_weight: u32,
169}
170
171impl RoleWeightedQuorum {
172    pub fn new(weights: std::collections::HashMap<String, u32>, min_quorum_weight: u32) -> Self {
173        Self { weights, min_quorum_weight }
174    }
175
176    /// Weight of a single member = the max weight across its roles
177    /// (0 if none of its roles are weighted).
178    fn member_weight(&self, m: &Member) -> u32 {
179        m.roles.iter().filter_map(|r| self.weights.get(r).copied()).max().unwrap_or(0)
180    }
181}
182
183impl DowningStrategy for RoleWeightedQuorum {
184    fn decide(&self, r: &[&Member], _u: &[&Member]) -> DowningDecision {
185        let reachable_weight: u32 =
186            r.iter().filter(|m| m.status == MemberStatus::Up).map(|m| self.member_weight(m)).sum();
187        if reachable_weight >= self.min_quorum_weight {
188            DowningDecision::DownUnreachable
189        } else {
190            DowningDecision::DownSelf
191        }
192    }
193}
194
195/// Observer notified when the SBR runtime concludes this node has lost
196/// quorum (and later, when it is regained). Implementations must be
197/// cheap and non-blocking; the runtime fires these from `tick`.
198///
199/// `on_quorum_lost` fires exactly once on the loss transition;
200/// `on_quorum_regained` fires exactly once when the partition heals
201/// after a prior loss. See [`crate::SbrRuntime::with_observer`].
202pub trait QuorumObserver: Send + Sync {
203    fn on_quorum_lost(&self);
204    fn on_quorum_regained(&self);
205}
206
207/// Facade that holds any of the strategies behind a trait object.
208pub struct SplitBrainResolver {
209    pub strategy: Box<dyn DowningStrategy>,
210}
211
212impl SplitBrainResolver {
213    pub fn new(strategy: Box<dyn DowningStrategy>) -> Self {
214        Self { strategy }
215    }
216    pub fn decide(&self, r: &[&Member], u: &[&Member]) -> DowningDecision {
217        self.strategy.decide(r, u)
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use atomr_core::actor::Address;
225
226    fn up(n: i32) -> Member {
227        let mut m = Member::new(Address::local(format!("N{n}")), vec![]);
228        m.status = MemberStatus::Up;
229        m.up_number = n;
230        m
231    }
232
233    fn up_roles(n: i32, roles: &[&str]) -> Member {
234        let mut m =
235            Member::new(Address::local(format!("N{n}")), roles.iter().map(|s| s.to_string()).collect());
236        m.status = MemberStatus::Up;
237        m.up_number = n;
238        m
239    }
240
241    #[test]
242    fn keep_majority_prefers_larger_side() {
243        let r = [up(1), up(2), up(3)];
244        let u = [up(4)];
245        let r_ref: Vec<&Member> = r.iter().collect();
246        let u_ref: Vec<&Member> = u.iter().collect();
247        assert_eq!(KeepMajorityStrategy.decide(&r_ref, &u_ref), DowningDecision::DownUnreachable);
248    }
249
250    #[test]
251    fn static_quorum_enforces_size() {
252        let r = [up(1)];
253        let u = [up(2)];
254        let r_ref: Vec<&Member> = r.iter().collect();
255        let u_ref: Vec<&Member> = u.iter().collect();
256        assert_eq!(StaticQuorumStrategy { quorum_size: 2 }.decide(&r_ref, &u_ref), DowningDecision::DownSelf);
257    }
258
259    #[test]
260    fn keep_oldest_picks_lowest_up_number() {
261        let r = [up(1)];
262        let u = [up(2), up(3)];
263        let r_ref: Vec<&Member> = r.iter().collect();
264        let u_ref: Vec<&Member> = u.iter().collect();
265        assert_eq!(KeepOldestStrategy::default().decide(&r_ref, &u_ref), DowningDecision::DownUnreachable);
266    }
267
268    #[test]
269    fn down_all_strategy_downs_every_member_when_partitioned() {
270        let r = [up(1), up(2)];
271        let u = [up(3)];
272        let r_ref: Vec<&Member> = r.iter().collect();
273        let u_ref: Vec<&Member> = u.iter().collect();
274        assert_eq!(DownAllStrategy.decide(&r_ref, &u_ref), DowningDecision::DownAll);
275    }
276
277    #[test]
278    fn down_all_strategy_stays_when_no_unreachable() {
279        let r = [up(1), up(2), up(3)];
280        let u: [Member; 0] = [];
281        let r_ref: Vec<&Member> = r.iter().collect();
282        let u_ref: Vec<&Member> = u.iter().collect();
283        assert_eq!(DownAllStrategy.decide(&r_ref, &u_ref), DowningDecision::Stay);
284    }
285
286    #[test]
287    fn role_weighted_downs_larger_but_lightweight_side() {
288        // Reachable side: 3 plain workers (weight 0 each) → total 0.
289        // Unreachable side holds the single "core" node.
290        // Even though reachable has MORE nodes, it lacks core weight and
291        // must step down.
292        let mut weights = std::collections::HashMap::new();
293        weights.insert("core".to_string(), 10);
294        weights.insert("worker".to_string(), 1);
295        let q = RoleWeightedQuorum::new(weights, 10);
296
297        let r = [up_roles(1, &["worker"]), up_roles(2, &["worker"]), up_roles(3, &["worker"])];
298        let u = [up_roles(4, &["core"])];
299        let r_ref: Vec<&Member> = r.iter().collect();
300        let u_ref: Vec<&Member> = u.iter().collect();
301        assert_eq!(q.decide(&r_ref, &u_ref), DowningDecision::DownSelf);
302    }
303
304    #[test]
305    fn role_weighted_keeps_side_with_core_weight() {
306        let mut weights = std::collections::HashMap::new();
307        weights.insert("core".to_string(), 10);
308        weights.insert("worker".to_string(), 1);
309        let q = RoleWeightedQuorum::new(weights, 10);
310
311        // Reachable holds the core node → meets quorum weight → survives.
312        let r = [up_roles(1, &["core"]), up_roles(2, &["worker"])];
313        let u = [up_roles(3, &["worker"]), up_roles(4, &["worker"]), up_roles(5, &["worker"])];
314        let r_ref: Vec<&Member> = r.iter().collect();
315        let u_ref: Vec<&Member> = u.iter().collect();
316        assert_eq!(q.decide(&r_ref, &u_ref), DowningDecision::DownUnreachable);
317    }
318
319    #[test]
320    fn role_weighted_member_weight_is_max_of_roles() {
321        // A member with both roles counts the MAX (10), not the sum.
322        let mut weights = std::collections::HashMap::new();
323        weights.insert("core".to_string(), 10);
324        weights.insert("worker".to_string(), 1);
325        let q = RoleWeightedQuorum::new(weights, 10);
326        let m = up_roles(1, &["core", "worker"]);
327        assert_eq!(q.member_weight(&m), 10);
328        // Unmatched role → 0.
329        let plain = up_roles(2, &["other"]);
330        assert_eq!(q.member_weight(&plain), 0);
331    }
332
333    #[test]
334    fn down_all_strategy_downs_even_with_majority_reachable() {
335        // Unlike KeepMajority, DownAll doesn't care about side sizes —
336        // any unreachable member triggers a full down.
337        let r = [up(1), up(2), up(3), up(4)];
338        let u = [up(5)];
339        let r_ref: Vec<&Member> = r.iter().collect();
340        let u_ref: Vec<&Member> = u.iter().collect();
341        assert_eq!(DownAllStrategy.decide(&r_ref, &u_ref), DowningDecision::DownAll);
342    }
343}