use crate::clock::{Micros, SkewSchedule};
use crate::disk::VirtualDisk;
use crate::network::{DeliveryOutcome, DropReason, LinkConfig, Network, NetworkStats, NodeId};
use crate::rng::Seed;
use crate::runtime::{Runtime, TaskBody, TaskId};
use serde::{Deserialize, Serialize};
use std::any::Any;
use std::collections::{BTreeMap, BTreeSet};
use std::panic::{catch_unwind, resume_unwind, AssertUnwindSafe};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::{env, fs, io};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Event {
TaskSpawned {
task: TaskId,
node: NodeId,
name: String,
},
TaskDone {
task: TaskId,
node: NodeId,
name: String,
},
MessageSent {
from: NodeId,
to: NodeId,
seq: u64,
deliver_at: Micros,
},
MessageDropped {
from: NodeId,
to: NodeId,
seq: u64,
reason: DropReason,
},
MessageDuplicated {
from: NodeId,
to: NodeId,
seq: u64,
},
MessageReordered {
from: NodeId,
to: NodeId,
seq: u64,
},
MessageDelivered {
from: NodeId,
to: NodeId,
seq: u64,
},
DiskWrite {
node: NodeId,
path: String,
len: usize,
},
DiskTornWrite {
node: NodeId,
path: String,
requested: usize,
written: usize,
},
DiskWriteFailed {
node: NodeId,
path: String,
},
DiskFsync {
node: NodeId,
path: String,
durable_len: usize,
},
DiskFsyncFailed {
node: NodeId,
path: String,
},
Crash {
node: NodeId,
},
Restart {
node: NodeId,
},
Partition {
group_a: Vec<NodeId>,
group_b: Vec<NodeId>,
},
Healed,
SkewUpdated {
node: NodeId,
},
ClockAdvanced {
from: Micros,
to: Micros,
},
Custom {
node: NodeId,
message: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ScenarioError {
#[error("deadlock detected at t={at_micros}: {waiting} task(s) waiting, none runnable")]
DeadlockDetected {
at_micros: Micros,
waiting: usize,
},
#[error("step limit {limit} exceeded (possible livelock)")]
StepLimitExceeded {
limit: u64,
},
#[error("task panicked: {0}")]
TaskPanicked(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunOutcome {
pub seed: Seed,
pub steps: u64,
pub sim_time_micros: Micros,
}
type ProcessFactory = (String, Box<dyn FnMut() -> TaskBody>);
pub struct Scenario {
seed: Seed,
runtime: Runtime,
network: Network,
disks: BTreeMap<NodeId, VirtualDisk>,
factories: BTreeMap<NodeId, ProcessFactory>,
crashed: BTreeSet<NodeId>,
events: Vec<Event>,
}
impl Scenario {
pub fn new(seed: Seed) -> Self {
Self {
seed,
runtime: Runtime::new(seed),
network: Network::new(LinkConfig::default()),
disks: BTreeMap::new(),
factories: BTreeMap::new(),
crashed: BTreeSet::new(),
events: Vec::new(),
}
}
pub fn seed(&self) -> Seed {
self.seed
}
pub fn now(&self) -> Micros {
self.runtime.clock().now()
}
pub fn events(&self) -> &[Event] {
&self.events
}
pub fn network_stats(&self) -> NetworkStats {
self.network.stats()
}
pub fn disk(&self, node: NodeId) -> Option<&VirtualDisk> {
self.disks.get(&node)
}
pub fn disk_mut(&mut self, node: NodeId) -> &mut VirtualDisk {
self.disks.entry(node).or_default()
}
pub fn add_node(
&mut self,
node: NodeId,
name: &str,
mut factory: impl FnMut() -> TaskBody + 'static,
) {
self.disks.entry(node).or_default();
let body = factory();
let task = self.runtime.spawn(node, name, body);
self.events.push(Event::TaskSpawned {
task,
node,
name: name.to_string(),
});
self.factories
.insert(node, (name.to_string(), Box::new(factory)));
}
pub fn set_link_config(&mut self, from: NodeId, to: NodeId, config: LinkConfig) {
self.network.set_link_config(from, to, config);
}
pub fn set_skew(&mut self, node: NodeId, schedule: SkewSchedule) {
self.runtime.clock_mut().set_skew(node, schedule);
self.events.push(Event::SkewUpdated { node });
}
pub fn partition(
&mut self,
group_a: impl IntoIterator<Item = NodeId>,
group_b: impl IntoIterator<Item = NodeId>,
) {
let group_a: Vec<NodeId> = group_a.into_iter().collect();
let group_b: Vec<NodeId> = group_b.into_iter().collect();
self.network
.partition(group_a.iter().copied(), group_b.iter().copied());
self.events.push(Event::Partition { group_a, group_b });
}
pub fn heal(&mut self) {
self.network.heal();
self.events.push(Event::Healed);
}
pub fn crash_node(&mut self, node: NodeId) {
if !self.crashed.insert(node) {
return;
}
self.runtime.remove_tasks_of(node);
self.network.clear_inbox(node);
if let Some(disk) = self.disks.get_mut(&node) {
disk.crash();
}
self.events.push(Event::Crash { node });
}
pub fn restart_node(&mut self, node: NodeId) {
if !self.crashed.remove(&node) {
return;
}
self.disks.entry(node).or_default();
let (name, factory) = self
.factories
.get_mut(&node)
.expect("no process registered for node");
let name = name.clone();
let body = factory();
let task = self.runtime.spawn(node, &name, body);
self.events.push(Event::Restart { node });
self.events.push(Event::TaskSpawned { task, node, name });
}
pub fn run(&mut self, max_steps: u64) -> Result<RunOutcome, ScenarioError> {
self.run_with(max_steps, |_| {})
}
pub fn run_with(
&mut self,
max_steps: u64,
mut chaos: impl FnMut(&mut Scenario),
) -> Result<RunOutcome, ScenarioError> {
let outcome = catch_unwind(AssertUnwindSafe(|| self.run_inner(max_steps, &mut chaos)));
match outcome {
Ok(Ok(report)) => Ok(report),
Ok(Err(error)) => {
self.persist_failure(&error);
Err(error)
}
Err(payload) => {
self.persist_failure(&ScenarioError::TaskPanicked(panic_message(
payload.as_ref(),
)));
resume_unwind(payload)
}
}
}
fn run_inner(
&mut self,
max_steps: u64,
chaos: &mut dyn FnMut(&mut Scenario),
) -> Result<RunOutcome, ScenarioError> {
let mut steps = 0u64;
loop {
chaos(self);
self.runtime.wake_due();
if let Some(task) = self.runtime.pick_ready() {
steps += 1;
if steps > max_steps {
return Err(ScenarioError::StepLimitExceeded { limit: max_steps });
}
let node = self.runtime.task_node(task);
let disk = self.disks.entry(node).or_default();
self.runtime
.step(task, &mut self.network, disk, &mut self.events);
continue;
}
let next = self
.network
.next_delivery()
.into_iter()
.chain(self.runtime.next_wake())
.min();
match next {
Some(target) => {
let from = self.now();
self.runtime
.clock_mut()
.advance_to(target)
.expect("event times are monotonic");
self.events.push(Event::ClockAdvanced { from, to: target });
self.deliver_due();
}
None => {
if self.runtime.all_done() {
return Ok(RunOutcome {
seed: self.seed,
steps,
sim_time_micros: self.now(),
});
}
return Err(ScenarioError::DeadlockDetected {
at_micros: self.now(),
waiting: self.runtime.unfinished_count(),
});
}
}
}
}
fn deliver_due(&mut self) {
let now = self.now();
let deliveries = self.network.deliver_due(now, &self.crashed);
let mut woke = BTreeSet::new();
for delivery in deliveries {
match delivery.outcome {
DeliveryOutcome::Delivered => {
self.events.push(Event::MessageDelivered {
from: delivery.from,
to: delivery.to,
seq: delivery.seq,
});
woke.insert(delivery.to);
}
DeliveryOutcome::Discarded(reason) => {
self.events.push(Event::MessageDropped {
from: delivery.from,
to: delivery.to,
seq: delivery.seq,
reason,
});
}
}
}
for node in woke {
self.runtime.wake_receivers(node);
}
}
fn persist_failure(&self, error: &ScenarioError) {
let dir = failure_dir();
if let Err(io_error) = persist_failure_report(&dir, self.seed, error, &self.events) {
eprintln!(
"mongreldb-sim: could not persist failure artifact to {}: {io_error}",
dir.display()
);
}
}
}
fn panic_message(payload: &dyn Any) -> String {
if let Some(message) = payload.downcast_ref::<&str>() {
(*message).to_string()
} else if let Some(message) = payload.downcast_ref::<String>() {
message.clone()
} else {
"unknown panic payload".to_string()
}
}
pub fn failure_dir() -> PathBuf {
env::var("MONGRELDB_SIM_FAILURES")
.map_or_else(|_| PathBuf::from("target/sim-failures"), PathBuf::from)
}
static FAILURE_COUNTER: AtomicU32 = AtomicU32::new(0);
pub fn persist_failure_report(
dir: &Path,
seed: Seed,
error: &ScenarioError,
events: &[Event],
) -> io::Result<PathBuf> {
fs::create_dir_all(dir)?;
let ordinal = FAILURE_COUNTER.fetch_add(1, Ordering::Relaxed);
let path = dir.join(format!("sim-failure-{}-{ordinal}.json", seed.get()));
let report = serde_json::json!({
"seed": seed.get(),
"error": error.to_string(),
"event_count": events.len(),
"events": events,
});
fs::write(&path, serde_json::to_string_pretty(&report)?)?;
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::TaskState;
const A: NodeId = NodeId(1);
#[test]
fn deadlock_is_detected() {
let mut scenario = Scenario::new(Seed::new(1));
scenario.add_node(A, "waiter", || Box::new(|_| TaskState::WaitForMessage));
let error = scenario.run_inner(1_000, &mut |_| {}).unwrap_err();
assert!(matches!(
error,
ScenarioError::DeadlockDetected { waiting: 1, .. }
));
}
#[test]
fn step_limit_guards_livelock() {
let mut scenario = Scenario::new(Seed::new(2));
scenario.add_node(A, "spinner", || Box::new(|_| TaskState::Yield));
let error = scenario.run_inner(100, &mut |_| {}).unwrap_err();
assert_eq!(error, ScenarioError::StepLimitExceeded { limit: 100 });
}
#[test]
fn happy_path_run_records_events() {
let mut scenario = Scenario::new(Seed::new(3));
scenario.add_node(A, "lonely", || {
Box::new(|ctx| {
ctx.log("hello");
TaskState::Done
})
});
let outcome = scenario.run(1_000).unwrap();
assert_eq!(outcome.seed, Seed::new(3));
assert_eq!(outcome.steps, 1);
assert!(scenario
.events()
.iter()
.any(|event| matches!(event, Event::Custom { message, .. } if message == "hello")));
}
#[test]
fn persist_failure_report_writes_seed_file() {
let dir =
env::temp_dir().join(format!("mongreldb-sim-persist-test-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let error = ScenarioError::StepLimitExceeded { limit: 5 };
let events = vec![Event::Healed];
let path = persist_failure_report(&dir, Seed::new(77), &error, &events).unwrap();
let written: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(written["seed"], 77);
assert!(written["error"].as_str().unwrap().contains("step limit"));
assert_eq!(written["events"], serde_json::json!(["Healed"]));
let _ = fs::remove_dir_all(&dir);
}
}