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}