#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum OpKind {
Get,
Set,
Remove,
Contains,
}
impl OpKind {
pub const ALL: [OpKind; 4] = [Self::Get, Self::Set, Self::Remove, Self::Contains];
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s {
"get" | "read" => Some(Self::Get),
"set" | "write" => Some(Self::Set),
"remove" => Some(Self::Remove),
"contains" => Some(Self::Contains),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FaultClass {
Latency,
Timeout,
Hang,
ReadFailure,
WriteFailure,
MetadataFailure,
Corruption,
Disconnect,
CapacityExhaustion,
FailureAfterN,
Cancellation,
PartialWrite,
}
impl FaultClass {
pub const ALL: [FaultClass; 12] = [
Self::Latency,
Self::Timeout,
Self::Hang,
Self::ReadFailure,
Self::WriteFailure,
Self::MetadataFailure,
Self::Corruption,
Self::Disconnect,
Self::CapacityExhaustion,
Self::FailureAfterN,
Self::Cancellation,
Self::PartialWrite,
];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Latency => "latency",
Self::Timeout => "timeout",
Self::Hang => "hang",
Self::ReadFailure => "read_failure",
Self::WriteFailure => "write_failure",
Self::MetadataFailure => "metadata_failure",
Self::Corruption => "corruption",
Self::Disconnect => "disconnect",
Self::CapacityExhaustion => "capacity_exhaustion",
Self::FailureAfterN => "failure_after_n_operations",
Self::Cancellation => "cancellation",
Self::PartialWrite => "partial_write",
}
}
#[must_use]
pub const fn is_scheduled_failure(self) -> bool {
matches!(
self,
Self::Timeout
| Self::ReadFailure
| Self::WriteFailure
| Self::MetadataFailure
| Self::Corruption
| Self::Disconnect
| Self::CapacityExhaustion
| Self::Cancellation
)
}
#[must_use]
pub fn applies_to(self, op: OpKind) -> bool {
match self {
Self::Latency
| Self::Hang
| Self::Disconnect
| Self::Cancellation
| Self::FailureAfterN => true,
Self::Timeout | Self::ReadFailure | Self::Corruption => {
matches!(op, OpKind::Get | OpKind::Contains)
}
Self::WriteFailure | Self::CapacityExhaustion | Self::PartialWrite => {
matches!(op, OpKind::Set | OpKind::Remove)
}
Self::MetadataFailure => matches!(op, OpKind::Get | OpKind::Set),
}
}
}
impl std::fmt::Display for FaultClass {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::fmt::Display for OpKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Get => "get",
Self::Set => "set",
Self::Remove => "remove",
Self::Contains => "contains",
})
}
}
#[derive(Debug, Clone)]
pub struct DeterministicRng {
state: u64,
}
impl DeterministicRng {
#[must_use]
pub fn new(seed: u64) -> Self {
Self {
state: if seed == 0 {
0x9E37_79B9_7F4A_7C15
} else {
seed
},
}
}
pub fn next_u64(&mut self) -> u64 {
let mut x = self.state;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.state = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
pub fn below(&mut self, len: usize) -> usize {
debug_assert!(len > 0, "below(0) would be a division by zero");
(self.next_u64() % len as u64) as usize
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ArmedFault {
pub op: OpKind,
pub class: FaultClass,
pub amount: u64,
}
#[derive(Debug, Clone, Default)]
pub struct FaultPlan {
seed: u64,
queue: Vec<ArmedFault>,
}
impl FaultPlan {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn from_seed(seed: u64, len: usize) -> Self {
let mut rng = DeterministicRng::new(seed);
let mut queue = Vec::with_capacity(len);
for _ in 0..len {
let class = FaultClass::ALL[rng.below(FaultClass::ALL.len())];
let candidates: Vec<OpKind> = OpKind::ALL
.into_iter()
.filter(|op| class.applies_to(*op))
.collect();
let op = candidates[rng.below(candidates.len())];
queue.push(ArmedFault {
op,
class,
amount: match class {
FaultClass::Latency => 1 + rng.below(50) as u64,
FaultClass::FailureAfterN => rng.below(3) as u64,
_ => 0,
},
});
}
Self { seed, queue }
}
#[must_use]
pub fn push(mut self, op: OpKind, class: FaultClass) -> Self {
self.queue.push(ArmedFault {
op,
class,
amount: 0,
});
self
}
#[must_use]
pub fn push_latency_ms(mut self, op: OpKind, ms: u64) -> Self {
self.queue.push(ArmedFault {
op,
class: FaultClass::Latency,
amount: ms,
});
self
}
#[must_use]
pub fn push_after_n(mut self, op: OpKind, class: FaultClass, n: u64) -> Self {
debug_assert!(
class.is_scheduled_failure(),
"{} is not an error a scheduled failure can produce",
class.as_str()
);
self.queue.push(ArmedFault {
op,
class,
amount: n,
});
self
}
pub fn rearm(&mut self, fault: ArmedFault) {
self.queue.insert(0, fault);
}
#[must_use]
pub fn seed(&self) -> u64 {
self.seed
}
#[must_use]
pub fn len(&self) -> usize {
self.queue.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.queue.is_empty()
}
pub fn take_for(&mut self, op: OpKind) -> Option<ArmedFault> {
let idx = self.queue.iter().position(|f| f.op == op)?;
Some(self.queue.remove(idx))
}
}
#[derive(Debug, Default)]
pub struct FaultLedger {
counts: [std::sync::atomic::AtomicU64; 12],
ops: std::sync::atomic::AtomicU64,
}
impl FaultLedger {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn record(&self, class: FaultClass) {
let idx = Self::index(class);
self.counts[idx].fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
#[must_use]
pub fn fired(&self, class: FaultClass) -> u64 {
self.counts[Self::index(class)].load(std::sync::atomic::Ordering::SeqCst)
}
#[must_use]
pub fn ops_observed(&self) -> u64 {
self.ops.load(std::sync::atomic::Ordering::SeqCst)
}
pub fn observe_op(&self) {
self.ops.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
#[must_use]
pub fn never_fired(&self, armed: &[FaultClass]) -> Vec<&'static str> {
armed
.iter()
.filter(|c| self.fired(**c) == 0)
.map(|c| c.as_str())
.collect()
}
const fn index(class: FaultClass) -> usize {
match class {
FaultClass::Latency => 0,
FaultClass::Timeout => 1,
FaultClass::Hang => 2,
FaultClass::ReadFailure => 3,
FaultClass::WriteFailure => 4,
FaultClass::MetadataFailure => 5,
FaultClass::Corruption => 6,
FaultClass::Disconnect => 7,
FaultClass::CapacityExhaustion => 8,
FaultClass::FailureAfterN => 9,
FaultClass::Cancellation => 10,
FaultClass::PartialWrite => 11,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rng_is_reproducible_from_seed() {
let a: Vec<u64> = {
let mut r = DeterministicRng::new(42);
(0..8).map(|_| r.next_u64()).collect()
};
let b: Vec<u64> = {
let mut r = DeterministicRng::new(42);
(0..8).map(|_| r.next_u64()).collect()
};
assert_eq!(a, b, "same seed must produce the same stream");
let c: Vec<u64> = {
let mut r = DeterministicRng::new(43);
(0..8).map(|_| r.next_u64()).collect()
};
assert_ne!(a, c, "different seeds must diverge");
}
#[test]
fn zero_seed_does_not_lock_the_generator() {
let mut r = DeterministicRng::new(0);
let first = r.next_u64();
let second = r.next_u64();
assert_ne!(first, second, "xorshift must escape a zero state");
}
#[test]
fn generated_plans_only_arm_applicable_faults() {
for seed in 0..500_u64 {
let plan = FaultPlan::from_seed(seed, 8);
assert_eq!(plan.seed(), seed);
let mut armed = plan.clone();
while let Some(f) = armed.take_for(OpKind::Get) {
assert!(
f.class.applies_to(OpKind::Get),
"seed {seed} armed {} for a read, which can never fire",
f.class.as_str()
);
}
let mut armed = plan.clone();
while let Some(f) = armed.take_for(OpKind::Set) {
assert!(
f.class.applies_to(OpKind::Set),
"seed {seed} armed {} for a write, which can never fire",
f.class.as_str()
);
}
}
}
#[test]
fn ledger_reports_armed_but_never_fired() {
let ledger = FaultLedger::new();
ledger.record(FaultClass::Hang);
let missed = ledger.never_fired(&[FaultClass::Hang, FaultClass::Corruption]);
assert_eq!(missed, vec!["corruption"]);
}
#[test]
fn queue_consumes_in_order_and_then_stops() {
let mut plan = FaultPlan::new()
.push(OpKind::Set, FaultClass::WriteFailure)
.push(OpKind::Set, FaultClass::WriteFailure);
assert_eq!(
plan.take_for(OpKind::Set).unwrap().class,
FaultClass::WriteFailure
);
assert_eq!(
plan.take_for(OpKind::Set).unwrap().class,
FaultClass::WriteFailure
);
assert!(plan.take_for(OpKind::Set).is_none());
assert!(plan.take_for(OpKind::Get).is_none(), "other ops unaffected");
}
}