#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
use std::collections::{BTreeMap, BTreeSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::entropy::{Entropy, SeededEntropy};
use crate::time::{ClockSource, MonotonicInstant};
const FAULT_STREAM_SALT: u64 = 0xFA17_0DEA_FA17_0DEA;
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FaultEffect {
DbCheckout,
JobExecution,
}
impl FaultEffect {
const fn label(self) -> &'static str {
match self {
Self::DbCheckout => "db_checkout",
Self::JobExecution => "job_execution",
}
}
const fn stream_salt(self) -> u64 {
match self {
Self::DbCheckout => 0xD8CE_4B07_D8CE_4B07,
Self::JobExecution => 0x70B5_EC07_70B5_EC07,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct PlannedFault {
pub effect: FaultEffect,
pub target: Option<String>,
pub ordinal: u32,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FaultPlan {
seed: u64,
entries: BTreeSet<PlannedFault>,
window: Option<(Duration, Duration)>,
}
impl FaultPlan {
#[must_use]
pub fn from_seed(seed: u64) -> Self {
Self {
seed,
entries: BTreeSet::new(),
window: None,
}
}
#[must_use]
pub const fn seed(&self) -> u64 {
self.seed
}
#[must_use]
pub fn fail_job_execution(self, ordinal: u32) -> Self {
self.push(FaultEffect::JobExecution, None, ordinal)
}
#[must_use]
pub fn fail_job(self, job: impl Into<String>, ordinal: u32) -> Self {
self.push(FaultEffect::JobExecution, Some(job.into()), ordinal)
}
#[must_use]
pub fn random_job_execution_faults(
self,
count: u32,
within: std::ops::RangeInclusive<u32>,
) -> Self {
self.push_random(FaultEffect::JobExecution, count, &within)
}
#[cfg(feature = "db")]
#[must_use]
pub fn fail_db_checkout(self, ordinal: u32) -> Self {
self.push(FaultEffect::DbCheckout, None, ordinal)
}
#[cfg(feature = "db")]
#[must_use]
pub fn fail_db_checkout_on(self, pool: impl Into<String>, ordinal: u32) -> Self {
self.push(FaultEffect::DbCheckout, Some(pool.into()), ordinal)
}
#[cfg(feature = "db")]
#[must_use]
pub fn random_db_checkout_faults(
self,
count: u32,
within: std::ops::RangeInclusive<u32>,
) -> Self {
self.push_random(FaultEffect::DbCheckout, count, &within)
}
#[must_use]
pub fn only_between(mut self, from: Duration, to: Duration) -> Self {
self.window = Some((from, to));
self
}
#[must_use]
pub fn planned(&self) -> Vec<PlannedFault> {
self.entries.iter().cloned().collect()
}
#[must_use]
pub fn describe(&self) -> String {
use std::fmt::Write as _;
let mut out = format!("fault plan (seed {:#018x})", self.seed);
if self.entries.is_empty() {
out.push_str("\n (no faults planned)");
}
for entry in &self.entries {
let _ = match entry.target.as_deref() {
Some(target) => write!(
out,
"\n {} ordinal {} of `{target}`",
entry.effect.label(),
entry.ordinal
),
None => write!(
out,
"\n {} ordinal {} (any target)",
entry.effect.label(),
entry.ordinal
),
};
}
if let Some((from, to)) = self.window {
let _ = write!(
out,
"\n window: fires only while elapsed is in [{}ms, {}ms)",
from.as_millis(),
to.as_millis()
);
}
out
}
#[must_use]
pub fn is_active(&self) -> bool {
!self.entries.is_empty()
}
pub(crate) const fn window(&self) -> Option<(Duration, Duration)> {
self.window
}
fn push(mut self, effect: FaultEffect, target: Option<String>, ordinal: u32) -> Self {
if ordinal >= 1 {
self.entries.insert(PlannedFault {
effect,
target,
ordinal,
});
}
self
}
fn push_random(
mut self,
effect: FaultEffect,
count: u32,
within: &std::ops::RangeInclusive<u32>,
) -> Self {
for ordinal in sample_distinct(self.seed, effect, count, within) {
if ordinal >= 1 {
self.entries.insert(PlannedFault {
effect,
target: None,
ordinal,
});
}
}
self
}
}
fn sample_distinct(
seed: u64,
effect: FaultEffect,
count: u32,
within: &std::ops::RangeInclusive<u32>,
) -> Vec<u32> {
let (lo, hi) = (*within.start(), *within.end());
if hi < lo || count == 0 {
return Vec::new();
}
let len = u64::from(hi - lo) + 1;
let take = u64::from(count).min(len);
let stream = SeededEntropy::new(seed ^ FAULT_STREAM_SALT ^ effect.stream_salt());
let mut chosen: BTreeSet<u32> = BTreeSet::new();
for j in (len - take)..len {
let offset = u32::try_from(stream.next_u64() % (j + 1)).unwrap_or(u32::MAX);
let candidate = lo.saturating_add(offset);
if !chosen.insert(candidate) {
let fallback = lo.saturating_add(u32::try_from(j).unwrap_or(u32::MAX));
chosen.insert(fallback);
}
}
chosen.into_iter().collect()
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FiredFault {
pub effect: FaultEffect,
pub target: String,
pub ordinal: u32,
pub target_ordinal: u32,
pub at: DateTime<Utc>,
pub elapsed_ms: u64,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReportedError {
pub status: u16,
pub method: Option<String>,
pub route: Option<String>,
pub message: String,
pub problem_type: Option<String>,
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FinalState {
pub db_checkouts: u64,
pub job_executions: u64,
pub job_executions_failed: u64,
pub job_executions_succeeded: u64,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FaultOutcome {
pub seed: u64,
pub fired: Vec<FiredFault>,
pub suppressed: Vec<FiredFault>,
pub unfired: Vec<PlannedFault>,
pub server_errors: Vec<ReportedError>,
pub final_state: FinalState,
}
impl FaultOutcome {
#[must_use]
pub fn to_json_string(&self) -> String {
serde_json::to_string(self).expect("FaultOutcome contains only serializable plain data")
}
#[must_use]
pub fn fingerprint(&self) -> u64 {
let mut hash = FNV_OFFSET_BASIS;
for byte in self.to_json_string().as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
pub fn from_json_str(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Decision {
Proceed,
Fail { ordinal: u32, target_ordinal: u32 },
}
#[derive(Debug, Default)]
struct Recorded {
fired: Vec<FiredFault>,
suppressed: Vec<FiredFault>,
reached: BTreeSet<PlannedFault>,
target_seq: BTreeMap<(FaultEffect, String), u64>,
}
struct FaultState {
seed: u64,
planned: Vec<PlannedFault>,
global_ordinals: BTreeMap<FaultEffect, BTreeSet<u32>>,
target_ordinals: BTreeMap<(FaultEffect, String), BTreeSet<u32>>,
window: Option<(Duration, Duration)>,
clock: Arc<dyn ClockSource>,
started_at: MonotonicInstant,
db_checkout_seq: AtomicU64,
job_execution_seq: AtomicU64,
job_executions_failed: AtomicU64,
job_executions_succeeded: AtomicU64,
recorded: Mutex<Recorded>,
server_errors: Mutex<Vec<ReportedError>>,
}
impl FaultState {
fn new(plan: &FaultPlan, clock: Arc<dyn ClockSource>, started_at: MonotonicInstant) -> Self {
let mut global_ordinals: BTreeMap<FaultEffect, BTreeSet<u32>> = BTreeMap::new();
let mut target_ordinals: BTreeMap<(FaultEffect, String), BTreeSet<u32>> = BTreeMap::new();
let planned = plan.planned();
for entry in &planned {
match entry.target.as_ref() {
Some(target) => {
target_ordinals
.entry((entry.effect, target.clone()))
.or_default()
.insert(entry.ordinal);
}
None => {
global_ordinals
.entry(entry.effect)
.or_default()
.insert(entry.ordinal);
}
}
}
Self {
seed: plan.seed(),
planned,
global_ordinals,
target_ordinals,
window: plan.window(),
clock,
started_at,
db_checkout_seq: AtomicU64::new(0),
job_execution_seq: AtomicU64::new(0),
job_executions_failed: AtomicU64::new(0),
job_executions_succeeded: AtomicU64::new(0),
recorded: Mutex::new(Recorded::default()),
server_errors: Mutex::new(Vec::new()),
}
}
fn observe(&self, effect: FaultEffect, target: &str) -> Decision {
let counter = match effect {
FaultEffect::DbCheckout => &self.db_checkout_seq,
FaultEffect::JobExecution => &self.job_execution_seq,
};
let mut recorded = self
.recorded
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let ordinal = saturating_ordinal(counter.fetch_add(1, Ordering::SeqCst) + 1);
let target_ordinal = {
let seq = recorded
.target_seq
.entry((effect, target.to_owned()))
.or_insert(0);
*seq += 1;
saturating_ordinal(*seq)
};
let matched_global = self
.global_ordinals
.get(&effect)
.is_some_and(|ordinals| ordinals.contains(&ordinal));
let matched_target = self
.target_ordinals
.get(&(effect, target.to_owned()))
.is_some_and(|ordinals| ordinals.contains(&target_ordinal));
if !matched_global && !matched_target {
return Decision::Proceed;
}
if matched_global {
recorded.reached.insert(PlannedFault {
effect,
target: None,
ordinal,
});
}
if matched_target {
recorded.reached.insert(PlannedFault {
effect,
target: Some(target.to_owned()),
ordinal: target_ordinal,
});
}
let elapsed = self
.clock
.monotonic()
.saturating_duration_since(self.started_at);
let record = FiredFault {
effect,
target: target.to_owned(),
ordinal,
target_ordinal,
at: self.clock.now(),
elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
};
let in_window = self
.window
.is_none_or(|(from, to)| elapsed >= from && elapsed < to);
if in_window {
recorded.fired.push(record);
Decision::Fail {
ordinal,
target_ordinal,
}
} else {
recorded.suppressed.push(record);
Decision::Proceed
}
}
fn record_execution_result(&self, succeeded: bool) {
let counter = if succeeded {
&self.job_executions_succeeded
} else {
&self.job_executions_failed
};
counter.fetch_add(1, Ordering::SeqCst);
}
#[cfg(feature = "reporting")]
fn record_server_error(&self, error: ReportedError) {
self.server_errors
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(error);
}
fn server_errors_len(&self) -> usize {
self.server_errors
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len()
}
fn outcome(&self) -> FaultOutcome {
let recorded = self
.recorded
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let unfired = self
.planned
.iter()
.filter(|entry| !recorded.reached.contains(*entry))
.cloned()
.collect();
let outcome = FaultOutcome {
seed: self.seed,
fired: recorded.fired.clone(),
suppressed: recorded.suppressed.clone(),
unfired,
server_errors: self
.server_errors
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone(),
final_state: FinalState {
db_checkouts: self.db_checkout_seq.load(Ordering::SeqCst),
job_executions: self.job_execution_seq.load(Ordering::SeqCst),
job_executions_failed: self.job_executions_failed.load(Ordering::SeqCst),
job_executions_succeeded: self.job_executions_succeeded.load(Ordering::SeqCst),
},
};
drop(recorded);
outcome
}
}
fn saturating_ordinal(value: u64) -> u32 {
u32::try_from(value).unwrap_or(u32::MAX)
}
#[non_exhaustive]
#[derive(Clone)]
pub struct FaultLedger(Arc<FaultState>);
impl std::fmt::Debug for FaultLedger {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FaultLedger")
.field("seed", &self.0.seed)
.field("planned", &self.0.planned)
.field("fired", &self.0.recorded.lock().map(|r| r.fired.len()).ok())
.finish()
}
}
impl FaultLedger {
#[must_use]
pub fn outcome(&self) -> FaultOutcome {
self.0.outcome()
}
pub(crate) fn new(
plan: &FaultPlan,
clock: Arc<dyn ClockSource>,
started_at: MonotonicInstant,
) -> Self {
Self(Arc::new(FaultState::new(plan, clock, started_at)))
}
pub(crate) fn job_interceptor(&self) -> Arc<dyn crate::interceptor::JobInterceptor> {
Arc::new(FaultJobInterceptor {
state: Arc::clone(&self.0),
})
}
#[cfg(feature = "db")]
pub(crate) fn db_interceptor(
&self,
inner: Option<Arc<dyn crate::interceptor::DbConnectionInterceptor>>,
) -> Arc<dyn crate::interceptor::DbConnectionInterceptor> {
Arc::new(FaultDbInterceptor {
state: Arc::clone(&self.0),
inner,
})
}
#[cfg(feature = "reporting")]
pub(crate) fn reporter(&self) -> Arc<dyn crate::reporting::ErrorReporter> {
Arc::new(FaultReporter {
state: Arc::clone(&self.0),
})
}
pub(crate) fn server_errors_len(&self) -> usize {
self.0.server_errors_len()
}
}
struct RecordOnUnwind<'a> {
state: &'a FaultState,
armed: bool,
}
impl Drop for RecordOnUnwind<'_> {
fn drop(&mut self) {
if self.armed {
self.state.record_execution_result(false);
}
}
}
struct FaultJobInterceptor {
state: Arc<FaultState>,
}
impl crate::interceptor::JobInterceptor for FaultJobInterceptor {
fn intercept_enqueue<'a>(
&'a self,
_name: &'a str,
_payload: &'a serde_json::Value,
next: std::pin::Pin<
Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>,
>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>>
{
next
}
fn intercept_execute<'a>(
&'a self,
name: &'a str,
_payload: &'a serde_json::Value,
next: std::pin::Pin<
Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>,
>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>>
{
Box::pin(async move {
match self.state.observe(FaultEffect::JobExecution, name) {
Decision::Fail {
ordinal,
target_ordinal,
} => {
self.state.record_execution_result(false);
Err(crate::AutumnError::service_unavailable_msg(format!(
"fault plan: injected job execution failure \
(execution #{ordinal} overall, #{target_ordinal} of `{name}`)"
)))
}
Decision::Proceed => {
let mut guard = RecordOnUnwind {
state: self.state.as_ref(),
armed: true,
};
let result = next.await;
guard.armed = false;
self.state.record_execution_result(result.is_ok());
result
}
}
})
}
}
#[cfg(feature = "db")]
struct FaultDbInterceptor {
state: Arc<FaultState>,
inner: Option<Arc<dyn crate::interceptor::DbConnectionInterceptor>>,
}
#[cfg(feature = "db")]
impl crate::interceptor::DbConnectionInterceptor for FaultDbInterceptor {
fn intercept_checkout<'a>(
&'a self,
ctx: crate::interceptor::DbCheckoutContext,
next: std::pin::Pin<
Box<
dyn std::future::Future<
Output = Result<crate::db::PooledConnection, crate::AutumnError>,
> + Send
+ 'a,
>,
>,
) -> std::pin::Pin<
Box<
dyn std::future::Future<
Output = Result<crate::db::PooledConnection, crate::AutumnError>,
> + Send
+ 'a,
>,
> {
let pool_name = ctx.pool_name.clone();
let faulted: std::pin::Pin<
Box<
dyn std::future::Future<
Output = Result<crate::db::PooledConnection, crate::AutumnError>,
> + Send
+ 'a,
>,
> = Box::pin(async move {
match self.state.observe(FaultEffect::DbCheckout, &pool_name) {
Decision::Fail {
ordinal,
target_ordinal,
} => Err(crate::AutumnError::service_unavailable_msg(format!(
"fault plan: injected database checkout failure \
(checkout #{ordinal} overall, #{target_ordinal} from `{pool_name}`)"
))),
Decision::Proceed => next.await,
}
});
match self.inner.as_ref() {
Some(inner) => inner.intercept_checkout(ctx, faulted),
None => faulted,
}
}
fn is_transactional_test(&self) -> bool {
self.inner
.as_ref()
.is_some_and(|inner| inner.is_transactional_test())
}
}
#[cfg(feature = "reporting")]
struct FaultReporter {
state: Arc<FaultState>,
}
#[cfg(feature = "reporting")]
impl crate::reporting::ErrorReporter for FaultReporter {
fn report<'a>(
&'a self,
event: &'a crate::reporting::ErrorEvent,
) -> crate::reporting::ReportFuture<'a> {
self.state.record_server_error(ReportedError {
status: event.status.as_u16(),
method: event.method.clone(),
route: event.route.clone(),
message: event.message.clone(),
problem_type: event.problem_type.clone(),
});
Box::pin(std::future::ready(()))
}
}
#[cfg(test)]
mod tests {
use super::{
Decision, FaultEffect, FaultOutcome, FaultPlan, FaultState, FinalState, PlannedFault,
sample_distinct,
};
use crate::time::{ClockSource as _, TickingClock};
use std::sync::Arc;
use std::time::Duration;
const SEED: u64 = 0x5EED;
fn epoch() -> chrono::DateTime<chrono::Utc> {
chrono::DateTime::from_timestamp(1_577_836_800, 0).expect("valid timestamp")
}
fn ticking_state(plan: &FaultPlan) -> (TickingClock, FaultState) {
let clock = TickingClock::starting_at(epoch());
let started_at = clock.monotonic();
let state = FaultState::new(plan, Arc::new(clock.clone()), started_at);
(clock, state)
}
#[test]
fn a_fresh_plan_is_inactive_and_describes_itself() {
let plan = FaultPlan::from_seed(SEED);
assert_eq!(plan.seed(), SEED);
assert!(!plan.is_active());
assert!(plan.planned().is_empty());
assert!(plan.describe().contains("no faults planned"));
}
#[test]
fn ordinal_zero_is_ignored() {
let plan = FaultPlan::from_seed(SEED)
.fail_job_execution(0)
.fail_job("probe", 0);
assert!(
!plan.is_active(),
"a 0 ordinal must never silently fault the first effect"
);
assert!(plan.fail_job_execution(1).is_active());
}
#[test]
fn duplicate_entries_collapse_and_planned_is_sorted() {
let plan = FaultPlan::from_seed(SEED)
.fail_job("b", 2)
.fail_job_execution(3)
.fail_job("a", 1)
.fail_job_execution(3) .fail_job("b", 2); let planned = plan.planned();
assert_eq!(planned.len(), 3, "duplicates collapse: {planned:?}");
let mut sorted = planned.clone();
sorted.sort();
assert_eq!(planned, sorted, "planned() is sorted");
assert_eq!(
planned[0],
PlannedFault {
effect: FaultEffect::JobExecution,
target: None,
ordinal: 3,
}
);
assert_eq!(planned[1].target.as_deref(), Some("a"));
assert_eq!(planned[2].target.as_deref(), Some("b"));
}
#[test]
fn authoring_order_does_not_change_the_plan() {
let a = FaultPlan::from_seed(SEED)
.fail_job_execution(1)
.fail_job("probe", 4);
let b = FaultPlan::from_seed(SEED)
.fail_job("probe", 4)
.fail_job_execution(1);
assert_eq!(a, b);
}
#[test]
fn describe_mentions_every_ordinal_and_the_window() {
let plan = FaultPlan::from_seed(SEED)
.fail_job_execution(2)
.fail_job("probe", 7)
.only_between(Duration::from_secs(5), Duration::from_secs(10));
let described = plan.describe();
assert!(!described.is_empty());
assert!(described.contains("ordinal 2"), "{described}");
assert!(described.contains("ordinal 7 of `probe`"), "{described}");
assert!(described.contains("job_execution"), "{described}");
assert!(described.contains("window"), "{described}");
assert!(described.contains("5000ms"), "{described}");
}
#[test]
fn random_ordinals_replay_from_the_seed() {
let build = |seed: u64| {
FaultPlan::from_seed(seed)
.random_job_execution_faults(3, 1..=20)
.planned()
};
assert_eq!(build(SEED), build(SEED), "same seed ⇒ same schedule");
assert_ne!(
build(SEED),
build(SEED + 1),
"different seeds should (overwhelmingly likely) diverge"
);
let planned = build(SEED);
assert_eq!(planned.len(), 3);
assert!(planned.iter().all(|p| (1..=20).contains(&p.ordinal)));
assert!(
planned
.iter()
.all(|p| p.effect == FaultEffect::JobExecution && p.target.is_none())
);
}
#[test]
fn random_ordinals_do_not_depend_on_the_call_position() {
let a = FaultPlan::from_seed(SEED)
.fail_job("probe", 9)
.random_job_execution_faults(2, 1..=8);
let b = FaultPlan::from_seed(SEED)
.random_job_execution_faults(2, 1..=8)
.fail_job("probe", 9);
assert_eq!(a, b);
}
#[test]
fn a_count_beyond_the_range_clamps_to_the_range() {
let planned = FaultPlan::from_seed(SEED)
.random_job_execution_faults(50, 3..=6)
.planned();
assert_eq!(planned.len(), 4, "range 3..=6 holds only 4 ordinals");
assert!(planned.iter().all(|p| (3..=6).contains(&p.ordinal)));
}
#[test]
fn degenerate_sampling_ranges_pick_nothing() {
let effect = FaultEffect::JobExecution;
assert!(
sample_distinct(SEED, effect, 0, &(1..=10)).is_empty(),
"count 0"
);
assert!(
sample_distinct(SEED, effect, 3, &std::ops::RangeInclusive::new(10, 1)).is_empty(),
"inverted range"
);
assert_eq!(
sample_distinct(SEED, effect, 3, &(7..=7)),
vec![7],
"single value"
);
let planned = FaultPlan::from_seed(SEED)
.random_job_execution_faults(1, 0..=0)
.planned();
assert!(planned.is_empty(), "ordinal 0 is filtered out of the plan");
}
#[test]
fn the_window_is_half_open_on_the_injected_clock() {
let window = (Duration::from_secs(5), Duration::from_secs(10));
let plan = FaultPlan::from_seed(SEED)
.fail_job_execution(1)
.fail_job_execution(2)
.fail_job_execution(3)
.only_between(window.0, window.1);
assert_eq!(plan.window(), Some(window));
let (clock, state) = ticking_state(&plan);
assert_eq!(
state.observe(FaultEffect::JobExecution, "probe"),
Decision::Proceed
);
let outcome = state.outcome();
assert!(outcome.fired.is_empty(), "before the window: {outcome:?}");
assert_eq!(outcome.suppressed.len(), 1);
clock.advance(Duration::from_secs(5));
assert_eq!(
state.observe(FaultEffect::JobExecution, "probe"),
Decision::Fail {
ordinal: 2,
target_ordinal: 2,
},
"the window start is inclusive"
);
let outcome = state.outcome();
assert_eq!(outcome.fired.len(), 1, "{outcome:?}");
assert_eq!(outcome.fired[0].elapsed_ms, 5_000);
assert_eq!(outcome.fired[0].at, epoch() + chrono::Duration::seconds(5));
clock.advance(Duration::from_secs(5));
assert_eq!(
state.observe(FaultEffect::JobExecution, "probe"),
Decision::Proceed
);
let outcome = state.outcome();
assert_eq!(
outcome.fired.len(),
1,
"the window end is exclusive: {outcome:?}"
);
assert_eq!(outcome.suppressed.len(), 2);
assert!(
outcome.unfired.is_empty(),
"every authored ordinal was reached: {outcome:?}"
);
}
#[test]
fn an_inverted_window_suppresses_everything() {
let plan = FaultPlan::from_seed(SEED)
.fail_job_execution(1)
.fail_job_execution(2)
.fail_job_execution(3)
.only_between(Duration::from_secs(10), Duration::from_secs(5));
let (clock, state) = ticking_state(&plan);
for _ in 0..3 {
assert_eq!(
state.observe(FaultEffect::JobExecution, "probe"),
Decision::Proceed
);
clock.advance(Duration::from_secs(7));
}
let outcome = state.outcome();
assert!(
outcome.fired.is_empty(),
"`from >= to` contains no instant at all: {outcome:?}"
);
assert_eq!(outcome.suppressed.len(), 3);
assert!(outcome.unfired.is_empty());
}
#[test]
fn a_pass_matching_a_global_and_a_target_entry_fires_once() {
let plan = FaultPlan::from_seed(SEED)
.fail_job_execution(2)
.fail_job("probe", 2);
assert_eq!(
plan.planned().len(),
2,
"two distinct entries were authored"
);
let (_clock, state) = ticking_state(&plan);
assert_eq!(
state.observe(FaultEffect::JobExecution, "probe"),
Decision::Proceed
);
assert_eq!(
state.observe(FaultEffect::JobExecution, "probe"),
Decision::Fail {
ordinal: 2,
target_ordinal: 2,
}
);
let outcome = state.outcome();
assert_eq!(
outcome.fired.len(),
1,
"one FiredFault per faulted pass, not per matched entry: {outcome:?}"
);
assert!(
outcome.unfired.is_empty(),
"both planned entries count as reached: {outcome:?}"
);
}
#[cfg(feature = "db")]
#[test]
fn db_builders_author_db_entries() {
let planned = FaultPlan::from_seed(SEED)
.fail_db_checkout(3)
.fail_db_checkout_on("replica", 2)
.random_db_checkout_faults(2, 1..=9)
.planned();
assert!(
planned
.iter()
.all(|p| p.effect == FaultEffect::DbCheckout && p.ordinal >= 1)
);
assert!(
planned
.iter()
.any(|p| p.target.as_deref() == Some("replica"))
);
assert!(planned.len() >= 3);
}
#[cfg(feature = "db")]
#[test]
fn the_random_lanes_draw_independent_ordinals() {
let planned = FaultPlan::from_seed(SEED)
.random_job_execution_faults(2, 1..=6)
.random_db_checkout_faults(2, 1..=6)
.planned();
let picks = |effect: FaultEffect| -> Vec<u32> {
planned
.iter()
.filter(|p| p.effect == effect)
.map(|p| p.ordinal)
.collect()
};
let jobs = picks(FaultEffect::JobExecution);
let dbs = picks(FaultEffect::DbCheckout);
assert_eq!(jobs.len(), 2, "{planned:?}");
assert_eq!(dbs.len(), 2, "{planned:?}");
assert_ne!(jobs, dbs, "each effect draws from its own salted stream");
}
fn sample_outcome() -> FaultOutcome {
FaultOutcome {
seed: SEED,
fired: vec![super::FiredFault {
effect: FaultEffect::JobExecution,
target: "probe".to_owned(),
ordinal: 2,
target_ordinal: 2,
at: chrono::DateTime::from_timestamp(1_577_836_806, 0).expect("valid timestamp"),
elapsed_ms: 6_000,
}],
suppressed: Vec::new(),
unfired: vec![PlannedFault {
effect: FaultEffect::DbCheckout,
target: Some("primary".to_owned()),
ordinal: 4,
}],
server_errors: vec![super::ReportedError {
status: 503,
method: Some("GET".to_owned()),
route: Some("/touch".to_owned()),
message: "fault plan: injected database checkout failure (checkout #3)".to_owned(),
problem_type: None,
}],
final_state: FinalState {
db_checkouts: 5,
job_executions: 4,
job_executions_failed: 1,
job_executions_succeeded: 3,
},
}
}
#[test]
fn outcome_json_round_trips_and_is_canonical() {
let outcome = sample_outcome();
let json = outcome.to_json_string();
assert_eq!(
json,
outcome.to_json_string(),
"serialization is byte-stable"
);
assert!(json.contains("\"fired\""), "{json}");
assert!(json.contains("\"server_errors\""), "{json}");
assert!(json.contains("\"final_state\""), "{json}");
assert!(json.contains("\"job_execution\""), "snake_case effects");
let parsed = FaultOutcome::from_json_str(&json).expect("round-trips");
assert_eq!(parsed, outcome);
assert_eq!(parsed.fingerprint(), outcome.fingerprint());
}
#[test]
fn fingerprint_is_stable_and_discriminating() {
let outcome = sample_outcome();
assert_eq!(outcome.fingerprint(), outcome.fingerprint());
let mut other = outcome.clone();
other.final_state.job_executions += 1;
assert_ne!(
outcome.fingerprint(),
other.fingerprint(),
"a different run must fingerprint differently"
);
}
#[test]
fn from_json_str_rejects_garbage() {
assert!(FaultOutcome::from_json_str("not json").is_err());
assert!(FaultOutcome::from_json_str("{}").is_err());
}
static SMOKE_ATTEMPTS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
struct SmokeProbeJobs;
impl crate::plugin::Plugin for SmokeProbeJobs {
fn build(self, app: crate::app::AppBuilder) -> crate::app::AppBuilder {
app.jobs(vec![crate::job::JobInfo::new(
"smoke_probe",
3,
10,
|_state, _payload| {
Box::pin(async move {
SMOKE_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(())
})
},
)])
}
}
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn a_planned_job_execution_fault_fires_and_is_retried() {
use std::sync::atomic::Ordering;
let _guard = crate::job::global_job_runtime_test_lock().lock().await;
crate::job::clear_global_job_client();
SMOKE_ATTEMPTS.store(0, Ordering::SeqCst);
let mut sim = crate::sim::Sim::from_seed(SEED);
let plan = FaultPlan::from_seed(SEED).fail_job("smoke_probe", 1);
sim.build(
crate::test::TestApp::new()
.plugin(SmokeProbeJobs)
.with_fault_plan(plan),
);
crate::job::enqueue("smoke_probe", serde_json::json!({}))
.await
.expect("enqueue succeeds");
sim.run_to_idle().await;
sim.advance(Duration::from_secs(1)).await;
sim.run_to_idle().await;
let outcome = sim
.client()
.fault_ledger()
.expect("a plan was attached")
.outcome();
assert_eq!(outcome.fired.len(), 1, "{outcome:?}");
assert_eq!(outcome.fired[0].effect, FaultEffect::JobExecution);
assert_eq!(outcome.fired[0].target, "smoke_probe");
assert_eq!(outcome.fired[0].ordinal, 1);
assert_eq!(outcome.fired[0].target_ordinal, 1);
assert!(outcome.unfired.is_empty());
assert_eq!(outcome.final_state.job_executions, 2);
assert_eq!(outcome.final_state.job_executions_failed, 1);
assert_eq!(outcome.final_state.job_executions_succeeded, 1);
assert_eq!(
SMOKE_ATTEMPTS.load(Ordering::SeqCst),
1,
"the faulted attempt never reached the handler; the retry did"
);
assert_eq!(sim.client().enqueued_jobs().len(), 1);
crate::job::clear_global_job_client();
}
}