#![forbid(unsafe_code)]
use std::future::Future;
use futures::StreamExt as _;
use futures::stream::FuturesUnordered;
use http::Uri;
use rand::Rng;
use rand::seq::SliceRandom;
use zingo_netutils::arm_race::{
LaunchPolicy, RESERVATION_CLUTCH_SIZE, RaceAction, RaceEvent, RaceState,
};
use zingo_netutils::time::TRANSMISSION_HEDGE_INTERVAL;
pub(crate) const MAX_TRANSMISSION_DESTINATIONS: usize = 6;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct DestinationAttempt<E> {
pub destination: String,
pub failure: E,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum EscalationError<E> {
NoIndexers,
AllFailed {
attempts: usize,
failures: Vec<DestinationAttempt<E>>,
},
}
impl<E: std::fmt::Display> std::fmt::Display for EscalationError<E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EscalationError::NoIndexers => write!(f, "the Destination list is empty"),
EscalationError::AllFailed { attempts, failures } => {
write!(
f,
"no indexer confirmed delivery after contacting {attempts} of them: "
)?;
for (i, attempt) in failures.iter().enumerate() {
if i > 0 {
write!(f, "; ")?;
}
write!(f, "{}: {}", attempt.destination, attempt.failure)?;
}
Ok(())
}
}
}
}
impl<E: std::fmt::Display + std::fmt::Debug> std::error::Error for EscalationError<E> {}
pub(crate) async fn escalating_transmit<A, F, E, R, P, T>(
indexers: &[Uri],
preferred: usize,
rng: &mut R,
cap: usize,
run_pull: A,
report: P,
) -> Result<T, EscalationError<E>>
where
A: Fn(Uri) -> F,
F: Future<Output = Result<T, E>>,
E: std::fmt::Display,
R: Rng + ?Sized,
P: Fn(String),
{
if indexers.is_empty() {
return Err(EscalationError::NoIndexers);
}
let mut order: Vec<usize> = (0..indexers.len()).collect();
order[preferred.min(indexers.len())..].shuffle(rng);
let host_of = |arm: usize| {
let indexer = &indexers[order[arm]];
indexer
.host()
.map(str::to_string)
.unwrap_or_else(|| indexer.to_string())
};
let mut race = RaceState::new(
order.len(),
cap,
LaunchPolicy::Hedged {
max_parallel: RESERVATION_CLUTCH_SIZE,
hedge_interval: TRANSMISSION_HEDGE_INTERVAL,
},
);
let mut pulls = FuturesUnordered::new();
let mut lost = false;
let mut failures: Vec<DestinationAttempt<E>> = Vec::new();
let mut hedge_timer: Option<std::pin::Pin<Box<tokio::time::Sleep>>> = None;
let apply = |actions: Vec<RaceAction>,
pulls: &mut FuturesUnordered<_>,
lost: &mut bool,
hedge_timer: &mut Option<std::pin::Pin<Box<tokio::time::Sleep>>>| {
for action in actions {
match action {
RaceAction::Launch { arm } => {
let pull = run_pull(indexers[order[arm]].clone());
pulls.push(async move { (arm, pull.await) });
}
RaceAction::SetHedgeTimer(interval) => {
*hedge_timer = Some(Box::pin(tokio::time::sleep(interval)));
}
RaceAction::GiveUp => *lost = true,
}
}
};
enum Wake<T, E> {
Pull(usize, Result<T, E>),
HedgeElapsed,
Drained,
}
apply(race.start(), &mut pulls, &mut lost, &mut hedge_timer);
report(race.progress().to_string());
while !lost {
let wake = {
let timer = async {
match hedge_timer.as_mut() {
Some(sleep) => sleep.as_mut().await,
None => std::future::pending().await,
}
};
tokio::select! {
biased;
next = pulls.next() => match next {
Some((arm, outcome)) => Wake::Pull(arm, outcome),
None => Wake::Drained,
},
() = timer => Wake::HedgeElapsed,
}
};
match wake {
Wake::Drained => break,
Wake::Pull(_, Ok(server_txid)) => return Ok(server_txid),
Wake::Pull(arm, Err(error)) => {
apply(
race.on_event(RaceEvent::PullFailed {
arm,
error: error.to_string(),
}),
&mut pulls,
&mut lost,
&mut hedge_timer,
);
failures.push(DestinationAttempt {
destination: host_of(arm),
failure: error,
});
report(race.progress().to_string());
}
Wake::HedgeElapsed => {
hedge_timer = None;
apply(
race.on_event(RaceEvent::HedgeElapsed),
&mut pulls,
&mut lost,
&mut hedge_timer,
);
report(race.progress().to_string());
}
}
}
Err(EscalationError::AllFailed {
attempts: race.launched(),
failures,
})
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Mutex;
use rand::SeedableRng;
use rand::rngs::StdRng;
use super::*;
fn uris(hosts: &[&str]) -> Vec<Uri> {
hosts
.iter()
.map(|h| format!("https://{h}:443").parse().unwrap())
.collect()
}
fn host_of(indexer: &Uri) -> String {
indexer.host().expect("indexer uri has a host").to_string()
}
struct MockArms {
scripts: HashMap<String, Result<String, String>>,
hang: std::collections::HashSet<String>,
contacted: Mutex<Vec<String>>,
}
impl MockArms {
fn new(scripts: &[(&str, Result<&str, &str>)]) -> Self {
let scripts = scripts
.iter()
.map(|(host, result)| {
let owned = match result {
Ok(txid) => Ok((*txid).to_string()),
Err(message) => Err((*message).to_string()),
};
((*host).to_string(), owned)
})
.collect();
MockArms {
scripts,
hang: std::collections::HashSet::new(),
contacted: Mutex::new(Vec::new()),
}
}
fn hanging(hosts: &[&str]) -> Self {
MockArms {
scripts: HashMap::new(),
hang: hosts.iter().map(|h| (*h).to_string()).collect(),
contacted: Mutex::new(Vec::new()),
}
}
fn run(&self, indexer: Uri) -> impl Future<Output = Result<String, String>> + '_ {
let host = host_of(&indexer);
self.contacted.lock().unwrap().push(host.clone());
let hang = self.hang.contains(&host);
let scripted = self
.scripts
.get(&host)
.cloned()
.unwrap_or_else(|| Err(format!("unscripted host {host}")));
async move {
if hang {
std::future::pending::<()>().await;
}
scripted
}
}
fn contacted(&self) -> Vec<String> {
self.contacted.lock().unwrap().clone()
}
}
fn shuffled_order(len: usize, seed: u64) -> Vec<usize> {
let mut order: Vec<usize> = (0..len).collect();
order.shuffle(&mut StdRng::seed_from_u64(seed));
order
}
#[tokio::test]
async fn empty_list_is_an_error() {
let mock = MockArms::new(&[]);
let err = escalating_transmit(
&[],
0,
&mut StdRng::seed_from_u64(1),
6,
|u| mock.run(u),
|_| (),
)
.await
.expect_err("no indexers");
assert!(matches!(err, EscalationError::NoIndexers));
assert!(mock.contacted().is_empty());
}
#[tokio::test]
async fn a_confirming_first_pull_contacts_exactly_one_destination() {
let indexers = uris(&["a", "b", "c", "d"]);
let mock = MockArms::new(&[
("a", Ok("txid")),
("b", Ok("txid")),
("c", Ok("txid")),
("d", Ok("txid")),
]);
let ok = escalating_transmit(
&indexers,
0,
&mut StdRng::seed_from_u64(7),
6,
|u| mock.run(u),
|_| (),
)
.await
.expect("first pick accepts");
assert_eq!(ok, "txid");
assert_eq!(mock.contacted().len(), 1, "only the first pull ran");
}
#[tokio::test]
async fn a_failure_widens_by_exactly_one_replacement() {
let hosts = ["a", "b", "c", "d", "e"];
let indexers = uris(&hosts);
let seed = 20;
let order = shuffled_order(hosts.len(), seed);
let first = hosts[order[0]];
let scripts: Vec<(&str, Result<&str, &str>)> = hosts
.iter()
.map(|&h| {
if h == first {
(h, Err("suppressed"))
} else {
(h, Ok("txid"))
}
})
.collect();
let mock = MockArms::new(&scripts);
let ok = escalating_transmit(
&indexers,
0,
&mut StdRng::seed_from_u64(seed),
6,
|u| mock.run(u),
|_| (),
)
.await
.expect("the replacement accepts");
assert_eq!(ok, "txid");
assert_eq!(
mock.contacted().len(),
2,
"one failure buys exactly one replacement"
);
assert_eq!(
mock.contacted()[0],
first,
"the first pull is the single pick"
);
}
#[tokio::test]
async fn all_failing_stops_at_the_cap() {
let hosts = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
let indexers = uris(&hosts);
let scripts: Vec<(&str, Result<&str, &str>)> =
hosts.iter().map(|&h| (h, Err("suppressed"))).collect();
let mock = MockArms::new(&scripts);
let err = escalating_transmit(
&indexers,
0,
&mut StdRng::seed_from_u64(3),
MAX_TRANSMISSION_DESTINATIONS,
|u| mock.run(u),
|_| (),
)
.await
.expect_err("no indexer confirms");
match err {
EscalationError::AllFailed { attempts, .. } => assert_eq!(attempts, 6),
other => panic!("expected AllFailed, got {other:?}"),
}
let contacted = mock.contacted();
assert_eq!(contacted.len(), 6, "capped at six Destinations");
let distinct: std::collections::HashSet<_> = contacted.iter().collect();
assert_eq!(distinct.len(), 6, "no indexer is contacted twice");
}
#[tokio::test]
async fn cap_is_bounded_by_the_list_length() {
let hosts = ["a", "b", "c"];
let indexers = uris(&hosts);
let scripts: Vec<(&str, Result<&str, &str>)> =
hosts.iter().map(|&h| (h, Err("down"))).collect();
let mock = MockArms::new(&scripts);
let err = escalating_transmit(
&indexers,
0,
&mut StdRng::seed_from_u64(5),
6,
|u| mock.run(u),
|_| (),
)
.await
.expect_err("all down");
match err {
EscalationError::AllFailed { attempts, .. } => assert_eq!(attempts, 3),
other => panic!("expected AllFailed, got {other:?}"),
}
assert_eq!(mock.contacted().len(), 3);
}
#[tokio::test]
async fn a_failed_escalation_records_every_destination_and_its_failure() {
let hosts = ["a", "b", "c"];
let indexers = uris(&hosts);
let scripts: Vec<(&str, Result<&str, &str>)> =
hosts.iter().map(|&h| (h, Err("suppressed"))).collect();
let mock = MockArms::new(&scripts);
let err = escalating_transmit(
&indexers,
0,
&mut StdRng::seed_from_u64(5),
6,
|u| mock.run(u),
|_| (),
)
.await
.expect_err("all suppressed");
let rendered = err.to_string();
let EscalationError::AllFailed { failures, .. } = err else {
panic!("expected AllFailed");
};
for host in hosts {
assert!(
failures
.iter()
.any(|a| a.destination == host && a.failure == "suppressed"),
"the typed attempts must record {host}: {failures:?}"
);
assert!(
rendered.contains(&format!("{host}: suppressed")),
"the rendering must name {host}: {rendered}"
);
}
}
#[tokio::test]
async fn a_failing_transmission_narrates_its_escalation() {
let hosts = ["a", "b", "c", "d", "e", "f"];
let indexers = uris(&hosts);
let scripts: Vec<(&str, Result<&str, &str>)> =
hosts.iter().map(|&h| (h, Err("suppressed"))).collect();
let mock = MockArms::new(&scripts);
let lines = Mutex::new(Vec::<String>::new());
let _ = escalating_transmit(
&indexers,
0,
&mut StdRng::seed_from_u64(11),
MAX_TRANSMISSION_DESTINATIONS,
|u| mock.run(u),
|line| lines.lock().expect("narration mutex poisoned").push(line),
)
.await
.expect_err("all suppressed");
let lines = lines.into_inner().expect("narration mutex poisoned");
assert!(
lines
.first()
.is_some_and(|line| line.contains("1 in flight")),
"the opening must narrate its single pull: {lines:?}"
);
assert!(
lines.iter().any(|line| line.contains("attempt 6/6")),
"the walk to the cap must be narrated: {lines:?}"
);
}
#[tokio::test(start_paused = true)]
async fn silence_alone_widens_the_race_by_one_pull() {
let hosts = ["a", "b"];
let indexers = uris(&hosts);
let mock = MockArms::hanging(&hosts);
const OBSERVATION_BUDGET: std::time::Duration =
TRANSMISSION_HEDGE_INTERVAL.saturating_mul(4);
let lines = Mutex::new(Vec::<String>::new());
let raced = tokio::time::timeout(
OBSERVATION_BUDGET,
escalating_transmit(
&indexers,
0,
&mut StdRng::seed_from_u64(2),
MAX_TRANSMISSION_DESTINATIONS,
|u| mock.run(u),
|line| lines.lock().expect("narration mutex poisoned").push(line),
),
)
.await;
assert!(raced.is_err(), "hanging pulls must never resolve the race");
let lines = lines.into_inner().expect("narration mutex poisoned");
assert!(
lines.iter().any(|line| line.contains("2 in flight")),
"the hedge must widen the race on silence: {lines:?}"
);
assert_eq!(
mock.contacted().len(),
2,
"the silence hedge contacts exactly the second Destination"
);
}
#[tokio::test]
async fn a_seed_picks_the_same_first_destination_every_time() {
let hosts = ["a", "b", "c", "d", "e"];
let indexers = uris(&hosts);
let scripts: Vec<(&str, Result<&str, &str>)> =
hosts.iter().map(|&h| (h, Err("down"))).collect();
let first_run = MockArms::new(&scripts);
let _ = escalating_transmit(
&indexers,
0,
&mut StdRng::seed_from_u64(99),
6,
|u| first_run.run(u),
|_| (),
)
.await;
let second_run = MockArms::new(&scripts);
let _ = escalating_transmit(
&indexers,
0,
&mut StdRng::seed_from_u64(99),
6,
|u| second_run.run(u),
|_| (),
)
.await;
assert_eq!(
first_run.contacted()[0],
second_run.contacted()[0],
"the seed fixes the opening Destination"
);
}
#[tokio::test]
async fn the_preferred_head_opens_the_race_in_order() {
let hosts = ["a", "b", "c", "d", "e"];
let indexers = uris(&hosts);
let scripts: Vec<(&str, Result<&str, &str>)> = vec![
("a", Err("suppressed")),
("b", Ok("txid")),
("c", Ok("txid")),
("d", Ok("txid")),
("e", Ok("txid")),
];
for seed in 0..8 {
let mock = MockArms::new(&scripts);
let ok = escalating_transmit(
&indexers,
2,
&mut StdRng::seed_from_u64(seed),
MAX_TRANSMISSION_DESTINATIONS,
|u| mock.run(u),
|_| (),
)
.await
.expect("the second preferred indexer accepts");
assert_eq!(ok, "txid");
assert_eq!(mock.contacted(), vec!["a", "b"], "seed {seed}");
}
}
}