use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
const DEFAULT_FAILURE_BUDGET: u64 = 3;
const DEFAULT_PERSISTENCE_WINDOW_SECS: u64 = 86_400;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct Record {
consecutive_failures: u64,
first_failure_unix: u64,
last_failure_unix: u64,
}
fn failure_budget() -> u64 {
crate::config::tuning_u64_in_range(
"net.health.failure_budget",
DEFAULT_FAILURE_BUDGET,
1,
1_000,
)
}
fn persistence_window_secs() -> u64 {
crate::config::tuning_u64_in_range(
"net.health.persistence_window_secs",
DEFAULT_PERSISTENCE_WINDOW_SECS,
60,
31_536_000,
)
}
fn ledger_path() -> Option<PathBuf> {
crate::config::state_dir()
.ok()
.map(|d| d.join("provider-health.tsv"))
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
fn load() -> Vec<(String, Record)> {
let Some(path) = ledger_path() else {
return Vec::new();
};
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
text.lines()
.filter_map(|line| {
let mut parts = line.split('\t');
let name = parts.next()?.to_owned();
let record = Record {
consecutive_failures: parts.next()?.parse().ok()?,
first_failure_unix: parts.next()?.parse().ok()?,
last_failure_unix: parts.next()?.parse().ok()?,
};
(!name.is_empty()).then_some((name, record))
})
.collect()
}
fn store(entries: &[(String, Record)]) {
let Some(path) = ledger_path() else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut body = String::new();
for (name, r) in entries {
body.push_str(&format!(
"{name}\t{}\t{}\t{}\n",
r.consecutive_failures, r.first_failure_unix, r.last_failure_unix
));
}
let _ = std::fs::write(path, body);
}
pub(crate) fn record_failure(provider: &str) {
let now = now_unix();
let mut entries = load();
match entries.iter_mut().find(|(name, _)| name == provider) {
Some((_, record)) => {
record.consecutive_failures = record.consecutive_failures.saturating_add(1);
record.last_failure_unix = now;
}
None => entries.push((
provider.to_owned(),
Record {
consecutive_failures: 1,
first_failure_unix: now,
last_failure_unix: now,
},
)),
}
store(&entries);
}
pub(crate) fn record_success(provider: &str) {
let mut entries = load();
let before = entries.len();
entries.retain(|(name, _)| name != provider);
if entries.len() != before {
store(&entries);
}
}
pub(crate) fn is_persistently_broken(provider: &str) -> bool {
verdict(
&load(),
provider,
failure_budget(),
persistence_window_secs(),
)
}
fn verdict(entries: &[(String, Record)], provider: &str, budget: u64, window: u64) -> bool {
entries
.iter()
.find(|(name, _)| name == provider)
.is_some_and(|(_, r)| {
r.consecutive_failures >= budget
&& r.last_failure_unix.saturating_sub(r.first_failure_unix) >= window
})
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(failures: u64, span: u64) -> Vec<(String, Record)> {
vec![(
"provider-x".to_owned(),
Record {
consecutive_failures: failures,
first_failure_unix: 1_000,
last_failure_unix: 1_000 + span,
},
)]
}
#[test]
fn only_count_and_duration_together_condemn_a_provider() {
assert!(
verdict(&entry(5, 90_000), "provider-x", 3, 86_400),
"five failures over a day is the case this exists for"
);
assert!(
!verdict(&entry(5, 60), "provider-x", 3, 86_400),
"five failures in a minute is a blip, not a broken upstream"
);
assert!(
!verdict(&entry(1, 90_000), "provider-x", 3, 86_400),
"one failure a day ago is not a run of failures"
);
}
#[test]
fn an_unrecorded_provider_is_never_condemned() {
assert!(!verdict(&entry(9, 999_999), "provider-y", 1, 1));
assert!(!verdict(&[], "provider-x", 1, 1));
}
#[test]
fn a_record_survives_the_line_format() {
let entries = entry(4, 90_000);
let mut body = String::new();
for (name, r) in &entries {
body.push_str(&format!(
"{name}\t{}\t{}\t{}\n",
r.consecutive_failures, r.first_failure_unix, r.last_failure_unix
));
}
let parsed: Vec<(String, Record)> = body
.lines()
.filter_map(|line| {
let mut parts = line.split('\t');
let name = parts.next()?.to_owned();
Some((
name,
Record {
consecutive_failures: parts.next()?.parse().ok()?,
first_failure_unix: parts.next()?.parse().ok()?,
last_failure_unix: parts.next()?.parse().ok()?,
},
))
})
.collect();
assert_eq!(parsed, entries);
}
#[test]
fn a_malformed_line_is_skipped_rather_than_fatal() {
let text = "provider-x\tnot-a-number\t1\t2\ngood\t1\t2\t3\n";
let parsed: Vec<String> = text
.lines()
.filter_map(|line| {
let mut parts = line.split('\t');
let name = parts.next()?.to_owned();
let _: u64 = parts.next()?.parse().ok()?;
let _: u64 = parts.next()?.parse().ok()?;
let _: u64 = parts.next()?.parse().ok()?;
Some(name)
})
.collect();
assert_eq!(parsed, vec!["good".to_string()]);
}
}