Skip to main content

oximedia_distributed/
cluster.rs

1//! Distributed cluster management.
2//!
3//! Provides types for managing a cluster of nodes in the distributed
4//! encoding system, including roles, health status, and topology.
5
6#![allow(dead_code)]
7
8/// Role of a node in the cluster.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum NodeRole {
11    /// The current cluster leader.
12    Leader,
13    /// A regular member that follows the leader.
14    Follower,
15    /// A node seeking election to become leader.
16    Candidate,
17    /// A read-only observer that does not participate in elections.
18    Observer,
19}
20
21impl NodeRole {
22    /// Returns true if this node can participate in voting.
23    #[must_use]
24    pub fn can_vote(&self) -> bool {
25        matches!(self, Self::Leader | Self::Follower | Self::Candidate)
26    }
27
28    /// Returns true if this node is the cluster leader.
29    #[must_use]
30    pub fn is_leader(&self) -> bool {
31        matches!(self, Self::Leader)
32    }
33
34    /// Returns a human-readable name for the role.
35    #[must_use]
36    pub fn name(&self) -> &str {
37        match self {
38            Self::Leader => "Leader",
39            Self::Follower => "Follower",
40            Self::Candidate => "Candidate",
41            Self::Observer => "Observer",
42        }
43    }
44}
45
46impl std::fmt::Display for NodeRole {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        write!(f, "{}", self.name())
49    }
50}
51
52/// Health status of a cluster node.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum NodeHealth {
55    /// Node is fully operational.
56    Healthy,
57    /// Node is operational but degraded (e.g. high load).
58    Degraded,
59    /// Node cannot be reached.
60    Unreachable,
61    /// Node is gracefully shutting down and not accepting new work.
62    Draining,
63}
64
65impl NodeHealth {
66    /// Returns true if the node is able to accept work.
67    #[must_use]
68    pub fn is_active(&self) -> bool {
69        matches!(self, Self::Healthy | Self::Degraded)
70    }
71
72    /// Returns a human-readable description of the health status.
73    #[must_use]
74    pub fn description(&self) -> &str {
75        match self {
76            Self::Healthy => "Healthy",
77            Self::Degraded => "Degraded",
78            Self::Unreachable => "Unreachable",
79            Self::Draining => "Draining",
80        }
81    }
82}
83
84impl std::fmt::Display for NodeHealth {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        write!(f, "{}", self.description())
87    }
88}
89
90/// A node in the distributed cluster.
91#[derive(Debug, Clone)]
92pub struct ClusterNode {
93    /// Unique node identifier.
94    pub node_id: String,
95    /// Network address of the node (e.g. "192.168.1.10:50051").
96    pub address: String,
97    /// Current role of the node.
98    pub role: NodeRole,
99    /// Current health status.
100    pub health: NodeHealth,
101    /// Unix epoch timestamp of the last received heartbeat.
102    pub last_heartbeat_epoch: u64,
103}
104
105impl ClusterNode {
106    /// Create a new cluster node.
107    #[must_use]
108    pub fn new(
109        node_id: impl Into<String>,
110        address: impl Into<String>,
111        role: NodeRole,
112        health: NodeHealth,
113        last_heartbeat_epoch: u64,
114    ) -> Self {
115        Self {
116            node_id: node_id.into(),
117            address: address.into(),
118            role,
119            health,
120            last_heartbeat_epoch,
121        }
122    }
123
124    /// Returns true if the node's last heartbeat is older than `timeout_secs` seconds.
125    #[must_use]
126    pub fn is_stale(&self, now_epoch: u64, timeout_secs: u64) -> bool {
127        now_epoch.saturating_sub(self.last_heartbeat_epoch) > timeout_secs
128    }
129
130    /// Returns true if this node can participate in voting.
131    #[must_use]
132    pub fn can_vote(&self) -> bool {
133        self.role.can_vote()
134    }
135
136    /// Returns true if this node is healthy and active.
137    #[must_use]
138    pub fn is_active(&self) -> bool {
139        self.health.is_active()
140    }
141}
142
143/// The full topology of the distributed cluster.
144#[derive(Debug, Default)]
145pub struct ClusterTopology {
146    /// All nodes known to be in the cluster.
147    pub nodes: Vec<ClusterNode>,
148}
149
150impl ClusterTopology {
151    /// Create a new empty cluster topology.
152    #[must_use]
153    pub fn new() -> Self {
154        Self { nodes: Vec::new() }
155    }
156
157    /// Add a node to the topology.
158    ///
159    /// If a node with the same `node_id` already exists it is replaced.
160    pub fn add_node(&mut self, node: ClusterNode) {
161        if let Some(existing) = self.nodes.iter_mut().find(|n| n.node_id == node.node_id) {
162            *existing = node;
163        } else {
164            self.nodes.push(node);
165        }
166    }
167
168    /// Find the current leader node, if any.
169    #[must_use]
170    pub fn find_leader(&self) -> Option<&ClusterNode> {
171        self.nodes.iter().find(|n| n.role.is_leader())
172    }
173
174    /// Returns all nodes that are currently healthy or degraded (active).
175    #[must_use]
176    pub fn healthy_nodes(&self) -> Vec<&ClusterNode> {
177        self.nodes.iter().filter(|n| n.health.is_active()).collect()
178    }
179
180    /// Returns the quorum size (majority of voting nodes).
181    ///
182    /// Quorum = ⌊N/2⌋ + 1 where N is the number of voting nodes.
183    #[must_use]
184    pub fn quorum_size(&self) -> usize {
185        let voters = self.nodes.iter().filter(|n| n.can_vote()).count();
186        voters / 2 + 1
187    }
188
189    /// Returns true if enough healthy voting nodes exist for a quorum.
190    #[must_use]
191    pub fn has_quorum(&self) -> bool {
192        let healthy_voters = self
193            .nodes
194            .iter()
195            .filter(|n| n.can_vote() && n.health.is_active())
196            .count();
197        healthy_voters >= self.quorum_size()
198    }
199
200    /// Returns the total number of nodes in the topology.
201    #[must_use]
202    pub fn node_count(&self) -> usize {
203        self.nodes.len()
204    }
205
206    /// Remove a node from the topology by ID.
207    ///
208    /// Returns true if a node was removed.
209    pub fn remove_node(&mut self, node_id: &str) -> bool {
210        let before = self.nodes.len();
211        self.nodes.retain(|n| n.node_id != node_id);
212        self.nodes.len() < before
213    }
214
215    /// Find a node by its ID.
216    #[must_use]
217    pub fn find_by_id(&self, node_id: &str) -> Option<&ClusterNode> {
218        self.nodes.iter().find(|n| n.node_id == node_id)
219    }
220
221    /// Returns all stale nodes whose last heartbeat exceeds `timeout_secs`.
222    #[must_use]
223    pub fn stale_nodes(&self, now_epoch: u64, timeout_secs: u64) -> Vec<&ClusterNode> {
224        self.nodes
225            .iter()
226            .filter(|n| n.is_stale(now_epoch, timeout_secs))
227            .collect()
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn make_node(id: &str, role: NodeRole, health: NodeHealth, ts: u64) -> ClusterNode {
236        ClusterNode::new(id, format!("10.0.0.1:{}", id), role, health, ts)
237    }
238
239    #[test]
240    fn test_node_role_can_vote() {
241        assert!(NodeRole::Leader.can_vote());
242        assert!(NodeRole::Follower.can_vote());
243        assert!(NodeRole::Candidate.can_vote());
244        assert!(!NodeRole::Observer.can_vote());
245    }
246
247    #[test]
248    fn test_node_role_is_leader() {
249        assert!(NodeRole::Leader.is_leader());
250        assert!(!NodeRole::Follower.is_leader());
251        assert!(!NodeRole::Candidate.is_leader());
252        assert!(!NodeRole::Observer.is_leader());
253    }
254
255    #[test]
256    fn test_node_role_display() {
257        assert_eq!(NodeRole::Leader.to_string(), "Leader");
258        assert_eq!(NodeRole::Follower.to_string(), "Follower");
259        assert_eq!(NodeRole::Observer.to_string(), "Observer");
260    }
261
262    #[test]
263    fn test_node_health_is_active() {
264        assert!(NodeHealth::Healthy.is_active());
265        assert!(NodeHealth::Degraded.is_active());
266        assert!(!NodeHealth::Unreachable.is_active());
267        assert!(!NodeHealth::Draining.is_active());
268    }
269
270    #[test]
271    fn test_node_health_display() {
272        assert_eq!(NodeHealth::Healthy.to_string(), "Healthy");
273        assert_eq!(NodeHealth::Unreachable.to_string(), "Unreachable");
274    }
275
276    #[test]
277    fn test_cluster_node_is_stale() {
278        let node = make_node("n1", NodeRole::Follower, NodeHealth::Healthy, 1000);
279        // timeout = 30 seconds
280        assert!(!node.is_stale(1020, 30)); // only 20s elapsed
281        assert!(node.is_stale(1031, 30)); // 31s elapsed
282    }
283
284    #[test]
285    fn test_cluster_topology_add_node() {
286        let mut topo = ClusterTopology::new();
287        topo.add_node(make_node("n1", NodeRole::Leader, NodeHealth::Healthy, 100));
288        topo.add_node(make_node(
289            "n2",
290            NodeRole::Follower,
291            NodeHealth::Healthy,
292            100,
293        ));
294        assert_eq!(topo.node_count(), 2);
295    }
296
297    #[test]
298    fn test_cluster_topology_add_node_replaces_existing() {
299        let mut topo = ClusterTopology::new();
300        topo.add_node(make_node(
301            "n1",
302            NodeRole::Follower,
303            NodeHealth::Healthy,
304            100,
305        ));
306        topo.add_node(make_node("n1", NodeRole::Leader, NodeHealth::Degraded, 200));
307        assert_eq!(topo.node_count(), 1);
308        assert_eq!(topo.nodes[0].role, NodeRole::Leader);
309    }
310
311    #[test]
312    fn test_cluster_topology_find_leader() {
313        let mut topo = ClusterTopology::new();
314        topo.add_node(make_node("n1", NodeRole::Leader, NodeHealth::Healthy, 100));
315        topo.add_node(make_node(
316            "n2",
317            NodeRole::Follower,
318            NodeHealth::Healthy,
319            100,
320        ));
321
322        let leader = topo.find_leader();
323        assert!(leader.is_some());
324        assert_eq!(leader.expect("leader should exist").node_id, "n1");
325    }
326
327    #[test]
328    fn test_cluster_topology_no_leader() {
329        let mut topo = ClusterTopology::new();
330        topo.add_node(make_node(
331            "n1",
332            NodeRole::Follower,
333            NodeHealth::Healthy,
334            100,
335        ));
336        assert!(topo.find_leader().is_none());
337    }
338
339    #[test]
340    fn test_cluster_topology_healthy_nodes() {
341        let mut topo = ClusterTopology::new();
342        topo.add_node(make_node("n1", NodeRole::Leader, NodeHealth::Healthy, 100));
343        topo.add_node(make_node(
344            "n2",
345            NodeRole::Follower,
346            NodeHealth::Degraded,
347            100,
348        ));
349        topo.add_node(make_node(
350            "n3",
351            NodeRole::Follower,
352            NodeHealth::Unreachable,
353            100,
354        ));
355
356        let healthy = topo.healthy_nodes();
357        assert_eq!(healthy.len(), 2);
358    }
359
360    #[test]
361    fn test_cluster_topology_quorum_size() {
362        let mut topo = ClusterTopology::new();
363        // 3 voters → quorum = 2
364        topo.add_node(make_node("n1", NodeRole::Leader, NodeHealth::Healthy, 100));
365        topo.add_node(make_node(
366            "n2",
367            NodeRole::Follower,
368            NodeHealth::Healthy,
369            100,
370        ));
371        topo.add_node(make_node(
372            "n3",
373            NodeRole::Follower,
374            NodeHealth::Healthy,
375            100,
376        ));
377        // 1 observer (non-voter)
378        topo.add_node(make_node(
379            "n4",
380            NodeRole::Observer,
381            NodeHealth::Healthy,
382            100,
383        ));
384
385        assert_eq!(topo.quorum_size(), 2);
386    }
387
388    #[test]
389    fn test_cluster_topology_has_quorum() {
390        let mut topo = ClusterTopology::new();
391        topo.add_node(make_node("n1", NodeRole::Leader, NodeHealth::Healthy, 100));
392        topo.add_node(make_node(
393            "n2",
394            NodeRole::Follower,
395            NodeHealth::Healthy,
396            100,
397        ));
398        topo.add_node(make_node(
399            "n3",
400            NodeRole::Follower,
401            NodeHealth::Unreachable,
402            100,
403        ));
404
405        // 3 voters, quorum = 2; only 2 healthy voters → has quorum
406        assert!(topo.has_quorum());
407    }
408
409    #[test]
410    fn test_cluster_topology_no_quorum() {
411        let mut topo = ClusterTopology::new();
412        topo.add_node(make_node(
413            "n1",
414            NodeRole::Leader,
415            NodeHealth::Unreachable,
416            100,
417        ));
418        topo.add_node(make_node(
419            "n2",
420            NodeRole::Follower,
421            NodeHealth::Unreachable,
422            100,
423        ));
424        topo.add_node(make_node(
425            "n3",
426            NodeRole::Follower,
427            NodeHealth::Healthy,
428            100,
429        ));
430
431        // 3 voters, quorum = 2; only 1 healthy → no quorum
432        assert!(!topo.has_quorum());
433    }
434
435    #[test]
436    fn test_cluster_topology_remove_node() {
437        let mut topo = ClusterTopology::new();
438        topo.add_node(make_node("n1", NodeRole::Leader, NodeHealth::Healthy, 100));
439        topo.add_node(make_node(
440            "n2",
441            NodeRole::Follower,
442            NodeHealth::Healthy,
443            100,
444        ));
445
446        assert!(topo.remove_node("n1"));
447        assert_eq!(topo.node_count(), 1);
448        assert!(!topo.remove_node("MISSING"));
449    }
450
451    #[test]
452    fn test_cluster_topology_stale_nodes() {
453        let mut topo = ClusterTopology::new();
454        topo.add_node(make_node("n1", NodeRole::Leader, NodeHealth::Healthy, 1000));
455        topo.add_node(make_node(
456            "n2",
457            NodeRole::Follower,
458            NodeHealth::Healthy,
459            900,
460        ));
461
462        // At time=1025 with 30s timeout: n2 (ts=900) is stale (125s elapsed), n1 (ts=1000, 25s) is not
463        let stale = topo.stale_nodes(1025, 30);
464        assert_eq!(stale.len(), 1);
465        assert_eq!(stale[0].node_id, "n2");
466    }
467}