Skip to main content

reifydb_testing/
chaos.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (c) 2026 ReifyDB
3
4use std::{
5	collections::hash_map::{DefaultHasher, RandomState},
6	env,
7	hash::{BuildHasher, Hash, Hasher},
8	panic::{self, AssertUnwindSafe},
9};
10
11const DEFAULT_ITERATIONS: u64 = 100;
12
13pub struct Chaos {
14	name: String,
15	iterations: u64,
16	base_seed: u64,
17}
18
19pub fn chaos(name: impl Into<String>) -> Chaos {
20	Chaos {
21		name: name.into(),
22		iterations: env_iterations().unwrap_or(DEFAULT_ITERATIONS),
23		base_seed: env_seed().unwrap_or_else(random_base_seed),
24	}
25}
26
27impl Chaos {
28	pub fn iterations(mut self, iterations: u64) -> Self {
29		self.iterations = iterations;
30		self
31	}
32
33	pub fn seed(mut self, base_seed: u64) -> Self {
34		self.base_seed = base_seed;
35		self
36	}
37
38	pub fn run(self, body: impl Fn(u64)) {
39		eprintln!("chaos \"{}\": {} iterations, base seed {}", self.name, self.iterations, self.base_seed);
40		for i in 0..self.iterations {
41			let seed = derive_seed(self.base_seed, i);
42			let result = panic::catch_unwind(AssertUnwindSafe(|| body(seed)));
43			if let Err(payload) = result {
44				eprintln!(
45					"\nchaos \"{}\" FAILED on iteration {} of {}\n  base seed:      {}\n  iteration seed: {}\n  reproduce:      make test-chaos SEED={} N={}",
46					self.name,
47					i,
48					self.iterations,
49					self.base_seed,
50					seed,
51					self.base_seed,
52					self.iterations
53				);
54				panic::resume_unwind(payload);
55			}
56		}
57	}
58}
59
60#[macro_export]
61macro_rules! chaos_test {
62	($name:ident, |$seed:ident| $body:block) => {
63		#[test]
64		fn $name() {
65			$crate::chaos::chaos(stringify!($name)).run(|$seed: u64| $body);
66		}
67	};
68}
69
70fn derive_seed(base: u64, salt: u64) -> u64 {
71	let mut h = DefaultHasher::new();
72	base.hash(&mut h);
73	salt.hash(&mut h);
74	h.finish()
75}
76
77fn random_base_seed() -> u64 {
78	RandomState::new().build_hasher().finish()
79}
80
81fn env_iterations() -> Option<u64> {
82	env::var("CHAOS_ITERATIONS").ok().and_then(|s| s.trim().parse::<u64>().ok())
83}
84
85fn env_seed() -> Option<u64> {
86	env::var("CHAOS_SEED").ok().and_then(|s| s.trim().parse::<u64>().ok())
87}
88
89#[cfg(test)]
90mod tests {
91	use std::{
92		panic::{AssertUnwindSafe, catch_unwind},
93		sync::atomic::{AtomicU64, Ordering},
94	};
95
96	use super::{chaos, derive_seed};
97
98	// The macro must expand to a real `#[test] fn` that runs the body with
99	// the iteration seed. If expansion breaks (wrong path, hygiene), this
100	// fails to compile; if the seed is not threaded, the arithmetic check
101	// would still hold, so the value here is the compile-time guard plus
102	// proof the body executes under the runner.
103	chaos_test!(macro_expands_to_a_runnable_test, |seed| {
104		assert_eq!(seed.wrapping_mul(2), seed.wrapping_add(seed));
105	});
106
107	#[test]
108	fn derive_seed_is_deterministic_and_decorrelated() {
109		// Same inputs hash identically; changing base or salt changes the
110		// stream. Reproduction relies on this: a fixed base seed replays
111		// the exact same iteration-seed sequence.
112		assert_eq!(derive_seed(1, 1), derive_seed(1, 1));
113		assert_ne!(derive_seed(1, 1), derive_seed(1, 2));
114		assert_ne!(derive_seed(1, 1), derive_seed(2, 1));
115	}
116
117	#[test]
118	fn derived_iteration_seeds_are_distinct() {
119		// Across a long run, no two iterations should share a seed, or
120		// the run would be silently re-exploring the same point.
121		let mut seeds: Vec<u64> = (0..1000u64).map(|i| derive_seed(42, i)).collect();
122		let total = seeds.len();
123		seeds.sort_unstable();
124		seeds.dedup();
125		assert_eq!(seeds.len(), total, "iteration seeds collide");
126	}
127
128	#[test]
129	fn passing_body_runs_exactly_iterations_times() {
130		// A body that never panics is invoked once per iteration.
131		let count = AtomicU64::new(0);
132		chaos("passing").seed(7).iterations(50).run(|_seed| {
133			count.fetch_add(1, Ordering::SeqCst);
134		});
135		assert_eq!(count.load(Ordering::SeqCst), 50);
136	}
137
138	#[test]
139	#[should_panic(expected = "boom")]
140	fn failing_iteration_is_caught_and_reraised_with_original_payload() {
141		// The body panics on the seed for iteration 3. The runner must
142		// catch it and re-raise the original payload so the test still
143		// fails with "boom" (not a wrapped message).
144		let target = derive_seed(123, 3);
145		chaos("failing").seed(123).iterations(100).run(move |seed| {
146			if seed == target {
147				panic!("boom");
148			}
149		});
150	}
151
152	#[test]
153	fn fixed_base_seed_stops_on_the_same_iteration() {
154		// Reproduction contract: re-running with the same base seed fails
155		// on the same iteration. invocations_until_panic counts how many
156		// times the body ran before the runner re-raised; that count is
157		// (failing index + 1) and must be stable across runs.
158		let target = derive_seed(999, 17);
159		let first = invocations_until_panic(999, target);
160		let second = invocations_until_panic(999, target);
161		assert_eq!(first, 18, "should panic on iteration index 17 (18th invocation)");
162		assert_eq!(first, second, "same base seed must stop on the same iteration");
163	}
164
165	fn invocations_until_panic(base: u64, target: u64) -> u64 {
166		let count = AtomicU64::new(0);
167		let outcome = catch_unwind(AssertUnwindSafe(|| {
168			chaos("probe").seed(base).iterations(100).run(|seed| {
169				count.fetch_add(1, Ordering::SeqCst);
170				if seed == target {
171					panic!("probe hit");
172				}
173			});
174		}));
175		assert!(outcome.is_err(), "expected the probe to hit its target seed within the run");
176		count.load(Ordering::SeqCst)
177	}
178}