Skip to main content

moonpool_sim/storage/
config.rs

1//! # Storage Simulation Configuration
2//!
3//! This module provides configuration for storage simulation, following
4//! FoundationDB's battle-tested simulation approach and TigerBeetle's deterministic
5//! testing patterns.
6//!
7//! ## Performance Parameters
8//!
9//! | Parameter | Config Field | Default | Description |
10//! |-----------|--------------|---------|-------------|
11//! | IOPS | `iops` | 25,000 | I/O operations per second limit |
12//! | Bandwidth | `bandwidth` | 150 MB/s | Maximum throughput in bytes/sec |
13//! | Read latency | `read_latency` | 50-200µs | Time for read operations |
14//! | Write latency | `write_latency` | 100-500µs | Time for write operations |
15//! | Sync latency | `sync_latency` | 1-5ms | Time for sync operations |
16//!
17//! ## Fault Injection
18//!
19//! | Fault | Config Field | Default | Real-World Scenario |
20//! |-------|--------------|---------|---------------------|
21//! | Read fault | `read_fault_probability` | 0% | Disk read errors, ECC failures |
22//! | Write fault | `write_fault_probability` | 0% | Write failures, disk full |
23//! | Crash fault | `crash_fault_probability` | 0% | Sudden power loss simulation |
24//! | Misdirected write | `misdirect_write_probability` | 0% | Write lands at wrong location |
25//! | Misdirected read | `misdirect_read_probability` | 0% | Read returns wrong data |
26//! | Phantom write | `phantom_write_probability` | 0% | Write appears to succeed but doesn't persist |
27//! | Sync failure | `sync_failure_probability` | 0% | fsync fails |
28//!
29//! ## Dynamic Disk Degradation Episodes
30//!
31//! Real disks degrade *episodically* rather than at a fixed steady-state rate.
32//! These knobs are off by default (probabilities 0, no-op multipliers); when
33//! enabled they make a file enter a stall or throttle episode for a duration,
34//! surfacing timeout cascades and backpressure collapse. FDB ref:
35//! `DiskFailureInjector` / `getDiskDelay()`.
36//!
37//! | Episode | Config Field | Default | Effect while active |
38//! |---------|--------------|---------|---------------------|
39//! | Stall | `disk_stall_probability` / `disk_stall_duration` | 0% | Disk frozen until expiry; I/O waits out the window |
40//! | Throttle | `disk_throttle_probability` / `disk_throttle_duration` | 0% | Effective IOPS/bandwidth divided by the multipliers |
41//! | Throttle factor | `disk_throttle_iops_multiplier` / `disk_throttle_bandwidth_multiplier` | 1.0 | Divisor applied to IOPS / bandwidth |
42//!
43//! ## Configuration Examples
44//!
45//! ### Fast Local Testing (No Chaos)
46//! ```rust
47//! use moonpool_sim::storage::StorageConfiguration;
48//!
49//! let config = StorageConfiguration::fast_local();
50//! // All faults disabled, minimal latencies
51//! ```
52//!
53//! ### Full Chaos Testing
54//! ```rust
55//! use moonpool_sim::storage::StorageConfiguration;
56//!
57//! let config = StorageConfiguration::random_for_seed();
58//! // Randomized fault parameters for comprehensive testing
59//! ```
60//!
61//! ## FDB/TigerBeetle References
62//!
63//! - Simulated file operations: FDB sim2.actor.cpp
64//! - Storage faults: TigerBeetle storage simulation
65//! - Crash consistency: FDB AsyncFileKAIO, TigerBeetle deterministic testing
66
67use crate::network::config::{LatencyDistribution, random_latency_for_seed};
68use crate::sim::rng::{config_random_bool, sim_random_range};
69use std::time::Duration;
70
71/// Configuration for storage simulation parameters.
72///
73/// This struct contains all settings related to storage simulation including
74/// performance characteristics and fault injection probabilities.
75#[derive(Debug, Clone)]
76pub struct StorageConfiguration {
77    // =========================================================================
78    // Performance Parameters
79    // =========================================================================
80    /// I/O operations per second limit.
81    ///
82    /// Typical values:
83    /// - `NVMe` SSD: 100,000-500,000 IOPS
84    /// - SATA SSD: 25,000-100,000 IOPS
85    /// - HDD: 100-200 IOPS
86    pub iops: u64,
87
88    /// Maximum bandwidth in bytes per second.
89    ///
90    /// Typical values:
91    /// - `NVMe` SSD: 3,000-7,000 MB/s
92    /// - SATA SSD: 500-600 MB/s
93    /// - HDD: 100-200 MB/s
94    pub bandwidth: u64,
95
96    /// Latency distribution for read operations.
97    ///
98    /// Typical values:
99    /// - `NVMe` SSD: 20-100µs
100    /// - SATA SSD: 50-200µs
101    /// - HDD: 2-10ms
102    pub read_latency: LatencyDistribution,
103
104    /// Latency distribution for write operations.
105    ///
106    /// Typical values:
107    /// - `NVMe` SSD: 20-100µs
108    /// - SATA SSD: 100-500µs
109    /// - HDD: 2-10ms
110    pub write_latency: LatencyDistribution,
111
112    /// Latency distribution for sync/flush operations.
113    ///
114    /// Sync operations ensure data durability and typically take
115    /// longer than regular read/write operations.
116    pub sync_latency: LatencyDistribution,
117
118    // =========================================================================
119    // Fault Injection Probabilities
120    // =========================================================================
121    /// Probability of read operation failing (0.0 - 1.0).
122    ///
123    /// # Real-World Scenario
124    /// Simulates disk read errors, ECC failures, or media degradation.
125    pub read_fault_probability: f64,
126
127    /// Probability of write operation failing (0.0 - 1.0).
128    ///
129    /// # Real-World Scenario
130    /// Simulates write failures due to disk full, bad sectors, or media errors.
131    pub write_fault_probability: f64,
132
133    /// Probability of crash fault during operation (0.0 - 1.0).
134    ///
135    /// # Real-World Scenario
136    /// Simulates sudden power loss or system crash during I/O.
137    /// Tests crash consistency and recovery logic.
138    pub crash_fault_probability: f64,
139
140    /// Probability of write landing at wrong location (0.0 - 1.0).
141    ///
142    /// # Real-World Scenario
143    /// Simulates misdirected writes where data is written to a different
144    /// block than intended. Tests checksum validation and corruption detection.
145    /// `TigerBeetle` ref: storage fault injection
146    pub misdirect_write_probability: f64,
147
148    /// Probability of read returning data from wrong location (0.0 - 1.0).
149    ///
150    /// # Real-World Scenario
151    /// Simulates misdirected reads where data is read from a different
152    /// block than intended. Tests checksum validation.
153    /// `TigerBeetle` ref: storage fault injection
154    pub misdirect_read_probability: f64,
155
156    /// Probability of write appearing to succeed but not persisting (0.0 - 1.0).
157    ///
158    /// # Real-World Scenario
159    /// Simulates phantom writes where the write reports success but data
160    /// is lost before reaching stable storage. Tests durability guarantees.
161    pub phantom_write_probability: f64,
162
163    /// Probability of sync/flush operation failing (0.0 - 1.0).
164    ///
165    /// # Real-World Scenario
166    /// Simulates fsync failures which can indicate serious storage issues.
167    /// Tests error handling in durability-critical code paths.
168    pub sync_failure_probability: f64,
169
170    // =========================================================================
171    // Dynamic Disk Degradation Episodes
172    // =========================================================================
173    // Real disks degrade *episodically* rather than at a fixed steady-state rate:
174    // brief full stalls (GC/thermal/firmware pauses) and longer throttle periods
175    // (reduced IOPS/bandwidth). These surface timeout cascades and backpressure
176    // collapse that steady-state timing never produces. Off by default (all 0).
177    // FDB ref: `DiskFailureInjector` / `getDiskDelay()`.
178    /// Per-operation probability of entering a *stall* episode (0.0 - 1.0).
179    ///
180    /// During a stall the disk is frozen until the episode expires; any I/O
181    /// scheduled in that window waits out the remaining time before completing.
182    pub disk_stall_probability: f64,
183
184    /// How long a stall episode freezes the disk once entered.
185    pub disk_stall_duration: Duration,
186
187    /// Per-operation probability of entering a *throttle* episode (0.0 - 1.0).
188    ///
189    /// During a throttle the effective IOPS/bandwidth are divided by the
190    /// configured multipliers for the duration of the episode.
191    pub disk_throttle_probability: f64,
192
193    /// How long a throttle episode reduces throughput once entered.
194    pub disk_throttle_duration: Duration,
195
196    /// Divisor applied to effective IOPS during a throttle episode (>= 1.0).
197    ///
198    /// E.g. `10.0` makes the disk handle one tenth its normal IOPS.
199    pub disk_throttle_iops_multiplier: f64,
200
201    /// Divisor applied to effective bandwidth during a throttle episode (>= 1.0).
202    pub disk_throttle_bandwidth_multiplier: f64,
203}
204
205impl Default for StorageConfiguration {
206    fn default() -> Self {
207        Self {
208            // Performance parameters matching a typical SATA SSD
209            iops: 25_000,
210            bandwidth: 150_000_000, // 150 MB/s
211            read_latency: LatencyDistribution::Uniform {
212                start: Duration::from_micros(50),
213                end: Duration::from_micros(200),
214            },
215            write_latency: LatencyDistribution::Uniform {
216                start: Duration::from_micros(100),
217                end: Duration::from_micros(500),
218            },
219            sync_latency: LatencyDistribution::Uniform {
220                start: Duration::from_millis(1),
221                end: Duration::from_millis(5),
222            },
223
224            // Fault probabilities - disabled by default for predictable behavior
225            read_fault_probability: 0.0,
226            write_fault_probability: 0.0,
227            crash_fault_probability: 0.0,
228            misdirect_write_probability: 0.0,
229            misdirect_read_probability: 0.0,
230            phantom_write_probability: 0.0,
231            sync_failure_probability: 0.0,
232
233            // Dynamic disk degradation - disabled by default (no-op multipliers)
234            disk_stall_probability: 0.0,
235            disk_stall_duration: Duration::ZERO,
236            disk_throttle_probability: 0.0,
237            disk_throttle_duration: Duration::ZERO,
238            disk_throttle_iops_multiplier: 1.0,
239            disk_throttle_bandwidth_multiplier: 1.0,
240        }
241    }
242}
243
244impl StorageConfiguration {
245    /// Create a new storage configuration with default settings.
246    #[must_use]
247    pub fn new() -> Self {
248        Self::default()
249    }
250
251    /// Create a randomized storage configuration for chaos testing.
252    ///
253    /// Uses deterministic random values based on the simulation seed
254    /// to create varied configurations across test runs.
255    #[must_use]
256    pub fn random_for_seed() -> Self {
257        Self {
258            // Randomize IOPS between 10,000 and 100,000
259            iops: sim_random_range(10_000..100_000),
260
261            // Randomize bandwidth between 50 MB/s and 500 MB/s
262            bandwidth: sim_random_range(50_000_000..500_000_000),
263
264            // Randomize latencies, mixing distribution shapes per field
265            read_latency: random_latency_for_seed(
266                Duration::from_micros(sim_random_range(20..100))
267                    ..Duration::from_micros(sim_random_range(100..500)),
268            ),
269            write_latency: random_latency_for_seed(
270                Duration::from_micros(sim_random_range(50..200))
271                    ..Duration::from_micros(sim_random_range(200..1000)),
272            ),
273            sync_latency: random_latency_for_seed(
274                Duration::from_micros(sim_random_range(500..2000))
275                    ..Duration::from_micros(sim_random_range(2000..10000)),
276            ),
277
278            // Low fault probabilities for chaos testing (0.001% to 0.1%)
279            read_fault_probability: f64::from(sim_random_range(0..100)) / 100_000.0,
280            write_fault_probability: f64::from(sim_random_range(0..100)) / 100_000.0,
281            crash_fault_probability: f64::from(sim_random_range(0..50)) / 100_000.0,
282            misdirect_write_probability: f64::from(sim_random_range(0..10)) / 100_000.0,
283            misdirect_read_probability: f64::from(sim_random_range(0..10)) / 100_000.0,
284            phantom_write_probability: f64::from(sim_random_range(0..20)) / 100_000.0,
285            sync_failure_probability: f64::from(sim_random_range(0..50)) / 100_000.0,
286
287            // Low-rate disk-degradation episodes (drawn after the faults so the
288            // existing per-field RNG sub-sequence is unchanged).
289            disk_stall_probability: f64::from(sim_random_range(0..100)) / 100_000.0,
290            disk_stall_duration: Duration::from_millis(sim_random_range(50..500)),
291            disk_throttle_probability: f64::from(sim_random_range(0..100)) / 100_000.0,
292            disk_throttle_duration: Duration::from_millis(sim_random_range(500..5000)),
293            disk_throttle_iops_multiplier: f64::from(sim_random_range(2..20)),
294            disk_throttle_bandwidth_multiplier: f64::from(sim_random_range(2..20)),
295        }
296    }
297
298    /// Create a swarm-testing storage configuration for seed-based testing.
299    ///
300    /// Starts from [`random_for_seed`](Self::random_for_seed), then disables each
301    /// fault family with ~50% probability (drawn from the independent `CONFIG_RNG`
302    /// stream). This implements *swarm testing* (Groce et al., ISSTA 2012): each
303    /// seed exercises a random *subset* of storage fault families — including the
304    /// all-off subset — instead of every family being slightly on at once (which
305    /// lets families crowd each other out, the passive-suppression anti-pattern).
306    #[must_use]
307    pub fn swarm_for_seed() -> Self {
308        let mut config = Self::random_for_seed();
309        config.apply_swarm_mask();
310        config
311    }
312
313    /// Disable each fault family with ~50% probability using the `CONFIG_RNG`
314    /// stream (see [`swarm_for_seed`](Self::swarm_for_seed)).
315    ///
316    /// Draws exactly nine `config_random_bool` values (one per family: the seven
317    /// per-op faults plus the stall and throttle episode families) so the
318    /// `CONFIG_RNG` call sequence is fixed and reproducible per seed. Performance
319    /// parameters (IOPS, bandwidth, latencies) stay as sampled — only the fault
320    /// families are masked.
321    fn apply_swarm_mask(&mut self) {
322        if !config_random_bool(0.5) {
323            self.read_fault_probability = 0.0;
324        }
325        if !config_random_bool(0.5) {
326            self.write_fault_probability = 0.0;
327        }
328        if !config_random_bool(0.5) {
329            self.crash_fault_probability = 0.0;
330        }
331        if !config_random_bool(0.5) {
332            self.misdirect_read_probability = 0.0;
333        }
334        if !config_random_bool(0.5) {
335            self.misdirect_write_probability = 0.0;
336        }
337        if !config_random_bool(0.5) {
338            self.phantom_write_probability = 0.0;
339        }
340        if !config_random_bool(0.5) {
341            self.sync_failure_probability = 0.0;
342        }
343        if !config_random_bool(0.5) {
344            self.disk_stall_probability = 0.0;
345        }
346        if !config_random_bool(0.5) {
347            self.disk_throttle_probability = 0.0;
348        }
349    }
350
351    /// Spike selected disk knob *magnitudes* under buggify (FDB's
352    /// `if (randomize && BUGGIFY) KNOB = random(lo, hi)`).
353    ///
354    /// Composes on top of [`random_for_seed`](Self::random_for_seed) /
355    /// [`swarm_for_seed`](Self::swarm_for_seed): each knob keeps its sampled value
356    /// unless its own [`buggify_knob!`](crate::buggify_knob) call site fires for the
357    /// seed. Demonstrates both throttling *down* (IOPS/bandwidth → extreme-slow
358    /// disk) and spiking a fault rate *up*. A representative subset — extend by
359    /// adding more `buggify_knob!` lines.
360    pub fn apply_buggify_knobs(&mut self) {
361        self.iops = crate::buggify_knob!(self.iops, 100..5_000);
362        self.bandwidth = crate::buggify_knob!(self.bandwidth, 1_000_000..20_000_000);
363        self.sync_failure_probability =
364            crate::buggify_knob!(self.sync_failure_probability, 0.05..0.2);
365        self.disk_stall_probability = crate::buggify_knob!(self.disk_stall_probability, 0.1..0.5);
366        self.disk_stall_duration = crate::buggify_knob!(
367            self.disk_stall_duration,
368            Duration::from_millis(100)..Duration::from_millis(500)
369        );
370        self.disk_throttle_probability =
371            crate::buggify_knob!(self.disk_throttle_probability, 0.1..0.5);
372        self.disk_throttle_duration = crate::buggify_knob!(
373            self.disk_throttle_duration,
374            Duration::from_secs(1)..Duration::from_secs(5)
375        );
376    }
377
378    /// Create a configuration optimized for fast local testing.
379    ///
380    /// Minimal latencies and no fault injection for predictable,
381    /// fast test execution.
382    #[must_use]
383    pub fn fast_local() -> Self {
384        let one_us = Duration::from_micros(1);
385        let uniform = LatencyDistribution::Uniform {
386            start: one_us,
387            end: one_us,
388        };
389        Self {
390            iops: 1_000_000,          // Very high IOPS
391            bandwidth: 1_000_000_000, // 1 GB/s
392            read_latency: uniform.clone(),
393            write_latency: uniform.clone(),
394            sync_latency: uniform,
395
396            // All faults disabled
397            read_fault_probability: 0.0,
398            write_fault_probability: 0.0,
399            crash_fault_probability: 0.0,
400            misdirect_write_probability: 0.0,
401            misdirect_read_probability: 0.0,
402            phantom_write_probability: 0.0,
403            sync_failure_probability: 0.0,
404
405            // Disk degradation disabled (no-op multipliers)
406            disk_stall_probability: 0.0,
407            disk_stall_duration: Duration::ZERO,
408            disk_throttle_probability: 0.0,
409            disk_throttle_duration: Duration::ZERO,
410            disk_throttle_iops_multiplier: 1.0,
411            disk_throttle_bandwidth_multiplier: 1.0,
412        }
413    }
414}
415
416#[cfg(test)]
417mod swarm_tests {
418    use super::StorageConfiguration;
419    use crate::sim::rng::{reset_sim_rng, set_config_seed, set_sim_seed};
420
421    /// The on/off state of each swarmed fault family, in mask order.
422    fn enabled_families(config: &StorageConfiguration) -> [bool; 9] {
423        [
424            config.read_fault_probability > 0.0,
425            config.write_fault_probability > 0.0,
426            config.crash_fault_probability > 0.0,
427            config.misdirect_read_probability > 0.0,
428            config.misdirect_write_probability > 0.0,
429            config.phantom_write_probability > 0.0,
430            config.sync_failure_probability > 0.0,
431            config.disk_stall_probability > 0.0,
432            config.disk_throttle_probability > 0.0,
433        ]
434    }
435
436    /// Build a swarm config the way the runner does: both streams seeded per iteration.
437    fn swarm_for(seed: u64) -> StorageConfiguration {
438        reset_sim_rng();
439        set_sim_seed(seed);
440        set_config_seed(seed);
441        StorageConfiguration::swarm_for_seed()
442    }
443
444    #[test]
445    fn swarm_subset_is_deterministic_per_seed() {
446        for seed in [0_u64, 1, 42, 12_345] {
447            let first = enabled_families(&swarm_for(seed));
448            let second = enabled_families(&swarm_for(seed));
449            assert_eq!(
450                first, second,
451                "swarm subset must be reproducible for seed {seed}"
452            );
453        }
454    }
455
456    #[test]
457    fn swarm_reaches_all_off_and_mixed_subsets() {
458        let mut saw_all_off = false;
459        let mut saw_mixed = false;
460
461        for seed in 0..1000_u64 {
462            let families = enabled_families(&swarm_for(seed));
463            let on = families.iter().filter(|&&e| e).count();
464            if on == 0 {
465                saw_all_off = true;
466            }
467            if on > 0 && on < families.len() {
468                saw_mixed = true;
469            }
470            if saw_all_off && saw_mixed {
471                break;
472            }
473        }
474
475        assert!(
476            saw_all_off,
477            "no seed in 0..1000 produced the all-off subset"
478        );
479        assert!(saw_mixed, "no seed in 0..1000 produced a mixed subset");
480    }
481
482    #[test]
483    fn swarm_all_off_seed_has_zero_fault_probabilities() {
484        // Find a seed whose subset is entirely off, then assert every family is inert.
485        let seed = (0..1000_u64)
486            .find(|&s| enabled_families(&swarm_for(s)).iter().all(|&e| !e))
487            .expect("expected an all-off seed within 0..1000");
488
489        let config = swarm_for(seed);
490        assert_zero(config.read_fault_probability);
491        assert_zero(config.write_fault_probability);
492        assert_zero(config.crash_fault_probability);
493        assert_zero(config.misdirect_read_probability);
494        assert_zero(config.misdirect_write_probability);
495        assert_zero(config.phantom_write_probability);
496        assert_zero(config.sync_failure_probability);
497        assert_zero(config.disk_stall_probability);
498        assert_zero(config.disk_throttle_probability);
499    }
500
501    /// Assert an f64 is exactly `+0.0` (bit-exact, avoiding the float-cmp lint).
502    fn assert_zero(value: f64) {
503        assert_eq!(
504            value.to_bits(),
505            0.0_f64.to_bits(),
506            "expected 0.0, got {value}"
507        );
508    }
509}
510
511#[cfg(test)]
512mod buggify_knob_tests {
513    use super::StorageConfiguration;
514    use crate::chaos::{buggify_init, buggify_reset};
515    use crate::sim::rng::{reset_sim_rng, set_config_seed, set_sim_seed};
516
517    /// Sample the buggify-spiked knobs the way the runner does: seed both RNG
518    /// streams and enable buggify before perturbing. Returns the spiked knobs
519    /// (f64/`Duration` knobs as bits/nanos for exact comparison).
520    fn buggified_knobs(seed: u64) -> [u64; 5] {
521        reset_sim_rng();
522        set_sim_seed(seed);
523        set_config_seed(seed);
524        buggify_init(0.8, 0.8);
525        let mut config = StorageConfiguration::swarm_for_seed();
526        config.apply_buggify_knobs();
527        buggify_reset();
528        [
529            config.iops,
530            config.bandwidth,
531            config.sync_failure_probability.to_bits(),
532            config.disk_stall_probability.to_bits(),
533            u64::try_from(config.disk_throttle_duration.as_nanos()).unwrap_or(u64::MAX),
534        ]
535    }
536
537    #[test]
538    fn buggify_knobs_deterministic_per_seed() {
539        for seed in [0_u64, 1, 42, 12_345] {
540            assert_eq!(
541                buggified_knobs(seed),
542                buggified_knobs(seed),
543                "buggify knob spikes must be reproducible for seed {seed}"
544            );
545        }
546    }
547
548    #[test]
549    fn buggify_knobs_vary_across_seeds() {
550        let distinct: std::collections::HashSet<_> = (0..50_u64).map(buggified_knobs).collect();
551        assert!(
552            distinct.len() > 1,
553            "buggify knob spikes should vary across seeds"
554        );
555    }
556}