use std::time::Duration;
use async_trait::async_trait;
use moonpool_sim::{
Attrition, AttritionScope, Chaos, ChaosMode, DomainLevel, FaultContext, FaultInjector,
LocalityConfig, NetworkProvider, Process, RebootKind, SimContext, SimulationBuilder,
SimulationError, SimulationResult, TcpListenerTrait, TimeProvider, Workload,
};
struct LocalityProcess {
expected_collocated: usize,
}
#[async_trait]
impl Process for LocalityProcess {
fn name(&self) -> &'static str {
"locality_node"
}
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()> {
let topo = ctx.topology();
let locality = topo.my_locality().cloned().ok_or_else(|| {
SimulationError::InvalidState("process booted without locality".into())
})?;
if !locality.machine().starts_with(locality.zone())
|| !locality.zone().starts_with(locality.datacenter())
{
return Err(SimulationError::InvalidState(format!(
"non-hierarchical locality: {}/{}/{}",
locality.datacenter(),
locality.zone(),
locality.machine()
)));
}
let collocated = topo.peers_on_my_machine();
if collocated.len() != self.expected_collocated {
return Err(SimulationError::InvalidState(format!(
"expected {} collocated peer(s), saw {}",
self.expected_collocated,
collocated.len()
)));
}
let listener = ctx.network().bind(ctx.my_ip()).await?;
loop {
moonpool_sim::select! {
biased;
_ = listener.accept() => {}
() = ctx.shutdown().cancelled() => return Ok(()),
}
}
}
}
struct TimedWorkload(Duration);
#[async_trait]
impl Workload for TimedWorkload {
fn name(&self) -> &'static str {
"timed"
}
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()> {
ctx.time()
.sleep(self.0)
.await
.map_err(|e| SimulationError::InvalidState(format!("sleep failed: {e}")))
}
}
struct DomainQueryWorkload;
#[async_trait]
impl Workload for DomainQueryWorkload {
fn name(&self) -> &'static str {
"domain_query"
}
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()> {
let topo = ctx.topology();
let total = topo.all_process_ips().len();
let dc1 = topo.ips_in_domain(DomainLevel::Datacenter, "dc1").len();
let zone = topo.ips_in_domain(DomainLevel::Zone, "dc1-z1").len();
let machine = topo.ips_in_domain(DomainLevel::Machine, "dc1-z1-m1").len();
let first = topo
.locality_for("10.0.1.1")
.map(|l| l.machine().to_string());
let ok = total == 8 && dc1 == 4 && zone == 4 && machine == 2;
if !ok || first.as_deref() != Some("dc1-z1-m1") {
return Err(SimulationError::InvalidState(format!(
"bad domain queries: total={total} dc1={dc1} zone={zone} machine={machine} first={first:?}"
)));
}
Ok(())
}
}
#[test]
fn cluster_assigns_locality_and_answers_domain_queries() {
let report = SimulationBuilder::new()
.cluster(LocalityConfig::new(2, 1, 2, 2), || {
Box::new(LocalityProcess {
expected_collocated: 1,
})
})
.workload(DomainQueryWorkload)
.set_iterations(1)
.set_debug_seeds(vec![42])
.run();
assert_eq!(report.successful_runs, 1, "domain queries should hold");
assert_eq!(report.failed_runs, 0);
}
struct MachineRebootInjector;
#[async_trait]
impl FaultInjector for MachineRebootInjector {
fn name(&self) -> &'static str {
"machine_reboot"
}
async fn inject(&mut self, ctx: &FaultContext) -> SimulationResult<()> {
ctx.time()
.sleep(Duration::from_millis(100))
.await
.map_err(|e| SimulationError::InvalidState(format!("sleep failed: {e}")))?;
let rebooted = ctx.reboot_machine("dc1-z1-m1", RebootKind::Crash)?;
if rebooted.len() != 2 {
return Err(SimulationError::InvalidState(format!(
"machine reboot hit {} processes, expected 2 (collocated)",
rebooted.len()
)));
}
let expected = ["10.0.1.1".to_string(), "10.0.1.2".to_string()];
if !rebooted.iter().all(|ip| expected.contains(ip)) {
return Err(SimulationError::InvalidState(format!(
"machine reboot hit unexpected IPs: {rebooted:?}"
)));
}
ctx.chaos_shutdown().cancelled().await;
Ok(())
}
}
#[test]
fn machine_reboot_is_collocated() {
let report = SimulationBuilder::new()
.cluster(LocalityConfig::new(1, 1, 2, 2), || {
Box::new(LocalityProcess {
expected_collocated: 1,
})
})
.workload(TimedWorkload(Duration::from_secs(20)))
.fault(MachineRebootInjector)
.chaos_duration(Duration::from_secs(5))
.set_iterations(1)
.set_debug_seeds(vec![42])
.run();
assert_eq!(
report.failed_runs, 0,
"collocated machine reboot + recovery should succeed"
);
}
#[test]
fn per_machine_attrition_respects_group_budget() {
let report = SimulationBuilder::new()
.cluster(LocalityConfig::new(1, 1, 3, 2), || {
Box::new(LocalityProcess {
expected_collocated: 1,
})
})
.workload(TimedWorkload(Duration::from_secs(25)))
.enable_chaos([Chaos::Attrition {
config: Attrition {
max_dead: 2,
prob_graceful: 0.3,
prob_crash: 0.5,
prob_wipe: 0.2,
recovery_delay_ms: None,
grace_period_ms: None,
scope: AttritionScope::PerMachine,
},
mode: ChaosMode::Random,
}])
.chaos_duration(Duration::from_secs(10))
.set_iterations(5)
.set_debug_seeds(vec![1, 2, 3, 4, 5])
.run();
assert_eq!(
report.failed_runs, 0,
"machine-scoped attrition should not deadlock or fail"
);
}
struct RangeTopologyWorkload;
#[async_trait]
impl Workload for RangeTopologyWorkload {
fn name(&self) -> &'static str {
"range_topology"
}
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()> {
let topo = ctx.topology();
for ip in topo.all_process_ips() {
if topo.locality_for(ip).is_none() {
return Err(SimulationError::InvalidState(format!(
"process {ip} has no locality"
)));
}
}
Ok(())
}
}
#[test]
fn range_topology_runs_clean_across_seeds() {
let report = SimulationBuilder::new()
.cluster(LocalityConfig::new(1..=3, 1, 1..=3, 2), || {
Box::new(LocalityProcess {
expected_collocated: 1,
})
})
.workload(RangeTopologyWorkload)
.set_iterations(8)
.set_debug_seeds(vec![10, 20, 30, 40, 50, 60, 70, 80])
.run();
assert_eq!(
report.failed_runs, 0,
"range topologies should all be valid"
);
assert_eq!(report.successful_runs, 8);
}