Skip to main content

oximedia_distributed/
fault_tolerance.rs

1//! Distributed fault tolerance.
2//!
3//! Provides circuit-breaker logic and per-node failure tracking.
4
5use std::collections::HashMap;
6
7/// Describes why a node failed.
8#[allow(dead_code)]
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum FailureType {
11    NodeCrash,
12    NetworkPartition,
13    SlowNode,
14    MemoryExhausted,
15    DiskFull,
16}
17
18/// A recorded failure event for a node.
19#[allow(dead_code)]
20#[derive(Debug, Clone)]
21pub struct NodeFailure {
22    pub node_id: u64,
23    pub failure_type: FailureType,
24    pub detected_at: u64,
25    pub recovered_at: Option<u64>,
26}
27
28/// State of a circuit breaker.
29#[allow(dead_code)]
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum CircuitState {
32    /// Normal operation; calls are allowed.
33    Closed,
34    /// Too many failures; calls are blocked.
35    Open,
36    /// After reset timeout; one probe call is allowed.
37    HalfOpen,
38}
39
40/// Per-node circuit breaker.
41#[allow(dead_code)]
42#[derive(Debug, Clone)]
43pub struct CircuitBreaker {
44    pub failure_count: u32,
45    pub threshold: u32,
46    pub state: CircuitState,
47    pub last_failure: u64,
48    pub reset_timeout_ms: u64,
49}
50
51impl CircuitBreaker {
52    /// Create a new circuit breaker in the `Closed` state.
53    #[must_use]
54    pub fn new(threshold: u32, reset_timeout_ms: u64) -> Self {
55        Self {
56            failure_count: 0,
57            threshold,
58            state: CircuitState::Closed,
59            last_failure: 0,
60            reset_timeout_ms,
61        }
62    }
63
64    /// Record a successful call; resets the breaker to `Closed`.
65    pub fn call_succeeded(&mut self, _now: u64) {
66        self.failure_count = 0;
67        self.state = CircuitState::Closed;
68    }
69
70    /// Record a failed call; may trip the breaker to `Open`.
71    pub fn call_failed(&mut self, now: u64) {
72        self.failure_count += 1;
73        self.last_failure = now;
74        if self.failure_count >= self.threshold {
75            self.state = CircuitState::Open;
76        }
77    }
78
79    /// Returns `true` if a call is permitted right now.
80    ///
81    /// - `Closed` -> always allowed.
82    /// - `Open`   -> allowed only after `reset_timeout_ms` has elapsed (and the
83    ///               state transitions to `HalfOpen`).
84    /// - `HalfOpen` -> allowed (one probe).
85    pub fn is_allowed(&mut self, now: u64) -> bool {
86        match self.state {
87            CircuitState::Closed => true,
88            CircuitState::HalfOpen => true,
89            CircuitState::Open => {
90                if now.saturating_sub(self.last_failure) >= self.reset_timeout_ms {
91                    self.state = CircuitState::HalfOpen;
92                    true
93                } else {
94                    false
95                }
96            }
97        }
98    }
99}
100
101/// Tracks failures across all nodes and manages their circuit breakers.
102#[allow(dead_code)]
103#[derive(Debug, Default)]
104pub struct FaultManager {
105    failures: Vec<NodeFailure>,
106    breakers: HashMap<u64, CircuitBreaker>,
107}
108
109impl FaultManager {
110    /// Create an empty fault manager.
111    #[must_use]
112    pub fn new() -> Self {
113        Self {
114            failures: Vec::new(),
115            breakers: HashMap::new(),
116        }
117    }
118
119    /// Report a new failure for a node.
120    pub fn report_failure(&mut self, node_id: u64, ftype: FailureType, now: u64) {
121        self.failures.push(NodeFailure {
122            node_id,
123            failure_type: ftype,
124            detected_at: now,
125            recovered_at: None,
126        });
127        // Trip the corresponding circuit breaker.
128        let breaker = self
129            .breakers
130            .entry(node_id)
131            .or_insert_with(|| CircuitBreaker::new(3, 5000));
132        breaker.call_failed(now);
133    }
134
135    /// Mark a node as recovered at `now`.
136    pub fn node_recovered(&mut self, node_id: u64, now: u64) {
137        for f in &mut self.failures {
138            if f.node_id == node_id && f.recovered_at.is_none() {
139                f.recovered_at = Some(now);
140            }
141        }
142        if let Some(b) = self.breakers.get_mut(&node_id) {
143            b.call_succeeded(now);
144        }
145    }
146
147    /// Return all failures that have not yet been recovered.
148    #[must_use]
149    pub fn active_failures(&self) -> Vec<&NodeFailure> {
150        self.failures
151            .iter()
152            .filter(|f| f.recovered_at.is_none())
153            .collect()
154    }
155
156    /// Returns `true` if the node's circuit breaker permits a call at `now`.
157    /// Nodes without a registered breaker are considered healthy.
158    pub fn is_node_healthy(&mut self, node_id: u64, now: u64) -> bool {
159        match self.breakers.get_mut(&node_id) {
160            Some(b) => b.is_allowed(now),
161            None => true,
162        }
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn test_circuit_breaker_initial_state() {
172        let cb = CircuitBreaker::new(3, 1000);
173        assert_eq!(cb.state, CircuitState::Closed);
174        assert_eq!(cb.failure_count, 0);
175    }
176
177    #[test]
178    fn test_circuit_breaker_trips_on_threshold() {
179        let mut cb = CircuitBreaker::new(3, 1000);
180        cb.call_failed(1);
181        cb.call_failed(2);
182        assert_eq!(cb.state, CircuitState::Closed);
183        cb.call_failed(3);
184        assert_eq!(cb.state, CircuitState::Open);
185    }
186
187    #[test]
188    fn test_circuit_breaker_blocked_when_open() {
189        let mut cb = CircuitBreaker::new(1, 5000);
190        cb.call_failed(100);
191        assert!(!cb.is_allowed(200)); // within timeout
192    }
193
194    #[test]
195    fn test_circuit_breaker_half_open_after_timeout() {
196        let mut cb = CircuitBreaker::new(1, 1000);
197        cb.call_failed(0);
198        assert!(cb.is_allowed(1001));
199        assert_eq!(cb.state, CircuitState::HalfOpen);
200    }
201
202    #[test]
203    fn test_circuit_breaker_resets_on_success() {
204        let mut cb = CircuitBreaker::new(1, 1000);
205        cb.call_failed(0);
206        cb.call_succeeded(2000);
207        assert_eq!(cb.state, CircuitState::Closed);
208        assert_eq!(cb.failure_count, 0);
209    }
210
211    #[test]
212    fn test_fault_manager_report_failure() {
213        let mut fm = FaultManager::new();
214        fm.report_failure(1, FailureType::NodeCrash, 100);
215        assert_eq!(fm.active_failures().len(), 1);
216    }
217
218    #[test]
219    fn test_fault_manager_node_recovered() {
220        let mut fm = FaultManager::new();
221        fm.report_failure(1, FailureType::NetworkPartition, 100);
222        fm.node_recovered(1, 200);
223        assert!(fm.active_failures().is_empty());
224    }
225
226    #[test]
227    fn test_fault_manager_active_failures_partial() {
228        let mut fm = FaultManager::new();
229        fm.report_failure(1, FailureType::SlowNode, 100);
230        fm.report_failure(2, FailureType::DiskFull, 110);
231        fm.node_recovered(1, 150);
232        let active = fm.active_failures();
233        assert_eq!(active.len(), 1);
234        assert_eq!(active[0].node_id, 2);
235    }
236
237    #[test]
238    fn test_fault_manager_is_node_healthy_unknown() {
239        let mut fm = FaultManager::new();
240        assert!(fm.is_node_healthy(99, 1000));
241    }
242
243    #[test]
244    fn test_fault_manager_is_node_unhealthy_after_failures() {
245        let mut fm = FaultManager::new();
246        // Default threshold is 3
247        fm.report_failure(5, FailureType::MemoryExhausted, 10);
248        fm.report_failure(5, FailureType::MemoryExhausted, 20);
249        fm.report_failure(5, FailureType::MemoryExhausted, 30);
250        assert!(!fm.is_node_healthy(5, 35));
251    }
252
253    #[test]
254    fn test_fault_manager_recovery_re_enables() {
255        let mut fm = FaultManager::new();
256        fm.report_failure(7, FailureType::NodeCrash, 10);
257        fm.report_failure(7, FailureType::NodeCrash, 20);
258        fm.report_failure(7, FailureType::NodeCrash, 30);
259        fm.node_recovered(7, 200);
260        assert!(fm.is_node_healthy(7, 200));
261    }
262
263    #[test]
264    fn test_all_failure_types() {
265        let mut fm = FaultManager::new();
266        let types = [
267            FailureType::NodeCrash,
268            FailureType::NetworkPartition,
269            FailureType::SlowNode,
270            FailureType::MemoryExhausted,
271            FailureType::DiskFull,
272        ];
273        for (i, ft) in types.into_iter().enumerate() {
274            fm.report_failure(i as u64, ft, i as u64 * 10);
275        }
276        assert_eq!(fm.active_failures().len(), 5);
277    }
278
279    #[test]
280    fn test_multiple_failures_same_node() {
281        let mut fm = FaultManager::new();
282        fm.report_failure(1, FailureType::SlowNode, 1);
283        fm.report_failure(1, FailureType::SlowNode, 2);
284        // Both recorded; both active
285        assert_eq!(fm.active_failures().len(), 2);
286    }
287}