reddb_server/replication/election.rs
1//! Term-based, quorum-gated automatic election (issue #834, PRD #819, ADR 0030).
2//!
3//! This is the consensus core that turns a primary loss into an automatic,
4//! *safe* promotion. It lives in the first-party-but-decoupled control-plane
5//! supervisor (ADR 0030) — distinct from the data path — and reuses the two
6//! pieces the rest of replication already built:
7//!
8//! * the **commit watermark** ([`super::commit_waiter`] / [`super::quorum`]) —
9//! the highest LSN durably replicated to a quorum that intersects every
10//! possible election majority. Nothing at or below it may ever be rolled
11//! back; and
12//! * the **FAILOVER handover machinery** ([`super::failover`]) — once a
13//! candidate wins, promotion is driven through the same coordinated
14//! role-swap, not a parallel state machine.
15//!
16//! ## The five hard requirements (ADR 0030, issue #834)
17//!
18//! 1. **Dry-run probe.** A candidate first asks "*would* you vote for me?"
19//! without bumping any term. Only a real election bumps the term. This
20//! keeps a flapping candidate from burning through terms and lets the
21//! supervisor probe liveness cheaply.
22//! 2. **Durable last-vote.** A voter persists `(term, voted_for)` *before*
23//! acknowledging a grant, so a voter that crashes and restarts mid-term
24//! never double-votes — the second request in the same term for a
25//! different candidate is refused from disk.
26//! 3. **Watermark vote rule (the safety core).** A voter MUST refuse any
27//! candidate whose log does not cover the commit watermark. An
28//! acknowledged synchronous write sits at or below the watermark, so a
29//! winner necessarily carries it — the write provably survives the
30//! failover. This is the one rule that may not be relaxed.
31//! 4. **Randomized election timeouts.** Candidates wait a randomized
32//! interval before standing, so split votes are rare and self-correcting.
33//! 5. **Membership rules.** A quorum is a *majority of voting members*.
34//! **Witness** members ([#836]) hold no data but vote, so `2 data + 1
35//! witness` is a valid HA shape. A **catching-up** replica is
36//! *non-voting* until it reaches a healthy state — it neither votes nor
37//! stands.
38//!
39//! ## No two primaries in a term
40//!
41//! This invariant is structural, not probabilistic:
42//!
43//! * a win requires a strict majority of voting members, and two strict
44//! majorities of the same set always intersect; and
45//! * the shared voter in any two majorities votes at most once per term
46//! (durable last-vote), so it cannot grant two different candidates the
47//! same term.
48//!
49//! Therefore at most one candidate can collect a majority in a given term,
50//! even under an arbitrary network partition. The partition tests exercise
51//! exactly this.
52//!
53//! ## Module shape
54//!
55//! Like [`super::failover`], the candidate-side [`ElectionCoordinator::run`]
56//! is a **pure state machine**: the clock, the per-peer vote RPC, the
57//! durable term bump, and the promotion are injected behind
58//! [`ElectionTransport`], so the whole election is exercised
59//! deterministically with a scripted fake — no clock, no network, no engine.
60//! The voter-side [`Voter`] wraps a [`LastVoteStore`] (durable on disk in
61//! production, in-memory in tests) and applies the vote rule.
62//!
63//! [#836]: https://github.com/reddb-io/reddb/issues/836
64
65use std::time::Duration;
66
67pub use reddb_file::FileLastVoteStore;
68
69// ---------------------------------------------------------------
70// Membership model
71// ---------------------------------------------------------------
72
73/// Whether a member holds data (and can therefore be promoted to primary)
74/// or is a vote-only witness (ADR 0030 — "a node that runs only the
75/// supervisor module").
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum MemberKind {
78 /// Holds data; can vote and can stand for election.
79 Data,
80 /// Vote-only witness ([#836]); counts toward quorum, never primary.
81 ///
82 /// [#836]: https://github.com/reddb-io/reddb/issues/836
83 Witness,
84}
85
86/// Whether a member currently participates in voting.
87///
88/// A data replica that is still catching up (has not reached a healthy,
89/// watermark-covering state) is [`VotingState::CatchingUp`] and is excluded
90/// from the voter set entirely — it neither votes nor counts toward the
91/// majority denominator. Once healthy it becomes [`VotingState::Voting`].
92/// Witnesses are always [`VotingState::Voting`].
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum VotingState {
95 /// Healthy member that participates in quorum.
96 Voting,
97 /// Replica still syncing; non-voting until healthy.
98 CatchingUp,
99}
100
101/// A cluster member as seen by the supervisor's membership view.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct Member {
104 /// Stable node identity (matches the replica registry / ack id).
105 pub id: String,
106 pub kind: MemberKind,
107 pub state: VotingState,
108}
109
110impl Member {
111 pub fn data_voting(id: impl Into<String>) -> Self {
112 Self {
113 id: id.into(),
114 kind: MemberKind::Data,
115 state: VotingState::Voting,
116 }
117 }
118
119 pub fn data_catching_up(id: impl Into<String>) -> Self {
120 Self {
121 id: id.into(),
122 kind: MemberKind::Data,
123 state: VotingState::CatchingUp,
124 }
125 }
126
127 pub fn witness(id: impl Into<String>) -> Self {
128 Self {
129 id: id.into(),
130 kind: MemberKind::Witness,
131 state: VotingState::Voting,
132 }
133 }
134
135 /// Does this member count toward quorum? Only healthy members vote;
136 /// a catching-up replica is non-voting (ADR 0030).
137 pub fn is_voter(&self) -> bool {
138 matches!(self.state, VotingState::Voting)
139 }
140
141 /// May this member stand for election? Only a healthy, data-bearing
142 /// member can become primary — a witness holds no data and a
143 /// catching-up replica is not healthy.
144 pub fn is_electable(&self) -> bool {
145 self.kind == MemberKind::Data && self.is_voter()
146 }
147}
148
149/// Quorum threshold for a set of members: a strict majority of the
150/// *voting* members. Witnesses count; catching-up replicas do not.
151///
152/// For `n` voting members the threshold is `floor(n/2) + 1`, the smallest
153/// count such that two qualifying sets always intersect — the structural
154/// basis for "no two primaries in a term".
155pub fn quorum_threshold(members: &[Member]) -> usize {
156 let voters = members.iter().filter(|m| m.is_voter()).count();
157 voters / 2 + 1
158}
159
160// ---------------------------------------------------------------
161// Durable last-vote
162// ---------------------------------------------------------------
163
164/// A node's durable voting record: the highest term it has participated in
165/// and who, if anyone, it granted that term. Persisted so a restart cannot
166/// erase the fact that a vote was already cast (requirement 2).
167#[derive(Debug, Clone, PartialEq, Eq, Default)]
168pub struct LastVote {
169 /// Highest term this node has observed in a (real) vote request.
170 pub term: u64,
171 /// Who this node granted `term` to, if anyone yet.
172 pub voted_for: Option<String>,
173}
174
175impl LastVote {
176 fn from_file(value: reddb_file::DurableLastVote) -> Self {
177 Self {
178 term: value.term,
179 voted_for: value.voted_for,
180 }
181 }
182
183 fn to_file(&self) -> reddb_file::DurableLastVote {
184 reddb_file::DurableLastVote::new(self.term, self.voted_for.clone())
185 }
186}
187
188#[derive(Debug)]
189pub enum LastVoteError {
190 Io(std::io::Error),
191 InvalidFormat(String),
192}
193
194impl std::fmt::Display for LastVoteError {
195 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196 match self {
197 Self::Io(err) => write!(f, "last-vote io error: {err}"),
198 Self::InvalidFormat(msg) => write!(f, "invalid last-vote format: {msg}"),
199 }
200 }
201}
202
203impl std::error::Error for LastVoteError {}
204
205impl From<reddb_file::RdbFileError> for LastVoteError {
206 fn from(value: reddb_file::RdbFileError) -> Self {
207 match value {
208 reddb_file::RdbFileError::Io(err) => Self::Io(err),
209 reddb_file::RdbFileError::InvalidOperation(msg) => Self::InvalidFormat(msg),
210 // The zone message names the zone and points at scrub/salvage
211 // (ADR 0074 §2/§4); keep it verbatim rather than flattening it.
212 err @ reddb_file::RdbFileError::ZoneUnrecoverable { .. } => {
213 Self::InvalidFormat(err.to_string())
214 }
215 }
216 }
217}
218
219/// Durable store for a node's last vote. The contract is narrow on purpose:
220/// `load` returns the persisted record (or the default `term 0, voted_for
221/// None` when nothing was ever written), and `persist` makes a record
222/// durable *before* the caller acknowledges a grant.
223pub trait LastVoteStore {
224 fn load(&self) -> Result<LastVote, LastVoteError>;
225 fn persist(&self, vote: &LastVote) -> Result<(), LastVoteError>;
226}
227
228/// In-memory last-vote store for tests and witnesses that do not need
229/// cross-restart durability. (A witness *should* still persist in
230/// production; the file store is used there.)
231#[derive(Debug, Default)]
232pub struct MemoryLastVoteStore {
233 inner: std::sync::Mutex<LastVote>,
234}
235
236impl MemoryLastVoteStore {
237 pub fn new() -> Self {
238 Self::default()
239 }
240
241 /// Seed an initial record — used by tests to simulate a node that
242 /// already voted before a restart.
243 pub fn seeded(vote: LastVote) -> Self {
244 Self {
245 inner: std::sync::Mutex::new(vote),
246 }
247 }
248}
249
250impl LastVoteStore for MemoryLastVoteStore {
251 fn load(&self) -> Result<LastVote, LastVoteError> {
252 Ok(self.inner.lock().expect("last-vote mutex").clone())
253 }
254
255 fn persist(&self, vote: &LastVote) -> Result<(), LastVoteError> {
256 *self.inner.lock().expect("last-vote mutex") = vote.clone();
257 Ok(())
258 }
259}
260
261impl LastVoteStore for FileLastVoteStore {
262 fn load(&self) -> Result<LastVote, LastVoteError> {
263 self.load_file()
264 .map(LastVote::from_file)
265 .map_err(LastVoteError::from)
266 }
267
268 fn persist(&self, vote: &LastVote) -> Result<(), LastVoteError> {
269 self.persist_file(&vote.to_file())
270 .map_err(LastVoteError::from)
271 }
272}
273
274// ---------------------------------------------------------------
275// Vote request / decision
276// ---------------------------------------------------------------
277
278/// A request for a vote, sent by a candidate to a voter.
279#[derive(Debug, Clone, PartialEq, Eq)]
280pub struct VoteRequest {
281 /// The candidate's stable identity.
282 pub candidate_id: String,
283 /// The term the candidate is standing for. For a real election this is
284 /// `current_term + 1`; for a dry-run probe it is the *prospective* term
285 /// the candidate would stand for, evaluated without committing to it.
286 pub term: u64,
287 /// The candidate's log frontier — the highest LSN durably in its log.
288 /// The watermark rule compares this against the commit watermark.
289 pub last_log_lsn: u64,
290 /// A dry-run probe gathers a would-be vote without persisting it or
291 /// advancing the voter's term (requirement 1). A real election sets
292 /// this `false`, which is the only path that persists a last-vote.
293 pub dry_run: bool,
294}
295
296impl VoteRequest {
297 pub fn probe(candidate_id: impl Into<String>, term: u64, last_log_lsn: u64) -> Self {
298 Self {
299 candidate_id: candidate_id.into(),
300 term,
301 last_log_lsn,
302 dry_run: true,
303 }
304 }
305
306 pub fn real(candidate_id: impl Into<String>, term: u64, last_log_lsn: u64) -> Self {
307 Self {
308 candidate_id: candidate_id.into(),
309 term,
310 last_log_lsn,
311 dry_run: false,
312 }
313 }
314}
315
316/// Why a voter refused a candidate.
317#[derive(Debug, Clone, PartialEq, Eq)]
318pub enum RefusalReason {
319 /// The candidate's log does not cover the commit watermark, so an
320 /// acknowledged synchronous write could be lost — the safety core
321 /// refuses (requirement 3).
322 WatermarkNotCovered { candidate_lsn: u64, watermark: u64 },
323 /// The candidate's term is not newer than a term this voter already
324 /// participated in, and the voter already granted that term to someone
325 /// else (durable double-vote guard, requirement 2).
326 AlreadyVoted { term: u64, voted_for: String },
327 /// The candidate's term is older than the voter's current term — a
328 /// stale candidate from a superseded term.
329 StaleTerm {
330 candidate_term: u64,
331 voter_term: u64,
332 },
333}
334
335/// The outcome of a voter considering a [`VoteRequest`].
336#[derive(Debug, Clone, PartialEq, Eq)]
337pub enum VoteDecision {
338 /// Vote granted. For a real (non-dry-run) request the grant has already
339 /// been persisted durably before this value was produced.
340 Granted,
341 /// Vote refused, with the reason.
342 Refused(RefusalReason),
343}
344
345impl VoteDecision {
346 pub fn is_granted(&self) -> bool {
347 matches!(self, VoteDecision::Granted)
348 }
349}
350
351// ---------------------------------------------------------------
352// Voter (voter-side vote rule)
353// ---------------------------------------------------------------
354
355/// A voting member. Wraps the durable [`LastVoteStore`] and applies the
356/// vote rule. The voter is the seat of correctness: the watermark rule and
357/// the durable double-vote guard both live here.
358pub struct Voter<S: LastVoteStore> {
359 id: String,
360 store: S,
361}
362
363impl<S: LastVoteStore> Voter<S> {
364 pub fn new(id: impl Into<String>, store: S) -> Self {
365 Self {
366 id: id.into(),
367 store,
368 }
369 }
370
371 pub fn id(&self) -> &str {
372 &self.id
373 }
374
375 /// This voter's current term — the highest term it has durably recorded.
376 pub fn current_term(&self) -> Result<u64, LastVoteError> {
377 Ok(self.store.load()?.term)
378 }
379
380 /// Consider a vote request against the current `commit_watermark`.
381 ///
382 /// The decision order is deliberate:
383 ///
384 /// 1. **Watermark first** — the safety core. A candidate that cannot
385 /// carry an acknowledged write is refused regardless of term, so the
386 /// durability guarantee can never be traded away for liveness.
387 /// 2. **Stale term** — reject candidates from a superseded term.
388 /// 3. **Double-vote guard** — within a term, a voter grants exactly one
389 /// candidate; a re-ask by the *same* candidate is idempotently
390 /// re-granted.
391 ///
392 /// For a real (non-dry-run) grant, the new `(term, candidate)` is
393 /// persisted **before** `Granted` is returned, so the durability holds
394 /// across a crash at any point after the caller observes the grant.
395 pub fn consider(
396 &self,
397 req: &VoteRequest,
398 commit_watermark: u64,
399 ) -> Result<VoteDecision, LastVoteError> {
400 // 1. Watermark rule — never relaxed, checked before anything else.
401 if req.last_log_lsn < commit_watermark {
402 return Ok(VoteDecision::Refused(RefusalReason::WatermarkNotCovered {
403 candidate_lsn: req.last_log_lsn,
404 watermark: commit_watermark,
405 }));
406 }
407
408 let last = self.store.load()?;
409
410 // 2. Stale term — the candidate is behind a term we already moved past.
411 if req.term < last.term {
412 return Ok(VoteDecision::Refused(RefusalReason::StaleTerm {
413 candidate_term: req.term,
414 voter_term: last.term,
415 }));
416 }
417
418 // 3. Double-vote guard within the *same* term.
419 if req.term == last.term {
420 match &last.voted_for {
421 // Already voted for someone else this term — refuse.
422 Some(other) if other != &req.candidate_id => {
423 return Ok(VoteDecision::Refused(RefusalReason::AlreadyVoted {
424 term: last.term,
425 voted_for: other.clone(),
426 }));
427 }
428 // Already voted for this same candidate — idempotent re-grant.
429 Some(_) => return Ok(VoteDecision::Granted),
430 // Same term observed but no vote cast yet — fall through to grant.
431 None => {}
432 }
433 }
434
435 // Grant. A dry-run probe must not persist or advance the term
436 // (requirement 1); a real grant persists durably before acking.
437 if !req.dry_run {
438 self.store.persist(&LastVote {
439 term: req.term,
440 voted_for: Some(req.candidate_id.clone()),
441 })?;
442 }
443 Ok(VoteDecision::Granted)
444 }
445}
446
447// ---------------------------------------------------------------
448// Randomized election timeout
449// ---------------------------------------------------------------
450
451/// A randomized election timeout in `[base, base + jitter)`.
452///
453/// Randomization keeps candidates from standing in lockstep, which is what
454/// makes split votes rare and self-correcting (requirement 4). The function
455/// is pure in `seed` so tests pin a deterministic value; production passes
456/// an entropy-derived seed.
457pub fn randomized_election_timeout(base: Duration, jitter: Duration, seed: u64) -> Duration {
458 if jitter.is_zero() {
459 return base;
460 }
461 let jitter_ms = jitter.as_millis().max(1) as u64;
462 base + Duration::from_millis(seed % jitter_ms)
463}
464
465// ---------------------------------------------------------------
466// ElectionCoordinator (candidate-side state machine)
467// ---------------------------------------------------------------
468
469/// A request to run an election on behalf of `candidate`.
470#[derive(Debug, Clone, PartialEq, Eq)]
471pub struct ElectionRequest {
472 /// The candidate standing for election. Must be electable (a healthy,
473 /// data-bearing member) or [`ElectionCoordinator::run`] refuses up front.
474 pub candidate: Member,
475 /// The term the cluster is serving now. A real election stands for
476 /// `current_term + 1`.
477 pub current_term: u64,
478 /// The candidate's log frontier — the highest LSN durably in its log.
479 pub last_log_lsn: u64,
480 /// The commit watermark the candidate believes is in force. The
481 /// candidate must itself cover it to be electable; voters re-check
482 /// against their own watermark view.
483 pub commit_watermark: u64,
484}
485
486impl ElectionRequest {
487 /// The term a successful election produces.
488 pub fn new_term(&self) -> u64 {
489 self.current_term + 1
490 }
491}
492
493/// The result of an election attempt.
494#[derive(Debug, Clone, PartialEq, Eq)]
495pub enum ElectionOutcome {
496 /// Won a majority and was promoted under `term`. `votes`/`needed` record
497 /// the tally (including the candidate's self-vote).
498 Elected {
499 term: u64,
500 votes: usize,
501 needed: usize,
502 },
503 /// The dry-run probe did not reach a majority, so no term was bumped and
504 /// no real election was attempted (requirement 1).
505 ProbeFailed { votes: usize, needed: usize },
506 /// A real election was attempted (term bumped) but did not reach a
507 /// majority — e.g. votes split or peers came online between probe and
508 /// election. The term has advanced; a later attempt stands for a higher
509 /// term.
510 Lost {
511 term: u64,
512 votes: usize,
513 needed: usize,
514 },
515 /// The candidate is not electable (a witness, or a catching-up replica,
516 /// or its own log does not cover the watermark). No probe was sent.
517 NotElectable,
518 /// The election ran past its randomized timeout before collecting a
519 /// majority. No promotion happened.
520 TimedOut { votes: usize, needed: usize },
521}
522
523impl ElectionOutcome {
524 pub fn is_elected(&self) -> bool {
525 matches!(self, ElectionOutcome::Elected { .. })
526 }
527}
528
529/// Cluster operations the candidate drives, injected so the state machine
530/// stays pure and deterministically testable. Production backs these onto
531/// the membership view, the per-peer vote RPC, the durable term store, and
532/// the FAILOVER handover; tests back them onto a scripted fake.
533pub trait ElectionTransport {
534 /// The candidate's current view of cluster membership. The denominator
535 /// for the majority is the *voting* members of this set.
536 fn members(&self) -> Vec<Member>;
537
538 /// Ask one peer for its vote. The candidate never asks itself (it always
539 /// self-grants). Implementors route this to the peer's [`Voter`].
540 fn request_vote(&mut self, peer_id: &str, req: &VoteRequest) -> VoteDecision;
541
542 /// Time elapsed since the election began, so the coordinator enforces
543 /// the randomized timeout without owning a clock.
544 fn elapsed(&self) -> Duration;
545
546 /// Durably advance this node's current term to `new_term`. Called once,
547 /// only when a real election begins (never for a dry-run). Persisted
548 /// alongside the node's other durable replication state.
549 fn bump_term(&mut self, new_term: u64);
550
551 /// Promote the candidate to primary under `new_term`, reusing the
552 /// FAILOVER handover machinery ([`super::failover`]). Called only after
553 /// a majority is collected in the real election.
554 fn promote(&mut self, new_term: u64);
555}
556
557/// The quorum-gated election state machine.
558pub struct ElectionCoordinator;
559
560impl ElectionCoordinator {
561 /// Run an election for `req`, driving the cluster through `tx`, bounded
562 /// by `timeout` (use [`randomized_election_timeout`]).
563 ///
564 /// The flow is: electability guard → dry-run probe (no term bump) →
565 /// real election (bump term, collect votes) → promote on majority. See
566 /// the module docs for the full contract.
567 pub fn run(
568 req: &ElectionRequest,
569 tx: &mut dyn ElectionTransport,
570 timeout: Duration,
571 ) -> ElectionOutcome {
572 // Electability guard. A witness or catching-up replica may not
573 // stand; nor may a candidate whose own log does not cover the
574 // watermark (it would violate the safety core the instant it won).
575 if !req.candidate.is_electable() || req.last_log_lsn < req.commit_watermark {
576 return ElectionOutcome::NotElectable;
577 }
578
579 let members = tx.members();
580 let needed = quorum_threshold(&members);
581 let new_term = req.new_term();
582
583 // The peers we ask: every *other* voting member. The candidate
584 // self-grants, so it is one vote without an RPC.
585 let peers: Vec<String> = members
586 .iter()
587 .filter(|m| m.is_voter() && m.id != req.candidate.id)
588 .map(|m| m.id.clone())
589 .collect();
590
591 // ---- Phase 1: dry-run probe (does NOT bump the term) ----
592 let probe = VoteRequest::probe(&req.candidate.id, new_term, req.last_log_lsn);
593 let probe_votes = match Self::collect(tx, &peers, &probe, needed, timeout) {
594 CollectResult::Reached(v) => v,
595 CollectResult::Exhausted(v) => {
596 return ElectionOutcome::ProbeFailed { votes: v, needed }
597 }
598 CollectResult::TimedOut(v) => return ElectionOutcome::TimedOut { votes: v, needed },
599 };
600 debug_assert!(probe_votes >= needed);
601
602 // ---- Phase 2: real election (bumps the term, then collects) ----
603 tx.bump_term(new_term);
604 let ballot = VoteRequest::real(&req.candidate.id, new_term, req.last_log_lsn);
605 match Self::collect(tx, &peers, &ballot, needed, timeout) {
606 CollectResult::Reached(votes) => {
607 tx.promote(new_term);
608 ElectionOutcome::Elected {
609 term: new_term,
610 votes,
611 needed,
612 }
613 }
614 CollectResult::Exhausted(votes) => ElectionOutcome::Lost {
615 term: new_term,
616 votes,
617 needed,
618 },
619 CollectResult::TimedOut(votes) => ElectionOutcome::TimedOut { votes, needed },
620 }
621 }
622
623 /// Collect votes from `peers`, starting at 1 for the candidate's
624 /// self-vote, until `needed` is reached, the peers are exhausted, or the
625 /// timeout elapses. Stops early on success — no need to ask the rest.
626 fn collect(
627 tx: &mut dyn ElectionTransport,
628 peers: &[String],
629 req: &VoteRequest,
630 needed: usize,
631 timeout: Duration,
632 ) -> CollectResult {
633 let mut votes = 1usize; // self-vote
634 if votes >= needed {
635 return CollectResult::Reached(votes);
636 }
637 for peer in peers {
638 if tx.elapsed() >= timeout {
639 return CollectResult::TimedOut(votes);
640 }
641 if tx.request_vote(peer, req).is_granted() {
642 votes += 1;
643 if votes >= needed {
644 return CollectResult::Reached(votes);
645 }
646 }
647 }
648 CollectResult::Exhausted(votes)
649 }
650}
651
652enum CollectResult {
653 Reached(usize),
654 Exhausted(usize),
655 TimedOut(usize),
656}
657
658#[cfg(test)]
659mod tests;