Skip to main content

moonpool_sim/runner/
fault_injector.rs

1//! Fault injection for simulation chaos testing.
2//!
3//! [`FaultInjector`] defines fault injection strategies (partitions, connection drops, etc.)
4//! that run during the chaos phase of a simulation. [`FaultContext`] provides access to
5//! `SimWorld` fault injection primitives.
6//!
7//! When `chaos_duration` is configured on the builder, fault injectors run concurrently
8//! with workloads. At the chaos boundary, `ctx.chaos_shutdown()` is cancelled and the
9//! system settles before running workload checks.
10//!
11//! # Usage
12//!
13//! ```ignore
14//! use moonpool_sim::{FaultInjector, FaultContext, SimulationResult};
15//! use std::time::Duration;
16//!
17//! struct RandomPartition { probability: f64 }
18//!
19//! #[async_trait]
20//! impl FaultInjector for RandomPartition {
21//!     fn name(&self) -> &str { "random_partition" }
22//!     async fn inject(&mut self, ctx: &FaultContext) -> SimulationResult<()> {
23//!         let ips = ctx.process_ips();
24//!         while !ctx.chaos_shutdown().is_cancelled() {
25//!             if ctx.random().random_bool(self.probability) && ips.len() >= 2 {
26//!                 ctx.partition(&ips[0], &ips[1])?;
27//!                 ctx.time().sleep(Duration::from_secs(5)).await?;
28//!                 ctx.heal_partition(&ips[0], &ips[1])?;
29//!             }
30//!             ctx.time().sleep(Duration::from_secs(1)).await?;
31//!         }
32//!         Ok(())
33//!     }
34//! }
35//! ```
36
37use std::time::Duration;
38
39use async_trait::async_trait;
40use moonpool_core::TimeProvider;
41
42use crate::SimulationResult;
43use crate::providers::{SimRandomProvider, SimTimeProvider};
44use crate::runner::locality::{DomainLevel, MachineRegistry};
45use crate::runner::process::{AttritionScope, RebootKind};
46use crate::runner::tags::TagRegistry;
47use crate::sim::SimWorld;
48use crate::{assert_reachable, assert_sometimes_each};
49
50/// Process-related state for fault injection targeting.
51pub struct ProcessInfo {
52    /// Server process IP addresses.
53    pub process_ips: Vec<String>,
54    /// Tag registry mapping process IPs to their resolved tags.
55    pub tag_registry: TagRegistry,
56    /// Machine registry mapping process IPs to their failure-domain locality.
57    pub machine_registry: MachineRegistry,
58    /// Shared count of currently dead (killed but not yet restarted) processes.
59    pub dead_count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
60}
61
62/// Context for fault injectors — gives access to `SimWorld` fault injection methods.
63///
64/// Unlike `SimContext` (which workloads receive), `FaultContext` provides direct
65/// access to network partitioning, reboot, and other fault primitives that normal
66/// workloads should not use.
67pub struct FaultContext {
68    sim: SimWorld,
69    process_info: ProcessInfo,
70    random: SimRandomProvider,
71    time: SimTimeProvider,
72    chaos_shutdown: tokio_util::sync::CancellationToken,
73}
74
75impl FaultContext {
76    /// Create a new fault context with process information.
77    #[must_use]
78    pub fn new(
79        sim: SimWorld,
80        process_info: ProcessInfo,
81        random: SimRandomProvider,
82        time: SimTimeProvider,
83        chaos_shutdown: tokio_util::sync::CancellationToken,
84    ) -> Self {
85        Self {
86            sim,
87            process_info,
88            random,
89            time,
90            chaos_shutdown,
91        }
92    }
93
94    /// Get the number of currently dead (killed but not yet restarted) processes.
95    #[must_use]
96    pub fn dead_count(&self) -> usize {
97        self.process_info
98            .dead_count
99            .load(std::sync::atomic::Ordering::Relaxed)
100    }
101
102    /// Create a bidirectional network partition between two IPs.
103    ///
104    /// The partition persists until [`heal_partition`](Self::heal_partition) is called.
105    ///
106    /// # Errors
107    ///
108    /// Returns an error if IP parsing fails or the operation is rejected by the simulator.
109    pub fn partition(&self, a: &str, b: &str) -> SimulationResult<()> {
110        let a_ip: std::net::IpAddr = a
111            .parse()
112            .map_err(|e| crate::SimulationError::InvalidState(format!("invalid IP '{a}': {e}")))?;
113        let b_ip: std::net::IpAddr = b
114            .parse()
115            .map_err(|e| crate::SimulationError::InvalidState(format!("invalid IP '{b}': {e}")))?;
116        // Use a long duration — heal_partition is the expected way to undo
117        self.sim.partition_pair(a_ip, b_ip, Duration::from_hours(1))
118    }
119
120    /// Remove a network partition between two IPs.
121    ///
122    /// # Errors
123    ///
124    /// Returns an error if IP parsing fails or the operation is rejected by the simulator.
125    pub fn heal_partition(&self, a: &str, b: &str) -> SimulationResult<()> {
126        let a_ip: std::net::IpAddr = a
127            .parse()
128            .map_err(|e| crate::SimulationError::InvalidState(format!("invalid IP '{a}': {e}")))?;
129        let b_ip: std::net::IpAddr = b
130            .parse()
131            .map_err(|e| crate::SimulationError::InvalidState(format!("invalid IP '{b}': {e}")))?;
132        self.sim.restore_partition(a_ip, b_ip)
133    }
134
135    /// Check whether two IPs are partitioned.
136    ///
137    /// # Errors
138    ///
139    /// Returns an error if IP parsing fails or the operation is rejected by the simulator.
140    pub fn is_partitioned(&self, a: &str, b: &str) -> SimulationResult<bool> {
141        let a_ip: std::net::IpAddr = a
142            .parse()
143            .map_err(|e| crate::SimulationError::InvalidState(format!("invalid IP '{a}': {e}")))?;
144        let b_ip: std::net::IpAddr = b
145            .parse()
146            .map_err(|e| crate::SimulationError::InvalidState(format!("invalid IP '{b}': {e}")))?;
147        self.sim.is_partitioned(a_ip, b_ip)
148    }
149
150    /// Get the seeded random provider.
151    #[must_use]
152    pub fn random(&self) -> &SimRandomProvider {
153        &self.random
154    }
155
156    /// Get the simulated time provider.
157    #[must_use]
158    pub fn time(&self) -> &SimTimeProvider {
159        &self.time
160    }
161
162    /// Get the chaos-phase shutdown token.
163    ///
164    /// This token is cancelled at the chaos→recovery boundary,
165    /// signaling fault injectors to stop.
166    #[must_use]
167    pub fn chaos_shutdown(&self) -> &tokio_util::sync::CancellationToken {
168        &self.chaos_shutdown
169    }
170
171    /// Get all server process IPs.
172    #[must_use]
173    pub fn process_ips(&self) -> &[String] {
174        &self.process_info.process_ips
175    }
176
177    /// Reboot a specific process by IP.
178    ///
179    /// For [`RebootKind::Graceful`]: schedules a `ProcessGracefulShutdown` event.
180    /// The orchestrator cancels the per-process shutdown token, giving the process
181    /// a grace period to drain buffers and clean up. After the grace period,
182    /// a force-kill aborts the task and connections, then schedules restart.
183    ///
184    /// For [`RebootKind::Crash`] and [`RebootKind::CrashAndWipe`]: immediately
185    /// aborts all connections and schedules a `ProcessRestart` event.
186    ///
187    /// # Errors
188    ///
189    /// Returns an error if IP parsing fails or the operation is rejected by the simulator.
190    pub fn reboot(&self, ip: &str, kind: RebootKind) -> SimulationResult<()> {
191        let recovery_range = 1000..10000;
192        let grace_range = 2000..5000;
193        self.reboot_with_delays(ip, kind, &recovery_range, &grace_range)
194    }
195
196    /// Reboot a process with custom delay ranges.
197    ///
198    /// Like [`reboot`](Self::reboot) but with configurable recovery delay and
199    /// grace period ranges (in milliseconds). Used by [`AttritionInjector`] to
200    /// pass through [`Attrition`](super::process::Attrition) configuration.
201    ///
202    /// # Errors
203    ///
204    /// Returns an error if IP parsing fails or the operation is rejected by the simulator.
205    pub fn reboot_with_delays(
206        &self,
207        ip: &str,
208        kind: RebootKind,
209        recovery_delay_range_ms: &std::ops::Range<usize>,
210        grace_period_range_ms: &std::ops::Range<usize>,
211    ) -> SimulationResult<()> {
212        let ip_addr: std::net::IpAddr = ip
213            .parse()
214            .map_err(|e| crate::SimulationError::InvalidState(format!("invalid IP '{ip}': {e}")))?;
215
216        match kind {
217            RebootKind::Graceful => {
218                assert_reachable!("reboot: graceful path");
219                let grace_ms = crate::sim::sim_random_range(grace_period_range_ms.clone()) as u64;
220                let recovery_ms =
221                    crate::sim::sim_random_range(recovery_delay_range_ms.clone()) as u64;
222                self.sim.schedule_event(
223                    crate::sim::Event::ProcessGracefulShutdown {
224                        ip: ip_addr,
225                        grace_period_ms: grace_ms,
226                        recovery_delay_ms: recovery_ms,
227                    },
228                    Duration::from_nanos(1),
229                );
230                tracing::info!(
231                    "Initiated graceful reboot for process at IP {} (grace={}ms, recovery={}ms)",
232                    ip,
233                    grace_ms,
234                    recovery_ms
235                );
236            }
237            RebootKind::Crash | RebootKind::CrashAndWipe => {
238                assert_reachable!("reboot: crash path");
239                self.sim.abort_all_connections_for_ip(ip_addr);
240                // Crash storage for this process
241                self.sim.simulate_crash_for_process(ip_addr, true);
242                // Wipe storage if CrashAndWipe
243                if kind == RebootKind::CrashAndWipe {
244                    self.sim.wipe_storage_for_process(ip_addr);
245                }
246                self.process_info
247                    .dead_count
248                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
249                let delay_ms = crate::sim::sim_random_range(recovery_delay_range_ms.clone()) as u64;
250                let recovery_delay = Duration::from_millis(delay_ms);
251                self.sim.schedule_process_restart(ip_addr, recovery_delay);
252                tracing::info!(
253                    "Crashed process at IP {} (recovery in {:?})",
254                    ip,
255                    recovery_delay
256                );
257            }
258        }
259
260        Ok(())
261    }
262
263    /// Reboot a random alive server process.
264    ///
265    /// Picks a random process from the process IP list and reboots it.
266    /// Returns `Ok(None)` if no processes are available.
267    ///
268    /// # Errors
269    ///
270    /// Returns an error if IP parsing fails or the operation is rejected by the simulator.
271    pub fn reboot_random(&self, kind: RebootKind) -> SimulationResult<Option<String>> {
272        if self.process_info.process_ips.is_empty() {
273            return Ok(None);
274        }
275        let idx = crate::sim::sim_random_range(0..self.process_info.process_ips.len());
276        let ip = self.process_info.process_ips[idx].clone();
277        self.reboot(&ip, kind)?;
278        Ok(Some(ip))
279    }
280
281    /// Reboot all processes matching a tag key=value pair.
282    ///
283    /// # Errors
284    ///
285    /// Returns an error if IP parsing fails or the operation is rejected by the simulator.
286    pub fn reboot_tagged(
287        &self,
288        key: &str,
289        value: &str,
290        kind: RebootKind,
291    ) -> SimulationResult<Vec<String>> {
292        let matching_ips: Vec<String> = self
293            .process_info
294            .tag_registry
295            .ips_tagged(key, value)
296            .into_iter()
297            .map(|ip| ip.to_string())
298            .collect();
299
300        for ip in &matching_ips {
301            self.reboot(ip, kind)?;
302        }
303
304        Ok(matching_ips)
305    }
306
307    /// The machine registry, for failure-domain queries during fault injection.
308    #[must_use]
309    pub fn machine_registry(&self) -> &MachineRegistry {
310        &self.process_info.machine_registry
311    }
312
313    /// Reboot every process collocated on a single machine — modeling correlated
314    /// (shared-fate) failure.
315    ///
316    /// Returns the IPs that were rebooted (empty if the machine is unknown).
317    ///
318    /// # Errors
319    ///
320    /// Returns an error if the operation is rejected by the simulator.
321    pub fn reboot_machine(
322        &self,
323        machine_id: &str,
324        kind: RebootKind,
325    ) -> SimulationResult<Vec<String>> {
326        self.reboot_domain(DomainLevel::Machine, machine_id, kind)
327    }
328
329    /// Reboot every process in a failure domain (`level` + `id`) together.
330    ///
331    /// Returns the IPs that were rebooted (empty if the domain is unknown).
332    ///
333    /// # Errors
334    ///
335    /// Returns an error if the operation is rejected by the simulator.
336    pub fn reboot_domain(
337        &self,
338        level: DomainLevel,
339        id: &str,
340        kind: RebootKind,
341    ) -> SimulationResult<Vec<String>> {
342        let ips: Vec<String> = self
343            .process_info
344            .machine_registry
345            .ips_in_domain(level, id)
346            .into_iter()
347            .map(|ip| ip.to_string())
348            .collect();
349
350        for ip in &ips {
351            self.reboot(ip, kind)?;
352        }
353
354        Ok(ips)
355    }
356}
357
358/// A fault injector that introduces failures during the chaos phase.
359///
360/// Fault injectors run concurrently with workloads when `chaos_duration` is set.
361/// They are signaled to stop via `ctx.chaos_shutdown()` when the chaos duration
362/// elapses. After all workloads complete, the system settles before checks run.
363#[async_trait]
364pub trait FaultInjector: Send + Sync + 'static {
365    /// Name of this fault injector for reporting.
366    fn name(&self) -> &str;
367
368    /// Inject faults using the provided context.
369    ///
370    /// Should respect `ctx.chaos_shutdown()` to allow graceful termination.
371    async fn inject(&mut self, ctx: &FaultContext) -> SimulationResult<()>;
372}
373
374/// Built-in fault injector that randomly reboots server processes.
375///
376/// Active only during the chaos phase. Respects `max_dead` to limit the
377/// number of simultaneously dead processes. The reboot type is chosen by
378/// weighted probability from the [`Attrition`](super::process::Attrition) config.
379pub(crate) struct AttritionInjector {
380    config: super::process::Attrition,
381}
382
383impl AttritionInjector {
384    /// Create a new attrition injector from the given configuration.
385    pub(crate) fn new(config: super::process::Attrition) -> Self {
386        Self { config }
387    }
388
389    /// Draw a reboot kind by weighted probability and record coverage.
390    fn choose_kind(&self) -> RebootKind {
391        let rand_val = f64::from(crate::sim::sim_random_range(0..10000)) / 10000.0;
392        let kind = self.config.choose_kind(rand_val);
393        assert_sometimes_each!("attrition_reboot_kind", [("kind", kind as i64)]);
394        kind
395    }
396
397    /// Configured recovery / grace-period delay ranges, with defaults.
398    fn delay_ranges(&self) -> (std::ops::Range<usize>, std::ops::Range<usize>) {
399        (
400            self.config.recovery_delay_ms.clone().unwrap_or(1000..10000),
401            self.config.grace_period_ms.clone().unwrap_or(2000..5000),
402        )
403    }
404
405    /// Reboot a single random process, respecting the `max_dead` budget.
406    fn inject_process(&self, ctx: &FaultContext) -> SimulationResult<()> {
407        if ctx.dead_count() >= self.config.max_dead {
408            assert_reachable!("attrition: max_dead limit enforced");
409            return Ok(());
410        }
411        let kind = self.choose_kind();
412        let (recovery_range, grace_range) = self.delay_ranges();
413        let idx = crate::sim::sim_random_range(0..ctx.process_ips().len());
414        let ip = ctx.process_ips()[idx].clone();
415        assert_sometimes_each!(
416            "attrition_process_targeted",
417            [("process_idx", i64::try_from(idx).unwrap_or(i64::MAX))]
418        );
419        ctx.reboot_with_delays(&ip, kind, &recovery_range, &grace_range)
420    }
421
422    /// Reboot every process in a random failure domain *together*, only if the
423    /// whole group fits within the `max_dead` budget. A no-op when no locality
424    /// topology is configured (`domains` empty).
425    fn inject_domain(
426        &self,
427        ctx: &FaultContext,
428        level: DomainLevel,
429        domains: &[String],
430    ) -> SimulationResult<()> {
431        if domains.is_empty() {
432            return Ok(());
433        }
434        let di = crate::sim::sim_random_range(0..domains.len());
435        let ips: Vec<String> = ctx
436            .machine_registry()
437            .ips_in_domain(level, &domains[di])
438            .into_iter()
439            .map(|ip| ip.to_string())
440            .collect();
441        // Whole-group gate: reboot the group atomically only if all of its
442        // processes fit within the remaining budget.
443        if ctx.dead_count() + ips.len() > self.config.max_dead {
444            assert_reachable!("attrition: max_dead limit enforced (group)");
445            return Ok(());
446        }
447        let kind = self.choose_kind();
448        let (recovery_range, grace_range) = self.delay_ranges();
449        assert_sometimes_each!(
450            "attrition_domain_targeted",
451            [("group_size", i64::try_from(ips.len()).unwrap_or(i64::MAX))]
452        );
453        for ip in &ips {
454            ctx.reboot_with_delays(ip, kind, &recovery_range, &grace_range)?;
455        }
456        Ok(())
457    }
458}
459
460#[async_trait]
461impl FaultInjector for AttritionInjector {
462    fn name(&self) -> &'static str {
463        "attrition"
464    }
465
466    async fn inject(&mut self, ctx: &FaultContext) -> SimulationResult<()> {
467        while !ctx.chaos_shutdown().is_cancelled() {
468            // Random delay between reboot attempts (1-5 seconds)
469            let delay_ms = crate::sim::sim_random_range(1000..5000);
470            ctx.time()
471                .sleep(Duration::from_millis(
472                    u64::try_from(delay_ms).expect("delay_ms is non-negative"),
473                ))
474                .await
475                .map_err(|e| crate::SimulationError::InvalidState(format!("sleep failed: {e}")))?;
476
477            if ctx.chaos_shutdown().is_cancelled() {
478                break;
479            }
480
481            if ctx.process_ips().is_empty() {
482                continue;
483            }
484
485            match self.config.scope {
486                AttritionScope::PerProcess => self.inject_process(ctx)?,
487                AttritionScope::PerMachine => {
488                    let machines = ctx.machine_registry().all_machines();
489                    self.inject_domain(ctx, DomainLevel::Machine, &machines)?;
490                }
491                AttritionScope::PerZone => {
492                    let zones = ctx.machine_registry().all_zones();
493                    self.inject_domain(ctx, DomainLevel::Zone, &zones)?;
494                }
495            }
496        }
497        Ok(())
498    }
499}