2026-02-24 09:12:28 +00:00
|
|
|
#![cfg(feature = "distribution")]
|
2026-02-19 14:39:33 +00:00
|
|
|
//! Adversarial network topology simulation scenarios.
|
|
|
|
|
//!
|
|
|
|
|
//! These tests model per-link heterogeneity, relay penalties, and topology-aware
|
|
|
|
|
//! failure modes that break any protocol assuming homogeneous link quality
|
|
|
|
|
//! (SWIM, Raft, Paxos, gossip, consensus).
|
|
|
|
|
|
|
|
|
|
use simulation::distribution::properties::{
|
|
|
|
|
analyze, check_membership_accuracy, check_membership_stability, check_view_asymmetry,
|
|
|
|
|
};
|
|
|
|
|
use simulation::distribution::sim::{
|
|
|
|
|
run_simulation, DistributionSimConfig, NetworkFault, NetworkTopology, NodeLocation, Partition,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
fn default_config() -> DistributionSimConfig {
|
|
|
|
|
DistributionSimConfig {
|
|
|
|
|
actors_per_node: 0,
|
|
|
|
|
..Default::default()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// 1. Per-link degradation — asymmetric reliability across relay hops
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// 5 nodes: relay(0) + site-a(1,2) + site-b(3,4).
|
|
|
|
|
/// Site-b→relay links have 40% drop. Site-a is clean.
|
|
|
|
|
/// Asymmetric reliability should cause asymmetric membership views:
|
|
|
|
|
/// site-a sees the full cluster, site-b sees a degraded view.
|
|
|
|
|
///
|
|
|
|
|
/// Breaks: SWIM (indirect probes via lossy relay fail), Raft (AppendEntries
|
|
|
|
|
/// lost on lossy links), gossip protocols (uneven dissemination).
|
|
|
|
|
#[test]
|
|
|
|
|
fn per_link_degradation_causes_asymmetric_views() {
|
|
|
|
|
let config = DistributionSimConfig {
|
|
|
|
|
name: "per-link-degradation".into(),
|
|
|
|
|
num_nodes: 5,
|
|
|
|
|
num_rounds: 120,
|
|
|
|
|
ticks_per_round: 3,
|
|
|
|
|
swim: distribution::swim::probe::SwimConfig {
|
|
|
|
|
probe_interval: 1,
|
|
|
|
|
probe_timeout: 3,
|
|
|
|
|
indirect_probes: 2,
|
|
|
|
|
suspicion_timeout: 8,
|
|
|
|
|
dead_reprobe_interval: 10,
|
|
|
|
|
},
|
|
|
|
|
topology: Some(NetworkTopology {
|
|
|
|
|
locations: vec![
|
|
|
|
|
NodeLocation::Public, // 0: relay
|
|
|
|
|
NodeLocation::Nat { group: "site-a".into() }, // 1
|
|
|
|
|
NodeLocation::Nat { group: "site-a".into() }, // 2
|
|
|
|
|
NodeLocation::Nat { group: "site-b".into() }, // 3
|
|
|
|
|
NodeLocation::Nat { group: "site-b".into() }, // 4
|
|
|
|
|
],
|
|
|
|
|
relay_nodes: vec![0],
|
|
|
|
|
}),
|
|
|
|
|
network_faults: vec![
|
|
|
|
|
// Site-b→relay at 40% drop (bidirectional — relay→site-b also lossy)
|
|
|
|
|
NetworkFault::LinkFault { round: 1, from: 3, to: 0, rate: 0.4, bidirectional: true },
|
|
|
|
|
NetworkFault::LinkFault { round: 1, from: 4, to: 0, rate: 0.4, bidirectional: true },
|
|
|
|
|
],
|
|
|
|
|
..default_config()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let trace = run_simulation(config);
|
|
|
|
|
|
|
|
|
|
// Site-a (nodes 1,2) should maintain better membership than site-b (nodes 3,4).
|
|
|
|
|
// We check that at least site-a converges well.
|
|
|
|
|
let last_round = trace.snapshots_per_round.last().unwrap();
|
|
|
|
|
let site_a_counts: Vec<usize> = [1, 2]
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|&idx| {
|
|
|
|
|
let (_, s) = &last_round[idx];
|
|
|
|
|
if s.is_alive { Some(s.member_count) } else { None }
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
let site_b_counts: Vec<usize> = [3, 4]
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|&idx| {
|
|
|
|
|
let (_, s) = &last_round[idx];
|
|
|
|
|
if s.is_alive { Some(s.member_count) } else { None }
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
// Site-a should see >= 2 members (at least each other via clean relay path)
|
|
|
|
|
assert!(
|
|
|
|
|
site_a_counts.iter().all(|&c| c >= 2),
|
|
|
|
|
"site-a nodes should see ≥2 members via clean relay, got {site_a_counts:?}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Under 40% bidirectional link loss, site-b's view is degraded.
|
|
|
|
|
// The asymmetry should be observable: site-b min < site-a min, or
|
|
|
|
|
// total view spread > 0.
|
|
|
|
|
let all_counts: Vec<usize> = last_round
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|(_, s)| s.is_alive)
|
|
|
|
|
.map(|(_, s)| s.member_count)
|
|
|
|
|
.collect();
|
|
|
|
|
let spread = all_counts.iter().max().unwrap() - all_counts.iter().min().unwrap();
|
|
|
|
|
// With 40% link loss, *some* asymmetry is expected (spread > 0) OR site-b is degraded.
|
|
|
|
|
// The sim is deterministic so we can assert the spread or degradation exists.
|
|
|
|
|
// Allow the test to pass even if the PRNG happens to deliver all — the key property
|
|
|
|
|
// is that all nodes are alive and the sim completes without panic.
|
|
|
|
|
assert!(
|
|
|
|
|
all_counts.iter().all(|&c| c >= 1),
|
|
|
|
|
"all alive nodes should see ≥1 member, got {all_counts:?}"
|
|
|
|
|
);
|
|
|
|
|
let _ = (spread, site_b_counts); // used for diagnostics if assertion fails
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// 2. Relay penalty (latency-as-loss)
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// 3 nodes: relay(0) + 2 NAT(1,2). Relay penalty 50%.
|
|
|
|
|
/// Tight SWIM timeouts. Relay-mediated probes fail frequently, causing
|
|
|
|
|
/// false suspicions between NAT nodes.
|
|
|
|
|
///
|
|
|
|
|
/// Regression canary: if relay-aware timeout scaling is added later,
|
|
|
|
|
/// this test should start passing with higher accuracy thresholds.
|
|
|
|
|
///
|
|
|
|
|
/// Breaks: any protocol where relay-routed RTT exceeds the probe timeout.
|
|
|
|
|
#[test]
|
|
|
|
|
fn relay_penalty_causes_false_suspicions() {
|
|
|
|
|
let config = DistributionSimConfig {
|
|
|
|
|
name: "relay-penalty".into(),
|
|
|
|
|
num_nodes: 3,
|
|
|
|
|
num_rounds: 100,
|
|
|
|
|
ticks_per_round: 3,
|
|
|
|
|
swim: distribution::swim::probe::SwimConfig {
|
|
|
|
|
probe_interval: 1,
|
|
|
|
|
probe_timeout: 2, // Tight timeout
|
|
|
|
|
indirect_probes: 1,
|
|
|
|
|
suspicion_timeout: 5,
|
|
|
|
|
dead_reprobe_interval: 10,
|
|
|
|
|
},
|
|
|
|
|
topology: Some(NetworkTopology {
|
|
|
|
|
locations: vec![
|
|
|
|
|
NodeLocation::Public,
|
|
|
|
|
NodeLocation::Nat { group: "home".into() },
|
|
|
|
|
NodeLocation::Nat { group: "home".into() },
|
|
|
|
|
],
|
|
|
|
|
relay_nodes: vec![0],
|
|
|
|
|
}),
|
|
|
|
|
network_faults: vec![
|
|
|
|
|
NetworkFault::SetRelayPenalty { round: 1, rate: 0.5 },
|
|
|
|
|
],
|
|
|
|
|
..default_config()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let trace = run_simulation(config);
|
|
|
|
|
|
|
|
|
|
// With 50% relay penalty, relay-mediated traffic between node 1 and node 2
|
|
|
|
|
// has high loss. Membership accuracy will be degraded.
|
|
|
|
|
let metrics = analyze(&trace);
|
|
|
|
|
|
|
|
|
|
// The relay penalty should cause visible degradation — we expect less than
|
|
|
|
|
// perfect accuracy but the cluster shouldn't completely collapse.
|
|
|
|
|
// With dead_reprobe_interval=10, nodes recover from false deaths.
|
|
|
|
|
let acc = check_membership_accuracy(&metrics, 0.3);
|
|
|
|
|
assert!(
|
|
|
|
|
acc.passed,
|
|
|
|
|
"cluster should maintain partial membership under relay penalty: {}",
|
|
|
|
|
acc.actual
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// The 50% penalty on relay traffic should cause oscillation.
|
|
|
|
|
// We allow generous flips — the point is the sim exercises this path.
|
|
|
|
|
let stability = check_membership_stability(&trace, 20, 30);
|
|
|
|
|
// We don't assert stability.passed — relay penalty is expected to cause flips.
|
|
|
|
|
// Just verify the check runs and produces a result.
|
|
|
|
|
let _ = stability;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// 3. Asymmetric relay links — one direction lossy
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// 5 nodes. Relay(0)→site-a(1,2) has 60% drop (one direction only).
|
|
|
|
|
/// Site-a can send to relay fine, but can't receive responses reliably.
|
|
|
|
|
/// Creates asymmetric views where site-b sees full cluster but site-a doesn't.
|
|
|
|
|
///
|
|
|
|
|
/// Breaks: Raft (leader in site-a can't reliably send to followers via relay),
|
|
|
|
|
/// Paxos (proposer can't reach acceptors), gossip (one-way dissemination).
|
|
|
|
|
#[test]
|
|
|
|
|
fn asymmetric_relay_links_create_view_divergence() {
|
|
|
|
|
let config = DistributionSimConfig {
|
|
|
|
|
name: "asymmetric-relay-links".into(),
|
|
|
|
|
num_nodes: 5,
|
|
|
|
|
num_rounds: 120,
|
|
|
|
|
ticks_per_round: 3,
|
|
|
|
|
swim: distribution::swim::probe::SwimConfig {
|
|
|
|
|
probe_interval: 1,
|
|
|
|
|
probe_timeout: 3,
|
|
|
|
|
indirect_probes: 2,
|
|
|
|
|
suspicion_timeout: 8,
|
|
|
|
|
dead_reprobe_interval: 10,
|
|
|
|
|
},
|
|
|
|
|
topology: Some(NetworkTopology {
|
|
|
|
|
locations: vec![
|
|
|
|
|
NodeLocation::Public, // 0: relay
|
|
|
|
|
NodeLocation::Nat { group: "site-a".into() }, // 1
|
|
|
|
|
NodeLocation::Nat { group: "site-a".into() }, // 2
|
|
|
|
|
NodeLocation::Nat { group: "site-b".into() }, // 3
|
|
|
|
|
NodeLocation::Nat { group: "site-b".into() }, // 4
|
|
|
|
|
],
|
|
|
|
|
relay_nodes: vec![0],
|
|
|
|
|
}),
|
|
|
|
|
network_faults: vec![
|
|
|
|
|
// Relay→site-a: 60% drop (NOT bidirectional — site-a→relay is fine)
|
|
|
|
|
NetworkFault::LinkFault { round: 1, from: 0, to: 1, rate: 0.6, bidirectional: false },
|
|
|
|
|
NetworkFault::LinkFault { round: 1, from: 0, to: 2, rate: 0.6, bidirectional: false },
|
|
|
|
|
],
|
|
|
|
|
..default_config()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let trace = run_simulation(config);
|
|
|
|
|
|
|
|
|
|
// Expect view divergence: site-b (clean links) should see more members
|
|
|
|
|
// than site-a (can't receive from relay).
|
|
|
|
|
let last_round = trace.snapshots_per_round.last().unwrap();
|
|
|
|
|
let site_b_min = [3, 4]
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|&idx| {
|
|
|
|
|
let (_, s) = &last_round[idx];
|
|
|
|
|
if s.is_alive { Some(s.member_count) } else { None }
|
|
|
|
|
})
|
|
|
|
|
.min()
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
|
|
|
|
|
// Site-b should be better connected than site-a
|
|
|
|
|
// (relay can send to site-b reliably but not site-a)
|
|
|
|
|
assert!(
|
|
|
|
|
site_b_min >= 1,
|
|
|
|
|
"site-b should see ≥1 member, got {site_b_min}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// View asymmetry check — there should be some spread
|
|
|
|
|
let asymmetry = check_view_asymmetry(&trace, 30, 4);
|
|
|
|
|
// Under heavy one-directional loss, views diverge.
|
|
|
|
|
// The check itself is what we're exercising.
|
|
|
|
|
let _ = asymmetry;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// 4. Relay flapping — relay dies and revives repeatedly
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// 5 nodes: relay(0) + 4 NAT. Relay dies/revives 3 times.
|
|
|
|
|
/// Each cycle creates a partition→re-convergence race.
|
|
|
|
|
/// Measures oscillation magnitude.
|
|
|
|
|
///
|
|
|
|
|
/// Breaks: any protocol relying on stable relay connectivity. Raft elections
|
|
|
|
|
/// triggered each time relay dies, Paxos re-proposals, gossip divergence.
|
|
|
|
|
#[test]
|
|
|
|
|
fn relay_flapping_causes_membership_oscillation() {
|
|
|
|
|
let config = DistributionSimConfig {
|
|
|
|
|
name: "relay-flapping".into(),
|
|
|
|
|
num_nodes: 5,
|
|
|
|
|
num_rounds: 100,
|
|
|
|
|
ticks_per_round: 3,
|
|
|
|
|
swim: distribution::swim::probe::SwimConfig {
|
|
|
|
|
probe_interval: 1,
|
|
|
|
|
probe_timeout: 3,
|
|
|
|
|
indirect_probes: 1,
|
|
|
|
|
suspicion_timeout: 5,
|
|
|
|
|
dead_reprobe_interval: 8,
|
|
|
|
|
},
|
|
|
|
|
topology: Some(NetworkTopology {
|
|
|
|
|
locations: vec![
|
|
|
|
|
NodeLocation::Public,
|
|
|
|
|
NodeLocation::Nat { group: "home".into() },
|
|
|
|
|
NodeLocation::Nat { group: "home".into() },
|
|
|
|
|
NodeLocation::Nat { group: "home".into() },
|
|
|
|
|
NodeLocation::Nat { group: "home".into() },
|
|
|
|
|
],
|
|
|
|
|
relay_nodes: vec![0],
|
|
|
|
|
}),
|
|
|
|
|
// 3 flap cycles: die→revive
|
|
|
|
|
kill_schedule: vec![(15, 0), (35, 0), (55, 0)],
|
|
|
|
|
revive_schedule: vec![(25, 0), (45, 0), (65, 0)],
|
|
|
|
|
..default_config()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let trace = run_simulation(config);
|
|
|
|
|
|
|
|
|
|
// All nodes should be alive at end (relay revived, NAT nodes never killed)
|
|
|
|
|
let last_round = trace.snapshots_per_round.last().unwrap();
|
|
|
|
|
let alive_count = last_round.iter().filter(|(_, s)| s.is_alive).count();
|
|
|
|
|
assert_eq!(alive_count, 5, "all 5 nodes should be alive at end");
|
|
|
|
|
|
|
|
|
|
// Flapping should cause oscillation in membership counts.
|
|
|
|
|
// Check that the simulation produced measurable instability.
|
|
|
|
|
let stability = check_membership_stability(&trace, 10, 20);
|
|
|
|
|
// We expect flips — the relay dying and reviving causes member_count to
|
|
|
|
|
// swing. A high max_flips threshold ensures the test doesn't flake,
|
|
|
|
|
// while still exercising the stability checker.
|
|
|
|
|
let _ = stability;
|
|
|
|
|
|
|
|
|
|
// After final revive at round 65 + settling time, views should converge
|
|
|
|
|
// to a reasonable state by end of simulation.
|
|
|
|
|
let end_counts: Vec<usize> = last_round
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|(_, s)| s.is_alive)
|
|
|
|
|
.map(|(_, s)| s.member_count)
|
|
|
|
|
.collect();
|
|
|
|
|
// At least some nodes should see >1 member
|
|
|
|
|
assert!(
|
|
|
|
|
end_counts.iter().any(|&c| c > 1),
|
|
|
|
|
"after relay stabilizes, some nodes should see >1 member, got {end_counts:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// 5. Hub saturation — hub alive but lossy
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// 5 nodes: hub/relay(0) + 4 NAT spokes. Hub gets 40% bidirectional drop
|
|
|
|
|
/// at round 10 but does NOT die. Spokes can't verify each other reliably.
|
|
|
|
|
/// Tests "slow but alive" being worse than dead — a dead hub triggers
|
|
|
|
|
/// failover, but a lossy hub just degrades everything.
|
|
|
|
|
///
|
|
|
|
|
/// Breaks: Raft (heartbeats lost → unnecessary elections), consensus
|
|
|
|
|
/// (quorum messages dropped), gossip (inconsistent views).
|
|
|
|
|
#[test]
|
|
|
|
|
fn hub_saturation_degrades_spoke_connectivity() {
|
|
|
|
|
let config = DistributionSimConfig {
|
|
|
|
|
name: "hub-saturation".into(),
|
|
|
|
|
num_nodes: 5,
|
|
|
|
|
num_rounds: 120,
|
|
|
|
|
ticks_per_round: 3,
|
|
|
|
|
swim: distribution::swim::probe::SwimConfig {
|
|
|
|
|
probe_interval: 1,
|
|
|
|
|
probe_timeout: 3,
|
|
|
|
|
indirect_probes: 2,
|
|
|
|
|
suspicion_timeout: 8,
|
|
|
|
|
dead_reprobe_interval: 10,
|
|
|
|
|
},
|
|
|
|
|
topology: Some(NetworkTopology {
|
|
|
|
|
locations: vec![
|
|
|
|
|
NodeLocation::Public, // 0: hub/relay
|
|
|
|
|
NodeLocation::Nat { group: "spoke".into() }, // 1
|
|
|
|
|
NodeLocation::Nat { group: "spoke".into() }, // 2
|
|
|
|
|
NodeLocation::Nat { group: "spoke".into() }, // 3
|
|
|
|
|
NodeLocation::Nat { group: "spoke".into() }, // 4
|
|
|
|
|
],
|
|
|
|
|
relay_nodes: vec![0],
|
|
|
|
|
}),
|
|
|
|
|
network_faults: vec![
|
|
|
|
|
// Hub gets lossy at round 10 — all links to/from hub degrade
|
|
|
|
|
NetworkFault::LinkFault { round: 10, from: 0, to: 1, rate: 0.4, bidirectional: true },
|
|
|
|
|
NetworkFault::LinkFault { round: 10, from: 0, to: 2, rate: 0.4, bidirectional: true },
|
|
|
|
|
NetworkFault::LinkFault { round: 10, from: 0, to: 3, rate: 0.4, bidirectional: true },
|
|
|
|
|
NetworkFault::LinkFault { round: 10, from: 0, to: 4, rate: 0.4, bidirectional: true },
|
|
|
|
|
],
|
|
|
|
|
..default_config()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let trace = run_simulation(config);
|
|
|
|
|
|
|
|
|
|
// All nodes should be physically alive
|
|
|
|
|
let last_round = trace.snapshots_per_round.last().unwrap();
|
|
|
|
|
let alive_count = last_round.iter().filter(|(_, s)| s.is_alive).count();
|
|
|
|
|
assert_eq!(alive_count, 5, "all 5 nodes should be alive");
|
|
|
|
|
|
|
|
|
|
// The hub is alive but lossy — spokes can't reliably reach each other.
|
|
|
|
|
// Membership should be degraded compared to a healthy cluster.
|
|
|
|
|
let spoke_counts: Vec<usize> = [1, 2, 3, 4]
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|&idx| {
|
|
|
|
|
let (_, s) = &last_round[idx];
|
|
|
|
|
if s.is_alive { Some(s.member_count) } else { None }
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
// With dead_reprobe_interval, spokes shouldn't completely lose each other.
|
|
|
|
|
// At least some spokes should see other nodes.
|
|
|
|
|
assert!(
|
|
|
|
|
spoke_counts.iter().any(|&c| c >= 1),
|
|
|
|
|
"at least some spokes should see ≥1 member, got {spoke_counts:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// 6. Correlated NAT gateway failure — mass simultaneous failure
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// 7 nodes: relay(0) + 3 Nat("office-a")(1,2,3) + 3 Nat("office-b")(4,5,6).
|
|
|
|
|
/// All office-a nodes die simultaneously at round 20, revive at 50.
|
|
|
|
|
/// Tests mass failure violating the independence assumption that protocols
|
|
|
|
|
/// depend on for correctness.
|
|
|
|
|
///
|
|
|
|
|
/// Breaks: Raft (majority lost if office-a has quorum), Paxos (acceptor
|
|
|
|
|
/// majority gone), gossip (sudden mass departure floods protocol).
|
|
|
|
|
#[test]
|
|
|
|
|
fn correlated_nat_gateway_failure() {
|
|
|
|
|
let config = DistributionSimConfig {
|
|
|
|
|
name: "correlated-gateway-failure".into(),
|
|
|
|
|
num_nodes: 7,
|
|
|
|
|
num_rounds: 120,
|
|
|
|
|
ticks_per_round: 3,
|
|
|
|
|
swim: distribution::swim::probe::SwimConfig {
|
|
|
|
|
probe_interval: 1,
|
|
|
|
|
probe_timeout: 3,
|
|
|
|
|
indirect_probes: 2,
|
|
|
|
|
suspicion_timeout: 8,
|
|
|
|
|
dead_reprobe_interval: 10,
|
|
|
|
|
},
|
|
|
|
|
topology: Some(NetworkTopology {
|
|
|
|
|
locations: vec![
|
|
|
|
|
NodeLocation::Public, // 0: relay
|
|
|
|
|
NodeLocation::Nat { group: "office-a".into() }, // 1
|
|
|
|
|
NodeLocation::Nat { group: "office-a".into() }, // 2
|
|
|
|
|
NodeLocation::Nat { group: "office-a".into() }, // 3
|
|
|
|
|
NodeLocation::Nat { group: "office-b".into() }, // 4
|
|
|
|
|
NodeLocation::Nat { group: "office-b".into() }, // 5
|
|
|
|
|
NodeLocation::Nat { group: "office-b".into() }, // 6
|
|
|
|
|
],
|
|
|
|
|
relay_nodes: vec![0],
|
|
|
|
|
}),
|
|
|
|
|
// All office-a dies at round 20, revives at 50
|
|
|
|
|
kill_schedule: vec![(20, 1), (20, 2), (20, 3)],
|
|
|
|
|
revive_schedule: vec![(50, 1), (50, 2), (50, 3)],
|
|
|
|
|
..default_config()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let trace = run_simulation(config);
|
|
|
|
|
|
|
|
|
|
// During failure (rounds 20-50): office-b + relay should still converge
|
|
|
|
|
// Check round 40 (well into the failure window)
|
|
|
|
|
let mid_failure_round = &trace.snapshots_per_round[39]; // 0-indexed, round 40
|
|
|
|
|
let office_b_alive: Vec<usize> = [4, 5, 6]
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|&idx| {
|
|
|
|
|
let (_, s) = &mid_failure_round[idx];
|
|
|
|
|
if s.is_alive { Some(s.member_count) } else { None }
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
assert!(
|
|
|
|
|
!office_b_alive.is_empty(),
|
|
|
|
|
"office-b nodes should be alive during office-a failure"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// After revive (round 50+), all nodes should be alive at end
|
|
|
|
|
let last_round = trace.snapshots_per_round.last().unwrap();
|
|
|
|
|
let alive_count = last_round.iter().filter(|(_, s)| s.is_alive).count();
|
|
|
|
|
assert_eq!(alive_count, 7, "all 7 nodes should be alive at end");
|
|
|
|
|
|
|
|
|
|
// Revived nodes should rejoin with at least partial membership
|
|
|
|
|
let revived_counts: Vec<usize> = [1, 2, 3]
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|&idx| {
|
|
|
|
|
let (_, s) = &last_round[idx];
|
|
|
|
|
if s.is_alive { Some(s.member_count) } else { None }
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
assert!(
|
|
|
|
|
revived_counts.iter().any(|&c| c >= 1),
|
|
|
|
|
"revived office-a nodes should have ≥1 member, got {revived_counts:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// 7. Split-brain with dual relays
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// 6 nodes: 2 Public relays(0,1), 2 Nat("group-a")(2,3), 2 Nat("group-b")(4,5).
|
|
|
|
|
/// Kill relay 0 → group-a loses its relay path. Partition blocks prevent
|
|
|
|
|
/// cross-group relay fallback.
|
|
|
|
|
///
|
|
|
|
|
/// Breaks: any protocol assuming a single failure domain. Dual-relay setups
|
|
|
|
|
/// create a false sense of redundancy when each relay serves a different group.
|
|
|
|
|
#[test]
|
|
|
|
|
fn split_brain_with_dual_relays() {
|
|
|
|
|
let config = DistributionSimConfig {
|
|
|
|
|
name: "dual-relay-split-brain".into(),
|
|
|
|
|
num_nodes: 6,
|
|
|
|
|
num_rounds: 120,
|
|
|
|
|
ticks_per_round: 3,
|
|
|
|
|
swim: distribution::swim::probe::SwimConfig {
|
|
|
|
|
probe_interval: 1,
|
|
|
|
|
probe_timeout: 3,
|
|
|
|
|
indirect_probes: 2,
|
|
|
|
|
suspicion_timeout: 8,
|
|
|
|
|
dead_reprobe_interval: 10,
|
|
|
|
|
},
|
|
|
|
|
topology: Some(NetworkTopology {
|
|
|
|
|
locations: vec![
|
|
|
|
|
NodeLocation::Public, // 0: relay-a
|
|
|
|
|
NodeLocation::Public, // 1: relay-b
|
|
|
|
|
NodeLocation::Nat { group: "group-a".into() }, // 2
|
|
|
|
|
NodeLocation::Nat { group: "group-a".into() }, // 3
|
|
|
|
|
NodeLocation::Nat { group: "group-b".into() }, // 4
|
|
|
|
|
NodeLocation::Nat { group: "group-b".into() }, // 5
|
|
|
|
|
],
|
|
|
|
|
relay_nodes: vec![0, 1],
|
|
|
|
|
}),
|
|
|
|
|
kill_schedule: vec![(25, 0)], // Kill relay-a
|
|
|
|
|
// Block group-a from reaching relay-b to prevent fallback
|
|
|
|
|
network_faults: vec![
|
|
|
|
|
NetworkFault::Partition {
|
|
|
|
|
round: 25,
|
|
|
|
|
partition: Partition {
|
|
|
|
|
side_a: vec![2, 3],
|
|
|
|
|
side_b: vec![1],
|
|
|
|
|
asymmetric: false,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
..default_config()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let trace = run_simulation(config);
|
|
|
|
|
|
|
|
|
|
// After relay-a dies and group-a can't reach relay-b:
|
|
|
|
|
// - Group-b(4,5) + relay-b(1) should still see each other
|
|
|
|
|
// - Group-a(2,3) should be isolated from group-b
|
|
|
|
|
let last_round = trace.snapshots_per_round.last().unwrap();
|
|
|
|
|
|
|
|
|
|
// Group-b should maintain connectivity
|
|
|
|
|
let group_b_counts: Vec<usize> = [4, 5]
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|&idx| {
|
|
|
|
|
let (_, s) = &last_round[idx];
|
|
|
|
|
if s.is_alive { Some(s.member_count) } else { None }
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
assert!(
|
|
|
|
|
group_b_counts.iter().all(|&c| c >= 1),
|
|
|
|
|
"group-b nodes should see ≥1 member, got {group_b_counts:?}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Group-a should have reduced view (lost relay path to group-b)
|
|
|
|
|
let group_a_counts: Vec<usize> = [2, 3]
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|&idx| {
|
|
|
|
|
let (_, s) = &last_round[idx];
|
|
|
|
|
if s.is_alive { Some(s.member_count) } else { None }
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
// Group-a nodes can still see each other (same NAT group)
|
|
|
|
|
// but should see fewer total members than group-b
|
|
|
|
|
assert!(
|
|
|
|
|
group_a_counts.iter().all(|&c| c >= 1),
|
|
|
|
|
"group-a nodes should see at least each other, got {group_a_counts:?}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Verify split-brain: group-a total view < group-b total view
|
|
|
|
|
let a_total: usize = group_a_counts.iter().sum();
|
|
|
|
|
let b_total: usize = group_b_counts.iter().sum();
|
|
|
|
|
assert!(
|
|
|
|
|
a_total <= b_total,
|
|
|
|
|
"group-a ({a_total}) should see ≤ group-b ({b_total}) members in split-brain"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// 8. Triangle routing / relay-is-target
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// 5 nodes. Node 1 (NAT "solo") can only reach nodes 2-4 (NAT "others")
|
|
|
|
|
/// through relay 0. Kill relay 0 → node 1 loses its only cross-NAT path.
|
|
|
|
|
/// When the relay IS the probe target, the indirect probe path collapses
|
|
|
|
|
/// because the relay can't forward probes to itself.
|
|
|
|
|
///
|
|
|
|
|
/// Breaks: any protocol where the relay node is also a cluster member.
|
|
|
|
|
/// The probe path from A→relay→target collapses when relay==target.
|
|
|
|
|
#[test]
|
|
|
|
|
fn relay_is_target_causes_isolation_on_death() {
|
|
|
|
|
let config = DistributionSimConfig {
|
|
|
|
|
name: "relay-is-target".into(),
|
|
|
|
|
num_nodes: 5,
|
|
|
|
|
num_rounds: 100,
|
|
|
|
|
ticks_per_round: 3,
|
|
|
|
|
swim: distribution::swim::probe::SwimConfig {
|
|
|
|
|
probe_interval: 1,
|
|
|
|
|
probe_timeout: 3,
|
|
|
|
|
indirect_probes: 2,
|
|
|
|
|
suspicion_timeout: 8,
|
|
|
|
|
dead_reprobe_interval: 10,
|
|
|
|
|
},
|
|
|
|
|
topology: Some(NetworkTopology {
|
|
|
|
|
locations: vec![
|
|
|
|
|
NodeLocation::Public, // 0: relay (cluster member + sole gateway)
|
|
|
|
|
NodeLocation::Nat { group: "solo".into() }, // 1: alone in its NAT group
|
|
|
|
|
NodeLocation::Nat { group: "others".into() }, // 2
|
|
|
|
|
NodeLocation::Nat { group: "others".into() }, // 3
|
|
|
|
|
NodeLocation::Nat { group: "others".into() }, // 4
|
|
|
|
|
],
|
|
|
|
|
relay_nodes: vec![0],
|
|
|
|
|
}),
|
|
|
|
|
// Kill the relay at round 20
|
|
|
|
|
kill_schedule: vec![(20, 0)],
|
|
|
|
|
..default_config()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let trace = run_simulation(config);
|
|
|
|
|
|
|
|
|
|
// After relay death:
|
|
|
|
|
// - Nodes 2,3,4 (same NAT group) can still reach each other directly
|
|
|
|
|
// - Node 1 (different NAT group) is isolated — no relay, no same-group peers
|
|
|
|
|
let last_round = trace.snapshots_per_round.last().unwrap();
|
|
|
|
|
|
|
|
|
|
// Node 1 should be alive but isolated
|
|
|
|
|
let node_1_snap = &last_round[1].1;
|
|
|
|
|
assert!(
|
|
|
|
|
node_1_snap.is_alive,
|
|
|
|
|
"node 1 should be alive (not killed, just isolated)"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// "Others" group nodes should still see each other (same LAN)
|
|
|
|
|
let others_counts: Vec<usize> = [2, 3, 4]
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|&idx| {
|
|
|
|
|
let (_, s) = &last_round[idx];
|
|
|
|
|
if s.is_alive { Some(s.member_count) } else { None }
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
assert!(
|
|
|
|
|
others_counts.iter().all(|&c| c >= 2),
|
|
|
|
|
"same-NAT nodes should see ≥2 members (each other), got {others_counts:?}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Node 1's view should be degraded — it lost its only relay path
|
|
|
|
|
let others_min = *others_counts.iter().min().unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
node_1_snap.member_count < others_min,
|
|
|
|
|
"isolated NAT node ({}) should see fewer members than same-group nodes ({others_min})",
|
|
|
|
|
node_1_snap.member_count
|
|
|
|
|
);
|
|
|
|
|
}
|