1use parking_lot::RwLock;
61use std::collections::HashMap;
62use std::fmt;
63use std::sync::atomic::{AtomicU64, Ordering};
64use std::time::{Duration, Instant};
65use thiserror::Error;
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
71pub struct RoundId(pub u64);
72
73impl RoundId {
74 pub fn next(&self) -> Self {
76 Self(self.0 + 1)
77 }
78}
79
80impl fmt::Display for RoundId {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 write!(f, "round-{}", self.0)
83 }
84}
85
86impl From<u64> for RoundId {
87 fn from(v: u64) -> Self {
88 Self(v)
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum Vote {
97 Commit,
99 Abort,
101 Abstain,
103}
104
105#[derive(Debug, Clone)]
109pub struct PeerVote {
110 pub peer_id: String,
112 pub round_id: RoundId,
114 pub vote: Vote,
116 pub voted_at: Instant,
118 pub gradient_cid: Option<String>,
121}
122
123impl PeerVote {
124 pub fn new(
126 peer_id: String,
127 round_id: RoundId,
128 vote: Vote,
129 gradient_cid: Option<String>,
130 ) -> Self {
131 Self {
132 peer_id,
133 round_id,
134 vote,
135 voted_at: Instant::now(),
136 gradient_cid,
137 }
138 }
139}
140
141#[derive(Debug, Clone, PartialEq)]
145pub enum QuorumResult {
146 Commit {
148 gradient_cids: Vec<String>,
150 },
151 Abort {
153 reason: String,
155 },
156 Pending {
158 votes_received: usize,
160 votes_needed: usize,
162 },
163 TimedOut,
165}
166
167#[derive(Debug, Clone)]
171pub struct QuorumPolicy {
172 pub min_peers: usize,
174 pub commit_threshold: f64,
177 pub timeout: Duration,
179}
180
181impl Default for QuorumPolicy {
182 fn default() -> Self {
183 Self {
184 min_peers: 3,
185 commit_threshold: 0.67,
186 timeout: Duration::from_secs(30),
187 }
188 }
189}
190
191impl QuorumPolicy {
192 pub fn check(&self, votes: &[PeerVote], elapsed: Duration) -> QuorumResult {
197 if elapsed >= self.timeout {
199 return QuorumResult::TimedOut;
200 }
201
202 let total = votes.len();
203 let commit_count = votes.iter().filter(|v| v.vote == Vote::Commit).count();
204 let abort_or_abstain = votes.iter().filter(|v| v.vote != Vote::Commit).count();
205
206 if total >= self.min_peers {
208 let fraction = commit_count as f64 / total as f64;
209 if fraction >= self.commit_threshold {
210 let gradient_cids = votes
211 .iter()
212 .filter(|v| v.vote == Vote::Commit)
213 .filter_map(|v| v.gradient_cid.clone())
214 .collect();
215 return QuorumResult::Commit { gradient_cids };
216 }
217 }
218
219 if total >= self.min_peers {
243 let non_commit_fraction = abort_or_abstain as f64 / total as f64;
244 if non_commit_fraction > (1.0 - self.commit_threshold) {
245 return QuorumResult::Abort {
246 reason: format!(
247 "commit threshold unreachable: {}/{} votes are non-commit ({:.0}% > {:.0}% max)",
248 abort_or_abstain,
249 total,
250 non_commit_fraction * 100.0,
251 (1.0 - self.commit_threshold) * 100.0,
252 ),
253 };
254 }
255 }
256
257 let votes_needed = if total < self.min_peers {
259 self.min_peers - total
260 } else {
261 let commits_needed = (self.commit_threshold * (total + 1) as f64).ceil() as usize;
263 commits_needed.saturating_sub(commit_count)
264 };
265
266 QuorumResult::Pending {
267 votes_received: total,
268 votes_needed,
269 }
270 }
271}
272
273#[derive(Debug, Clone)]
277pub enum RoundStatus {
278 Active,
280 Committed,
282 Aborted {
284 reason: String,
286 },
287}
288
289impl RoundStatus {
290 fn is_terminal(&self) -> bool {
292 matches!(self, RoundStatus::Committed | RoundStatus::Aborted { .. })
293 }
294}
295
296struct RoundState {
300 round_id: u64,
301 #[allow(dead_code)]
304 expected_peers: Vec<String>,
305 votes: Vec<PeerVote>,
306 started_at: Instant,
307 status: RoundStatus,
308}
309
310impl RoundState {
311 fn new(round_id: u64, expected_peers: Vec<String>) -> Self {
312 Self {
313 round_id,
314 expected_peers,
315 votes: Vec::new(),
316 started_at: Instant::now(),
317 status: RoundStatus::Active,
318 }
319 }
320
321 fn elapsed(&self) -> Duration {
322 self.started_at.elapsed()
323 }
324
325 fn has_voted(&self, peer_id: &str) -> bool {
326 self.votes.iter().any(|v| v.peer_id == peer_id)
327 }
328}
329
330pub struct ConsensusStats {
334 pub total_rounds_started: AtomicU64,
336 pub total_committed: AtomicU64,
338 pub total_aborted: AtomicU64,
340 pub total_votes_cast: AtomicU64,
342}
343
344impl ConsensusStats {
345 fn new() -> Self {
346 Self {
347 total_rounds_started: AtomicU64::new(0),
348 total_committed: AtomicU64::new(0),
349 total_aborted: AtomicU64::new(0),
350 total_votes_cast: AtomicU64::new(0),
351 }
352 }
353
354 pub fn snapshot(&self) -> ConsensusStatsSnapshot {
356 ConsensusStatsSnapshot {
357 total_rounds_started: self.total_rounds_started.load(Ordering::Relaxed),
358 total_committed: self.total_committed.load(Ordering::Relaxed),
359 total_aborted: self.total_aborted.load(Ordering::Relaxed),
360 total_votes_cast: self.total_votes_cast.load(Ordering::Relaxed),
361 }
362 }
363}
364
365impl fmt::Debug for ConsensusStats {
366 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
367 f.debug_struct("ConsensusStats")
368 .field(
369 "total_rounds_started",
370 &self.total_rounds_started.load(Ordering::Relaxed),
371 )
372 .field(
373 "total_committed",
374 &self.total_committed.load(Ordering::Relaxed),
375 )
376 .field("total_aborted", &self.total_aborted.load(Ordering::Relaxed))
377 .field(
378 "total_votes_cast",
379 &self.total_votes_cast.load(Ordering::Relaxed),
380 )
381 .finish()
382 }
383}
384
385#[derive(Debug, Clone, PartialEq, Eq)]
387pub struct ConsensusStatsSnapshot {
388 pub total_rounds_started: u64,
390 pub total_committed: u64,
392 pub total_aborted: u64,
394 pub total_votes_cast: u64,
396}
397
398#[derive(Debug, Error, Clone, PartialEq, Eq)]
402pub enum ConsensusError {
403 #[error("round {0} not found")]
405 RoundNotFound(u64),
406
407 #[error("round {0} already exists")]
409 RoundAlreadyExists(u64),
410
411 #[error("round {0} is not active")]
414 RoundNotActive(u64),
415
416 #[error("duplicate vote from peer '{peer_id}' in round {round_id}")]
418 DuplicateVote {
419 peer_id: String,
421 round_id: u64,
423 },
424}
425
426pub struct RoundConsensusTracker {
434 rounds: RwLock<HashMap<u64, RoundState>>,
435 pub policy: QuorumPolicy,
437 pub stats: ConsensusStats,
439}
440
441impl RoundConsensusTracker {
442 pub fn new(policy: QuorumPolicy) -> Self {
444 Self {
445 rounds: RwLock::new(HashMap::new()),
446 policy,
447 stats: ConsensusStats::new(),
448 }
449 }
450
451 pub fn begin_round(
456 &self,
457 round_id: RoundId,
458 expected_peers: Vec<String>,
459 ) -> Result<(), ConsensusError> {
460 let key = round_id.0;
461 let mut guard = self.rounds.write();
462 if guard.contains_key(&key) {
463 return Err(ConsensusError::RoundAlreadyExists(key));
464 }
465 guard.insert(key, RoundState::new(key, expected_peers));
466 drop(guard);
467 self.stats
468 .total_rounds_started
469 .fetch_add(1, Ordering::Relaxed);
470 Ok(())
471 }
472
473 pub fn cast_vote(&self, vote: PeerVote) -> Result<QuorumResult, ConsensusError> {
486 let key = vote.round_id.0;
487 let peer_id = vote.peer_id.clone();
488
489 let mut guard = self.rounds.write();
490 let state = guard
491 .get_mut(&key)
492 .ok_or(ConsensusError::RoundNotFound(key))?;
493
494 if state.status.is_terminal() {
495 return Err(ConsensusError::RoundNotActive(key));
496 }
497 if state.has_voted(&peer_id) {
498 return Err(ConsensusError::DuplicateVote {
499 peer_id,
500 round_id: key,
501 });
502 }
503
504 state.votes.push(vote);
505 self.stats.total_votes_cast.fetch_add(1, Ordering::Relaxed);
506
507 let elapsed = state.elapsed();
508 let result = self.policy.check(&state.votes, elapsed);
509
510 match &result {
511 QuorumResult::Commit { .. } => {
512 state.status = RoundStatus::Committed;
513 drop(guard);
514 self.stats.total_committed.fetch_add(1, Ordering::Relaxed);
515 }
516 QuorumResult::Abort { reason } => {
517 state.status = RoundStatus::Aborted {
518 reason: reason.clone(),
519 };
520 drop(guard);
521 self.stats.total_aborted.fetch_add(1, Ordering::Relaxed);
522 }
523 QuorumResult::TimedOut => {
524 state.status = RoundStatus::Aborted {
525 reason: "timeout".to_string(),
526 };
527 drop(guard);
528 self.stats.total_aborted.fetch_add(1, Ordering::Relaxed);
529 }
530 QuorumResult::Pending { .. } => {
531 drop(guard);
532 }
533 }
534
535 Ok(result)
536 }
537
538 pub fn check_round(&self, round_id: &RoundId) -> Option<QuorumResult> {
541 let key = round_id.0;
542 let guard = self.rounds.read();
543 let state = guard.get(&key)?;
544
545 match &state.status {
546 RoundStatus::Committed => {
547 let gradient_cids = state
548 .votes
549 .iter()
550 .filter(|v| v.vote == Vote::Commit)
551 .filter_map(|v| v.gradient_cid.clone())
552 .collect();
553 Some(QuorumResult::Commit { gradient_cids })
554 }
555 RoundStatus::Aborted { reason } => Some(QuorumResult::Abort {
556 reason: reason.clone(),
557 }),
558 RoundStatus::Active => {
559 let elapsed = state.elapsed();
560 Some(self.policy.check(&state.votes, elapsed))
561 }
562 }
563 }
564
565 pub fn abort_round(&self, round_id: &RoundId, reason: &str) -> Result<(), ConsensusError> {
572 let key = round_id.0;
573 let mut guard = self.rounds.write();
574 let state = guard
575 .get_mut(&key)
576 .ok_or(ConsensusError::RoundNotFound(key))?;
577
578 if state.status.is_terminal() {
579 return Err(ConsensusError::RoundNotActive(key));
580 }
581
582 state.status = RoundStatus::Aborted {
583 reason: reason.to_string(),
584 };
585 drop(guard);
586 self.stats.total_aborted.fetch_add(1, Ordering::Relaxed);
587 Ok(())
588 }
589
590 pub fn complete_rounds(&self) -> Vec<u64> {
593 let guard = self.rounds.read();
594 guard
595 .values()
596 .filter(|s| s.status.is_terminal())
597 .map(|s| s.round_id)
598 .collect()
599 }
600
601 pub fn active_round_count(&self) -> usize {
603 let guard = self.rounds.read();
604 guard
605 .values()
606 .filter(|s| matches!(s.status, RoundStatus::Active))
607 .count()
608 }
609}
610
611impl fmt::Debug for RoundConsensusTracker {
612 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
613 let guard = self.rounds.read();
614 f.debug_struct("RoundConsensusTracker")
615 .field("total_rounds", &guard.len())
616 .field("active_rounds", &self.active_round_count())
617 .field("stats", &self.stats)
618 .finish()
619 }
620}
621
622#[cfg(test)]
625mod tests {
626 use super::*;
627
628 fn default_tracker() -> RoundConsensusTracker {
631 RoundConsensusTracker::new(QuorumPolicy::default())
632 }
633
634 fn make_commit_vote(peer: &str, round: u64, cid: Option<&str>) -> PeerVote {
635 PeerVote::new(
636 peer.to_string(),
637 RoundId::from(round),
638 Vote::Commit,
639 cid.map(|s| s.to_string()),
640 )
641 }
642
643 fn make_abort_vote(peer: &str, round: u64) -> PeerVote {
644 PeerVote::new(peer.to_string(), RoundId::from(round), Vote::Abort, None)
645 }
646
647 fn make_abstain_vote(peer: &str, round: u64) -> PeerVote {
648 PeerVote::new(peer.to_string(), RoundId::from(round), Vote::Abstain, None)
649 }
650
651 fn three_peers() -> Vec<String> {
652 vec!["p1".to_string(), "p2".to_string(), "p3".to_string()]
653 }
654
655 #[test]
658 fn test_begin_round_and_single_vote_pending() {
659 let tracker = default_tracker();
660 let rid = RoundId::from(1);
661 tracker
662 .begin_round(rid, three_peers())
663 .expect("test: should succeed");
664
665 let result = tracker
666 .cast_vote(make_commit_vote("p1", 1, None))
667 .expect("test: should succeed");
668 assert!(
669 matches!(result, QuorumResult::Pending { .. }),
670 "expected Pending after 1/3 votes, got {result:?}"
671 );
672 }
673
674 #[test]
677 fn test_commit_quorum_reached_on_third_vote() {
678 let tracker = default_tracker();
679 let rid = RoundId::from(2);
680 tracker
681 .begin_round(rid, three_peers())
682 .expect("test: should succeed");
683
684 tracker
685 .cast_vote(make_commit_vote("p1", 2, Some("cid1")))
686 .expect("test: should succeed");
687 tracker
688 .cast_vote(make_commit_vote("p2", 2, Some("cid2")))
689 .expect("test: should succeed");
690 let result = tracker
691 .cast_vote(make_commit_vote("p3", 2, Some("cid3")))
692 .expect("test: should succeed");
693
694 match result {
695 QuorumResult::Commit { gradient_cids } => {
696 assert_eq!(gradient_cids.len(), 3);
697 assert!(gradient_cids.contains(&"cid1".to_string()));
698 assert!(gradient_cids.contains(&"cid2".to_string()));
699 assert!(gradient_cids.contains(&"cid3".to_string()));
700 }
701 other => panic!("expected Commit, got {other:?}"),
702 }
703 }
704
705 #[test]
708 fn test_abort_when_majority_votes_abort() {
709 let tracker = default_tracker();
710 let rid = RoundId::from(3);
711 tracker
712 .begin_round(rid, three_peers())
713 .expect("test: should succeed");
714
715 tracker
716 .cast_vote(make_abort_vote("p1", 3))
717 .expect("test: should succeed");
718 tracker
719 .cast_vote(make_abort_vote("p2", 3))
720 .expect("test: should succeed");
721 let result = tracker
722 .cast_vote(make_abort_vote("p3", 3))
723 .expect("test: should succeed");
724
725 assert!(
726 matches!(result, QuorumResult::Abort { .. }),
727 "expected Abort, got {result:?}"
728 );
729 }
730
731 #[test]
734 fn test_timeout_detection() {
735 let policy = QuorumPolicy {
736 min_peers: 1,
737 commit_threshold: 0.67,
738 timeout: Duration::from_millis(1), };
740 let tracker = RoundConsensusTracker::new(policy);
741 let rid = RoundId::from(4);
742 tracker
743 .begin_round(rid, vec!["p1".to_string()])
744 .expect("test: should succeed");
745
746 std::thread::sleep(Duration::from_millis(10));
748
749 let result = tracker
750 .cast_vote(make_commit_vote("p1", 4, None))
751 .expect("test: should succeed");
752
753 assert_eq!(result, QuorumResult::TimedOut);
754 }
755
756 #[test]
759 fn test_duplicate_vote_rejected() {
760 let tracker = default_tracker();
761 tracker
762 .begin_round(RoundId::from(5), three_peers())
763 .expect("test: should succeed");
764 tracker
765 .cast_vote(make_commit_vote("p1", 5, None))
766 .expect("test: should succeed");
767
768 let err = tracker
769 .cast_vote(make_commit_vote("p1", 5, None))
770 .unwrap_err();
771
772 assert!(
773 matches!(err, ConsensusError::DuplicateVote { ref peer_id, round_id } if peer_id == "p1" && round_id == 5),
774 "unexpected error: {err}"
775 );
776 }
777
778 #[test]
781 fn test_round_not_found() {
782 let tracker = default_tracker();
783 let err = tracker
784 .cast_vote(make_commit_vote("p1", 99, None))
785 .unwrap_err();
786 assert_eq!(err, ConsensusError::RoundNotFound(99));
787 }
788
789 #[test]
792 fn test_abort_round_manual() {
793 let tracker = default_tracker();
794 let rid = RoundId::from(7);
795 tracker
796 .begin_round(rid, three_peers())
797 .expect("test: should succeed");
798 tracker
799 .abort_round(&rid, "coordinator decision")
800 .expect("test: should succeed");
801
802 let result = tracker.check_round(&rid).expect("test: should succeed");
803 match result {
804 QuorumResult::Abort { reason } => {
805 assert!(reason.contains("coordinator decision"));
806 }
807 other => panic!("expected Abort, got {other:?}"),
808 }
809 }
810
811 #[test]
814 fn test_complete_rounds_list() {
815 let tracker = default_tracker();
816
817 tracker
819 .begin_round(RoundId::from(10), three_peers())
820 .expect("test: should succeed");
821 tracker
822 .cast_vote(make_commit_vote("p1", 10, None))
823 .expect("test: should succeed");
824 tracker
825 .cast_vote(make_commit_vote("p2", 10, None))
826 .expect("test: should succeed");
827 tracker
828 .cast_vote(make_commit_vote("p3", 10, None))
829 .expect("test: should succeed");
830
831 tracker
833 .begin_round(RoundId::from(11), three_peers())
834 .expect("test: should succeed");
835
836 tracker
838 .begin_round(RoundId::from(12), three_peers())
839 .expect("test: should succeed");
840 tracker
841 .abort_round(&RoundId::from(12), "test")
842 .expect("test: should succeed");
843
844 let complete = tracker.complete_rounds();
845 assert!(
846 complete.contains(&10),
847 "committed round 10 missing: {complete:?}"
848 );
849 assert!(
850 complete.contains(&12),
851 "aborted round 12 missing: {complete:?}"
852 );
853 assert!(
854 !complete.contains(&11),
855 "active round 11 should not be complete"
856 );
857 }
858
859 #[test]
862 fn test_gradient_cids_collected_on_commit() {
863 let tracker = default_tracker();
864 let rid = RoundId::from(9);
865 tracker
866 .begin_round(rid, three_peers())
867 .expect("test: should succeed");
868
869 tracker
871 .cast_vote(make_commit_vote("p1", 9, Some("bafy1")))
872 .expect("test: should succeed");
873 tracker
874 .cast_vote(make_commit_vote("p2", 9, None))
875 .expect("test: should succeed");
876 let result = tracker
877 .cast_vote(make_commit_vote("p3", 9, Some("bafy3")))
878 .expect("test: should succeed");
879
880 match result {
881 QuorumResult::Commit { gradient_cids } => {
882 assert_eq!(gradient_cids.len(), 2);
884 assert!(gradient_cids.contains(&"bafy1".to_string()));
885 assert!(gradient_cids.contains(&"bafy3".to_string()));
886 }
887 other => panic!("expected Commit, got {other:?}"),
888 }
889 }
890
891 #[test]
894 fn test_stats_accumulation() {
895 let tracker = default_tracker();
896
897 tracker
899 .begin_round(RoundId::from(20), three_peers())
900 .expect("test: should succeed");
901 tracker
902 .cast_vote(make_commit_vote("p1", 20, None))
903 .expect("test: should succeed");
904 tracker
905 .cast_vote(make_commit_vote("p2", 20, None))
906 .expect("test: should succeed");
907 tracker
908 .cast_vote(make_commit_vote("p3", 20, None))
909 .expect("test: should succeed"); tracker
912 .begin_round(RoundId::from(21), three_peers())
913 .expect("test: should succeed");
914 tracker
915 .abort_round(&RoundId::from(21), "test")
916 .expect("test: should succeed");
917
918 let snap = tracker.stats.snapshot();
919 assert_eq!(snap.total_rounds_started, 2);
920 assert_eq!(snap.total_committed, 1);
921 assert_eq!(snap.total_aborted, 1);
922 assert_eq!(snap.total_votes_cast, 3);
923 }
924
925 #[test]
928 fn test_round_id_next_and_display() {
929 let r = RoundId::from(5_u64);
930 assert_eq!(r.next(), RoundId::from(6_u64));
931 assert_eq!(r.to_string(), "round-5");
932 }
933
934 #[test]
937 fn test_active_round_count() {
938 let tracker = default_tracker();
939 assert_eq!(tracker.active_round_count(), 0);
940
941 tracker
942 .begin_round(RoundId::from(30), three_peers())
943 .expect("test: should succeed");
944 tracker
945 .begin_round(RoundId::from(31), three_peers())
946 .expect("test: should succeed");
947 assert_eq!(tracker.active_round_count(), 2);
948
949 tracker
950 .abort_round(&RoundId::from(30), "x")
951 .expect("test: should succeed");
952 assert_eq!(tracker.active_round_count(), 1);
953 }
954
955 #[test]
958 fn test_check_round_unknown() {
959 let tracker = default_tracker();
960 assert!(tracker.check_round(&RoundId::from(999)).is_none());
961 }
962
963 #[test]
966 fn test_begin_round_duplicate_returns_error() {
967 let tracker = default_tracker();
968 tracker
969 .begin_round(RoundId::from(40), three_peers())
970 .expect("test: should succeed");
971 let err = tracker
972 .begin_round(RoundId::from(40), three_peers())
973 .unwrap_err();
974 assert_eq!(err, ConsensusError::RoundAlreadyExists(40));
975 }
976
977 #[test]
980 fn test_vote_on_committed_round_returns_not_active() {
981 let tracker = default_tracker();
982 tracker
983 .begin_round(RoundId::from(50), three_peers())
984 .expect("test: should succeed");
985 tracker
986 .cast_vote(make_commit_vote("p1", 50, None))
987 .expect("test: should succeed");
988 tracker
989 .cast_vote(make_commit_vote("p2", 50, None))
990 .expect("test: should succeed");
991 tracker
992 .cast_vote(make_commit_vote("p3", 50, None))
993 .expect("test: should succeed"); let err = tracker
997 .cast_vote(make_commit_vote("p4", 50, None))
998 .unwrap_err();
999 assert_eq!(err, ConsensusError::RoundNotActive(50));
1000 }
1001
1002 #[test]
1005 fn test_abstain_counts_against_commit_threshold() {
1006 let tracker = default_tracker();
1008 tracker
1009 .begin_round(RoundId::from(60), three_peers())
1010 .expect("test: should succeed");
1011
1012 tracker
1013 .cast_vote(make_commit_vote("p1", 60, None))
1014 .expect("test: should succeed");
1015 tracker
1016 .cast_vote(make_commit_vote("p2", 60, None))
1017 .expect("test: should succeed");
1018 let result = tracker
1020 .cast_vote(make_abstain_vote("p3", 60))
1021 .expect("test: should succeed");
1022
1023 assert!(
1029 matches!(
1030 result,
1031 QuorumResult::Abort { .. } | QuorumResult::Pending { .. }
1032 ),
1033 "unexpected result {result:?}"
1034 );
1035 }
1036
1037 #[test]
1040 fn test_abort_already_aborted_round() {
1041 let tracker = default_tracker();
1042 let rid = RoundId::from(70);
1043 tracker
1044 .begin_round(rid, three_peers())
1045 .expect("test: should succeed");
1046 tracker
1047 .abort_round(&rid, "first")
1048 .expect("test: should succeed");
1049
1050 let err = tracker.abort_round(&rid, "second").unwrap_err();
1051 assert_eq!(err, ConsensusError::RoundNotActive(70));
1052 }
1053
1054 #[test]
1057 fn test_custom_policy_unanimous_commit() {
1058 let policy = QuorumPolicy {
1059 min_peers: 3,
1060 commit_threshold: 1.0,
1061 timeout: Duration::from_secs(60),
1062 };
1063 let tracker = RoundConsensusTracker::new(policy);
1064 let rid = RoundId::from(80);
1065 tracker
1066 .begin_round(rid, three_peers())
1067 .expect("test: should succeed");
1068
1069 tracker
1070 .cast_vote(make_commit_vote("p1", 80, None))
1071 .expect("test: should succeed");
1072 tracker
1073 .cast_vote(make_commit_vote("p2", 80, None))
1074 .expect("test: should succeed");
1075 let result = tracker
1076 .cast_vote(make_commit_vote("p3", 80, None))
1077 .expect("test: should succeed");
1078
1079 assert!(
1080 matches!(result, QuorumResult::Commit { .. }),
1081 "unanimous policy should commit: {result:?}"
1082 );
1083 }
1084}