#![forbid(unsafe_code)]
use std::sync;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::destination::health;
pub const MAX_HISTORY_ATTEMPTS: usize = 1024;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AttemptRoute {
Nakednet,
Mixnet,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AttemptKind {
Send,
Probe,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FailureKind {
Timeout,
Unreachable,
Queued,
Rejected,
Other,
}
impl FailureKind {
#[cfg_attr(not(feature = "nym"), allow(dead_code))]
pub(crate) fn as_str(self) -> &'static str {
match self {
FailureKind::Timeout => "timeout",
FailureKind::Unreachable => "unreachable",
FailureKind::Queued => "queued",
FailureKind::Rejected => "rejected",
FailureKind::Other => "other",
}
}
pub(crate) fn classify(detail: &str) -> Self {
let lowered = detail.to_ascii_lowercase();
if lowered.contains("already queued for download") {
return FailureKind::Queued;
}
if lowered.contains("timed out")
|| lowered.contains("timeout")
|| lowered.contains("deadlineexceeded")
|| lowered.contains("deadline exceeded")
|| lowered.contains("deadline has elapsed")
{
return FailureKind::Timeout;
}
if lowered.contains("connect")
|| lowered.contains("refused")
|| lowered.contains("unreachable")
|| lowered.contains("proxy")
|| lowered.contains("socks")
|| lowered.contains("dns")
{
return FailureKind::Unreachable;
}
if lowered.contains("reject") || lowered.contains("invalid") {
return FailureKind::Rejected;
}
FailureKind::Other
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IndexerAttempt {
pub unix_secs: u64,
pub host: crate::destination::Host,
pub route: AttemptRoute,
pub kind: AttemptKind,
pub millis: u64,
pub outcome: Result<(), FailureKind>,
pub fault_domain: Option<health::FaultDomain>,
}
pub(crate) fn now_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs())
.unwrap_or(0)
}
#[derive(Clone, Debug, Default)]
pub struct IndexerHistoryHandle {
attempts: sync::Arc<sync::Mutex<Vec<IndexerAttempt>>>,
health: sync::Arc<sync::Mutex<health::Health>>,
}
impl IndexerHistoryHandle {
#[cfg_attr(not(feature = "nym"), allow(dead_code))]
pub(crate) fn health(&self) -> &sync::Mutex<health::Health> {
&self.health
}
pub(crate) fn record(&self, attempt: &IndexerAttempt) {
self.health.lock().expect("health mutex").note(
&attempt.host,
attempt.outcome.is_err(),
attempt.fault_domain,
);
let mut attempts = self.attempts.lock().expect("attempts mutex");
if attempts.len() >= MAX_HISTORY_ATTEMPTS {
attempts.remove(0);
}
attempts.push(attempt.clone());
}
pub fn load(&self) -> Vec<IndexerAttempt> {
self.attempts.lock().expect("attempts mutex").clone()
}
}
#[cfg(test)]
mod tests {
use super::{
AttemptKind, AttemptRoute, FailureKind, IndexerAttempt, IndexerHistoryHandle,
MAX_HISTORY_ATTEMPTS,
};
use crate::destination::{Host, health};
fn an_attempt(host: &str, outcome: Result<(), FailureKind>) -> IndexerAttempt {
IndexerAttempt {
unix_secs: 1_700_000_000,
host: Host::of_host_str(host),
route: AttemptRoute::Mixnet,
kind: AttemptKind::Send,
millis: 1234,
fault_domain: outcome.is_err().then_some(health::FaultDomain::Destination),
outcome,
}
}
#[test]
fn attempts_read_back_in_the_order_recorded() {
let handle = IndexerHistoryHandle::default();
let ok = an_attempt("zec.rocks", Ok(()));
let failed = an_attempt("carover0.xyz", Err(FailureKind::Timeout));
handle.record(&ok);
handle.record(&failed);
assert_eq!(handle.load(), vec![ok, failed]);
}
#[test]
fn clones_share_one_store() {
let handle = IndexerHistoryHandle::default();
let clone = handle.clone();
clone.record(&an_attempt("zec.rocks", Ok(())));
assert_eq!(
handle.load().len(),
1,
"the original sees the clone's entry"
);
}
#[test]
fn the_store_is_bounded_and_keeps_the_newest() {
let handle = IndexerHistoryHandle::default();
let total = MAX_HISTORY_ATTEMPTS + 3;
for index in 0..total {
let mut attempt = an_attempt("zec.rocks", Ok(()));
attempt.unix_secs = index as u64;
handle.record(&attempt);
}
let loaded = handle.load();
assert_eq!(
loaded.len(),
MAX_HISTORY_ATTEMPTS,
"the store stays bounded"
);
assert_eq!(
loaded.last().expect("nonempty").unix_secs,
(total - 1) as u64,
"the newest entry survives"
);
assert!(
loaded.first().expect("nonempty").unix_secs > 0,
"the oldest entries are the ones dropped"
);
}
#[test]
fn recording_attributes_the_failure_to_the_host_in_health() {
let handle = IndexerHistoryHandle::default();
let host = Host::of_host_str("carover0.xyz");
let healthy = |handle: &IndexerHistoryHandle| {
handle
.health()
.lock()
.expect("health mutex")
.is_healthy(&host)
};
for _ in 0..health::UNHEALTHY_FAILURE_THRESHOLD - 1 {
handle.record(&an_attempt("carover0.xyz", Err(FailureKind::Unreachable)));
assert!(
healthy(&handle),
"one attributed failure short of the threshold holds"
);
}
handle.record(&an_attempt("carover0.xyz", Err(FailureKind::Unreachable)));
assert!(!healthy(&handle), "the threshold failure lands");
}
#[test]
fn an_elapsed_deadline_is_a_timeout() {
assert_eq!(
FailureKind::classify(
"transport to zec.rocks:443 failed (transport error: deadline has elapsed)"
),
FailureKind::Timeout
);
assert_eq!(
FailureKind::classify("deadline has elapsed"),
FailureKind::Timeout
);
}
#[test]
fn the_classifier_covers_the_failure_families() {
assert_eq!(
FailureKind::classify("transaction already queued for download"),
FailureKind::Queued
);
assert_eq!(
FailureKind::classify("nakednet connect timed out after 30s"),
FailureKind::Timeout
);
assert_eq!(
FailureKind::classify("status: DeadlineExceeded"),
FailureKind::Timeout
);
assert_eq!(
FailureKind::classify("SOCKS5 handshake failed"),
FailureKind::Unreachable
);
assert_eq!(
FailureKind::classify("connection refused"),
FailureKind::Unreachable
);
assert_eq!(
FailureKind::classify("transaction rejected: txid deadbeef"),
FailureKind::Rejected
);
assert_eq!(
FailureKind::classify("zebra said something novel"),
FailureKind::Other
);
}
}