From 8a58bc48be3f13af6e3690302c228d18b1e6f702 Mon Sep 17 00:00:00 2001 From: tv Date: Mon, 26 Oct 2026 21:19:40 +0100 Subject: add test ensuring at most one remake commits --- src/remake/vote.rs | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) (limited to 'src/remake/vote.rs') diff --git a/src/remake/vote.rs b/src/remake/vote.rs index b4ba24e..9bc8890 100644 --- a/src/remake/vote.rs +++ b/src/remake/vote.rs @@ -324,6 +324,8 @@ fn random_part(limit: Duration) -> Duration { #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use super::*; use crate::document::replica::Epoch; use crate::remake::records::{Policy, Proposal}; @@ -571,4 +573,153 @@ mod tests { ]; assert_eq!(freedom("a", &genesis(), &all, &none, started), Freedom::Any); } + + /// What one member knows: the records it has seen (as of when it saw them), the + /// votes register as far as it reached it, and when it last started. + #[derive(Clone, Default)] + struct View { + records: BTreeMap, + votes: Register, + started: u64, + } + + impl View { + fn all(&self) -> Vec<(String, Record)> { + self.records + .iter() + .map(|(id, record)| (id.clone(), record.clone())) + .collect() + } + } + + /// Over random histories in one electorate: concurrent proposals; members that vote + /// on what they have seen, learn records and votes in any order, and restart + /// (losing their own votes, but not the records: a node votes only once it holds + /// the document); coordinators that commit or abort on what *they* have seen. At + /// most one remake of the epoch commits. + #[test] + fn at_most_one_remake_commits() { + const GRACE: u64 = 1_000_000; + crate::testrand::for_seeds(500, |rng| { + let n = rng.between(1, 7); + let members: Vec = (0..n).map(|i| format!("m{i}")).collect(); + let electorate: Vec<&str> = members.iter().map(String::as_str).collect(); + // The truth: every record as decided, every vote cast. + let mut records: BTreeMap = BTreeMap::new(); + let mut cast = Register::new(); + let mut views = vec![View::default(); n]; + let mut now = 1_000; + + for _ in 0..rng.between(1, 60) { + now += rng.between(1, 50) as u64; + let m = rng.below(n); + match rng.below(5) { + // m proposes. + 0 if records.len() < 4 => { + let mut record = record(now, &electorate); + record.proposal.coordinator = members[m].clone(); + let id = format!("r{}", records.len()); + records.insert(id.clone(), record.clone()); + views[m].records.insert(id, record); + } + // m learns a record, and a member's entry of the register. + 1 => { + if let Some((id, record)) = records.iter().nth(rng.below(records.len())) { + views[m].records.insert(id.clone(), record.clone()); + } + let other = &members[rng.below(n)]; + if let Some(votes) = cast.get(other) { + views[m].votes.insert(other.clone(), votes.clone()); + } + } + // m votes, if it should. + 2 => { + let view = &views[m]; + let chosen = choice( + &members[m], + &genesis(), + &view.all(), + &view.votes, + UnixMillis(view.started), + UnixMillis(now), + GRACE, + ); + if let Some(id) = chosen { + let vote = Vote { + remake: id, + at: UnixMillis(now), + }; + cast.entry(members[m].clone()) + .or_default() + .push(vote.clone()); + views[m] + .votes + .entry(members[m].clone()) + .or_default() + .push(vote); + } + } + // m, as a coordinator, decides what it sees: commit on a majority, + // sometimes abort without one. + 3 => { + let all = views[m].all(); + for (id, record) in &all { + if record.proposal.coordinator != members[m] + || record.decision.is_some() + || records[id].decision.is_some() + { + continue; + } + // As `coordinator::obstacle`: not after a competitor's commit. + let competitor = all.iter().any(|(other, candidate)| { + other != id + && matches!( + candidate.decision, + Some(Decision::Committed { .. }) + ) + }); + if competitor { + continue; + } + let decision = if tally(id, record, &all, &views[m].votes).reached() { + Decision::Committed { + to_epoch: Epoch { + generation: 1, + id: [1; 32], + } + .into(), + base_heads: None, + snapshot_bytes: 0, + decided_at: UnixMillis(now), + } + } else if rng.chance(0.3) { + Decision::Aborted { + reason: "test".into(), + decided_at: UnixMillis(now), + } + } else { + continue; + }; + let mut decided = record.clone(); + decided.decision = Some(decision); + records.insert(id.clone(), decided.clone()); + views[m].records.insert(id.clone(), decided); + } + } + // m restarts: its own votes are gone from what it knows. + _ => { + views[m].votes.remove(&members[m]); + views[m].started = now; + } + } + } + + let committed: Vec<&String> = records + .iter() + .filter(|(_, record)| matches!(record.decision, Some(Decision::Committed { .. }))) + .map(|(id, _)| id) + .collect(); + assert!(committed.len() <= 1, "committed: {committed:?}"); + }); + } } -- cgit v1.3.1