Skip to main content

atomr_cluster/
sbr_runtime.rs

1//! SBR runtime — wires a [`crate::DowningStrategy`] into a
2//! [`crate::MembershipState`] and emits the resulting downing
3//! actions.
4//!
5//! Runs on a tick with a stability deadline; if the partition has been
6//! observed for at least `stable_after`, it consults the configured
7//! strategy and returns the actions the leader should apply.
8
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11
12use crate::member::{Member, MemberStatus};
13use crate::membership::MembershipState;
14use crate::sbr::{DowningDecision, DowningStrategy, QuorumObserver};
15
16/// Action emitted by [`SbrRuntime::tick`].
17#[derive(Debug, Clone, PartialEq, Eq)]
18#[non_exhaustive]
19pub enum SbrAction {
20    /// No change; partition not yet stable, or no decision.
21    None,
22    /// Down each address (the unreachable side).
23    DownUnreachable(Vec<String>),
24    /// Down every member (catastrophic — the strategy chose `DownAll`).
25    DownAll(Vec<String>),
26    /// Down this node (we lost; voluntarily exit).
27    DownSelf,
28}
29
30/// Runtime that pairs a strategy with a stability deadline.
31pub struct SbrRuntime<S: DowningStrategy> {
32    strategy: S,
33    stable_after: Duration,
34    /// When did we first observe a non-empty unreachable set?
35    /// Reset to `None` when the unreachable set is empty.
36    unstable_since: Option<Instant>,
37    /// Optional quorum observer (FR-7b). Notified once on the loss
38    /// transition (action becomes `DownSelf`) and once on the
39    /// subsequent regain (partition heals after a prior loss).
40    observer: Option<Arc<dyn QuorumObserver>>,
41    /// `true` once we have fired `on_quorum_lost` and not yet fired the
42    /// matching `on_quorum_regained`. Drives edge-triggered semantics.
43    quorum_lost: bool,
44}
45
46impl<S: DowningStrategy> SbrRuntime<S> {
47    pub fn new(strategy: S, stable_after: Duration) -> Self {
48        Self { strategy, stable_after, unstable_since: None, observer: None, quorum_lost: false }
49    }
50
51    /// Attach a [`QuorumObserver`] (FR-7b). Builder-style; consumes and
52    /// returns `self`.
53    pub fn with_observer(mut self, observer: Arc<dyn QuorumObserver>) -> Self {
54        self.observer = Some(observer);
55        self
56    }
57
58    /// Fire `on_quorum_lost` exactly once per loss episode.
59    fn note_quorum_lost(&mut self) {
60        if !self.quorum_lost {
61            self.quorum_lost = true;
62            if let Some(o) = &self.observer {
63                o.on_quorum_lost();
64            }
65        }
66    }
67
68    /// Fire `on_quorum_regained` exactly once, and only if we had
69    /// previously lost quorum.
70    fn note_quorum_regained(&mut self) {
71        if self.quorum_lost {
72            self.quorum_lost = false;
73            if let Some(o) = &self.observer {
74                o.on_quorum_regained();
75            }
76        }
77    }
78
79    /// One scheduling tick. Returns the action the leader should
80    /// apply — typically nothing, sometimes a downing list.
81    pub fn tick(&mut self, state: &MembershipState, now: Instant) -> SbrAction {
82        // Partition the members by reachability.
83        let mut reachable: Vec<&Member> = Vec::new();
84        let mut unreachable: Vec<&Member> = Vec::new();
85        for m in &state.members {
86            if matches!(m.status, MemberStatus::Down | MemberStatus::Removed) {
87                continue;
88            }
89            if state.reachability.is_reachable(&m.address) {
90                reachable.push(m);
91            } else {
92                unreachable.push(m);
93            }
94        }
95
96        if unreachable.is_empty() {
97            // Healthy — reset the stability clock and, if we had
98            // previously declared quorum lost, announce the regain.
99            self.unstable_since = None;
100            self.note_quorum_regained();
101            return SbrAction::None;
102        }
103
104        // First observation of a partition.
105        let since = *self.unstable_since.get_or_insert(now);
106        if now.duration_since(since) < self.stable_after {
107            return SbrAction::None;
108        }
109
110        match self.strategy.decide(&reachable, &unreachable) {
111            DowningDecision::Stay => SbrAction::None,
112            DowningDecision::DownUnreachable => {
113                SbrAction::DownUnreachable(unreachable.iter().map(|m| m.address.to_string()).collect())
114            }
115            DowningDecision::DownAll => {
116                SbrAction::DownAll(state.members.iter().map(|m| m.address.to_string()).collect())
117            }
118            DowningDecision::DownSelf => {
119                // We are on the losing side — quorum is lost. Fire the
120                // observer once for this episode.
121                self.note_quorum_lost();
122                SbrAction::DownSelf
123            }
124        }
125    }
126
127    /// `true` once the partition has been observed for at least
128    /// `stable_after`. Useful for telemetry.
129    pub fn is_stable(&self, now: Instant) -> bool {
130        match self.unstable_since {
131            Some(t) => now.duration_since(t) >= self.stable_after,
132            None => true, // healthy → trivially stable
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::sbr::KeepMajorityStrategy;
141    use atomr_core::actor::Address;
142
143    fn member(addr: &str, status: MemberStatus) -> Member {
144        let mut m = Member::new(Address::local(addr), vec![]);
145        m.status = status;
146        m
147    }
148
149    #[test]
150    fn none_when_no_partition() {
151        let mut s = MembershipState::new();
152        s.add_or_update(member("a", MemberStatus::Up));
153        s.add_or_update(member("b", MemberStatus::Up));
154        let mut rt = SbrRuntime::new(KeepMajorityStrategy, Duration::from_millis(0));
155        let r = rt.tick(&s, Instant::now());
156        assert_eq!(r, SbrAction::None);
157        assert!(rt.is_stable(Instant::now()));
158    }
159
160    #[test]
161    fn waits_for_stability_window() {
162        let mut s = MembershipState::new();
163        s.add_or_update(member("a", MemberStatus::Up));
164        s.add_or_update(member("b", MemberStatus::Up));
165        s.add_or_update(member("c", MemberStatus::Up));
166        s.reachability.unreachable(Address::local("b"), Address::local("c"));
167        let mut rt = SbrRuntime::new(KeepMajorityStrategy, Duration::from_secs(60));
168        let now = Instant::now();
169        // First tick records the deadline; returns None.
170        assert_eq!(rt.tick(&s, now), SbrAction::None);
171        assert!(!rt.is_stable(now));
172    }
173
174    #[test]
175    fn fires_after_stability_window_with_majority() {
176        let mut s = MembershipState::new();
177        s.add_or_update(member("a", MemberStatus::Up));
178        s.add_or_update(member("b", MemberStatus::Up));
179        s.add_or_update(member("c", MemberStatus::Up));
180        // c is unreachable.
181        s.reachability.unreachable(Address::local("b"), Address::local("c"));
182        let mut rt = SbrRuntime::new(KeepMajorityStrategy, Duration::from_millis(0));
183        let r = rt.tick(&s, Instant::now());
184        match r {
185            SbrAction::DownUnreachable(addrs) => {
186                assert_eq!(addrs, vec!["akka://c".to_string()]);
187            }
188            other => panic!("expected DownUnreachable, got {other:?}"),
189        }
190    }
191
192    #[test]
193    fn quorum_observer_fires_on_loss_and_regain() {
194        use crate::sbr::{QuorumObserver, StaticQuorumStrategy};
195        use std::sync::atomic::{AtomicU32, Ordering};
196
197        struct Counter {
198            lost: AtomicU32,
199            regained: AtomicU32,
200        }
201        impl QuorumObserver for Counter {
202            fn on_quorum_lost(&self) {
203                self.lost.fetch_add(1, Ordering::SeqCst);
204            }
205            fn on_quorum_regained(&self) {
206                self.regained.fetch_add(1, Ordering::SeqCst);
207            }
208        }
209
210        let obs = Arc::new(Counter { lost: AtomicU32::new(0), regained: AtomicU32::new(0) });
211
212        let mut s = MembershipState::new();
213        s.add_or_update(member("a", MemberStatus::Up));
214        s.add_or_update(member("b", MemberStatus::Up));
215        s.add_or_update(member("c", MemberStatus::Up));
216        // a is the local survivor candidate but quorum_size=3 means a
217        // single-reachable partition loses → DownSelf.
218        s.reachability.unreachable(Address::local("a"), Address::local("b"));
219        s.reachability.unreachable(Address::local("a"), Address::local("c"));
220
221        // quorum_size 3, only `a` reachable → DownSelf → loss fires.
222        let mut rt = SbrRuntime::new(StaticQuorumStrategy { quorum_size: 3 }, Duration::from_millis(0))
223            .with_observer(obs.clone());
224        let now = Instant::now();
225        assert_eq!(rt.tick(&s, now), SbrAction::DownSelf);
226        assert_eq!(obs.lost.load(Ordering::SeqCst), 1);
227        // Idempotent within the same loss episode.
228        assert_eq!(rt.tick(&s, now), SbrAction::DownSelf);
229        assert_eq!(obs.lost.load(Ordering::SeqCst), 1);
230        assert_eq!(obs.regained.load(Ordering::SeqCst), 0);
231
232        // Heal the partition → regain fires once.
233        s.reachability.reachable(Address::local("a"), Address::local("b"));
234        s.reachability.reachable(Address::local("a"), Address::local("c"));
235        assert_eq!(rt.tick(&s, now + Duration::from_secs(1)), SbrAction::None);
236        assert_eq!(obs.regained.load(Ordering::SeqCst), 1);
237        // Idempotent — stays healthy.
238        assert_eq!(rt.tick(&s, now + Duration::from_secs(2)), SbrAction::None);
239        assert_eq!(obs.regained.load(Ordering::SeqCst), 1);
240    }
241
242    #[test]
243    fn resets_clock_when_partition_heals() {
244        let mut s = MembershipState::new();
245        s.add_or_update(member("a", MemberStatus::Up));
246        s.add_or_update(member("b", MemberStatus::Up));
247        s.reachability.unreachable(Address::local("a"), Address::local("b"));
248        let mut rt = SbrRuntime::new(KeepMajorityStrategy, Duration::from_secs(60));
249        let now = Instant::now();
250        let _ = rt.tick(&s, now);
251        // Heal partition.
252        s.reachability.reachable(Address::local("a"), Address::local("b"));
253        let r = rt.tick(&s, now + Duration::from_secs(1));
254        assert_eq!(r, SbrAction::None);
255        assert!(rt.is_stable(now + Duration::from_secs(1)));
256    }
257}