use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::watch;
use crate::route::{HealthState, RouteHandle};
const DEFAULT_BACKOFF_MIN: Duration = Duration::from_millis(500);
const DEFAULT_BACKOFF_MAX: Duration = Duration::from_secs(30);
const DEFAULT_BACKOFF_FACTOR: f64 = 2.0;
#[derive(Debug, Clone)]
pub struct Backoff {
min: Duration,
max: Duration,
factor: f64,
current: Duration,
}
impl Backoff {
pub fn new(min: Duration, max: Duration, factor: f64) -> Self {
Backoff {
min,
max,
factor,
current: min,
}
}
pub fn production_default() -> Self {
Backoff::new(
DEFAULT_BACKOFF_MIN,
DEFAULT_BACKOFF_MAX,
DEFAULT_BACKOFF_FACTOR,
)
}
#[allow(clippy::should_implement_trait)]
pub fn next(&mut self) -> Duration {
let delay = self.current;
let grown = self.current.mul_f64(self.factor);
self.current = grown.min(self.max);
delay
}
pub fn reset(&mut self) {
self.current = self.min;
}
}
fn record_route_up(name: &str, state: HealthState) {
let up = if matches!(state, HealthState::Live) {
1.0
} else {
0.0
};
metrics::gauge!(crate::prometheus::ROUTE_UP, "route" => name.to_string()).set(up);
}
fn record_reconnect(name: &str) {
metrics::counter!(crate::prometheus::SOURCE_RECONNECTS_TOTAL, "route" => name.to_string())
.increment(1);
}
#[tracing::instrument(
name = "route",
skip(attempt, route_handle, backoff, name, shutdown),
fields(route = %name)
)]
pub async fn supervise_driver<F, Fut>(
mut attempt: F,
route_handle: Arc<RouteHandle>,
mut backoff: Backoff,
name: String,
mut shutdown: watch::Receiver<bool>,
) where
F: FnMut(Arc<RouteHandle>) -> Fut + Send + 'static,
Fut: Future<Output = crate::Result<()>> + Send,
{
tracing::info!("connecting");
route_handle.set_health(HealthState::Connecting);
record_route_up(&name, HealthState::Connecting);
let mut attempt_no: u64 = 0;
loop {
if *shutdown.borrow() {
break;
}
route_handle.set_health(HealthState::Connecting);
let result = attempt(route_handle.clone()).await;
let reached_live = route_handle.health() == HealthState::Live;
match result {
Ok(()) if reached_live => tracing::info!("ingest ended after being live"),
Ok(()) => {
attempt_no += 1;
tracing::warn!(attempt = attempt_no, "ended before ever becoming live");
}
Err(e) if reached_live => tracing::warn!(error = %e, "pipeline stopped"),
Err(e) => {
attempt_no += 1;
tracing::warn!(error = %e, attempt = attempt_no, "failed to connect");
}
}
if reached_live {
attempt_no = 0;
backoff.reset();
}
route_handle.set_health(HealthState::Reconnecting);
record_route_up(&name, HealthState::Reconnecting);
record_reconnect(&name);
if *shutdown.borrow() {
break;
}
let delay = backoff.next();
tracing::warn!(
delay_ms = delay.as_millis() as u64,
attempt = attempt_no,
"reconnecting after backoff"
);
tokio::select! {
() = tokio::time::sleep(delay) => {}
_ = shutdown.changed() => {
break;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn tiny_backoff() -> Backoff {
Backoff::new(Duration::from_millis(1), Duration::from_millis(20), 2.0)
}
async fn wait_until(timeout: Duration, mut f: impl FnMut() -> bool) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
loop {
if f() {
return true;
}
if tokio::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(Duration::from_millis(1)).await;
}
}
#[test]
fn backoff_grows_and_caps() {
let mut b = Backoff::new(Duration::from_millis(10), Duration::from_millis(100), 2.0);
assert_eq!(b.next(), Duration::from_millis(10));
assert_eq!(b.next(), Duration::from_millis(20));
assert_eq!(b.next(), Duration::from_millis(40));
assert_eq!(b.next(), Duration::from_millis(80));
assert_eq!(b.next(), Duration::from_millis(100));
assert_eq!(b.next(), Duration::from_millis(100), "stays capped");
}
#[test]
fn backoff_reset_returns_to_min() {
let mut b = Backoff::new(Duration::from_millis(10), Duration::from_millis(100), 2.0);
let _ = b.next();
let _ = b.next();
b.reset();
assert_eq!(
b.next(),
Duration::from_millis(10),
"back to min after reset"
);
}
fn flaky_attempt(
fail_times: usize,
call_count: Arc<AtomicUsize>,
) -> impl FnMut(
Arc<RouteHandle>,
) -> std::pin::Pin<Box<dyn Future<Output = crate::Result<()>> + Send>>
+ Send
+ 'static {
move |route_handle: Arc<RouteHandle>| {
let call_count = call_count.clone();
Box::pin(async move {
let n = call_count.fetch_add(1, Ordering::SeqCst);
if n < fail_times {
return Err(crate::MultimuxError::Connect {
reason: "flaky attempt: simulated failure".into(),
});
}
route_handle.set_health(HealthState::Live);
tokio::time::sleep(Duration::from_millis(20)).await;
Ok(())
})
}
}
#[tokio::test]
async fn reconnects_after_a_failing_attempt_reaches_live_and_retries_again_after_ending() {
let route = Arc::new(RouteHandle::new(1.0, 500, 8));
let call_count = Arc::new(AtomicUsize::new(0));
let attempt = flaky_attempt(1, call_count.clone());
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let handle = tokio::spawn(supervise_driver(
attempt,
route.clone(),
tiny_backoff(),
"test-route".to_string(),
shutdown_rx,
));
let reached_live = wait_until(Duration::from_secs(2), || {
route.health() == HealthState::Live
})
.await;
assert!(
reached_live,
"route must reach Live after one failing attempt"
);
let retried_again = wait_until(Duration::from_secs(2), || {
call_count.load(Ordering::SeqCst) >= 3
})
.await;
assert!(
retried_again,
"attempt must be called again after a live attempt ends"
);
shutdown_tx.send(true).unwrap();
tokio::time::timeout(Duration::from_secs(1), handle)
.await
.expect("supervise_driver returns promptly on shutdown")
.expect("supervise_driver task did not panic");
}
#[tokio::test]
async fn shutdown_stops_supervise_driver_promptly_mid_backoff() {
let route = Arc::new(RouteHandle::new(1.0, 500, 8));
let call_count = Arc::new(AtomicUsize::new(0));
let attempt = flaky_attempt(usize::MAX, call_count);
let backoff = Backoff::new(Duration::from_secs(10), Duration::from_secs(30), 2.0);
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let handle = tokio::spawn(supervise_driver(
attempt,
route,
backoff,
"test-route".to_string(),
shutdown_rx,
));
tokio::time::sleep(Duration::from_millis(20)).await;
shutdown_tx.send(true).unwrap();
tokio::time::timeout(Duration::from_millis(500), handle)
.await
.expect("supervise_driver must return promptly on shutdown, not after the 10s backoff")
.expect("supervise_driver task did not panic");
}
}