diff options
| author | tv <tv@krebsco.de> | 2026-10-24 13:09:22 +0200 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-10-24 13:09:22 +0200 |
| commit | 1df85d13453b717dbee8f4ebf47b87de4f647866 (patch) | |
| tree | e86df90da61f74320c99aa5b19f5871c6918f09d /src/remake/vote.rs | |
| parent | c852ea8478963f5298e5b186ca5e1d7284504fd1 (diff) | |
introduce majority voting on remakes
Diffstat (limited to 'src/remake/vote.rs')
| -rw-r--r-- | src/remake/vote.rs | 291 |
1 files changed, 291 insertions, 0 deletions
diff --git a/src/remake/vote.rs b/src/remake/vote.rs new file mode 100644 index 0000000..d73326d --- /dev/null +++ b/src/remake/vote.rs @@ -0,0 +1,291 @@ +//! Votes on remakes: a remake commits only once a majority of its electorate voted for +//! it. +//! +//! The electorate is fixed in the proposal: the members that renewed their alive stamp +//! within the coordinator's `--electorate-window-days` (`swarm::members::electorate`), +//! the coordinator always included. Each member votes for at most one open remake of +//! its epoch: the first it sees (the oldest proposal, if it sees several at once). Two +//! competing remakes, even in different partitions, can't both reach a majority, so at +//! most one commits. A vote for a remake that aborted or expired is void; the member +//! may vote again. +//! +//! Votes live in the record (`votes/<pk>`, written only by that member). A node votes +//! once it is ready, on its current epoch, and only if it is in the electorate. +//! +//! Gap: votes are not persisted locally. A node whose vote reached only the coordinator +//! and that then restarts and resyncs from nodes that never saw the vote may vote again +//! for a competitor. + +use std::collections::HashSet; + +use tracing::{info, warn}; + +use super::Host; +use super::records::{self, EpochRecord, Phase, Record, Vote, unix_millis}; +use crate::document::store::StoreError; + +/// A majority of `electorate` members. +pub(crate) fn quorum(electorate: usize) -> usize { + electorate / 2 + 1 +} + +/// Where a remake stands with its electorate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Tally { + pub(crate) electorate: usize, + pub(crate) quorum: usize, + /// Voted for it. + pub(crate) yes: usize, + /// Voted for a competing open remake. + pub(crate) elsewhere: usize, + /// Not voted (yet). + pub(crate) pending: usize, +} + +impl Tally { + pub(crate) fn reached(&self) -> bool { + self.yes >= self.quorum + } + + /// Even if every pending member votes for it, it falls short. + pub(crate) fn out_of_reach(&self) -> bool { + self.yes + self.pending < self.quorum + } +} + +impl std::fmt::Display for Tally { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} of {} voted for it, {} for a competing remake, {} not yet; {} needed", + self.yes, self.electorate, self.elsewhere, self.pending, self.quorum + ) + } +} + +fn open(record: &Record, now: u64, grace: u64) -> bool { + record.phase(now, grace).is_open() +} + +/// The tally of remake `id` among `all` records. +pub(crate) fn tally( + id: &str, + record: &Record, + all: &[(String, Record)], + now: u64, + grace: u64, +) -> Tally { + let electorate: HashSet<&str> = record + .proposal + .electorate + .iter() + .map(String::as_str) + .collect(); + let yes: HashSet<&str> = record + .votes + .keys() + .map(String::as_str) + .filter(|voter| electorate.contains(voter)) + .collect(); + let elsewhere: HashSet<&str> = all + .iter() + .filter(|(other, candidate)| { + other != id + && candidate.proposal.from_epoch == record.proposal.from_epoch + && open(candidate, now, grace) + }) + .flat_map(|(_, candidate)| candidate.votes.keys().map(String::as_str)) + .filter(|voter| electorate.contains(voter) && !yes.contains(voter)) + .collect(); + Tally { + electorate: electorate.len(), + quorum: record.proposal.quorum, + yes: yes.len(), + elsewhere: elsewhere.len(), + pending: electorate.len() - yes.len() - elsewhere.len(), + } +} + +/// The remake `me` should vote for now, if any: none if it already voted for an open +/// remake of `epoch`; otherwise the oldest proposed one whose electorate includes it. +pub(crate) fn choice( + me: &str, + epoch: &EpochRecord, + all: &[(String, Record)], + now: u64, + grace: u64, +) -> Option<String> { + let of_epoch = || { + all.iter() + .filter(move |(_, record)| &record.proposal.from_epoch == epoch) + }; + if of_epoch().any(|(_, record)| open(record, now, grace) && record.votes.contains_key(me)) { + return None; + } + of_epoch() + .filter(|(_, record)| { + record.phase(now, grace) == Phase::Proposed + && record.proposal.electorate.iter().any(|member| member == me) + }) + .min_by(|(a_id, a), (b_id, b)| { + (a.proposal.proposed_at, a_id).cmp(&(b.proposal.proposed_at, b_id)) + }) + .map(|(id, _)| id.clone()) +} + +/// Votes for a remake of the local epoch if this node should. Called by the remake +/// worker whenever the records change (and on its timer). +pub(super) async fn cast<H: Host>(host: &H) { + if !host.is_node_ready().await { + return; + } + let me = host.node_id(); + let epoch = EpochRecord::from(host.current_epoch()); + let grace = host.dormant_after().as_millis() as u64; + + // Usually there is nothing to vote on: look first, without writing. + let (voter, wanted) = (me.clone(), epoch.clone()); + let due = host + .read_doc(move |doc| { + choice( + &voter, + &wanted, + &records::load_all(doc), + unix_millis(), + grace, + ) + .is_some() + }) + .await; + if !due { + return; + } + + // Decided again under the write lock, so two passes can't both vote. + let voter = me.clone(); + let result = host + .commit_doc(move |doc| -> Result<Option<String>, StoreError> { + let now = unix_millis(); + let all = records::load_all(doc); + let Some(id) = choice(&voter, &epoch, &all, now, grace) else { + return Ok(None); + }; + records::write_vote(doc, &id, &voter, &Vote { at: now })?; + Ok(Some(id)) + }) + .await; + match result { + Ok(Some(id)) => info!(remake = %id, "Voted for remake"), + Ok(None) => {} + Err(error) => warn!(error = %format!("{error:#}"), "Failed to vote on a remake"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::document::replica::Epoch; + use crate::remake::records::{Decision, Policy, Proposal}; + use serde_json::Value as JsonValue; + use std::collections::BTreeMap; + + fn record(proposed_at: u64, electorate: &[&str], votes: &[&str]) -> Record { + Record { + proposal: Proposal { + coordinator: electorate[0].to_owned(), + from_epoch: Epoch::GENESIS.into(), + base_heads: Vec::new(), + policy: Policy { + replay_window: Policy::default_replay_window(), + archive_retention: Policy::default_archive_retention(), + }, + settle_ms: 1_000, + proposed_at, + settle_until: proposed_at + 1_000, + switch_delay_ms: None, + electorate: electorate.iter().map(|m| (*m).to_owned()).collect(), + quorum: quorum(electorate.len()), + kind: "compact".to_owned(), + content: JsonValue::Null, + translation: JsonValue::Null, + }, + decision: None, + abort_requested: None, + votes: votes + .iter() + .map(|v| ((*v).to_owned(), Vote { at: proposed_at })) + .collect::<BTreeMap<_, _>>(), + } + } + + const ALL: [&str; 5] = ["a", "b", "c", "d", "e"]; + + #[test] + fn majorities() { + assert_eq!(quorum(1), 1); + assert_eq!(quorum(2), 2); + assert_eq!(quorum(3), 2); + assert_eq!(quorum(4), 3); + assert_eq!(quorum(250), 126); + } + + #[test] + fn competing_remakes_split_the_vote() { + let all = vec![ + ("x".to_owned(), record(100, &ALL, &["a", "b"])), + ("y".to_owned(), record(200, &ALL, &["c", "z"])), // z isn't in the electorate + ]; + let x = tally("x", &all[0].1, &all, 150, 0); + assert_eq!((x.yes, x.elsewhere, x.pending, x.quorum), (2, 1, 2, 3)); + assert!(!x.reached() && !x.out_of_reach()); + + let y = tally("y", &all[1].1, &all, 150, 0); + assert_eq!((y.yes, y.elsewhere, y.pending), (1, 2, 2)); + assert!(!y.out_of_reach()); + + // d votes for x: x has its majority, y can't get one any more. + let all = vec![ + ("x".to_owned(), record(100, &ALL, &["a", "b", "d"])), + ("y".to_owned(), record(200, &ALL, &["c"])), + ]; + assert!(tally("x", &all[0].1, &all, 150, 0).reached()); + assert!(tally("y", &all[1].1, &all, 150, 0).out_of_reach()); + } + + #[test] + fn votes_for_aborted_remakes_are_void() { + let mut aborted = record(100, &ALL, &["a", "b", "c"]); + aborted.decision = Some(Decision::Aborted { + reason: "test".into(), + decided_at: 150, + }); + let all = vec![ + ("x".to_owned(), aborted), + ("y".to_owned(), record(200, &ALL, &["d"])), + ]; + let y = tally("y", &all[1].1, &all, 250, 0); + assert_eq!((y.yes, y.elsewhere, y.pending), (1, 0, 4)); + // c may vote again. + assert_eq!( + choice("c", &Epoch::GENESIS.into(), &all, 250, 0).as_deref(), + Some("y") + ); + } + + #[test] + fn one_vote_per_epoch_for_the_oldest() { + let all = vec![ + ("y".to_owned(), record(200, &ALL, &[])), + ("x".to_owned(), record(100, &ALL, &[])), + ]; + let epoch: EpochRecord = Epoch::GENESIS.into(); + assert_eq!(choice("a", &epoch, &all, 150, 0).as_deref(), Some("x")); + assert_eq!(choice("outsider", &epoch, &all, 150, 0), None); + + let voted = vec![ + ("y".to_owned(), record(200, &ALL, &["a"])), + ("x".to_owned(), record(100, &ALL, &[])), + ]; + assert_eq!(choice("a", &epoch, &voted, 150, 0), None); + } +} |
