moonpool_sim/network/config.rs
1//! # Network Chaos Configuration
2//!
3//! This module provides configuration for network chaos testing, following
4//! FoundationDB's battle-tested simulation approach and TigerBeetle's deterministic
5//! testing patterns.
6//!
7//! ## Connection Failure Modes
8//!
9//! | Failure | Config Field | Default | Real-World Scenario |
10//! |---------|--------------|---------|---------------------|
11//! | Random close | `random_close_probability` | 0.001% | Reconnection logic, message redelivery, connection pooling |
12//! | Asymmetric close | `random_close_explicit_ratio` | 30% explicit | Half-closed sockets, FIN vs RST handling |
13//! | Connect failure | `connect_failure_mode` | Probabilistic | Connection establishment retries, timeout handling |
14//!
15//! ## Network Latency & Congestion
16//!
17//! | Delay Type | Config Field | Default | Real-World Scenario |
18//! |------------|--------------|---------|---------------------|
19//! | Operation latency | `bind/accept/connect/read/write_latency` | Various ranges | Timeout settings, async operation ordering |
20//! | Write clogging | `clog_probability` + `clog_duration` | 0%, 100-300ms | Backpressure handling, flow control |
21//! | Read clogging | Same as write | Same | Symmetric flow control |
22//! | Clock drift | `clock_drift_enabled` + `clock_drift_max` | true, 100ms | Lease expiration, distributed consensus, TTL handling |
23//! | Buggified delay | `buggified_delay_enabled` + `buggified_delay_max` | true, 100ms | Race conditions, timing-dependent bugs |
24//!
25//! ## Network Partitions
26//!
27//! | Partition Type | Config/Method | Default | Real-World Scenario |
28//! |----------------|---------------|---------|---------------------|
29//! | Random partition | `partition_probability` + `partition_duration` | 0%, 200ms-2s | Split-brain, quorum loss, leader election |
30//! | Bi-directional | `partition_pair()` | Manual | Complete isolation between nodes |
31//! | Send-only block | `partition_send_from()` | Manual | Asymmetric network failures |
32//! | Recv-only block | `partition_recv_to()` | Manual | Asymmetric network failures |
33//! | Partition strategy | `partition_strategy` | Random | Different failure patterns (uniform, isolate) |
34//!
35//! ## Data Integrity Faults
36//!
37//! | Fault | Config Field | Default | Real-World Scenario |
38//! |-------|--------------|---------|---------------------|
39//! | Bit flip | `bit_flip_probability` + `bit_flip_min/max_bits` | 0.01%, 1-32 bits | CRC/checksum validation, data corruption detection |
40//!
41//! ## Partial Write/Read Simulation
42//!
43//! | Feature | Config Field | Default | Real-World Scenario |
44//! |---------|--------------|---------|---------------------|
45//! | Short writes | `partial_write_max_bytes` | 1000 bytes | TCP fragmentation handling, message framing |
46//! | Short reads | `partial_read_max_bytes` | 1000 bytes | TCP short reads, message reassembly / framing |
47//!
48//! ## Configuration Examples
49//!
50//! ### Fast Local Testing (No Chaos)
51//! ```rust
52//! use moonpool_sim::network::{NetworkConfiguration, ChaosConfiguration};
53//!
54//! let config = NetworkConfiguration::fast_local();
55//! // All chaos disabled, minimal latencies
56//! ```
57//!
58//! ### Full Chaos Testing
59//! ```rust
60//! use moonpool_sim::network::{NetworkConfiguration, ChaosConfiguration};
61//!
62//! let config = NetworkConfiguration::random_for_seed();
63//! // Randomized chaos parameters for comprehensive testing
64//! ```
65//!
66//! ### Custom Configuration
67//! ```rust
68//! use moonpool_sim::network::{NetworkConfiguration, PartitionStrategy};
69//!
70//! let mut config = NetworkConfiguration::default();
71//! config.chaos.partition_strategy = PartitionStrategy::IsolateSingle;
72//! config.chaos.partition_probability = 0.05; // 5%
73//! ```
74//!
75//! ## FDB/TigerBeetle References
76//!
77//! - Random close: FDB sim2.actor.cpp:580-605
78//! - Partitions: FDB SimClogging, TigerBeetle partition modes
79//! - Bit flips: FDB FlowTransport.actor.cpp:1297
80//! - Clock drift: FDB sim2.actor.cpp:1058-1064
81//! - Connect failures: FDB sim2.actor.cpp:1243-1250
82
83use crate::sim::rng::{
84 config_random_bool, sim_random_f64, sim_random_range, sim_random_range_or_default,
85};
86use std::ops::Range;
87use std::time::Duration;
88
89/// Network partition strategy for chaos testing.
90///
91/// Controls how nodes are selected for partitioning during chaos testing.
92/// `TigerBeetle` ref: packet_simulator.zig:12-488
93///
94/// # Real-World Scenario
95///
96/// Different partition strategies test different failure modes:
97/// - Random: General chaos, unpredictable failures
98/// - `UniformSize`: Tests various quorum sizes and split scenarios
99/// - `IsolateSingle`: Tests single-node isolation (common in production)
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
101pub enum PartitionStrategy {
102 /// Random IP pairs selected for partitioning.
103 /// Current behavior - randomly selects which connections to partition.
104 #[default]
105 Random,
106
107 /// Uniform size partitions - randomly choose partition size from 1 to n-1 nodes.
108 /// `TigerBeetle` pattern: creates partitions of varying sizes to test different
109 /// quorum scenarios.
110 UniformSize,
111
112 /// Isolate single node - always partition exactly one node from the rest.
113 /// Tests the common production scenario where a single node becomes unreachable.
114 IsolateSingle,
115}
116
117/// Connection establishment failure mode for fault injection.
118///
119/// Controls how connection attempts fail during chaos testing.
120/// FDB ref: sim2.actor.cpp:1243-1250 (`SIM_CONNECT_ERROR_MODE`)
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
122pub enum ConnectFailureMode {
123 /// Disabled - no connection failures injected
124 #[default]
125 Disabled,
126 /// Always fail with `ConnectionRefused` when buggified
127 AlwaysFail,
128 /// Probabilistic: 50% fail with `ConnectionRefused`, 50% hang forever
129 Probabilistic,
130}
131
132impl ConnectFailureMode {
133 /// Create a random failure mode for chaos testing
134 #[must_use]
135 pub fn random_for_seed() -> Self {
136 match sim_random_range(0..3) {
137 0 => Self::Disabled,
138 1 => Self::AlwaysFail,
139 _ => Self::Probabilistic,
140 }
141 }
142}
143
144/// Configuration for chaos injection in simulations.
145///
146/// This struct contains all settings related to fault injection and chaos testing,
147/// following `FoundationDB`'s BUGGIFY patterns for deterministic testing.
148#[derive(Debug, Clone)]
149pub struct ChaosConfiguration {
150 /// Clogging probability for individual writes (0.0 - 1.0)
151 pub clog_probability: f64,
152 /// Duration range for clog delays
153 pub clog_duration: Range<Duration>,
154
155 /// Network partition probability (0.0 - 1.0)
156 pub partition_probability: f64,
157 /// Duration range for network partitions
158 pub partition_duration: Range<Duration>,
159
160 /// Bit flip probability for packet corruption (0.0 - 1.0)
161 pub bit_flip_probability: f64,
162 /// Minimum number of bits to flip (power-law distribution lower bound)
163 pub bit_flip_min_bits: u32,
164 /// Maximum number of bits to flip (power-law distribution upper bound)
165 pub bit_flip_max_bits: u32,
166 /// Cooldown duration after bit flip to prevent excessive corruption
167 pub bit_flip_cooldown: Duration,
168
169 /// Maximum bytes for partial write simulation (BUGGIFY truncates writes to 0-max_bytes)
170 /// Following FDB's approach of truncating writes to test TCP backpressure handling
171 pub partial_write_max_bytes: usize,
172
173 /// Maximum bytes for partial read simulation (BUGGIFY truncates reads to 1-max_bytes)
174 /// Mirrors FDB's `Sim2Conn` partial delivery on the receiver to test short reads
175 /// and message reassembly. Always delivers at least one byte so a partial read is
176 /// never mistaken for EOF.
177 pub partial_read_max_bytes: usize,
178
179 /// Random connection close probability per I/O operation (0.0 - 1.0)
180 /// FDB default: 0.00001 (0.001%) - see sim2.actor.cpp:584
181 pub random_close_probability: f64,
182
183 /// Cooldown duration after a random close event (prevents cascading failures)
184 /// FDB uses connectionFailuresDisableDuration - see sim2.actor.cpp:583
185 pub random_close_cooldown: Duration,
186
187 /// Ratio of explicit exceptions vs silent failures (0.0 - 1.0)
188 /// FDB default: 0.3 (30% explicit) - see sim2.actor.cpp:602
189 pub random_close_explicit_ratio: f64,
190
191 /// Enable clock drift simulation
192 /// When enabled, `timer()` can return a time up to `clock_drift_max` ahead of `now()`
193 /// FDB ref: sim2.actor.cpp:1058-1064
194 pub clock_drift_enabled: bool,
195
196 /// Maximum clock drift (default 100ms per FDB)
197 /// `timer()` can be up to this much ahead of `now()`
198 pub clock_drift_max: Duration,
199
200 /// Enable buggified delays on sleep/timer operations
201 /// When enabled, 25% of sleep operations get extra delay
202 /// FDB ref: sim2.actor.cpp:1100-1105
203 pub buggified_delay_enabled: bool,
204
205 /// Maximum additional delay for buggified sleep (default 100ms)
206 /// Uses power-law distribution: `max_delay` * `pow(random01()`, 1000.0)
207 /// FDB ref: sim2.actor.cpp:1104
208 pub buggified_delay_max: Duration,
209
210 /// Probability of adding buggified delay (default 25% per FDB)
211 pub buggified_delay_probability: f64,
212
213 /// Connection establishment failure mode (per FDB)
214 /// FDB ref: sim2.actor.cpp:1243-1250 (`SIM_CONNECT_ERROR_MODE`)
215 pub connect_failure_mode: ConnectFailureMode,
216
217 /// Probability of connect failure when Probabilistic mode is enabled (default 50%)
218 pub connect_failure_probability: f64,
219
220 /// Permanent per-IP-pair latency range (FDB `SimClogging::MAX_CLOGGING_LATENCY`).
221 /// Each ordered IP pair samples a fixed latency from this range at first contact
222 /// (via [`sample_duration`]), then adds it to every delivery on that pair for the
223 /// whole run — modelling a stably-slow link. An all-zero range (`end` is zero)
224 /// disables it, leaving behavior unchanged. FDB's `MAX * random01()` is the
225 /// `ZERO..MAX` case. FDB ref: `sim2.actor.cpp` ~294-299, 352-354.
226 pub max_pair_latency: Range<Duration>,
227
228 /// Network partition strategy.
229 /// Controls how nodes are selected for partitioning.
230 /// `TigerBeetle` ref: `packet_simulator.zig` partition modes
231 ///
232 /// # Real-World Scenario
233 /// Different strategies test different failure scenarios:
234 /// - Random: unpredictable chaos
235 /// - `UniformSize`: various quorum sizes
236 /// - `IsolateSingle`: single node isolation (common in production)
237 pub partition_strategy: PartitionStrategy,
238}
239
240impl Default for ChaosConfiguration {
241 fn default() -> Self {
242 Self {
243 clog_probability: 0.0,
244 clog_duration: Duration::from_millis(100)..Duration::from_millis(300),
245 partition_probability: 0.0,
246 partition_duration: Duration::from_millis(200)..Duration::from_secs(2),
247 bit_flip_probability: 0.0001, // 0.01% - matches FDB's BUGGIFY_WITH_PROB(0.0001)
248 bit_flip_min_bits: 1,
249 bit_flip_max_bits: 32,
250 bit_flip_cooldown: Duration::ZERO, // No cooldown by default for maximum chaos
251 partial_write_max_bytes: 1000, // Matches FDB's randomInt(0, 1000)
252 partial_read_max_bytes: 1000, // Symmetric with partial writes
253 random_close_probability: 0.00001, // 0.001% - matches FDB's sim2.actor.cpp:584
254 random_close_cooldown: Duration::from_secs(5), // Reasonable default
255 random_close_explicit_ratio: 0.3, // 30% explicit - matches FDB's sim2.actor.cpp:602
256 clock_drift_enabled: true, // Enable by default for chaos testing
257 clock_drift_max: Duration::from_millis(100), // FDB default: 0.1 seconds
258 buggified_delay_enabled: true, // Enable by default for chaos testing
259 buggified_delay_max: Duration::from_millis(100), // FDB: MAX_BUGGIFIED_DELAY
260 buggified_delay_probability: 0.25, // FDB: random01() < 0.25
261 connect_failure_mode: ConnectFailureMode::Probabilistic, // FDB: SIM_CONNECT_ERROR_MODE = 2
262 connect_failure_probability: 0.5, // FDB: random01() > 0.5
263 max_pair_latency: Duration::ZERO..Duration::ZERO, // FDB: MAX_CLOGGING_LATENCY default 0
264 partition_strategy: PartitionStrategy::default(),
265 }
266 }
267}
268
269impl ChaosConfiguration {
270 /// Create a configuration with all chaos disabled (for fast local testing)
271 #[must_use]
272 pub fn disabled() -> Self {
273 Self {
274 clog_probability: 0.0,
275 clog_duration: Duration::ZERO..Duration::ZERO,
276 partition_probability: 0.0,
277 partition_duration: Duration::ZERO..Duration::ZERO,
278 bit_flip_probability: 0.0,
279 bit_flip_min_bits: 1,
280 bit_flip_max_bits: 32,
281 bit_flip_cooldown: Duration::ZERO,
282 partial_write_max_bytes: 1000,
283 partial_read_max_bytes: 1000,
284 random_close_probability: 0.0,
285 random_close_cooldown: Duration::ZERO,
286 random_close_explicit_ratio: 0.3,
287 clock_drift_enabled: false,
288 clock_drift_max: Duration::from_millis(100),
289 buggified_delay_enabled: false,
290 buggified_delay_max: Duration::from_millis(100),
291 buggified_delay_probability: 0.25,
292 connect_failure_mode: ConnectFailureMode::Disabled,
293 connect_failure_probability: 0.5,
294 max_pair_latency: Duration::ZERO..Duration::ZERO,
295 partition_strategy: PartitionStrategy::Random, // Default strategy
296 }
297 }
298
299 /// Create a randomized chaos configuration for seed-based testing
300 #[must_use]
301 pub fn random_for_seed() -> Self {
302 Self {
303 clog_probability: f64::from(sim_random_range(0..20)) / 100.0, // 0-20% for clogging
304 clog_duration: Duration::from_micros(sim_random_range(50_000..300_000))
305 ..Duration::from_micros(sim_random_range(100_000..500_000)),
306 partition_probability: f64::from(sim_random_range(0..15)) / 100.0, // 0-15% (lower than faults)
307 partition_duration: Duration::from_millis(sim_random_range(100..1000))
308 ..Duration::from_millis(sim_random_range(500..3000)),
309 // Bit flip probability range: 0.001% to 0.02% (very low, like FDB)
310 bit_flip_probability: f64::from(sim_random_range(1..20)) / 100_000.0,
311 bit_flip_min_bits: 1,
312 bit_flip_max_bits: 32,
313 bit_flip_cooldown: Duration::from_millis(sim_random_range(0..100)),
314 partial_write_max_bytes: sim_random_range(100..2000), // Vary max bytes for different scenarios
315 // Random close probability: 0.0001% to 0.01% (very low, like FDB)
316 random_close_probability: f64::from(sim_random_range(1..100)) / 1_000_000.0,
317 random_close_cooldown: Duration::from_millis(sim_random_range(1000..10_000)),
318 random_close_explicit_ratio: f64::from(sim_random_range(20..40)) / 100.0, // 20-40%
319 clock_drift_enabled: true,
320 clock_drift_max: Duration::from_millis(sim_random_range(50..150)), // 50-150ms
321 buggified_delay_enabled: true,
322 buggified_delay_max: Duration::from_millis(sim_random_range(50..150)), // 50-150ms
323 buggified_delay_probability: f64::from(sim_random_range(20..30)) / 100.0, // 20-30%
324 connect_failure_mode: ConnectFailureMode::random_for_seed(),
325 connect_failure_probability: f64::from(sim_random_range(40..60)) / 100.0, // 40-60%
326 // Randomly choose partition strategy
327 partition_strategy: match sim_random_range(0..3) {
328 0 => PartitionStrategy::Random,
329 1 => PartitionStrategy::UniformSize,
330 _ => PartitionStrategy::IsolateSingle,
331 },
332 // Permanent per-pair latency, randomized upper bound up to 100ms (FDB
333 // buggifies MAX_CLOGGING_LATENCY to 0.1s). Kept last so existing RNG
334 // draws above are unaffected.
335 max_pair_latency: Duration::ZERO..Duration::from_millis(sim_random_range(0..100)),
336 // Vary max bytes for different scenarios. Appended last (after
337 // max_pair_latency) so the RNG draws above keep their per-seed values.
338 partial_read_max_bytes: sim_random_range(100..2000),
339 }
340 }
341
342 /// Create a swarm-testing chaos configuration for seed-based testing.
343 ///
344 /// Starts from [`random_for_seed`](Self::random_for_seed), then disables each
345 /// fault family with ~50% probability (drawn from the independent `CONFIG_RNG`
346 /// stream). This implements *swarm testing* (Groce et al., ISSTA 2012): each
347 /// seed exercises a random *subset* of fault families — including the all-off
348 /// subset — instead of every family being slightly on at once (which lets
349 /// families crowd each other out, the passive-suppression anti-pattern).
350 #[must_use]
351 pub fn swarm_for_seed() -> Self {
352 let mut chaos = Self::random_for_seed();
353 chaos.apply_swarm_mask();
354 chaos
355 }
356
357 /// Disable each fault family with ~50% probability using the `CONFIG_RNG`
358 /// stream (see [`swarm_for_seed`](Self::swarm_for_seed)).
359 ///
360 /// Draws exactly eight `config_random_bool` values (one per family) so the
361 /// `CONFIG_RNG` call sequence is fixed and reproducible per seed. Durations,
362 /// cooldowns, and strategy stay as sampled — they are inert once their family
363 /// is off.
364 fn apply_swarm_mask(&mut self) {
365 if !config_random_bool(0.5) {
366 self.clog_probability = 0.0;
367 }
368 if !config_random_bool(0.5) {
369 self.partition_probability = 0.0;
370 }
371 if !config_random_bool(0.5) {
372 self.bit_flip_probability = 0.0;
373 }
374 if !config_random_bool(0.5) {
375 self.random_close_probability = 0.0;
376 }
377 if !config_random_bool(0.5) {
378 self.connect_failure_mode = ConnectFailureMode::Disabled;
379 self.connect_failure_probability = 0.0;
380 }
381 self.clock_drift_enabled = config_random_bool(0.5);
382 self.buggified_delay_enabled = config_random_bool(0.5);
383 // Appended last: keeps the seven draws above stable across seeds.
384 if !config_random_bool(0.5) {
385 self.max_pair_latency = Duration::ZERO..Duration::ZERO;
386 }
387 }
388
389 /// Spike selected fault *magnitudes* under buggify (FDB's
390 /// `if (randomize && BUGGIFY) KNOB = random(lo, hi)`).
391 ///
392 /// Composes on top of [`random_for_seed`](Self::random_for_seed) /
393 /// [`swarm_for_seed`](Self::swarm_for_seed): each knob keeps its sampled value
394 /// unless its own [`buggify_knob!`](crate::buggify_knob) call site fires for the
395 /// seed, in which case it jumps to an aggressive value within bounds. A
396 /// representative subset — extend by adding more `buggify_knob!` lines.
397 pub fn apply_buggify_knobs(&mut self) {
398 self.clog_probability = crate::buggify_knob!(self.clog_probability, 0.5..1.0);
399 self.partition_probability = crate::buggify_knob!(self.partition_probability, 0.3..0.8);
400 self.random_close_probability =
401 crate::buggify_knob!(self.random_close_probability, 0.01..0.1);
402 }
403}
404
405/// Configuration for network simulation parameters
406#[derive(Debug, Clone)]
407pub struct NetworkConfiguration {
408 /// Latency distribution for bind operations
409 pub bind_latency: LatencyDistribution,
410 /// Latency distribution for accept operations
411 pub accept_latency: LatencyDistribution,
412 /// Latency distribution for connect operations
413 pub connect_latency: LatencyDistribution,
414 /// Latency distribution for read operations
415 pub read_latency: LatencyDistribution,
416 /// Latency distribution for write operations
417 pub write_latency: LatencyDistribution,
418
419 /// Chaos injection configuration
420 pub chaos: ChaosConfiguration,
421}
422
423impl Default for NetworkConfiguration {
424 fn default() -> Self {
425 Self {
426 bind_latency: LatencyDistribution::Uniform {
427 start: Duration::from_micros(50),
428 end: Duration::from_micros(150),
429 },
430 accept_latency: LatencyDistribution::Uniform {
431 start: Duration::from_millis(1),
432 end: Duration::from_millis(6),
433 },
434 connect_latency: LatencyDistribution::Uniform {
435 start: Duration::from_millis(1),
436 end: Duration::from_millis(11),
437 },
438 read_latency: LatencyDistribution::Uniform {
439 start: Duration::from_micros(10),
440 end: Duration::from_micros(60),
441 },
442 write_latency: LatencyDistribution::Uniform {
443 start: Duration::from_micros(100),
444 end: Duration::from_micros(600),
445 },
446 chaos: ChaosConfiguration::default(),
447 }
448 }
449}
450
451/// Sample a random duration from a range
452#[must_use]
453pub fn sample_duration(range: &Range<Duration>) -> Duration {
454 uniform_nanos(range.start, range.end)
455}
456
457/// Sample a uniform duration in `[start, end)` using the simulation RNG.
458///
459/// Shared by [`sample_duration`] and [`LatencyDistribution::Uniform`] so both
460/// consume exactly one RNG draw with identical semantics. A degenerate range
461/// (`start >= end`) returns `start` and consumes no draw (see
462/// [`sim_random_range_or_default`]).
463fn uniform_nanos(start: Duration, end: Duration) -> Duration {
464 let start_nanos = u64::try_from(start.as_nanos()).unwrap_or(u64::MAX);
465 let end_nanos = u64::try_from(end.as_nanos()).unwrap_or(u64::MAX);
466 Duration::from_nanos(sim_random_range_or_default(start_nanos..end_nanos))
467}
468
469/// A pluggable latency distribution for per-operation latency sampling.
470///
471/// Replaces plain uniform `Range<Duration>` sampling so simulations can exercise
472/// the heavy P99 tail where timeout cascades, retry storms, and backpressure
473/// collapse live. All variants sample deterministically through the simulation
474/// RNG, so the same seed always yields the same sequence.
475///
476/// The default is [`Uniform`](Self::Uniform), which samples identically to the
477/// historical [`sample_duration`] (one RNG draw, no extra draws), keeping default
478/// behavior unchanged.
479///
480/// # References
481///
482/// - `Exponential` mirrors `TigerBeetle`'s `random_int_exponential` storage/network
483/// delay (`packet_simulator.zig`).
484/// - `Bimodal` mirrors `FoundationDB`'s `halfLatency` fast/slow split (`sim2.actor.cpp`).
485#[derive(Debug, Clone, PartialEq)]
486pub enum LatencyDistribution {
487 /// Uniform latency in `[start, end)`. Equivalent to the historical
488 /// `Range<Duration>` sampling.
489 Uniform {
490 /// Inclusive lower bound.
491 start: Duration,
492 /// Exclusive upper bound.
493 end: Duration,
494 },
495 /// Exponential latency with a minimum floor, modelling a long tail.
496 ///
497 /// Samples `min + mean * (-ln(u))` with `u` drawn uniformly from `(0, 1]`,
498 /// giving a mean delay of roughly `min + mean`. Note this is the additive
499 /// form from the issue; `TigerBeetle` instead clamps with `max(min, exp(mean))`.
500 Exponential {
501 /// Minimum latency added to every sample.
502 min: Duration,
503 /// Mean of the exponential component (the tail scale).
504 mean: Duration,
505 },
506 /// Bimodal latency: a fast cluster most of the time, a slow tail rarely.
507 ///
508 /// With probability `slow_probability` the sample is drawn uniformly from
509 /// `slow_range`; otherwise from `fast_range`.
510 Bimodal {
511 /// Range sampled on the common, fast path.
512 fast_range: Range<Duration>,
513 /// Range sampled on the rare, slow path.
514 slow_range: Range<Duration>,
515 /// Probability in `[0, 1]` of taking the slow path.
516 slow_probability: f64,
517 },
518}
519
520impl Default for LatencyDistribution {
521 fn default() -> Self {
522 // Callers (config constructors) set real bounds per field; this
523 // type-level default is only a neutral fallback.
524 Self::Uniform {
525 start: Duration::ZERO,
526 end: Duration::ZERO,
527 }
528 }
529}
530
531impl LatencyDistribution {
532 /// Return the `(start, end)` bounds when this is a [`Uniform`](Self::Uniform)
533 /// distribution, otherwise `None`. Convenience for tests and reporting.
534 #[must_use]
535 pub fn uniform_bounds(&self) -> Option<(Duration, Duration)> {
536 match self {
537 Self::Uniform { start, end } => Some((*start, *end)),
538 _ => None,
539 }
540 }
541}
542
543/// Sample a latency from a [`LatencyDistribution`] using the simulation RNG.
544///
545/// Deterministic for a given seed. The [`Uniform`](LatencyDistribution::Uniform)
546/// variant consumes exactly one RNG draw (identical to [`sample_duration`]);
547/// `Exponential` consumes one `f64` draw; `Bimodal` consumes one `f64` draw to
548/// pick the branch plus one uniform draw.
549#[must_use]
550pub fn sample_latency(distribution: &LatencyDistribution) -> Duration {
551 match distribution {
552 LatencyDistribution::Uniform { start, end } => uniform_nanos(*start, *end),
553 LatencyDistribution::Exponential { min, mean } => {
554 // u in [0.0, 1.0); 1.0 - u in (0.0, 1.0] so -ln(.) in [0.0, +inf),
555 // never inf or NaN. Compute in f64 seconds via `Duration`'s own API
556 // to avoid lossy manual casts. A product that overflows `Duration`
557 // saturates to `Duration::MAX` instead of panicking.
558 let u = sim_random_f64();
559 let factor = -(1.0 - u).ln();
560 let extra_secs = mean.as_secs_f64() * factor;
561 let extra = Duration::try_from_secs_f64(extra_secs).unwrap_or(Duration::MAX);
562 min.saturating_add(extra)
563 }
564 LatencyDistribution::Bimodal {
565 fast_range,
566 slow_range,
567 slow_probability,
568 } => {
569 // Draw the branch selector unconditionally so the RNG call-count is
570 // stable regardless of which path is taken.
571 if sim_random_f64() < *slow_probability {
572 uniform_nanos(slow_range.start, slow_range.end)
573 } else {
574 uniform_nanos(fast_range.start, fast_range.end)
575 }
576 }
577 }
578}
579
580/// Pick a per-field [`LatencyDistribution`] for chaos seeds, mixing all three
581/// variants around a baseline uniform range.
582///
583/// One in three seeds keeps the field uniform; the others derive an exponential
584/// or bimodal shape from the same baseline so the mean stays comparable. Draws
585/// from the simulation RNG, so it is deterministic per seed.
586pub(crate) fn random_latency_for_seed(uniform: Range<Duration>) -> LatencyDistribution {
587 match sim_random_range(0..3) {
588 0 => LatencyDistribution::Uniform {
589 start: uniform.start,
590 end: uniform.end,
591 },
592 1 => LatencyDistribution::Exponential {
593 min: uniform.start,
594 // Mean tail scale of one baseline width above the floor.
595 mean: uniform.end.saturating_sub(uniform.start),
596 },
597 _ => {
598 // Slow path is one decade beyond the baseline upper bound.
599 let slow_start = uniform.end;
600 let slow_end = uniform.end.saturating_mul(10);
601 LatencyDistribution::Bimodal {
602 fast_range: uniform,
603 slow_range: slow_start..slow_end,
604 // 0.1% .. 1% slow tail.
605 slow_probability: f64::from(sim_random_range(1..10)) / 1000.0,
606 }
607 }
608 }
609}
610
611impl NetworkConfiguration {
612 /// Create a new network configuration with default settings
613 #[must_use]
614 pub fn new() -> Self {
615 Self::default()
616 }
617
618 /// Create a randomized network configuration for chaos testing
619 #[must_use]
620 pub fn random_for_seed() -> Self {
621 Self {
622 bind_latency: random_latency_for_seed(
623 Duration::from_micros(sim_random_range(10..200))
624 ..Duration::from_micros(sim_random_range(50..300)),
625 ),
626 accept_latency: random_latency_for_seed(
627 Duration::from_micros(sim_random_range(1000..10_000))
628 ..Duration::from_micros(sim_random_range(5000..15_000)),
629 ),
630 connect_latency: random_latency_for_seed(
631 Duration::from_micros(sim_random_range(1000..50_000))
632 ..Duration::from_micros(sim_random_range(10_000..100_000)),
633 ),
634 read_latency: random_latency_for_seed(
635 Duration::from_micros(sim_random_range(5..100))
636 ..Duration::from_micros(sim_random_range(50..200)),
637 ),
638 write_latency: random_latency_for_seed(
639 Duration::from_micros(sim_random_range(50..1000))
640 ..Duration::from_micros(sim_random_range(200..2000)),
641 ),
642 chaos: ChaosConfiguration::random_for_seed(),
643 }
644 }
645
646 /// Create a swarm-testing network configuration for seed-based testing.
647 ///
648 /// Identical to [`random_for_seed`](Self::random_for_seed) for the baseline
649 /// latencies, but the embedded [`ChaosConfiguration`] enables only a random
650 /// *subset* of fault families per seed. See
651 /// [`ChaosConfiguration::swarm_for_seed`].
652 #[must_use]
653 pub fn swarm_for_seed() -> Self {
654 let mut config = Self::random_for_seed();
655 config.chaos.apply_swarm_mask();
656 config
657 }
658
659 /// Create a configuration optimized for fast local testing
660 #[must_use]
661 pub fn fast_local() -> Self {
662 let one_us = Duration::from_micros(1);
663 let ten_us = Duration::from_micros(10);
664 let uniform = |start, end| LatencyDistribution::Uniform { start, end };
665 Self {
666 bind_latency: uniform(one_us, one_us),
667 accept_latency: uniform(ten_us, ten_us),
668 connect_latency: uniform(ten_us, ten_us),
669 read_latency: uniform(one_us, one_us),
670 write_latency: uniform(one_us, one_us),
671 chaos: ChaosConfiguration::disabled(),
672 }
673 }
674}
675
676#[cfg(test)]
677mod swarm_tests {
678 use super::{ChaosConfiguration, ConnectFailureMode, NetworkConfiguration};
679 use crate::sim::rng::{reset_sim_rng, set_config_seed, set_sim_seed};
680
681 /// The on/off state of each swarmed fault family, in mask order.
682 fn enabled_families(chaos: &ChaosConfiguration) -> [bool; 7] {
683 [
684 chaos.clog_probability > 0.0,
685 chaos.partition_probability > 0.0,
686 chaos.bit_flip_probability > 0.0,
687 chaos.random_close_probability > 0.0,
688 chaos.connect_failure_mode != ConnectFailureMode::Disabled,
689 chaos.clock_drift_enabled,
690 chaos.buggified_delay_enabled,
691 ]
692 }
693
694 /// Build a swarm config the way the runner does: both streams seeded per iteration.
695 fn swarm_for(seed: u64) -> NetworkConfiguration {
696 reset_sim_rng();
697 set_sim_seed(seed);
698 set_config_seed(seed);
699 NetworkConfiguration::swarm_for_seed()
700 }
701
702 #[test]
703 fn swarm_subset_is_deterministic_per_seed() {
704 for seed in [0_u64, 1, 42, 12_345] {
705 let first = enabled_families(&swarm_for(seed).chaos);
706 let second = enabled_families(&swarm_for(seed).chaos);
707 assert_eq!(
708 first, second,
709 "swarm subset must be reproducible for seed {seed}"
710 );
711 }
712 }
713
714 #[test]
715 fn swarm_reaches_all_off_and_mixed_subsets() {
716 let mut saw_all_off = false;
717 let mut saw_mixed = false;
718
719 for seed in 0..1000_u64 {
720 let families = enabled_families(&swarm_for(seed).chaos);
721 let on = families.iter().filter(|&&e| e).count();
722 if on == 0 {
723 saw_all_off = true;
724 }
725 if on > 0 && on < families.len() {
726 saw_mixed = true;
727 }
728 if saw_all_off && saw_mixed {
729 break;
730 }
731 }
732
733 assert!(
734 saw_all_off,
735 "no seed in 0..1000 produced the all-off subset"
736 );
737 assert!(saw_mixed, "no seed in 0..1000 produced a mixed subset");
738 }
739
740 #[test]
741 fn swarm_all_off_seed_has_zero_fault_probabilities() {
742 // Find a seed whose subset is entirely off, then assert every family is inert.
743 let seed = (0..1000_u64)
744 .find(|&s| enabled_families(&swarm_for(s).chaos).iter().all(|&e| !e))
745 .expect("expected an all-off seed within 0..1000");
746
747 let chaos = swarm_for(seed).chaos;
748 assert_zero(chaos.clog_probability);
749 assert_zero(chaos.partition_probability);
750 assert_zero(chaos.bit_flip_probability);
751 assert_zero(chaos.random_close_probability);
752 assert_eq!(chaos.connect_failure_mode, ConnectFailureMode::Disabled);
753 assert_zero(chaos.connect_failure_probability);
754 assert!(!chaos.clock_drift_enabled);
755 assert!(!chaos.buggified_delay_enabled);
756 }
757
758 /// Assert an f64 is exactly `+0.0` (bit-exact, avoiding the float-cmp lint).
759 fn assert_zero(value: f64) {
760 assert_eq!(
761 value.to_bits(),
762 0.0_f64.to_bits(),
763 "expected 0.0, got {value}"
764 );
765 }
766}
767
768#[cfg(test)]
769mod latency_distribution_tests {
770 use super::{LatencyDistribution, NetworkConfiguration, sample_duration, sample_latency};
771 use crate::sim::rng::set_sim_seed;
772 use crate::storage::StorageConfiguration;
773 use std::time::Duration;
774
775 /// Collect `n` samples from a distribution under a fresh seed.
776 fn samples(seed: u64, dist: &LatencyDistribution, n: usize) -> Vec<Duration> {
777 set_sim_seed(seed);
778 (0..n).map(|_| sample_latency(dist)).collect()
779 }
780
781 /// The 99th percentile of a sample set, by sorted index.
782 fn p99(mut values: Vec<Duration>) -> Duration {
783 values.sort_unstable();
784 let idx = ((values.len() * 99) / 100).min(values.len() - 1);
785 values[idx]
786 }
787
788 #[test]
789 fn uniform_matches_sample_duration_byte_for_byte() {
790 // The Uniform variant must consume exactly the same RNG as the legacy
791 // `sample_duration`: identical value AND identical resulting RNG state
792 // (one draw, no extra). Two interleaved draws prove both.
793 let start = Duration::from_micros(100);
794 let end = Duration::from_micros(600);
795 let dist = LatencyDistribution::Uniform { start, end };
796 let range = start..end;
797
798 set_sim_seed(7);
799 let a1 = sample_latency(&dist);
800 let a2 = sample_duration(&range);
801
802 set_sim_seed(7);
803 let b1 = sample_duration(&range);
804 let b2 = sample_duration(&range);
805
806 assert_eq!((a1, a2), (b1, b2));
807 }
808
809 #[test]
810 fn each_variant_is_deterministic_per_seed() {
811 let variants = [
812 LatencyDistribution::Uniform {
813 start: Duration::from_micros(10),
814 end: Duration::from_micros(60),
815 },
816 LatencyDistribution::Exponential {
817 min: Duration::from_micros(10),
818 mean: Duration::from_micros(100),
819 },
820 LatencyDistribution::Bimodal {
821 fast_range: Duration::from_millis(1)..Duration::from_millis(2),
822 slow_range: Duration::from_millis(50)..Duration::from_millis(100),
823 slow_probability: 0.05,
824 },
825 ];
826 for dist in &variants {
827 let first = samples(42, dist, 64);
828 let second = samples(42, dist, 64);
829 assert_eq!(first, second, "distribution not deterministic: {dist:?}");
830 }
831 }
832
833 #[test]
834 fn default_configs_are_all_uniform() {
835 let net = NetworkConfiguration::default();
836 for dist in [
837 &net.bind_latency,
838 &net.accept_latency,
839 &net.connect_latency,
840 &net.read_latency,
841 &net.write_latency,
842 ] {
843 assert!(
844 dist.uniform_bounds().is_some(),
845 "network default not uniform: {dist:?}"
846 );
847 }
848 let storage = StorageConfiguration::default();
849 for dist in [
850 &storage.read_latency,
851 &storage.write_latency,
852 &storage.sync_latency,
853 ] {
854 assert!(
855 dist.uniform_bounds().is_some(),
856 "storage default not uniform: {dist:?}"
857 );
858 }
859 }
860
861 #[test]
862 fn exponential_has_heavier_tail_than_uniform_at_equal_mean() {
863 // Uniform [0, 2ms) and Exponential{min: 0, mean: 1ms} share a 1ms mean,
864 // but the exponential's tail pushes its p99 well above the uniform's.
865 let uniform = LatencyDistribution::Uniform {
866 start: Duration::ZERO,
867 end: Duration::from_millis(2),
868 };
869 let exponential = LatencyDistribution::Exponential {
870 min: Duration::ZERO,
871 mean: Duration::from_millis(1),
872 };
873 let uni_p99 = p99(samples(123, &uniform, 10_000));
874 let exp_p99 = p99(samples(123, &exponential, 10_000));
875 assert!(
876 exp_p99 > uni_p99,
877 "exponential p99 {exp_p99:?} should exceed uniform p99 {uni_p99:?}"
878 );
879 }
880
881 #[test]
882 fn bimodal_shows_fast_cluster_and_slow_tail() {
883 let fast = Duration::from_millis(1)..Duration::from_millis(2);
884 let slow = Duration::from_millis(50)..Duration::from_millis(100);
885 let dist = LatencyDistribution::Bimodal {
886 fast_range: fast.clone(),
887 slow_range: slow.clone(),
888 slow_probability: 0.05,
889 };
890 let values = samples(99, &dist, 10_000);
891 let fast_count = values.iter().filter(|d| fast.contains(d)).count();
892 let slow_count = values.iter().filter(|d| slow.contains(d)).count();
893 assert!(
894 fast_count > 8_000,
895 "expected a dominant fast cluster, got {fast_count}"
896 );
897 assert!(slow_count > 0, "expected a non-empty slow tail");
898 assert_eq!(fast_count + slow_count, values.len());
899 }
900
901 #[test]
902 fn exponential_with_zero_mean_returns_min() {
903 let dist = LatencyDistribution::Exponential {
904 min: Duration::from_micros(42),
905 mean: Duration::ZERO,
906 };
907 set_sim_seed(5);
908 for _ in 0..100 {
909 assert_eq!(sample_latency(&dist), Duration::from_micros(42));
910 }
911 }
912
913 #[test]
914 fn bimodal_probability_bounds_select_expected_range() {
915 let fast = Duration::from_millis(1)..Duration::from_millis(2);
916 let slow = Duration::from_millis(50)..Duration::from_millis(100);
917 let never_slow = LatencyDistribution::Bimodal {
918 fast_range: fast.clone(),
919 slow_range: slow.clone(),
920 slow_probability: 0.0,
921 };
922 let always_slow = LatencyDistribution::Bimodal {
923 fast_range: fast.clone(),
924 slow_range: slow.clone(),
925 slow_probability: 1.0,
926 };
927 for d in samples(1, &never_slow, 500) {
928 assert!(fast.contains(&d), "slow_probability 0.0 produced {d:?}");
929 }
930 for d in samples(2, &always_slow, 500) {
931 assert!(slow.contains(&d), "slow_probability 1.0 produced {d:?}");
932 }
933 }
934
935 #[test]
936 fn exponential_saturates_instead_of_panicking() {
937 // A mean near the Duration ceiling can overflow when scaled by the tail
938 // factor; sampling must saturate, never panic.
939 let dist = LatencyDistribution::Exponential {
940 min: Duration::from_secs(1),
941 mean: Duration::from_secs(u64::MAX / 2),
942 };
943 set_sim_seed(3);
944 for _ in 0..1_000 {
945 let sampled = sample_latency(&dist);
946 assert!(sampled >= Duration::from_secs(1));
947 }
948 }
949}