summaryrefslogtreecommitdiffstats
path: root/src/remake
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-26 21:19:40 +0100
committertv <tv@krebsco.de>2026-10-26 21:20:27 +0100
commit8a58bc48be3f13af6e3690302c228d18b1e6f702 (patch)
tree27d76bec623da99af6addd91f7b458390aa21f30 /src/remake
parentf06f0bae7ec5b9e9bf063646d528dd5c94a44743 (diff)
add test ensuring at most one remake commits
Diffstat (limited to 'src/remake')
-rw-r--r--src/remake/vote.rs151
1 files changed, 151 insertions, 0 deletions
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<String, Record>,
+ 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<String> = (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<String, Record> = 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:?}");
+ });
+ }
}