1use crate::clock::{Clock, SimClock};
22use std::cell::RefCell;
23use std::fmt;
24use std::rc::Rc;
25
26const DEFAULT_BUGGIFY_PPM: u64 = 10_000;
27const PPM_DENOMINATOR: u64 = 1_000_000;
28
29pub const SECTOR_BYTES: u64 = 512;
31
32pub const FAULT_ENV: &str = "REDDB_DST_FAULT";
34
35thread_local! {
36 static ACTIVE: RefCell<Option<Rc<RefCell<ActiveSimulation>>>> = const { RefCell::new(None) };
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
42pub enum FaultClass {
43 TornWrite,
45 MisdirectedWrite,
47 BitRot,
49 LostWrite,
51}
52
53impl FaultClass {
54 pub const ALL: [Self; 4] = [
56 Self::TornWrite,
57 Self::MisdirectedWrite,
58 Self::BitRot,
59 Self::LostWrite,
60 ];
61
62 pub const fn name(self) -> &'static str {
64 match self {
65 Self::TornWrite => "torn_write",
66 Self::MisdirectedWrite => "misdirected_write",
67 Self::BitRot => "bit_rot",
68 Self::LostWrite => "lost_write",
69 }
70 }
71
72 pub fn from_name(name: &str) -> Option<Self> {
74 Self::ALL.into_iter().find(|class| class.name() == name)
75 }
76
77 const fn index(self) -> usize {
78 match self {
79 Self::TornWrite => 0,
80 Self::MisdirectedWrite => 1,
81 Self::BitRot => 2,
82 Self::LostWrite => 3,
83 }
84 }
85}
86
87impl fmt::Display for FaultClass {
88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89 f.write_str(self.name())
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum FaultDecision {
96 TornWrite { persisted: u64 },
98 MisdirectedWrite { actual_offset: u64 },
100 BitRot { byte_offset: u64, bit: u8 },
102 LostWrite,
104}
105
106impl FaultDecision {
107 pub const fn class(self) -> FaultClass {
109 match self {
110 Self::TornWrite { .. } => FaultClass::TornWrite,
111 Self::MisdirectedWrite { .. } => FaultClass::MisdirectedWrite,
112 Self::BitRot { .. } => FaultClass::BitRot,
113 Self::LostWrite => FaultClass::LostWrite,
114 }
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct FaultRecord {
122 pub class: FaultClass,
123 pub file: String,
124 pub offset: u64,
125 pub length: u64,
126 pub decision: FaultDecision,
127}
128
129impl FaultRecord {
130 pub fn new(file: impl Into<String>, offset: u64, length: u64, decision: FaultDecision) -> Self {
132 Self {
133 class: decision.class(),
134 file: file.into(),
135 offset,
136 length,
137 decision,
138 }
139 }
140}
141
142impl fmt::Display for FaultRecord {
144 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145 write!(
146 f,
147 "class={} file={} offset={} length={}",
148 self.class, self.file, self.offset, self.length
149 )?;
150 match self.decision {
151 FaultDecision::TornWrite { persisted } => write!(f, " persisted={persisted}"),
152 FaultDecision::MisdirectedWrite { actual_offset } => {
153 write!(f, " actual_offset={actual_offset}")
154 }
155 FaultDecision::BitRot { byte_offset, bit } => {
156 write!(f, " byte_offset={byte_offset} bit={bit}")
157 }
158 FaultDecision::LostWrite => Ok(()),
159 }
160 }
161}
162
163#[derive(Debug, Clone, Copy)]
164pub struct SimulationContext {
165 seed: u64,
166 clock_start_ms: u64,
167 buggify_ppm: u64,
168 fault_ppm: [u64; 4],
171}
172
173impl SimulationContext {
174 pub fn new(seed: u64) -> Self {
175 Self {
176 seed,
177 clock_start_ms: seed,
178 buggify_ppm: DEFAULT_BUGGIFY_PPM,
179 fault_ppm: [0; 4],
180 }
181 }
182
183 pub fn with_buggify_ppm(seed: u64, buggify_ppm: u64) -> Self {
184 Self {
185 buggify_ppm: buggify_ppm.min(PPM_DENOMINATOR),
186 ..Self::new(seed)
187 }
188 }
189
190 #[must_use]
192 pub fn with_fault_class(mut self, class: FaultClass, ppm: u64) -> Self {
193 self.fault_ppm[class.index()] = ppm.min(PPM_DENOMINATOR);
194 self
195 }
196
197 pub fn fault_ppm(&self, class: FaultClass) -> u64 {
199 self.fault_ppm[class.index()]
200 }
201
202 pub fn install(self) -> SimulationGuard {
203 let active = Rc::new(RefCell::new(ActiveSimulation::new(self)));
204 let previous = ACTIVE.with(|slot| slot.replace(Some(Rc::clone(&active))));
205 SimulationGuard { active, previous }
206 }
207}
208
209pub struct SimulationGuard {
210 active: Rc<RefCell<ActiveSimulation>>,
211 previous: Option<Rc<RefCell<ActiveSimulation>>>,
212}
213
214impl SimulationGuard {
215 pub fn trace(&self) -> Vec<u8> {
216 self.active.borrow().trace.clone()
217 }
218
219 pub fn fault_log(&self) -> Vec<FaultRecord> {
221 self.active.borrow().fault_log.clone()
222 }
223
224 pub fn fault_log_lines(&self) -> String {
226 self.fault_log()
227 .iter()
228 .map(|record| format!("{record}\n"))
229 .collect()
230 }
231}
232
233impl Drop for SimulationGuard {
234 fn drop(&mut self) {
235 let previous = self.previous.take();
236 ACTIVE.with(|slot| {
237 slot.replace(previous);
238 });
239 }
240}
241
242struct ActiveSimulation {
243 clock: SimClock,
244 rng: SplitMix64,
245 buggify_ppm: u64,
246 fault_ppm: [u64; 4],
247 tick: u64,
248 trace: Vec<u8>,
249 fault_log: Vec<FaultRecord>,
250}
251
252impl ActiveSimulation {
253 fn new(context: SimulationContext) -> Self {
254 let mut trace = Vec::new();
255 trace.extend_from_slice(format!("seed={}\n", context.seed).as_bytes());
256 Self {
257 clock: SimClock::from_seed(context.clock_start_ms),
258 rng: SplitMix64::new(context.seed ^ 0x4255_4747_4946_595F), buggify_ppm: context.buggify_ppm,
260 fault_ppm: context.fault_ppm,
261 tick: 0,
262 trace,
263 fault_log: Vec::new(),
264 }
265 }
266
267 fn boundary(&mut self, env: &str, point: &str, ppm: u64) -> bool {
268 self.tick = self.tick.saturating_add(1);
269 self.clock.advance_ms(1);
270 let roll = self.rng.below(PPM_DENOMINATOR);
271 let threshold = ppm.min(PPM_DENOMINATOR);
272 let fired = roll < threshold;
273 self.trace.extend_from_slice(
274 format!(
275 "tick={} clock_ms={} env={} point={} roll={} ppm={} fired={}\n",
276 self.tick,
277 self.clock.now_unix_millis(),
278 env,
279 point,
280 roll,
281 threshold,
282 fired
283 )
284 .as_bytes(),
285 );
286 fired
287 }
288}
289
290pub fn buggify(env: &str, point: &str) -> bool {
291 ACTIVE.with(|slot| {
292 let Some(active) = slot.borrow().clone() else {
293 return false;
294 };
295 let ppm = { active.borrow().buggify_ppm };
296 let fired = active.borrow_mut().boundary(env, point, ppm);
297 fired
298 })
299}
300
301pub fn buggify_at(env: &str, point: &str, ppm: u64) -> bool {
302 ACTIVE.with(|slot| {
303 let Some(active) = slot.borrow().clone() else {
304 return false;
305 };
306 let fired = active.borrow_mut().boundary(env, point, ppm);
307 fired
308 })
309}
310
311pub fn is_active() -> bool {
312 ACTIVE.with(|slot| slot.borrow().is_some())
313}
314
315pub fn roll_fault(class: FaultClass, offset: u64, length: u64) -> Option<FaultDecision> {
325 ACTIVE.with(|slot| {
326 let active = slot.borrow().clone()?;
327 let mut active = active.borrow_mut();
328 let ppm = active.fault_ppm[class.index()];
329 if ppm == 0 || !active.boundary(FAULT_ENV, class.name(), ppm) {
332 return None;
333 }
334 let decision = derive_fault(class, offset, length, &mut || active.rng.next_u64());
335 Some(decision)
336 })
337}
338
339pub fn record_fault(record: FaultRecord) {
342 ACTIVE.with(|slot| {
343 if let Some(active) = slot.borrow().clone() {
344 active.borrow_mut().fault_log.push(record);
345 }
346 });
347}
348
349pub fn derive_fault(
360 class: FaultClass,
361 offset: u64,
362 length: u64,
363 next: &mut dyn FnMut() -> u64,
364) -> FaultDecision {
365 match class {
366 FaultClass::TornWrite => FaultDecision::TornWrite {
367 persisted: torn_prefix(offset, length, next),
368 },
369 FaultClass::MisdirectedWrite => {
370 let delta = SECTOR_BYTES * (1 + next() % 4);
371 let backwards = next() % 2 == 1;
372 let actual_offset = if backwards && offset >= delta {
373 offset - delta
374 } else {
375 offset.saturating_add(delta)
376 };
377 FaultDecision::MisdirectedWrite { actual_offset }
378 }
379 FaultClass::BitRot => {
380 let byte_offset = offset + if length == 0 { 0 } else { next() % length };
381 let bit = u8::try_from(next() % 8).unwrap_or(0);
382 FaultDecision::BitRot { byte_offset, bit }
383 }
384 FaultClass::LostWrite => FaultDecision::LostWrite,
385 }
386}
387
388fn torn_prefix(offset: u64, length: u64, next: &mut dyn FnMut() -> u64) -> u64 {
391 if length == 0 {
392 return 0;
393 }
394 let end = offset + length;
395 let first_boundary = (offset / SECTOR_BYTES + 1) * SECTOR_BYTES;
396 if first_boundary >= end {
397 return next() % length;
399 }
400 let boundaries = (end - 1 - first_boundary) / SECTOR_BYTES + 1;
401 let chosen = first_boundary + (next() % boundaries) * SECTOR_BYTES;
402 chosen - offset
403}
404
405#[derive(Debug, Clone)]
406struct SplitMix64 {
407 state: u64,
408}
409
410impl SplitMix64 {
411 fn new(seed: u64) -> Self {
412 Self { state: seed }
413 }
414
415 fn next_u64(&mut self) -> u64 {
416 self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
417 let mut z = self.state;
418 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
419 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
420 z ^ (z >> 31)
421 }
422
423 fn below(&mut self, bound: u64) -> u64 {
424 if bound == 0 {
425 0
426 } else {
427 self.next_u64() % bound
428 }
429 }
430}
431
432#[macro_export]
433macro_rules! buggify {
434 ($env:expr, $point:expr) => {{
435 #[cfg(debug_assertions)]
436 {
437 $crate::dst::buggify($env, $point)
438 }
439 #[cfg(not(debug_assertions))]
440 {
441 let _ = ($env, $point);
442 false
443 }
444 }};
445}
446
447#[macro_export]
452macro_rules! buggify_fault {
453 ($class:expr, $offset:expr, $length:expr) => {{
454 #[cfg(debug_assertions)]
455 {
456 $crate::dst::roll_fault($class, $offset, $length)
457 }
458 #[cfg(not(debug_assertions))]
459 {
460 let _ = ($class, $offset, $length);
461 ::core::option::Option::<$crate::dst::FaultDecision>::None
462 }
463 }};
464}
465
466#[cfg(test)]
467mod tests {
468 use crate::dst::{FaultClass, FaultDecision, FaultRecord, SECTOR_BYTES};
469 use crate::SimulationContext;
470
471 const ENV: &str = "REDDB_EMBEDDED_RDB_CRASH_AT";
472
473 fn trace_for(seed: u64) -> Vec<u8> {
474 let context = SimulationContext::with_buggify_ppm(seed, 1_000_000);
475 let guard = context.install();
476 assert!(crate::buggify!(ENV, "wal_after_frame_write"));
477 assert!(crate::buggify!(ENV, "wal_after_frame_sync"));
478 assert!(crate::buggify!(ENV, "wal_after_superblock_write"));
479 guard.trace()
480 }
481
482 #[test]
483 fn same_seed_produces_byte_identical_buggify_trace() {
484 let first = trace_for(0x5EED);
485 let second = trace_for(0x5EED);
486 assert_eq!(first, second);
487 }
488
489 #[test]
490 fn buggify_can_fire_at_named_crash_boundary() {
491 let context = SimulationContext::with_buggify_ppm(123, 1_000_000);
492 let guard = context.install();
493 assert!(crate::buggify!(ENV, "snapshot_after_manifest_write"));
494 let trace = String::from_utf8(guard.trace()).unwrap();
495 assert!(trace.contains("env=REDDB_EMBEDDED_RDB_CRASH_AT"));
496 assert!(trace.contains("point=snapshot_after_manifest_write"));
497 assert!(trace.contains("fired=true"));
498 }
499
500 #[test]
501 fn fault_classes_round_trip_through_their_names() {
502 for class in FaultClass::ALL {
503 assert_eq!(FaultClass::from_name(class.name()), Some(class));
504 }
505 assert_eq!(FaultClass::from_name("not_a_class"), None);
506 }
507
508 #[test]
509 fn every_fault_class_is_off_by_default() {
510 let context = SimulationContext::new(7);
511 for class in FaultClass::ALL {
512 assert_eq!(context.fault_ppm(class), 0, "{class} must default to off");
513 }
514 let guard = context.install();
515 for class in FaultClass::ALL {
516 assert_eq!(crate::buggify_fault!(class, 0, 64), None);
517 }
518 assert!(guard.fault_log().is_empty());
519 assert!(!String::from_utf8(guard.trace())
521 .unwrap()
522 .contains("env=REDDB_DST_FAULT"));
523 }
524
525 #[test]
526 fn each_class_is_individually_triggerable_by_name_and_ppm() {
527 for armed in FaultClass::ALL {
528 let guard = SimulationContext::new(99)
529 .with_fault_class(armed, 1_000_000)
530 .install();
531 for class in FaultClass::ALL {
532 let decision = crate::buggify_fault!(class, SECTOR_BYTES + 8, 64);
533 if class == armed {
534 let decision = decision.unwrap_or_else(|| panic!("{armed} must fire at 1e6"));
535 assert_eq!(decision.class(), armed);
536 } else {
537 assert_eq!(
538 decision, None,
539 "{class} must stay off while {armed} is armed"
540 );
541 }
542 }
543 let trace = String::from_utf8(guard.trace()).unwrap();
544 assert!(trace.contains(&format!("point={armed}")));
545 }
546 }
547
548 #[test]
549 fn a_torn_write_crossing_a_sector_is_cut_at_a_sector_boundary() {
550 let guard = SimulationContext::new(5)
551 .with_fault_class(FaultClass::TornWrite, 1_000_000)
552 .install();
553 let offset = 8;
555 let Some(FaultDecision::TornWrite { persisted }) =
556 crate::buggify_fault!(FaultClass::TornWrite, offset, 1_200)
557 else {
558 panic!("torn_write must fire at 1e6")
559 };
560 assert!(
561 (offset + persisted) % SECTOR_BYTES == 0,
562 "cut at {persisted}"
563 );
564 assert!(persisted < 1_200, "a torn write always loses bytes");
565 drop(guard);
566 }
567
568 #[test]
569 fn a_torn_write_inside_one_sector_is_cut_at_a_strict_byte_prefix() {
570 let _guard = SimulationContext::new(6)
571 .with_fault_class(FaultClass::TornWrite, 1_000_000)
572 .install();
573 let Some(FaultDecision::TornWrite { persisted }) =
575 crate::buggify_fault!(FaultClass::TornWrite, 64, 48)
576 else {
577 panic!("torn_write must fire at 1e6")
578 };
579 assert!(persisted < 48, "a torn write always loses bytes");
580 }
581
582 #[test]
583 fn a_misdirected_write_lands_a_whole_number_of_sectors_away() {
584 let _guard = SimulationContext::new(11)
585 .with_fault_class(FaultClass::MisdirectedWrite, 1_000_000)
586 .install();
587 for offset in [0, 64, 4_096, 10_000] {
588 let Some(FaultDecision::MisdirectedWrite { actual_offset }) =
589 crate::buggify_fault!(FaultClass::MisdirectedWrite, offset, 64)
590 else {
591 panic!("misdirected_write must fire at 1e6")
592 };
593 assert_ne!(actual_offset, offset, "the write must land elsewhere");
594 assert_eq!(actual_offset.abs_diff(offset) % SECTOR_BYTES, 0);
595 }
596 }
597
598 #[test]
599 fn bit_rot_targets_a_byte_inside_the_read_region() {
600 let _guard = SimulationContext::new(13)
601 .with_fault_class(FaultClass::BitRot, 1_000_000)
602 .install();
603 for _ in 0..32 {
604 let Some(FaultDecision::BitRot { byte_offset, bit }) =
605 crate::buggify_fault!(FaultClass::BitRot, 100, 40)
606 else {
607 panic!("bit_rot must fire at 1e6")
608 };
609 assert!((100..140).contains(&byte_offset));
610 assert!(bit < 8);
611 }
612 }
613
614 #[test]
615 fn the_fault_log_records_class_file_offset_and_length() {
616 let guard = SimulationContext::new(21)
617 .with_fault_class(FaultClass::LostWrite, 1_000_000)
618 .install();
619 let decision = crate::buggify_fault!(FaultClass::LostWrite, 4_096, 512)
620 .expect("lost_write must fire at 1e6");
621 crate::dst::record_fault(FaultRecord::new("/db/wal.log", 4_096, 512, decision));
622
623 let log = guard.fault_log();
624 assert_eq!(log.len(), 1);
625 assert_eq!(log[0].class, FaultClass::LostWrite);
626 assert_eq!(log[0].file, "/db/wal.log");
627 assert_eq!(log[0].offset, 4_096);
628 assert_eq!(log[0].length, 512);
629 assert_eq!(
630 guard.fault_log_lines(),
631 "class=lost_write file=/db/wal.log offset=4096 length=512\n"
632 );
633 }
634
635 #[test]
636 fn same_seed_produces_an_identical_fault_schedule() {
637 fn schedule(seed: u64) -> Vec<String> {
638 let mut context = SimulationContext::new(seed);
639 for class in FaultClass::ALL {
640 context = context.with_fault_class(class, 300_000);
641 }
642 let guard = context.install();
643 for step in 0..64u64 {
644 for class in FaultClass::ALL {
645 let offset = step * 97;
646 if let Some(decision) = crate::buggify_fault!(class, offset, 64) {
647 crate::dst::record_fault(FaultRecord::new(
648 "/db/wal.log",
649 offset,
650 64,
651 decision,
652 ));
653 }
654 }
655 }
656 guard.fault_log().iter().map(ToString::to_string).collect()
657 }
658
659 let first = schedule(0xDEAD);
660 assert!(!first.is_empty(), "the sweep must inject something");
661 assert_eq!(first, schedule(0xDEAD), "same seed → same fault schedule");
662 assert_ne!(first, schedule(0xBEEF), "different seeds must diverge");
663 }
664
665 #[test]
666 fn fault_classes_compose_with_the_crash_knob_deterministically() {
667 fn run(seed: u64) -> Vec<u8> {
668 let guard = SimulationContext::with_buggify_ppm(seed, 500_000)
669 .with_fault_class(FaultClass::TornWrite, 400_000)
670 .install();
671 for _ in 0..32 {
672 let _crashed = crate::buggify!(ENV, "wal_after_frame_write");
673 if let Some(decision) = crate::buggify_fault!(FaultClass::TornWrite, 64, 48) {
674 crate::dst::record_fault(FaultRecord::new("/db/wal.log", 64, 48, decision));
675 }
676 }
677 let mut out = guard.trace();
678 out.extend_from_slice(guard.fault_log_lines().as_bytes());
679 out
680 }
681 assert_eq!(
682 run(1234),
683 run(1234),
684 "crash + torn_write must replay exactly"
685 );
686 }
687
688 #[test]
689 fn no_fault_fires_without_an_installed_context() {
690 for class in FaultClass::ALL {
691 assert_eq!(crate::buggify_fault!(class, 0, 64), None);
692 }
693 }
694
695 #[test]
700 fn the_fault_macro_is_release_inert() {
701 let _guard = SimulationContext::new(3)
702 .with_fault_class(FaultClass::LostWrite, 1_000_000)
703 .install();
704 let fired = crate::buggify_fault!(FaultClass::LostWrite, 0, 8);
705 if cfg!(debug_assertions) {
706 assert_eq!(fired, Some(FaultDecision::LostWrite));
707 } else {
708 assert_eq!(fired, None, "release builds must never inject a fault");
709 }
710 }
711}