moonpool_sim/runner/process.rs
1//! Process trait and reboot types for simulation testing.
2//!
3//! Processes represent the **system under test** — server nodes that can be
4//! killed and restarted (rebooted). Each process gets fresh in-memory state
5//! on every boot; persistence is only through storage.
6//!
7//! This is separate from [`Workload`](super::workload::Workload), which
8//! represents the **test driver** that survives server reboots.
9//!
10//! # Usage
11//!
12//! ```ignore
13//! use moonpool_sim::{Process, SimContext, SimulationResult};
14//!
15//! struct PaxosNode;
16//!
17//! #[async_trait]
18//! impl Process for PaxosNode {
19//! fn name(&self) -> &str { "paxos" }
20//! async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()> {
21//! let role = ctx.topology().my_tags().get("role")
22//! .ok_or_else(|| moonpool_sim::SimulationError::InvalidState("missing role tag".into()))?;
23//! // Run based on assigned role from tags...
24//! Ok(())
25//! }
26//! }
27//! ```
28
29use std::ops::Range;
30
31use async_trait::async_trait;
32
33use crate::SimulationResult;
34
35use super::context::SimContext;
36
37/// A process that participates in simulation as part of the system under test.
38///
39/// Processes are the primary unit of server behavior. A fresh instance is created
40/// from the factory on every boot (first boot and every reboot). State only
41/// persists through storage, not in-memory fields.
42///
43/// The process reads its tags and index from [`SimContext`] to determine its role.
44#[async_trait]
45pub trait Process: Send + Sync + 'static {
46 /// Name of this process type for reporting.
47 fn name(&self) -> &str;
48
49 /// Run the process. Called on each boot (first boot and every reboot).
50 ///
51 /// The [`SimContext`] has fresh providers each boot. The process should
52 /// bind listeners, establish connections, and run its main loop.
53 ///
54 /// Returns when the process exits voluntarily, or gets cancelled on reboot.
55 async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()>;
56}
57
58/// The type of reboot to perform on a process.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum RebootKind {
61 /// Signal shutdown token, wait grace period, drain send buffers, then restart.
62 ///
63 /// The process's `ctx.shutdown()` token fires. The process has a grace period
64 /// to finish up. If it doesn't exit in time, the task is force-cancelled.
65 /// Send buffers drain during the grace period (FIN delivery).
66 Graceful,
67
68 /// Instant kill: task cancelled, all connections abort immediately.
69 ///
70 /// No buffer drain. Peers see connection reset errors. Unsynced storage
71 /// data may be lost (when per-IP storage scoping is implemented).
72 Crash,
73
74 /// Instant kill + wipe all storage for this process.
75 ///
76 /// Same as [`Crash`](RebootKind::Crash) but also deletes all persistent
77 /// storage owned by this process's IP. Simulates total data loss or a
78 /// new node joining the cluster.
79 CrashAndWipe,
80}
81
82/// The failure domain a reboot targets.
83///
84/// Controls how [`AttritionInjector`](super::fault_injector) selects victims.
85/// `PerMachine` / `PerZone` reboot all collocated processes *together* — modeling
86/// correlated failure — and require a [`.cluster()`](super::builder::SimulationBuilder::cluster)
87/// topology. Without locality they are a no-op.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
89pub enum AttritionScope {
90 /// Reboot a single random process (the historical behavior).
91 #[default]
92 PerProcess,
93 /// Reboot every process on a single random machine atomically.
94 PerMachine,
95 /// Reboot every process in a single random zone atomically.
96 PerZone,
97}
98
99/// Built-in attrition configuration for automatic process reboots.
100///
101/// Provides a default chaos mechanism that randomly kills and restarts server
102/// processes during the chaos phase. For custom fault injection strategies,
103/// implement [`FaultInjector`](super::fault_injector::FaultInjector) instead.
104///
105/// # Probabilities
106///
107/// The `prob_*` fields are weights that get normalized internally. They don't
108/// need to sum to 1.0, but all must be non-negative.
109///
110/// # Example
111///
112/// ```ignore
113/// Attrition {
114/// max_dead: 1,
115/// prob_graceful: 0.3,
116/// prob_crash: 0.5,
117/// prob_wipe: 0.2,
118/// recovery_delay_ms: None,
119/// grace_period_ms: None,
120/// scope: AttritionScope::PerProcess,
121/// }
122/// ```
123#[derive(Debug, Clone, PartialEq)]
124pub struct Attrition {
125 /// Maximum number of simultaneously dead processes.
126 ///
127 /// The attrition injector will not kill a process if the number of currently
128 /// dead (not yet restarted) processes is already at this limit.
129 pub max_dead: usize,
130
131 /// Weight for [`RebootKind::Graceful`] reboots.
132 pub prob_graceful: f64,
133
134 /// Weight for [`RebootKind::Crash`] reboots.
135 pub prob_crash: f64,
136
137 /// Weight for [`RebootKind::CrashAndWipe`] reboots.
138 pub prob_wipe: f64,
139
140 /// Recovery delay range in milliseconds.
141 ///
142 /// After a process is killed (crash or force-kill after grace), it restarts
143 /// after a seeded random delay drawn from this range.
144 ///
145 /// Defaults to `1000..10000` (1-10 seconds) if not set.
146 pub recovery_delay_ms: Option<Range<usize>>,
147
148 /// Grace period range in milliseconds (for graceful reboots).
149 ///
150 /// After the per-process shutdown token is cancelled, the process has this
151 /// long to clean up before being force-killed. The actual duration is a
152 /// seeded random value from this range.
153 ///
154 /// Defaults to `2000..5000` (2-5 seconds) if not set.
155 pub grace_period_ms: Option<Range<usize>>,
156
157 /// The failure domain each reboot targets.
158 ///
159 /// [`AttritionScope::PerProcess`] (the default) kills one random process at
160 /// a time. [`AttritionScope::PerMachine`] / [`AttritionScope::PerZone`] kill
161 /// all collocated processes together, modeling correlated failure; they
162 /// require a [`.cluster()`](super::builder::SimulationBuilder::cluster)
163 /// topology and respect `max_dead` as a whole-group budget.
164 pub scope: AttritionScope,
165}
166
167impl Attrition {
168 /// Choose a [`RebootKind`] based on the configured probabilities using the
169 /// given random value in `[0.0, 1.0)`.
170 pub(crate) fn choose_kind(&self, rand_val: f64) -> RebootKind {
171 let total = self.prob_graceful + self.prob_crash + self.prob_wipe;
172 if total <= 0.0 {
173 return RebootKind::Crash;
174 }
175
176 let normalized = rand_val * total;
177 if normalized < self.prob_graceful {
178 RebootKind::Graceful
179 } else if normalized < self.prob_graceful + self.prob_crash {
180 RebootKind::Crash
181 } else {
182 RebootKind::CrashAndWipe
183 }
184 }
185
186 /// Derive a per-seed swarm reboot regime from this base configuration.
187 ///
188 /// Implements *swarm testing* (Groce et al., ISSTA 2012) for attrition: each
189 /// seed exercises a random reboot *regime* rather than the fixed configured
190 /// one. This surfaces bug classes that a single fixed regime hides — most
191 /// importantly the **never-reboot** case (needed to find slow leaks / timer
192 /// overflows) and single-mode cases ("always crash", "graceful-only").
193 ///
194 /// Draws exactly four `config_random_bool` values from the independent
195 /// `CONFIG_RNG` stream (fixed sequence ⇒ reproducible per seed, never
196 /// perturbs in-run randomness). The first draw, with ~50% probability, sets
197 /// `max_dead = 0` — the never-reboot regime, where the injector's
198 /// `dead_count() >= max_dead` gate is always true so it never reboots. The
199 /// remaining three each mask one reboot-kind weight to `0.0` with ~50%
200 /// probability.
201 ///
202 /// When all three kind weights are masked off, [`choose_kind`](Self::choose_kind)
203 /// falls back to [`RebootKind::Crash`] — the "always crash" single-mode regime.
204 #[must_use]
205 pub fn swarm_for_seed(&self) -> Attrition {
206 let mut regime = self.clone();
207 if !crate::sim::config_random_bool(0.5) {
208 regime.max_dead = 0;
209 }
210 if !crate::sim::config_random_bool(0.5) {
211 regime.prob_graceful = 0.0;
212 }
213 if !crate::sim::config_random_bool(0.5) {
214 regime.prob_crash = 0.0;
215 }
216 if !crate::sim::config_random_bool(0.5) {
217 regime.prob_wipe = 0.0;
218 }
219 regime
220 }
221}
222
223#[cfg(test)]
224mod swarm_tests {
225 use super::{Attrition, AttritionScope};
226 use crate::sim::rng::set_config_seed;
227
228 /// A representative base regime: all three reboot kinds enabled.
229 fn base() -> Attrition {
230 Attrition {
231 max_dead: 2,
232 prob_graceful: 0.3,
233 prob_crash: 0.5,
234 prob_wipe: 0.2,
235 recovery_delay_ms: None,
236 grace_period_ms: None,
237 scope: AttritionScope::PerProcess,
238 }
239 }
240
241 /// Build a swarm regime the way the runner does: config stream seeded per iteration.
242 fn swarm_for(seed: u64) -> Attrition {
243 set_config_seed(seed);
244 base().swarm_for_seed()
245 }
246
247 #[test]
248 fn swarm_regime_is_deterministic_per_seed() {
249 for seed in [0_u64, 1, 42, 12_345] {
250 assert_eq!(
251 swarm_for(seed),
252 swarm_for(seed),
253 "swarm regime must be reproducible for seed {seed}"
254 );
255 }
256 }
257
258 #[test]
259 fn swarm_reaches_never_reboot() {
260 let saw_never_reboot = (0..1000_u64).any(|s| swarm_for(s).max_dead == 0);
261 assert!(
262 saw_never_reboot,
263 "no seed in 0..1000 produced the never-reboot regime (max_dead == 0)"
264 );
265 }
266
267 #[test]
268 fn swarm_reaches_single_mode() {
269 let saw_single_mode = (0..1000_u64).any(|s| {
270 let r = swarm_for(s);
271 let on = [r.prob_graceful, r.prob_crash, r.prob_wipe]
272 .iter()
273 .filter(|&&w| w > 0.0)
274 .count();
275 on == 1
276 });
277 assert!(
278 saw_single_mode,
279 "no seed in 0..1000 produced a single-mode regime (one kind enabled)"
280 );
281 }
282}