use std::sync::Arc;
use std::sync::atomic::{AtomicU8, AtomicU32, Ordering};
use std::time::Duration;
use tokio::sync::watch;
use tokio::task::JoinHandle;
pub const INITIAL_BACKOFF_MS: u64 = 1_000;
pub const MAX_BACKOFF_MS: u64 = 60_000;
pub fn next_backoff_ms(current_ms: u64) -> u64 {
current_ms.saturating_mul(2).min(MAX_BACKOFF_MS)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Criticality {
Required,
Optional,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TaskState {
Running,
Restarting,
Failed,
ShutDown,
}
impl TaskState {
pub fn as_str(self) -> &'static str {
match self {
Self::Running => "running",
Self::Restarting => "restarting",
Self::Failed => "failed",
Self::ShutDown => "shut_down",
}
}
fn from_u8(v: u8) -> Self {
match v {
1 => Self::Restarting,
2 => Self::Failed,
3 => Self::ShutDown,
_ => Self::Running,
}
}
fn as_u8(self) -> u8 {
match self {
Self::Running => 0,
Self::Restarting => 1,
Self::Failed => 2,
Self::ShutDown => 3,
}
}
}
#[derive(Clone, Debug)]
pub struct TaskReport {
pub name: &'static str,
pub criticality: Criticality,
pub state: TaskState,
pub restarts: u32,
}
impl TaskReport {
pub fn blocks_readiness(&self) -> bool {
self.criticality == Criticality::Required && self.state == TaskState::Failed
}
pub fn is_degraded(&self) -> bool {
matches!(self.state, TaskState::Restarting | TaskState::Failed)
}
}
struct Slot {
name: &'static str,
criticality: Criticality,
state: AtomicU8,
restarts: AtomicU32,
}
impl Slot {
fn report(&self) -> TaskReport {
TaskReport {
name: self.name,
criticality: self.criticality,
state: TaskState::from_u8(self.state.load(Ordering::Relaxed)),
restarts: self.restarts.load(Ordering::Relaxed),
}
}
fn set(&self, state: TaskState) {
self.state.store(state.as_u8(), Ordering::Relaxed);
}
}
#[derive(Clone)]
pub struct Shutdown(watch::Receiver<bool>);
impl Shutdown {
pub fn is_signalled(&self) -> bool {
*self.0.borrow()
}
pub async fn signalled(&mut self) {
while !*self.0.borrow_and_update() {
if self.0.changed().await.is_err() {
return;
}
}
}
pub async fn sleep(&mut self, duration: Duration) -> bool {
tokio::select! {
_ = tokio::time::sleep(duration) => true,
_ = self.signalled() => false,
}
}
}
pub struct TaskRegistry {
shutdown_tx: watch::Sender<bool>,
slots: std::sync::Mutex<Vec<Arc<Slot>>>,
joins: std::sync::Mutex<Vec<(&'static str, JoinHandle<()>)>>,
}
impl Default for TaskRegistry {
fn default() -> Self {
Self::new()
}
}
impl TaskRegistry {
pub fn new() -> Self {
let (shutdown_tx, _) = watch::channel(false);
Self {
shutdown_tx,
slots: std::sync::Mutex::new(Vec::new()),
joins: std::sync::Mutex::new(Vec::new()),
}
}
pub fn shutdown_signal(&self) -> Shutdown {
Shutdown(self.shutdown_tx.subscribe())
}
fn register(&self, name: &'static str, criticality: Criticality) -> Arc<Slot> {
let slot = Arc::new(Slot {
name,
criticality,
state: AtomicU8::new(TaskState::Running.as_u8()),
restarts: AtomicU32::new(0),
});
lock(&self.slots).push(slot.clone());
slot
}
pub fn supervise<F, Fut>(&self, name: &'static str, criticality: Criticality, body: F)
where
F: Fn(Shutdown) -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
let slot = self.register(name, criticality);
let mut shutdown = self.shutdown_signal();
let join = tokio::spawn(async move {
let mut backoff_ms = INITIAL_BACKOFF_MS;
loop {
let attempt = tokio::spawn(body(shutdown.clone()));
let outcome = attempt.await;
if shutdown.is_signalled() {
slot.set(TaskState::ShutDown);
return;
}
match outcome {
Ok(()) => tracing::error!(
task = name,
backoff_ms,
"Background task returned before shutdown; restarting"
),
Err(e) => tracing::error!(
task = name,
backoff_ms,
error = %e,
"Background task died; restarting"
),
}
crate::metrics::record_error("background_task");
slot.set(TaskState::Restarting);
if !shutdown.sleep(Duration::from_millis(backoff_ms)).await {
slot.set(TaskState::ShutDown);
return;
}
backoff_ms = next_backoff_ms(backoff_ms);
slot.restarts.fetch_add(1, Ordering::Relaxed);
slot.set(TaskState::Running);
}
});
lock(&self.joins).push((name, join));
}
pub fn guard(&self, name: &'static str, criticality: Criticality) -> TaskGuard {
TaskGuard {
slot: self.register(name, criticality),
shutdown: self.shutdown_signal(),
}
}
pub fn report(&self) -> Vec<TaskReport> {
lock(&self.slots).iter().map(|s| s.report()).collect()
}
pub fn blocking_readiness(&self) -> Vec<&'static str> {
self.report()
.into_iter()
.filter(TaskReport::blocks_readiness)
.map(|r| r.name)
.collect()
}
pub fn signal_shutdown(&self) {
let _ = self.shutdown_tx.send(true);
}
pub async fn shutdown(&self, deadline: Duration) {
self.signal_shutdown();
let joins = std::mem::take(&mut *lock(&self.joins));
if joins.is_empty() {
return;
}
tracing::info!(tasks = joins.len(), "Stopping background tasks...");
let names: Vec<&'static str> = joins.iter().map(|(name, _)| *name).collect();
let all = futures::future::join_all(joins.into_iter().map(|(_, join)| join));
if tokio::time::timeout(deadline, all).await.is_err() {
let still_running: Vec<&'static str> = self
.report()
.into_iter()
.filter(|r| r.state != TaskState::ShutDown)
.map(|r| r.name)
.collect();
tracing::warn!(
deadline_secs = deadline.as_secs(),
registered = ?names,
still_running = ?still_running,
"Background tasks did not all stop within the shutdown deadline"
);
}
}
}
#[derive(Clone)]
pub struct TaskGuard {
slot: Arc<Slot>,
shutdown: Shutdown,
}
impl TaskGuard {
pub async fn run<Fut: Future<Output = ()>>(self, body: Fut) {
body.await;
}
}
impl Drop for TaskGuard {
fn drop(&mut self) {
if self.shutdown.is_signalled() {
self.slot.set(TaskState::ShutDown);
return;
}
self.slot.set(TaskState::Failed);
crate::metrics::record_error("background_task");
tracing::error!(
task = self.slot.name,
"Background task stopped before shutdown and cannot be restarted (it owns \
its queue's receiver, so the queue behind it is now closed)"
);
}
}
fn lock<T>(m: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|p| p.into_inner())
}
#[cfg(test)]
mod tests {
#![allow(clippy::panic)]
use super::*;
use std::sync::atomic::AtomicUsize;
fn state_of(registry: &TaskRegistry, name: &str) -> TaskState {
registry
.report()
.into_iter()
.find(|r| r.name == name)
.expect("the task is registered")
.state
}
#[tokio::test]
async fn a_panicking_guarded_task_is_recorded_and_blocks_readiness() {
let registry = TaskRegistry::new();
let guard = registry.guard("persistence", Criticality::Required);
let join = tokio::spawn(guard.run(async {
panic!("worker exploded");
}));
let _ = join.await;
assert_eq!(state_of(®istry, "persistence"), TaskState::Failed);
assert_eq!(registry.blocking_readiness(), vec!["persistence"]);
}
#[tokio::test]
async fn an_optional_task_degrades_health_without_blocking_readiness() {
let registry = TaskRegistry::new();
let guard = registry.guard("retention", Criticality::Optional);
let join = tokio::spawn(guard.run(async {}));
join.await.expect("clean return");
let report = registry.report();
assert_eq!(report[0].state, TaskState::Failed);
assert!(report[0].is_degraded());
assert!(registry.blocking_readiness().is_empty());
}
#[tokio::test]
async fn a_task_that_stops_on_the_signal_is_not_a_failure() {
let registry = TaskRegistry::new();
registry.supervise("looper", Criticality::Required, |mut shutdown| async move {
shutdown.signalled().await;
});
registry.shutdown(Duration::from_secs(5)).await;
assert_eq!(state_of(®istry, "looper"), TaskState::ShutDown);
assert!(registry.blocking_readiness().is_empty());
}
#[tokio::test(start_paused = true)]
async fn a_supervised_task_is_restarted_after_a_failure() {
let registry = TaskRegistry::new();
let attempts = Arc::new(AtomicUsize::new(0));
let seen = attempts.clone();
registry.supervise("flaky", Criticality::Optional, move |mut shutdown| {
let attempts = seen.clone();
async move {
let n = attempts.fetch_add(1, Ordering::SeqCst);
if n < 2 {
panic!("attempt {n} fails");
}
shutdown.signalled().await;
}
});
for _ in 0..40 {
tokio::time::advance(Duration::from_millis(200)).await;
tokio::task::yield_now().await;
}
assert_eq!(
attempts.load(Ordering::SeqCst),
3,
"two restarts, then it held"
);
assert_eq!(state_of(®istry, "flaky"), TaskState::Running);
assert_eq!(registry.report()[0].restarts, 2);
registry.shutdown(Duration::from_secs(5)).await;
}
#[tokio::test(start_paused = true)]
async fn shutdown_is_bounded_by_its_deadline() {
let registry = TaskRegistry::new();
registry.supervise("stuck", Criticality::Optional, |_shutdown| async move {
std::future::pending::<()>().await;
});
let started = tokio::time::Instant::now();
registry.shutdown(Duration::from_secs(2)).await;
assert!(started.elapsed() >= Duration::from_secs(2));
}
#[test]
fn the_backoff_doubles_and_is_capped() {
assert_eq!(next_backoff_ms(INITIAL_BACKOFF_MS), 2_000);
assert_eq!(next_backoff_ms(40_000), 60_000);
assert_eq!(next_backoff_ms(MAX_BACKOFF_MS), MAX_BACKOFF_MS);
assert_eq!(next_backoff_ms(u64::MAX), MAX_BACKOFF_MS);
}
}