Skip to main content

oximedia_distributed/
replication.rs

1//! Log replication and term tracking for distributed consensus support.
2//!
3//! Provides lightweight log entry management, term tracking, and
4//! quorum calculation utilities used by the consensus module.
5
6use std::collections::VecDeque;
7
8/// A single entry in the replicated log
9#[allow(dead_code)]
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct LogEntry {
12    /// The term in which this entry was created
13    pub term: u64,
14    /// The log index (1-based)
15    pub index: u64,
16    /// Payload data
17    pub data: Vec<u8>,
18    /// Human-readable command tag
19    pub command: String,
20}
21
22impl LogEntry {
23    /// Create a new log entry
24    #[allow(dead_code)]
25    pub fn new(term: u64, index: u64, command: impl Into<String>, data: Vec<u8>) -> Self {
26        Self {
27            term,
28            index,
29            data,
30            command: command.into(),
31        }
32    }
33}
34
35/// Replicated log with bounded capacity
36#[allow(dead_code)]
37pub struct ReplicatedLog {
38    entries: VecDeque<LogEntry>,
39    /// Maximum entries retained before compaction
40    max_size: usize,
41    /// Index of the highest entry applied to the state machine
42    commit_index: u64,
43    /// Index of the last entry known to be applied
44    last_applied: u64,
45}
46
47impl ReplicatedLog {
48    /// Create a new log with the given maximum capacity
49    #[allow(dead_code)]
50    #[must_use]
51    pub fn new(max_size: usize) -> Self {
52        Self {
53            entries: VecDeque::new(),
54            max_size,
55            commit_index: 0,
56            last_applied: 0,
57        }
58    }
59
60    /// Append an entry and return its assigned index.
61    ///
62    /// If the log exceeds `max_size`, the oldest entry is dropped (compaction).
63    #[allow(dead_code)]
64    pub fn append(&mut self, term: u64, command: impl Into<String>, data: Vec<u8>) -> u64 {
65        let index = self.last_index() + 1;
66        let entry = LogEntry::new(term, index, command, data);
67        self.entries.push_back(entry);
68        if self.entries.len() > self.max_size {
69            self.entries.pop_front();
70        }
71        index
72    }
73
74    /// Return the index of the last entry (0 if empty).
75    #[allow(dead_code)]
76    #[must_use]
77    pub fn last_index(&self) -> u64 {
78        self.entries.back().map_or(0, |e| e.index)
79    }
80
81    /// Return the term of the last entry (0 if empty).
82    #[allow(dead_code)]
83    #[must_use]
84    pub fn last_term(&self) -> u64 {
85        self.entries.back().map_or(0, |e| e.term)
86    }
87
88    /// Advance the commit index up to `index`.
89    #[allow(dead_code)]
90    pub fn commit_up_to(&mut self, index: u64) {
91        if index > self.commit_index {
92            self.commit_index = index.min(self.last_index());
93        }
94    }
95
96    /// Advance `last_applied` to match `commit_index` and return applied entries.
97    #[allow(dead_code)]
98    pub fn apply_committed(&mut self) -> Vec<LogEntry> {
99        let mut applied = Vec::new();
100        while self.last_applied < self.commit_index {
101            self.last_applied += 1;
102            if let Some(entry) = self.get(self.last_applied) {
103                applied.push(entry.clone());
104            }
105        }
106        applied
107    }
108
109    /// Get entry by 1-based index (may not exist after compaction).
110    #[allow(dead_code)]
111    #[must_use]
112    pub fn get(&self, index: u64) -> Option<&LogEntry> {
113        self.entries.iter().find(|e| e.index == index)
114    }
115
116    /// Return current commit index.
117    #[allow(dead_code)]
118    #[must_use]
119    pub fn commit_index(&self) -> u64 {
120        self.commit_index
121    }
122
123    /// Return the number of retained entries.
124    #[allow(dead_code)]
125    #[must_use]
126    pub fn len(&self) -> usize {
127        self.entries.len()
128    }
129
130    /// True if no entries have been appended (or all were compacted away).
131    #[allow(dead_code)]
132    #[must_use]
133    pub fn is_empty(&self) -> bool {
134        self.entries.is_empty()
135    }
136}
137
138/// Tracks the current term and votes for leader election
139#[allow(dead_code)]
140pub struct TermTracker {
141    current_term: u64,
142    voted_for: Option<String>,
143}
144
145impl TermTracker {
146    /// Create a new tracker starting at term 0.
147    #[allow(dead_code)]
148    #[must_use]
149    pub fn new() -> Self {
150        Self {
151            current_term: 0,
152            voted_for: None,
153        }
154    }
155
156    /// Return the current term.
157    #[allow(dead_code)]
158    #[must_use]
159    pub fn current_term(&self) -> u64 {
160        self.current_term
161    }
162
163    /// Advance to a new term, clearing the vote.
164    ///
165    /// Returns `true` if the term was actually advanced.
166    #[allow(dead_code)]
167    pub fn advance_term(&mut self, new_term: u64) -> bool {
168        if new_term > self.current_term {
169            self.current_term = new_term;
170            self.voted_for = None;
171            true
172        } else {
173            false
174        }
175    }
176
177    /// Attempt to cast a vote for `candidate_id` in the current term.
178    ///
179    /// Returns `true` if the vote was granted (can only vote once per term).
180    #[allow(dead_code)]
181    pub fn grant_vote(&mut self, candidate_id: impl Into<String>) -> bool {
182        if self.voted_for.is_none() {
183            self.voted_for = Some(candidate_id.into());
184            true
185        } else {
186            false
187        }
188    }
189
190    /// The candidate voted for in the current term, if any.
191    #[allow(dead_code)]
192    #[must_use]
193    pub fn voted_for(&self) -> Option<&str> {
194        self.voted_for.as_deref()
195    }
196}
197
198impl Default for TermTracker {
199    fn default() -> Self {
200        Self::new()
201    }
202}
203
204/// Quorum calculation utilities
205#[allow(dead_code)]
206pub struct QuorumHelper;
207
208impl QuorumHelper {
209    /// Minimum votes needed for a quorum given `cluster_size` nodes.
210    #[allow(dead_code)]
211    #[must_use]
212    pub fn majority(cluster_size: usize) -> usize {
213        cluster_size / 2 + 1
214    }
215
216    /// True if `votes` constitutes a quorum for `cluster_size`.
217    #[allow(dead_code)]
218    #[must_use]
219    pub fn has_quorum(votes: usize, cluster_size: usize) -> bool {
220        votes >= Self::majority(cluster_size)
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_log_entry_new() {
230        let e = LogEntry::new(1, 1, "set", b"data".to_vec());
231        assert_eq!(e.term, 1);
232        assert_eq!(e.index, 1);
233        assert_eq!(e.command, "set");
234    }
235
236    #[test]
237    fn test_replicated_log_append_increments_index() {
238        let mut log = ReplicatedLog::new(100);
239        let i1 = log.append(1, "cmd1", vec![]);
240        let i2 = log.append(1, "cmd2", vec![]);
241        assert_eq!(i1, 1);
242        assert_eq!(i2, 2);
243    }
244
245    #[test]
246    fn test_replicated_log_last_index_and_term() {
247        let mut log = ReplicatedLog::new(100);
248        assert_eq!(log.last_index(), 0);
249        assert_eq!(log.last_term(), 0);
250        log.append(2, "cmd", vec![]);
251        assert_eq!(log.last_index(), 1);
252        assert_eq!(log.last_term(), 2);
253    }
254
255    #[test]
256    fn test_replicated_log_compaction() {
257        let mut log = ReplicatedLog::new(3);
258        for _ in 0..5 {
259            log.append(1, "x", vec![]);
260        }
261        assert_eq!(log.len(), 3);
262    }
263
264    #[test]
265    fn test_replicated_log_get_entry() {
266        let mut log = ReplicatedLog::new(10);
267        log.append(1, "cmd", b"hello".to_vec());
268        let entry = log.get(1).expect("get should return a value");
269        assert_eq!(entry.data, b"hello");
270    }
271
272    #[test]
273    fn test_replicated_log_get_missing() {
274        let log = ReplicatedLog::new(10);
275        assert!(log.get(99).is_none());
276    }
277
278    #[test]
279    fn test_commit_and_apply() {
280        let mut log = ReplicatedLog::new(100);
281        log.append(1, "a", vec![]);
282        log.append(1, "b", vec![]);
283        log.append(1, "c", vec![]);
284        log.commit_up_to(2);
285        let applied = log.apply_committed();
286        assert_eq!(applied.len(), 2);
287        assert_eq!(applied[0].command, "a");
288        assert_eq!(applied[1].command, "b");
289    }
290
291    #[test]
292    fn test_commit_up_to_capped_at_last_index() {
293        let mut log = ReplicatedLog::new(100);
294        log.append(1, "x", vec![]);
295        log.commit_up_to(999);
296        assert_eq!(log.commit_index(), 1);
297    }
298
299    #[test]
300    fn test_term_tracker_initial_state() {
301        let t = TermTracker::new();
302        assert_eq!(t.current_term(), 0);
303        assert!(t.voted_for().is_none());
304    }
305
306    #[test]
307    fn test_term_tracker_advance_term() {
308        let mut t = TermTracker::new();
309        assert!(t.advance_term(3));
310        assert_eq!(t.current_term(), 3);
311        assert!(!t.advance_term(2)); // going backwards fails
312    }
313
314    #[test]
315    fn test_term_tracker_grant_vote_once() {
316        let mut t = TermTracker::new();
317        t.advance_term(1);
318        assert!(t.grant_vote("node-a"));
319        assert!(!t.grant_vote("node-b")); // already voted
320        assert_eq!(t.voted_for(), Some("node-a"));
321    }
322
323    #[test]
324    fn test_term_tracker_vote_cleared_on_term_advance() {
325        let mut t = TermTracker::new();
326        t.advance_term(1);
327        t.grant_vote("node-a");
328        t.advance_term(2);
329        assert!(t.voted_for().is_none());
330    }
331
332    #[test]
333    fn test_quorum_majority_odd_cluster() {
334        assert_eq!(QuorumHelper::majority(5), 3);
335        assert_eq!(QuorumHelper::majority(3), 2);
336    }
337
338    #[test]
339    fn test_quorum_majority_even_cluster() {
340        assert_eq!(QuorumHelper::majority(4), 3);
341    }
342
343    #[test]
344    fn test_has_quorum() {
345        assert!(QuorumHelper::has_quorum(3, 5));
346        assert!(!QuorumHelper::has_quorum(2, 5));
347        assert!(QuorumHelper::has_quorum(2, 3));
348    }
349}