Skip to main content

noxu_rep/elections/
proposal.rs

1//! Election proposal.
2//!
3//! Rep.elections.TimebasedProposalGenerator`  -  represents a
4//! candidate's bid to become master and defines the total ordering used to
5//! decide which candidate wins.
6//!
7//! ## Ordering rules (JE Ranking, major=DTVLSN, minor=VLSN)
8//!
9//! 0. **Higher DTVLSN wins** - the node with the most *durable* transactions
10//!    (replicated to a majority) is preferred over one with a higher raw VLSN
11//!    but uncommitted tail. DTVLSN 0 = UNINITIALIZED -> falls back to VLSN.
12//!
13//! Proposals are compared in the following order (each tiebreaker is consulted
14//! only when the previous field is equal):
15//!
16//! 1. **Higher VLSN wins**  -  the node with the most up-to-date data is
17//!    preferred so that no committed transactions are lost.
18//! 2. **Higher priority wins**  -  allows operators to steer mastership towards
19//!    specific nodes (e.g. nodes with faster storage).
20//! 3. **Higher term wins**  -  more recent elections take precedence.
21//! 4. **Lexicographic node name**  -  deterministic tiebreaker when all else is
22//!    equal.
23
24use std::cmp::Ordering;
25use std::time::{SystemTime, UNIX_EPOCH};
26
27/// Represents a candidate's election proposal.
28///
29/// A proposal captures the state of the candidate at the moment it decides to
30/// run for master. The [`Ord`] implementation encodes the election's preference
31/// rules so that `max(proposals)` yields the winning candidate.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Proposal {
34    /// Name of the candidate node.
35    pub node_name: String,
36    /// Durable Transaction VLSN (the MAJOR ranking key, JE Ranking.major).
37    /// The highest VLSN known durable on a majority of nodes. 0 means
38    /// UNINITIALIZED (pre-DTVLSN), in which case ranking falls back to `vlsn`
39    /// (JE MasterSuggestionGenerator.getRanking: `if dtvlsn == UNINITIALIZED
40    /// return Ranking(vlsn, 0)`). Defaults to 0 for back-compat constructors.
41    pub dtvlsn: u64,
42    /// Highest VLSN this node has acknowledged (the MINOR ranking key).
43    pub vlsn: u64,
44    /// Election priority assigned to this node (higher = preferred).
45    pub priority: u32,
46    /// Election term number.
47    pub term: u64,
48    /// Millisecond timestamp of when the proposal was created.
49    pub timestamp_ms: u64,
50}
51
52impl Proposal {
53    /// Create a new proposal, automatically timestamped to "now".
54    pub fn new(node_name: String, vlsn: u64, priority: u32, term: u64) -> Self {
55        let timestamp_ms = SystemTime::now()
56            .duration_since(UNIX_EPOCH)
57            .unwrap_or_default()
58            .as_millis() as u64;
59
60        Self { node_name, dtvlsn: 0, vlsn, priority, term, timestamp_ms }
61    }
62
63    /// Create a proposal with an explicit timestamp (useful for tests and
64    /// deserialization).
65    pub fn with_timestamp(
66        node_name: String,
67        vlsn: u64,
68        priority: u32,
69        term: u64,
70        timestamp_ms: u64,
71    ) -> Self {
72        Self { node_name, dtvlsn: 0, vlsn, priority, term, timestamp_ms }
73    }
74
75    /// Returns `true` if this proposal is strictly better than `other`
76    /// according to the election ordering rules.
77    pub fn is_better_than(&self, other: &Proposal) -> bool {
78        self.cmp(other) == Ordering::Greater
79    }
80
81    /// Builder: set the DTVLSN (the major ranking key). The election driver
82    /// populates this from `ReplicatedEnvironment::get_dtvlsn()` so the most
83    /// durable node, not merely the highest-raw-VLSN node, wins (D2).
84    pub fn with_dtvlsn(mut self, dtvlsn: u64) -> Self {
85        self.dtvlsn = dtvlsn;
86        self
87    }
88}
89
90impl Ord for Proposal {
91    fn cmp(&self, other: &Self) -> Ordering {
92        // JE Ranking(major=dtvlsn, minor=vlsn), then priority / term / name.
93        // 1. Higher DTVLSN wins (the most-durable node). When both DTVLSNs are
94        //    0 (UNINITIALIZED / pre-DTVLSN), this is a tie and the comparison
95        //    falls through to VLSN — exactly JE getRanking's pre-DTVLSN
96        //    fallback `Ranking(vlsn, 0)`.
97        self.dtvlsn
98            .cmp(&other.dtvlsn)
99            // 2. Higher VLSN wins.
100            .then_with(|| self.vlsn.cmp(&other.vlsn))
101            // 3. Higher priority wins.
102            .then_with(|| self.priority.cmp(&other.priority))
103            // 4. Higher term wins.
104            .then_with(|| self.term.cmp(&other.term))
105            // 5. Lexicographic node name tiebreaker.
106            .then_with(|| self.node_name.cmp(&other.node_name))
107    }
108}
109
110impl PartialOrd for Proposal {
111    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
112        Some(self.cmp(other))
113    }
114}
115
116impl std::fmt::Display for Proposal {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        write!(
119            f,
120            "Proposal(node={}, vlsn={}, priority={}, term={}, ts={})",
121            self.node_name,
122            self.vlsn,
123            self.priority,
124            self.term,
125            self.timestamp_ms
126        )
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn test_new_sets_timestamp() {
136        let p = Proposal::new("node1".into(), 100, 1, 1);
137        assert!(p.timestamp_ms > 0);
138    }
139
140    #[test]
141    fn test_with_timestamp() {
142        let p = Proposal::with_timestamp("n".into(), 1, 1, 1, 42);
143        assert_eq!(p.timestamp_ms, 42);
144    }
145
146    // --- VLSN ordering ---
147
148    #[test]
149    fn test_higher_dtvlsn_wins_over_higher_vlsn() {
150        // D2: a node with a higher DTVLSN (more durable txns) beats a node
151        // with a higher raw VLSN but uncommitted tail. JE Ranking(major=dtvlsn).
152        let durable = Proposal::with_timestamp("durable".into(), 100, 1, 1, 0)
153            .with_dtvlsn(90);
154        let laggard_tail =
155            Proposal::with_timestamp("laggard".into(), 200, 1, 1, 0)
156                .with_dtvlsn(50);
157        assert!(
158            durable.is_better_than(&laggard_tail),
159            "higher DTVLSN must win over higher raw VLSN"
160        );
161        assert!(!laggard_tail.is_better_than(&durable));
162    }
163
164    #[test]
165    fn test_dtvlsn_tie_falls_back_to_vlsn() {
166        // Equal (or both-zero/UNINITIALIZED) DTVLSN -> compare by VLSN, the
167        // JE pre-DTVLSN fallback Ranking(vlsn, 0).
168        let a =
169            Proposal::with_timestamp("a".into(), 200, 1, 1, 0).with_dtvlsn(0);
170        let b =
171            Proposal::with_timestamp("b".into(), 100, 1, 1, 0).with_dtvlsn(0);
172        assert!(a.is_better_than(&b), "dtvlsn tie -> higher vlsn wins");
173        // Same with equal non-zero dtvlsn.
174        let c =
175            Proposal::with_timestamp("c".into(), 200, 1, 1, 0).with_dtvlsn(50);
176        let d =
177            Proposal::with_timestamp("d".into(), 100, 1, 1, 0).with_dtvlsn(50);
178        assert!(c.is_better_than(&d));
179    }
180
181    #[test]
182    fn test_higher_vlsn_wins() {
183        let a = Proposal::with_timestamp("node1".into(), 200, 1, 1, 0);
184        let b = Proposal::with_timestamp("node2".into(), 100, 1, 1, 0);
185        assert!(a.is_better_than(&b));
186        assert!(!b.is_better_than(&a));
187    }
188
189    #[test]
190    fn test_higher_vlsn_wins_regardless_of_priority() {
191        let a = Proposal::with_timestamp("node1".into(), 200, 1, 1, 0);
192        let b = Proposal::with_timestamp("node2".into(), 100, 999, 1, 0);
193        assert!(a.is_better_than(&b));
194    }
195
196    // --- Priority ordering (same VLSN) ---
197
198    #[test]
199    fn test_higher_priority_wins_same_vlsn() {
200        let a = Proposal::with_timestamp("node1".into(), 100, 10, 1, 0);
201        let b = Proposal::with_timestamp("node2".into(), 100, 5, 1, 0);
202        assert!(a.is_better_than(&b));
203        assert!(!b.is_better_than(&a));
204    }
205
206    // --- Term ordering (same VLSN, same priority) ---
207
208    #[test]
209    fn test_higher_term_wins_same_vlsn_priority() {
210        let a = Proposal::with_timestamp("node1".into(), 100, 5, 3, 0);
211        let b = Proposal::with_timestamp("node2".into(), 100, 5, 1, 0);
212        assert!(a.is_better_than(&b));
213        assert!(!b.is_better_than(&a));
214    }
215
216    // --- Name tiebreaker ---
217
218    #[test]
219    fn test_name_tiebreaker() {
220        let a = Proposal::with_timestamp("node_b".into(), 100, 5, 1, 0);
221        let b = Proposal::with_timestamp("node_a".into(), 100, 5, 1, 0);
222        // "node_b" > "node_a" lexicographically.
223        assert!(a.is_better_than(&b));
224        assert!(!b.is_better_than(&a));
225    }
226
227    #[test]
228    fn test_equal_proposals() {
229        let a = Proposal::with_timestamp("node1".into(), 100, 5, 1, 0);
230        let b = Proposal::with_timestamp("node1".into(), 100, 5, 1, 0);
231        assert!(!a.is_better_than(&b));
232        assert!(!b.is_better_than(&a));
233        assert_eq!(a, b);
234    }
235
236    // --- Ord / sorting ---
237
238    #[test]
239    fn test_sort_picks_best_proposal() {
240        let proposals = [
241            Proposal::with_timestamp("low".into(), 50, 1, 1, 0),
242            Proposal::with_timestamp("high_vlsn".into(), 200, 1, 1, 0),
243            Proposal::with_timestamp("high_prio".into(), 100, 99, 1, 0),
244        ];
245        let best = proposals.iter().max().unwrap();
246        assert_eq!(best.node_name, "high_vlsn");
247    }
248
249    #[test]
250    fn test_sort_tiebreaker_chain() {
251        let mut proposals = [
252            Proposal::with_timestamp("c".into(), 100, 5, 1, 0),
253            Proposal::with_timestamp("a".into(), 100, 5, 1, 0),
254            Proposal::with_timestamp("b".into(), 100, 5, 1, 0),
255        ];
256        proposals.sort();
257        // Ascending: a < b < c
258        assert_eq!(proposals[0].node_name, "a");
259        assert_eq!(proposals[1].node_name, "b");
260        assert_eq!(proposals[2].node_name, "c");
261    }
262
263    #[test]
264    fn test_display() {
265        let p = Proposal::with_timestamp("n1".into(), 42, 3, 7, 1000);
266        let s = format!("{}", p);
267        assert!(s.contains("n1"));
268        assert!(s.contains("42"));
269        assert!(s.contains("term=7"));
270    }
271
272    #[test]
273    fn test_is_better_than_symmetry() {
274        let a = Proposal::with_timestamp("x".into(), 10, 1, 1, 0);
275        let b = Proposal::with_timestamp("y".into(), 20, 1, 1, 0);
276        // Exactly one of the two is "better".
277        assert!(b.is_better_than(&a));
278        assert!(!a.is_better_than(&b));
279    }
280
281    #[test]
282    fn test_zero_priority_loses() {
283        let zero = Proposal::with_timestamp("node1".into(), 100, 0, 1, 0);
284        let one = Proposal::with_timestamp("node2".into(), 100, 1, 1, 0);
285        assert!(one.is_better_than(&zero));
286    }
287}