Skip to main content

oximedia_distributed/
raft_primitives.rs

1//! Raft-like consensus primitives.
2//!
3//! Provides building-block types for implementing a Raft consensus
4//! protocol in the distributed encoding cluster. These primitives
5//! focus on state management and log structures.
6//!
7//! # Raft Consensus Protocol Usage
8//!
9//! The Raft algorithm guarantees consensus in a distributed cluster by electing
10//! a single *leader* that coordinates all writes. The key phases are:
11//!
12//! ## Leader Election
13//!
14//! - All nodes start as **Followers** with term 0.
15//! - If a Follower does not receive a heartbeat within the **election timeout**
16//!   (typically 150–300 ms), it increments its term, transitions to **Candidate**,
17//!   and broadcasts `RequestVote` RPCs.
18//! - A node grants a vote if it has not voted in the current term and the
19//!   candidate's log is at least as up-to-date as its own.
20//! - A Candidate wins if it receives a majority (⌊N/2⌋ + 1) of votes and
21//!   transitions to **Leader**.
22//!
23//! ## Log Replication
24//!
25//! - The Leader appends each client command to its local [`RaftLog`], then
26//!   broadcasts `AppendEntries` RPCs (replicated to followers in parallel).
27//! - Once a majority of nodes have acknowledged the entry, the Leader marks
28//!   it as *committed* (updates `commit_index`).
29//! - Followers apply committed entries to their state machines in order.
30//!
31//! ## Heartbeat and Timeout Values
32//!
33//! | Constant            | Typical Value | Notes                                      |
34//! |---------------------|---------------|--------------------------------------------|
35//! | Heartbeat interval  | 50 ms         | Leader sends empty `AppendEntries` per hop |
36//! | Election timeout    | 150–300 ms    | Randomised to avoid split votes            |
37//! | RPC timeout         | 30 ms         | After which the RPC is retried             |
38
39#![allow(dead_code)]
40
41use std::sync::atomic::{AtomicU64, Ordering};
42use std::time::Instant;
43
44/// A single entry in the Raft replicated log.
45#[derive(Debug, Clone)]
46pub struct LogEntry {
47    /// Term in which this entry was created.
48    pub term: u64,
49    /// Index of this entry in the log (1-based).
50    pub index: u64,
51    /// The command/payload encoded as a string.
52    pub command: String,
53}
54
55impl LogEntry {
56    /// Create a new log entry.
57    #[must_use]
58    pub fn new(term: u64, index: u64, command: impl Into<String>) -> Self {
59        Self {
60            term,
61            index,
62            command: command.into(),
63        }
64    }
65
66    /// Returns true if the entry is valid (term > 0 and index > 0).
67    #[must_use]
68    pub fn is_valid(&self) -> bool {
69        self.term > 0 && self.index > 0
70    }
71}
72
73/// Role of a node in the Raft protocol.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum RaftRole {
76    /// The current term's leader.
77    Leader,
78    /// A regular member following the leader.
79    Follower,
80    /// A node seeking election.
81    Candidate,
82}
83
84impl RaftRole {
85    /// Returns true if this node can accept write operations.
86    #[must_use]
87    pub fn can_accept_writes(&self) -> bool {
88        matches!(self, Self::Leader)
89    }
90
91    /// Returns a human-readable name.
92    #[must_use]
93    pub fn name(&self) -> &str {
94        match self {
95            Self::Leader => "Leader",
96            Self::Follower => "Follower",
97            Self::Candidate => "Candidate",
98        }
99    }
100}
101
102impl std::fmt::Display for RaftRole {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        write!(f, "{}", self.name())
105    }
106}
107
108/// Persistent and volatile state for a Raft node.
109#[derive(Debug)]
110pub struct RaftState {
111    /// Latest term this node has seen.
112    pub current_term: u64,
113    /// Candidate node ID this node voted for in the current term.
114    pub voted_for: Option<String>,
115    /// Index of the highest log entry known to be committed.
116    pub commit_index: u64,
117    /// Index of the highest log entry applied to the state machine.
118    pub last_applied: u64,
119    /// Current role.
120    pub role: RaftRole,
121}
122
123impl RaftState {
124    /// Create a new Raft state in Follower role with term 0.
125    #[must_use]
126    pub fn new() -> Self {
127        Self {
128            current_term: 0,
129            voted_for: None,
130            commit_index: 0,
131            last_applied: 0,
132            role: RaftRole::Follower,
133        }
134    }
135
136    /// Advance the current term to `new_term` (only if larger).
137    pub fn advance_term(&mut self, new_term: u64) {
138        if new_term > self.current_term {
139            self.current_term = new_term;
140            self.voted_for = None;
141        }
142    }
143
144    /// Transition to Candidate and start a new election.
145    pub fn become_candidate(&mut self) {
146        self.current_term += 1;
147        self.role = RaftRole::Candidate;
148        self.voted_for = None;
149    }
150
151    /// Transition to Leader.
152    pub fn become_leader(&mut self) {
153        self.role = RaftRole::Leader;
154    }
155
156    /// Step down to Follower with the given term.
157    pub fn become_follower(&mut self, term: u64) {
158        self.current_term = term;
159        self.role = RaftRole::Follower;
160        self.voted_for = None;
161    }
162
163    /// Record a vote cast for the given candidate in the current term.
164    pub fn vote_for(&mut self, candidate_id: impl Into<String>) {
165        self.voted_for = Some(candidate_id.into());
166    }
167
168    /// Advance the commit index if `index` is larger than the current value.
169    pub fn update_commit_index(&mut self, index: u64) {
170        if index > self.commit_index {
171            self.commit_index = index;
172        }
173    }
174
175    /// Advance `last_applied` if `index` is larger and not beyond `commit_index`.
176    pub fn apply_up_to(&mut self, index: u64) {
177        if index <= self.commit_index && index > self.last_applied {
178            self.last_applied = index;
179        }
180    }
181}
182
183impl Default for RaftState {
184    fn default() -> Self {
185        Self::new()
186    }
187}
188
189/// The Raft replicated log.
190#[derive(Debug, Default)]
191pub struct RaftLog {
192    /// All log entries in order.
193    pub entries: Vec<LogEntry>,
194}
195
196impl RaftLog {
197    /// Create a new empty log.
198    #[must_use]
199    pub fn new() -> Self {
200        Self {
201            entries: Vec::new(),
202        }
203    }
204
205    /// Append an entry to the log.
206    pub fn append(&mut self, entry: LogEntry) {
207        self.entries.push(entry);
208    }
209
210    /// Get the entry at the given 1-based index.
211    #[must_use]
212    pub fn get(&self, index: u64) -> Option<&LogEntry> {
213        if index == 0 {
214            return None;
215        }
216        self.entries.get((index - 1) as usize)
217    }
218
219    /// Returns the index of the last entry (0 if the log is empty).
220    #[must_use]
221    pub fn last_index(&self) -> u64 {
222        self.entries.len() as u64
223    }
224
225    /// Returns the term of the last entry (0 if the log is empty).
226    #[must_use]
227    pub fn last_term(&self) -> u64 {
228        self.entries.last().map_or(0, |e| e.term)
229    }
230
231    /// Returns all entries up to and including `commit_index`.
232    #[must_use]
233    pub fn committed_entries(&self, commit_index: u64) -> Vec<&LogEntry> {
234        self.entries
235            .iter()
236            .filter(|e| e.index <= commit_index)
237            .collect()
238    }
239
240    /// Truncate the log to `last_kept_index`, removing all entries after it.
241    pub fn truncate_after(&mut self, last_kept_index: u64) {
242        self.entries.retain(|e| e.index <= last_kept_index);
243    }
244
245    /// Returns true if the log is empty.
246    #[must_use]
247    pub fn is_empty(&self) -> bool {
248        self.entries.is_empty()
249    }
250}
251
252/// Latency metrics for a Raft node.
253///
254/// All values are stored as microseconds in [`AtomicU64`] counters so they can
255/// be read from any thread without acquiring a lock.
256///
257/// Use [`RaftMetrics::record_propose_commit`] after a proposal is committed
258/// and [`RaftMetrics::record_heartbeat_rtt`] after each heartbeat round-trip.
259/// Then call [`RaftMetrics::report`] to obtain a snapshot.
260#[derive(Debug, Default)]
261pub struct RaftMetrics {
262    /// Total number of commit latency samples recorded.
263    propose_commit_samples: AtomicU64,
264    /// Sum of propose-to-commit latencies in microseconds.
265    propose_commit_sum_us: AtomicU64,
266    /// Maximum propose-to-commit latency seen (microseconds).
267    propose_commit_max_us: AtomicU64,
268    /// Total number of heartbeat RTT samples recorded.
269    heartbeat_samples: AtomicU64,
270    /// Sum of heartbeat RTTs in microseconds.
271    heartbeat_sum_us: AtomicU64,
272    /// Maximum heartbeat RTT seen (microseconds).
273    heartbeat_max_us: AtomicU64,
274}
275
276/// A point-in-time snapshot of [`RaftMetrics`].
277#[derive(Debug, Clone)]
278pub struct RaftMetricsSnapshot {
279    /// Number of propose-to-commit latency samples.
280    pub propose_commit_samples: u64,
281    /// Average propose-to-commit latency in milliseconds.
282    pub propose_commit_avg_ms: f64,
283    /// Maximum propose-to-commit latency in milliseconds.
284    pub propose_commit_max_ms: f64,
285    /// Number of heartbeat RTT samples.
286    pub heartbeat_samples: u64,
287    /// Average heartbeat RTT in milliseconds.
288    pub heartbeat_rtt_avg_ms: f64,
289    /// Maximum heartbeat RTT in milliseconds.
290    pub heartbeat_rtt_max_ms: f64,
291}
292
293impl RaftMetrics {
294    /// Create a new, zeroed metrics instance.
295    #[must_use]
296    pub fn new() -> Self {
297        Self::default()
298    }
299
300    /// Record the elapsed time from a proposal being submitted to being
301    /// committed.  Pass the `Instant` captured when the proposal was first
302    /// submitted; this method captures the current time to compute the
303    /// elapsed duration.
304    pub fn record_propose_commit(&self, propose_start: Instant) {
305        let elapsed_us = propose_start.elapsed().as_micros() as u64;
306        self.propose_commit_samples.fetch_add(1, Ordering::Relaxed);
307        self.propose_commit_sum_us
308            .fetch_add(elapsed_us, Ordering::Relaxed);
309        // Update max (relaxed compare-and-swap loop)
310        let mut current = self.propose_commit_max_us.load(Ordering::Relaxed);
311        while elapsed_us > current {
312            match self.propose_commit_max_us.compare_exchange_weak(
313                current,
314                elapsed_us,
315                Ordering::Relaxed,
316                Ordering::Relaxed,
317            ) {
318                Ok(_) => break,
319                Err(c) => current = c,
320            }
321        }
322    }
323
324    /// Record the round-trip time for a single heartbeat.  Pass the `Instant`
325    /// when the heartbeat was sent; this method captures the current time.
326    pub fn record_heartbeat_rtt(&self, send_start: Instant) {
327        let elapsed_us = send_start.elapsed().as_micros() as u64;
328        self.heartbeat_samples.fetch_add(1, Ordering::Relaxed);
329        self.heartbeat_sum_us
330            .fetch_add(elapsed_us, Ordering::Relaxed);
331        let mut current = self.heartbeat_max_us.load(Ordering::Relaxed);
332        while elapsed_us > current {
333            match self.heartbeat_max_us.compare_exchange_weak(
334                current,
335                elapsed_us,
336                Ordering::Relaxed,
337                Ordering::Relaxed,
338            ) {
339                Ok(_) => break,
340                Err(c) => current = c,
341            }
342        }
343    }
344
345    /// Record a raw propose-to-commit latency value in microseconds (for
346    /// testing / synthetic benchmarks where you control the exact value).
347    pub fn record_propose_commit_us(&self, latency_us: u64) {
348        self.propose_commit_samples.fetch_add(1, Ordering::Relaxed);
349        self.propose_commit_sum_us
350            .fetch_add(latency_us, Ordering::Relaxed);
351        let mut current = self.propose_commit_max_us.load(Ordering::Relaxed);
352        while latency_us > current {
353            match self.propose_commit_max_us.compare_exchange_weak(
354                current,
355                latency_us,
356                Ordering::Relaxed,
357                Ordering::Relaxed,
358            ) {
359                Ok(_) => break,
360                Err(c) => current = c,
361            }
362        }
363    }
364
365    /// Record a raw heartbeat RTT value in microseconds.
366    pub fn record_heartbeat_rtt_us(&self, rtt_us: u64) {
367        self.heartbeat_samples.fetch_add(1, Ordering::Relaxed);
368        self.heartbeat_sum_us.fetch_add(rtt_us, Ordering::Relaxed);
369        let mut current = self.heartbeat_max_us.load(Ordering::Relaxed);
370        while rtt_us > current {
371            match self.heartbeat_max_us.compare_exchange_weak(
372                current,
373                rtt_us,
374                Ordering::Relaxed,
375                Ordering::Relaxed,
376            ) {
377                Ok(_) => break,
378                Err(c) => current = c,
379            }
380        }
381    }
382
383    /// Return a snapshot of the current metrics.
384    #[must_use]
385    pub fn report(&self) -> RaftMetricsSnapshot {
386        let pc_samples = self.propose_commit_samples.load(Ordering::Relaxed);
387        let pc_sum = self.propose_commit_sum_us.load(Ordering::Relaxed);
388        let pc_max = self.propose_commit_max_us.load(Ordering::Relaxed);
389
390        let hb_samples = self.heartbeat_samples.load(Ordering::Relaxed);
391        let hb_sum = self.heartbeat_sum_us.load(Ordering::Relaxed);
392        let hb_max = self.heartbeat_max_us.load(Ordering::Relaxed);
393
394        let pc_avg_ms = if pc_samples > 0 {
395            pc_sum as f64 / pc_samples as f64 / 1000.0
396        } else {
397            0.0
398        };
399
400        let hb_avg_ms = if hb_samples > 0 {
401            hb_sum as f64 / hb_samples as f64 / 1000.0
402        } else {
403            0.0
404        };
405
406        RaftMetricsSnapshot {
407            propose_commit_samples: pc_samples,
408            propose_commit_avg_ms: pc_avg_ms,
409            propose_commit_max_ms: pc_max as f64 / 1000.0,
410            heartbeat_samples: hb_samples,
411            heartbeat_rtt_avg_ms: hb_avg_ms,
412            heartbeat_rtt_max_ms: hb_max as f64 / 1000.0,
413        }
414    }
415
416    /// Reset all counters to zero.
417    pub fn reset(&self) {
418        self.propose_commit_samples.store(0, Ordering::Relaxed);
419        self.propose_commit_sum_us.store(0, Ordering::Relaxed);
420        self.propose_commit_max_us.store(0, Ordering::Relaxed);
421        self.heartbeat_samples.store(0, Ordering::Relaxed);
422        self.heartbeat_sum_us.store(0, Ordering::Relaxed);
423        self.heartbeat_max_us.store(0, Ordering::Relaxed);
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn test_log_entry_is_valid() {
433        assert!(LogEntry::new(1, 1, "cmd").is_valid());
434        assert!(!LogEntry::new(0, 1, "cmd").is_valid()); // term = 0
435        assert!(!LogEntry::new(1, 0, "cmd").is_valid()); // index = 0
436        assert!(!LogEntry::new(0, 0, "cmd").is_valid());
437    }
438
439    #[test]
440    fn test_raft_role_can_accept_writes() {
441        assert!(RaftRole::Leader.can_accept_writes());
442        assert!(!RaftRole::Follower.can_accept_writes());
443        assert!(!RaftRole::Candidate.can_accept_writes());
444    }
445
446    #[test]
447    fn test_raft_role_display() {
448        assert_eq!(RaftRole::Leader.to_string(), "Leader");
449        assert_eq!(RaftRole::Follower.to_string(), "Follower");
450        assert_eq!(RaftRole::Candidate.to_string(), "Candidate");
451    }
452
453    #[test]
454    fn test_raft_state_initial() {
455        let state = RaftState::new();
456        assert_eq!(state.current_term, 0);
457        assert!(state.voted_for.is_none());
458        assert_eq!(state.commit_index, 0);
459        assert_eq!(state.last_applied, 0);
460        assert_eq!(state.role, RaftRole::Follower);
461    }
462
463    #[test]
464    fn test_raft_state_advance_term() {
465        let mut state = RaftState::new();
466        state.vote_for("node1");
467        state.advance_term(5);
468        assert_eq!(state.current_term, 5);
469        // advance_term should clear voted_for
470        assert!(state.voted_for.is_none());
471
472        // Should not regress
473        state.advance_term(3);
474        assert_eq!(state.current_term, 5);
475    }
476
477    #[test]
478    fn test_raft_state_become_candidate() {
479        let mut state = RaftState::new();
480        state.become_candidate();
481        assert_eq!(state.current_term, 1);
482        assert_eq!(state.role, RaftRole::Candidate);
483    }
484
485    #[test]
486    fn test_raft_state_become_leader() {
487        let mut state = RaftState::new();
488        state.become_candidate();
489        state.become_leader();
490        assert_eq!(state.role, RaftRole::Leader);
491    }
492
493    #[test]
494    fn test_raft_state_become_follower() {
495        let mut state = RaftState::new();
496        state.become_leader();
497        state.become_follower(7);
498        assert_eq!(state.role, RaftRole::Follower);
499        assert_eq!(state.current_term, 7);
500        assert!(state.voted_for.is_none());
501    }
502
503    #[test]
504    fn test_raft_state_update_commit_index() {
505        let mut state = RaftState::new();
506        state.update_commit_index(5);
507        assert_eq!(state.commit_index, 5);
508        // Should not go backwards
509        state.update_commit_index(3);
510        assert_eq!(state.commit_index, 5);
511    }
512
513    #[test]
514    fn test_raft_state_apply_up_to() {
515        let mut state = RaftState::new();
516        state.update_commit_index(10);
517        state.apply_up_to(7);
518        assert_eq!(state.last_applied, 7);
519        // Cannot exceed commit_index
520        state.apply_up_to(15);
521        assert_eq!(state.last_applied, 7);
522    }
523
524    #[test]
525    fn test_raft_log_append_and_get() {
526        let mut log = RaftLog::new();
527        assert!(log.is_empty());
528        assert_eq!(log.last_index(), 0);
529        assert_eq!(log.last_term(), 0);
530
531        log.append(LogEntry::new(1, 1, "set x=1"));
532        log.append(LogEntry::new(1, 2, "set y=2"));
533        log.append(LogEntry::new(2, 3, "set z=3"));
534
535        assert_eq!(log.last_index(), 3);
536        assert_eq!(log.last_term(), 2);
537        assert!(!log.is_empty());
538    }
539
540    #[test]
541    fn test_raft_log_get_valid_index() {
542        let mut log = RaftLog::new();
543        log.append(LogEntry::new(1, 1, "cmd1"));
544        log.append(LogEntry::new(2, 2, "cmd2"));
545
546        let e = log.get(1).expect("get should return a value");
547        assert_eq!(e.command, "cmd1");
548        assert_eq!(e.term, 1);
549    }
550
551    #[test]
552    fn test_raft_log_get_invalid_index() {
553        let log = RaftLog::new();
554        assert!(log.get(0).is_none());
555        assert!(log.get(1).is_none());
556    }
557
558    #[test]
559    fn test_raft_log_committed_entries() {
560        let mut log = RaftLog::new();
561        log.append(LogEntry::new(1, 1, "a"));
562        log.append(LogEntry::new(1, 2, "b"));
563        log.append(LogEntry::new(2, 3, "c"));
564
565        let committed = log.committed_entries(2);
566        assert_eq!(committed.len(), 2);
567        assert_eq!(committed[0].command, "a");
568        assert_eq!(committed[1].command, "b");
569    }
570
571    #[test]
572    fn test_raft_log_truncate_after() {
573        let mut log = RaftLog::new();
574        log.append(LogEntry::new(1, 1, "a"));
575        log.append(LogEntry::new(1, 2, "b"));
576        log.append(LogEntry::new(2, 3, "c"));
577
578        log.truncate_after(2);
579        assert_eq!(log.last_index(), 2);
580        assert!(log.get(3).is_none());
581    }
582
583    // ---- RaftMetrics tests ----
584
585    #[test]
586    fn test_raft_metrics_captures_latency() {
587        let metrics = RaftMetrics::new();
588
589        // Record 3 synthetic propose-to-commit samples
590        metrics.record_propose_commit_us(2_000); // 2 ms
591        metrics.record_propose_commit_us(4_000); // 4 ms
592        metrics.record_propose_commit_us(6_000); // 6 ms
593
594        // Record 2 heartbeat RTT samples
595        metrics.record_heartbeat_rtt_us(500); // 0.5 ms
596        metrics.record_heartbeat_rtt_us(1_500); // 1.5 ms
597
598        let snap = metrics.report();
599
600        assert_eq!(snap.propose_commit_samples, 3);
601        // avg = (2+4+6)/3 = 4 ms
602        assert!((snap.propose_commit_avg_ms - 4.0).abs() < 0.01);
603        // max = 6 ms
604        assert!((snap.propose_commit_max_ms - 6.0).abs() < 0.01);
605
606        assert_eq!(snap.heartbeat_samples, 2);
607        // avg = (0.5+1.5)/2 = 1.0 ms
608        assert!((snap.heartbeat_rtt_avg_ms - 1.0).abs() < 0.01);
609        // max = 1.5 ms
610        assert!((snap.heartbeat_rtt_max_ms - 1.5).abs() < 0.01);
611    }
612
613    #[test]
614    fn test_raft_metrics_empty_report() {
615        let metrics = RaftMetrics::new();
616        let snap = metrics.report();
617        assert_eq!(snap.propose_commit_samples, 0);
618        assert_eq!(snap.heartbeat_samples, 0);
619        assert_eq!(snap.propose_commit_avg_ms, 0.0);
620        assert_eq!(snap.heartbeat_rtt_avg_ms, 0.0);
621    }
622
623    #[test]
624    fn test_raft_metrics_reset() {
625        let metrics = RaftMetrics::new();
626        metrics.record_propose_commit_us(1_000);
627        metrics.record_heartbeat_rtt_us(500);
628        metrics.reset();
629
630        let snap = metrics.report();
631        assert_eq!(snap.propose_commit_samples, 0);
632        assert_eq!(snap.heartbeat_samples, 0);
633    }
634
635    #[test]
636    fn test_raft_metrics_instant_recording() {
637        let metrics = RaftMetrics::new();
638        let t = Instant::now();
639        // Tiny sleep to ensure elapsed > 0
640        std::thread::sleep(std::time::Duration::from_micros(100));
641        metrics.record_propose_commit(t);
642        let snap = metrics.report();
643        assert_eq!(snap.propose_commit_samples, 1);
644        assert!(snap.propose_commit_max_ms >= 0.0);
645    }
646
647    #[test]
648    fn test_raft_metrics_max_tracks_correctly() {
649        let metrics = RaftMetrics::new();
650        metrics.record_propose_commit_us(100);
651        metrics.record_propose_commit_us(9_000); // 9 ms
652        metrics.record_propose_commit_us(500);
653
654        let snap = metrics.report();
655        assert!((snap.propose_commit_max_ms - 9.0).abs() < 0.01);
656    }
657}