Skip to main content

oximedia_distributed/
leader_election.rs

1#![allow(dead_code)]
2//! Leader election primitives for `OxiMedia` distributed cluster.
3//!
4//! Provides a simplified Bully/term-based leader election model without external
5//! dependencies: nodes nominate themselves, collect votes, and the node with the
6//! most votes in a term wins.
7
8use std::collections::HashMap;
9use std::time::{Duration, Instant};
10
11/// State of a node in the election process.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ElectionState {
14    /// No election is currently running on this node.
15    Idle,
16    /// This node has started or joined an election and awaits votes.
17    Candidate,
18    /// This node follows the elected leader.
19    Follower,
20    /// This node won the election and is the current leader.
21    Leader,
22}
23
24impl ElectionState {
25    /// Return `true` if the node is currently a candidate.
26    #[must_use]
27    pub fn is_candidate(self) -> bool {
28        self == ElectionState::Candidate
29    }
30
31    /// Return `true` if the node holds leadership.
32    #[must_use]
33    pub fn is_leader(self) -> bool {
34        self == ElectionState::Leader
35    }
36
37    /// Human-readable label.
38    #[must_use]
39    pub fn label(self) -> &'static str {
40        match self {
41            ElectionState::Idle => "idle",
42            ElectionState::Candidate => "candidate",
43            ElectionState::Follower => "follower",
44            ElectionState::Leader => "leader",
45        }
46    }
47}
48
49/// A vote cast by a single node in a given election term.
50#[derive(Debug, Clone)]
51pub struct NodeVote {
52    /// ID of the node casting the vote.
53    pub voter_id: String,
54    /// ID of the node being voted for.
55    pub candidate_id: String,
56    /// Election term this vote belongs to.
57    pub term: u64,
58    /// When the vote was cast.
59    pub cast_at: Instant,
60    /// Optional reason / justification for the vote.
61    pub reason: Option<String>,
62}
63
64impl NodeVote {
65    /// Create a new vote.
66    #[must_use]
67    pub fn new(voter_id: impl Into<String>, candidate_id: impl Into<String>, term: u64) -> Self {
68        Self {
69            voter_id: voter_id.into(),
70            candidate_id: candidate_id.into(),
71            term,
72            cast_at: Instant::now(),
73            reason: None,
74        }
75    }
76
77    /// Return `true` if the vote is for the given term and the voter and candidate
78    /// are distinct, non-empty nodes.
79    #[must_use]
80    pub fn is_valid(&self, expected_term: u64) -> bool {
81        self.term == expected_term
82            && !self.voter_id.is_empty()
83            && !self.candidate_id.is_empty()
84            && self.voter_id != self.candidate_id
85    }
86
87    /// Age of the vote in milliseconds.
88    #[must_use]
89    pub fn age_ms(&self, now: Instant) -> u64 {
90        now.saturating_duration_since(self.cast_at).as_millis() as u64
91    }
92}
93
94/// Manager for a single-node's view of the cluster election.
95#[derive(Debug)]
96pub struct ElectionManager {
97    /// This node's identifier.
98    pub node_id: String,
99    /// Current election term.
100    pub term: u64,
101    /// State of this node in the current term.
102    pub state: ElectionState,
103    /// Votes received in the current term, keyed by `voter_id`.
104    votes: HashMap<String, NodeVote>,
105    /// Total cluster size (used to determine quorum).
106    cluster_size: usize,
107    /// When the current election was started.
108    election_started_at: Option<Instant>,
109    /// Timeout after which the election is considered failed.
110    election_timeout: Duration,
111}
112
113impl ElectionManager {
114    /// Create a new election manager for `node_id` in a cluster of `cluster_size` nodes.
115    #[must_use]
116    pub fn new(
117        node_id: impl Into<String>,
118        cluster_size: usize,
119        election_timeout: Duration,
120    ) -> Self {
121        Self {
122            node_id: node_id.into(),
123            term: 0,
124            state: ElectionState::Idle,
125            votes: HashMap::new(),
126            cluster_size,
127            election_started_at: None,
128            election_timeout,
129        }
130    }
131
132    /// Advance to the next term and transition this node to `Candidate`.
133    pub fn start_election(&mut self) {
134        self.term += 1;
135        self.state = ElectionState::Candidate;
136        self.votes.clear();
137        self.election_started_at = Some(Instant::now());
138    }
139
140    /// Record a vote for the current term.
141    ///
142    /// Returns `true` if the vote was accepted (valid for current term and not a
143    /// duplicate from the same voter).
144    pub fn record_vote(&mut self, vote: NodeVote) -> bool {
145        if !vote.is_valid(self.term) {
146            return false;
147        }
148        // Idempotent: ignore duplicate votes from same voter.
149        if self.votes.contains_key(&vote.voter_id) {
150            return false;
151        }
152        self.votes.insert(vote.voter_id.clone(), vote);
153        self.update_state();
154        true
155    }
156
157    /// Return the candidate with the most votes, or `None` if no votes yet.
158    #[must_use]
159    pub fn winner(&self) -> Option<&str> {
160        let mut tally: HashMap<&str, usize> = HashMap::new();
161        for vote in self.votes.values() {
162            *tally.entry(vote.candidate_id.as_str()).or_insert(0) += 1;
163        }
164        tally
165            .into_iter()
166            .max_by_key(|(_, count)| *count)
167            .map(|(id, _)| id)
168    }
169
170    /// Quorum required to win: majority of the cluster.
171    #[must_use]
172    pub fn quorum(&self) -> usize {
173        self.cluster_size / 2 + 1
174    }
175
176    /// Number of votes received in the current term.
177    #[must_use]
178    pub fn vote_count(&self) -> usize {
179        self.votes.len()
180    }
181
182    /// Return `true` if the election has timed out.
183    #[must_use]
184    pub fn is_timed_out(&self, now: Instant) -> bool {
185        match self.election_started_at {
186            None => false,
187            Some(started) => now.saturating_duration_since(started) >= self.election_timeout,
188        }
189    }
190
191    /// Transition this node to follower of `leader_id`, resetting election state.
192    pub fn become_follower(&mut self) {
193        self.state = ElectionState::Follower;
194        self.votes.clear();
195        self.election_started_at = None;
196    }
197
198    /// Force-set this node as leader (called after winning election externally).
199    pub fn become_leader(&mut self) {
200        self.state = ElectionState::Leader;
201    }
202
203    fn update_state(&mut self) {
204        // Check if this node reached quorum.
205        let my_id = self.node_id.clone();
206        let my_votes = self
207            .votes
208            .values()
209            .filter(|v| v.candidate_id == my_id)
210            .count();
211        if my_votes >= self.quorum() {
212            self.state = ElectionState::Leader;
213        }
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use std::time::Duration;
221
222    fn make_manager(node_id: &str, cluster_size: usize) -> ElectionManager {
223        ElectionManager::new(node_id, cluster_size, Duration::from_secs(5))
224    }
225
226    fn cast_vote(manager: &mut ElectionManager, voter: &str, candidate: &str) -> bool {
227        let vote = NodeVote::new(voter, candidate, manager.term);
228        manager.record_vote(vote)
229    }
230
231    #[test]
232    fn test_election_state_labels() {
233        assert_eq!(ElectionState::Idle.label(), "idle");
234        assert_eq!(ElectionState::Candidate.label(), "candidate");
235        assert_eq!(ElectionState::Follower.label(), "follower");
236        assert_eq!(ElectionState::Leader.label(), "leader");
237    }
238
239    #[test]
240    fn test_is_candidate() {
241        assert!(ElectionState::Candidate.is_candidate());
242        assert!(!ElectionState::Leader.is_candidate());
243        assert!(!ElectionState::Idle.is_candidate());
244    }
245
246    #[test]
247    fn test_is_leader() {
248        assert!(ElectionState::Leader.is_leader());
249        assert!(!ElectionState::Candidate.is_leader());
250    }
251
252    #[test]
253    fn test_node_vote_is_valid() {
254        let vote = NodeVote::new("voter1", "node2", 3);
255        assert!(vote.is_valid(3));
256        assert!(!vote.is_valid(2)); // wrong term
257    }
258
259    #[test]
260    fn test_node_vote_invalid_self_vote() {
261        let vote = NodeVote::new("node1", "node1", 1);
262        assert!(!vote.is_valid(1)); // voter == candidate
263    }
264
265    #[test]
266    fn test_node_vote_invalid_empty_ids() {
267        let vote = NodeVote::new("", "node2", 1);
268        assert!(!vote.is_valid(1));
269    }
270
271    #[test]
272    fn test_start_election_increments_term() {
273        let mut mgr = make_manager("n1", 5);
274        assert_eq!(mgr.term, 0);
275        mgr.start_election();
276        assert_eq!(mgr.term, 1);
277        assert!(mgr.state.is_candidate());
278    }
279
280    #[test]
281    fn test_start_election_clears_votes() {
282        let mut mgr = make_manager("n1", 3);
283        mgr.start_election();
284        cast_vote(&mut mgr, "n2", "n1");
285        mgr.start_election(); // new election
286        assert_eq!(mgr.vote_count(), 0);
287    }
288
289    #[test]
290    fn test_record_vote_accepted() {
291        let mut mgr = make_manager("n1", 3);
292        mgr.start_election();
293        assert!(cast_vote(&mut mgr, "n2", "n1"));
294        assert_eq!(mgr.vote_count(), 1);
295    }
296
297    #[test]
298    fn test_record_vote_duplicate_rejected() {
299        let mut mgr = make_manager("n1", 5);
300        mgr.start_election();
301        assert!(cast_vote(&mut mgr, "n2", "n1"));
302        assert!(!cast_vote(&mut mgr, "n2", "n1")); // duplicate
303        assert_eq!(mgr.vote_count(), 1);
304    }
305
306    #[test]
307    fn test_winner_after_majority() {
308        let mut mgr = make_manager("n1", 3);
309        mgr.start_election();
310        cast_vote(&mut mgr, "n2", "n1");
311        cast_vote(&mut mgr, "n3", "n1");
312        assert_eq!(mgr.winner(), Some("n1"));
313        assert!(mgr.state.is_leader());
314    }
315
316    #[test]
317    fn test_quorum_calculation() {
318        let mgr3 = make_manager("n1", 3);
319        assert_eq!(mgr3.quorum(), 2);
320        let mgr5 = make_manager("n1", 5);
321        assert_eq!(mgr5.quorum(), 3);
322    }
323
324    #[test]
325    fn test_become_follower() {
326        let mut mgr = make_manager("n1", 3);
327        mgr.start_election();
328        mgr.become_follower();
329        assert_eq!(mgr.state, ElectionState::Follower);
330        assert_eq!(mgr.vote_count(), 0);
331    }
332
333    #[test]
334    fn test_is_timed_out() {
335        let mut mgr = ElectionManager::new("n1", 3, Duration::from_millis(1));
336        mgr.start_election();
337        std::thread::sleep(Duration::from_millis(5));
338        assert!(mgr.is_timed_out(Instant::now()));
339    }
340
341    #[test]
342    fn test_not_timed_out_before_deadline() {
343        let mut mgr = ElectionManager::new("n1", 3, Duration::from_secs(60));
344        mgr.start_election();
345        assert!(!mgr.is_timed_out(Instant::now()));
346    }
347}