Skip to content

Commit 2a42ad1

Browse files
committed
test: turmoil: deterministic fuzz scheduling via BTreeSet
`HashSet` re-seeds its `RandomState` from the OS RNG on every process launch, so iteration order varies across runs. The fuzz driver's scheduling decisions — minority/leader-targeted partition membership, the next membership change set, and the victim picked when shrinking voters — all depended on that order, breaking `--reproduce` even though per-node openraft RNG and turmoil-sim RNG were both already seeded. The voter-removal path used `active_voters.iter().next().unwrap()`, which under `HashSet` happens to pick an "arbitrary" voter but under `BTreeSet` would always pick the smallest id — a fixed bias, not randomness. Switched it to draw from `member_rng`, the existing membership-decisions RNG, so we keep seed-deterministic random-looking choice. Verified: three back-to-back runs of `--reproduce 509 --max-steps 50000` now produce byte-identical output. Previously the same seed could yield PASS, FAIL with `matching: [1, 2]`, or FAIL with `matching: [1, 4]`. This unblocks deterministic investigation of the `CommittedNotOnQuorum` violation captured in `tests-turmoil/ISSUES.md`, which previously could not be pinned to a specific seed for follow-up.
1 parent d095b87 commit 2a42ad1

2 files changed

Lines changed: 21 additions & 16 deletions

File tree

tests-turmoil/src/bin/fuzz.rs

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
//! Reproduce mode: fuzz --reproduce <ITERATION_SEED> --max-steps <N> [--crash-file <PATH>]
66
77
use std::collections::BTreeMap;
8-
use std::collections::HashSet;
8+
use std::collections::BTreeSet;
99
use std::fs;
1010
use std::sync::Arc;
1111
use std::sync::Mutex;
@@ -340,7 +340,7 @@ async fn chaos_agent_loop(
340340
let minority_size = rng.gen_range(1..=max_nodes / 2);
341341
let mut all: Vec<u64> = (1..=max_nodes).collect();
342342
all.shuffle(&mut rng);
343-
let minority: std::collections::HashSet<u64> = all.into_iter().take(minority_size as usize).collect();
343+
let minority: BTreeSet<u64> = all.into_iter().take(minority_size as usize).collect();
344344
for &a in &minority {
345345
for b in 1..=max_nodes {
346346
if !minority.contains(&b) {
@@ -367,7 +367,7 @@ async fn chaos_agent_loop(
367367
};
368368
let mut others: Vec<u64> = (1..=max_nodes).filter(|n| *n != leader_id).collect();
369369
others.shuffle(&mut rng);
370-
let mut minority: std::collections::HashSet<u64> = others.into_iter().take(extra as usize).collect();
370+
let mut minority: BTreeSet<u64> = others.into_iter().take(extra as usize).collect();
371371
minority.insert(leader_id);
372372
for &a in &minority {
373373
for b in 1..=max_nodes {
@@ -384,7 +384,7 @@ async fn chaos_agent_loop(
384384

385385
async fn membership_agent_loop(
386386
cluster_state: Arc<Mutex<ClusterState>>,
387-
next_membership: Arc<Mutex<Option<HashSet<NodeId>>>>,
387+
next_membership: Arc<Mutex<Option<BTreeSet<NodeId>>>>,
388388
potential_nodes: BTreeMap<NodeId, Node>,
389389
) -> Result<(), Box<dyn std::error::Error>> {
390390
loop {
@@ -483,7 +483,7 @@ fn run_single_iteration(
483483
});
484484

485485
let cluster_state = Arc::new(Mutex::new(ClusterState::new()));
486-
let next_membership = Arc::new(Mutex::new(None::<HashSet<NodeId>>));
486+
let next_membership = Arc::new(Mutex::new(None::<BTreeSet<NodeId>>));
487487

488488
// Potential cluster members: every host we spawn has an entry here so the
489489
// membership agent can construct a `Node` when adding a new learner.
@@ -542,7 +542,7 @@ fn run_single_iteration(
542542
let mut violations: Vec<String> = Vec::new();
543543
let mut chaos_rng = StdRng::seed_from_u64(iteration_seed.wrapping_add(3000));
544544
let mut member_rng = StdRng::seed_from_u64(iteration_seed.wrapping_add(5000));
545-
let mut active_voters: HashSet<NodeId> = (1..=derived.num_initial_nodes as u64).collect();
545+
let mut active_voters: BTreeSet<NodeId> = (1..=derived.num_initial_nodes as u64).collect();
546546
let mut next_node_id = (derived.num_initial_nodes as u64) + 1;
547547
let mut invariants = InvariantChecker::default();
548548

@@ -565,7 +565,8 @@ fn run_single_iteration(
565565
*next_membership.lock().unwrap() = Some(active_voters.clone());
566566
}
567567
} else if active_voters.len() > 3 {
568-
let victim = *active_voters.iter().next().unwrap();
568+
let voters: Vec<NodeId> = active_voters.iter().copied().collect();
569+
let victim = voters[member_rng.gen_range(0..voters.len())];
569570
println!("MEMBERSHIP: Requesting remove node {victim}...");
570571
active_voters.remove(&victim);
571572
*next_membership.lock().unwrap() = Some(active_voters.clone());
@@ -585,13 +586,11 @@ fn run_single_iteration(
585586
// Crash a random voter and schedule its bounce after a downtime
586587
// window. Two flavors:
587588
//
588-
// - Short outage (majority case): window straddles
589-
// `election_timeout_max`, mixing "no re-election" with "leader
590-
// churn / quorum loss" on restart.
591-
// - Long outage (rare, `long_outage_chance`): 5k-15k ticks, so
592-
// the crashed node falls behind by more than
593-
// `replication_lag_threshold` and must receive a snapshot
594-
// install instead of log shipping when it rejoins.
589+
// - Short outage (majority case): window straddles `election_timeout_max`, mixing "no re-election"
590+
// with "leader churn / quorum loss" on restart.
591+
// - Long outage (rare, `long_outage_chance`): 5k-15k ticks, so the crashed node falls behind by
592+
// more than `replication_lag_threshold` and must receive a snapshot install instead of log
593+
// shipping when it rejoins.
595594
if steps > 0 && steps.is_multiple_of(derived.chaos_interval) && chaos_rng.gen_bool(derived.restart_chance) {
596595
let crashable: Vec<_> =
597596
active_voters.iter().copied().filter(|id| !pending_bounces.iter().any(|(p, _)| p == id)).collect();
@@ -608,7 +607,10 @@ fn run_single_iteration(
608607
crash_node(&mut sim, victim, &cluster_state);
609608
pending_bounces.push((victim, steps + downtime));
610609
let kind = if is_long_outage { "CRASH(long)" } else { "CRASH" };
611-
println!("{kind}: node {victim} for {downtime} ticks (bounce at step {})", steps + downtime);
610+
println!(
611+
"{kind}: node {victim} for {downtime} ticks (bounce at step {})",
612+
steps + downtime
613+
);
612614
}
613615
}
614616

tests-turmoil/src/cluster.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,10 @@ impl ClusterState {
8080

8181
/// Return the node id of the current leader, if any.
8282
pub fn find_leader_id(&self) -> Option<NodeId> {
83-
self.rafts.iter().find(|(_, raft)| raft.metrics().borrow_watched().state.is_leader()).map(|(id, _)| *id)
83+
self.rafts
84+
.iter()
85+
.find(|(_, raft)| raft.metrics().borrow_watched().state.is_leader())
86+
.map(|(id, _)| *id)
8487
}
8588

8689
/// Register a freshly started Raft instance as live.

0 commit comments

Comments
 (0)