Skip to main content

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}