foxtive-supervisor 0.2.0

Foxtive Supervisor - Advanced task supervision for Rust applications
Documentation

🦊 Foxtive Supervisor

Because tasks crash. We help them get back up.

A Rust supervision library that keeps your async tasks running, even when things go wrong. Think of it as a caring parent for your background jobs.

Why?

In production, things fail:

  • Network hiccups
  • Temporary resource exhaustion
  • Race conditions you didn't catch in testing
  • That one edge case from 3am

Foxtive Supervisor automatically restarts your tasks with configurable policies, handles panics gracefully, and gives you hooks to observe what's happening.

Installation

[dependencies]
foxtive-supervisor = "0.2"
tokio = { version = "1", features = ["full"] }
anyhow = "1"
async-trait = "0.1"

Quick Start

use foxtive_supervisor::{Supervisor, SupervisedTask};

struct MyWorker;

#[async_trait::async_trait]
impl SupervisedTask for MyWorker {
    fn id(&self) -> &'static str {
        "my-worker"
    }

    async fn run(&self) -> anyhow::Result<()> {
        process_messages().await?;
        Ok(())
    }
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    Supervisor::new()
        .add(MyWorker)
        .start_and_wait_any()
        .await?;

    Ok(())
}

That's it. Your task will automatically restart if it fails.

Task Identity

Every task declares a unique id. This is used for logging, monitoring, and dependency resolution:

impl SupervisedTask for MyTask {
    fn id(&self) -> &'static str {
        "my-task"   // must be unique across registered tasks
    }

    // Optional: human-readable name (defaults to id)
    fn name(&self) -> String {
        "My Task (friendly name)".to_string()
    }
}

Task Dependencies

Declare which tasks must complete their setup() phase before yours starts:

impl SupervisedTask for KafkaConsumer {
    fn id(&self) -> &'static str {
        "kafka-consumer"
    }

    fn dependencies(&self) -> &'static [&'static str] {
        &["database", "redis"]  // waits for these task IDs to finish setup
    }

    async fn run(&self) -> anyhow::Result<()> {
        // guaranteed: database and redis are ready
        Ok(())
    }
}

The supervisor validates the dependency graph at startup — unknown IDs and circular dependencies are caught immediately with a clear error before any task spawns.

Prerequisites

Need to wait for something external before any task starts? Use prerequisites:

// Wait for your HTTP server to bind before starting consumers
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();

// somewhere in your server startup code:
// ready_tx.send(()).unwrap();

Supervisor::new()
    .require("http-server-bound", async move {
        ready_rx.await.map_err(|_| anyhow::anyhow!("server never signalled ready"))
    })
    .add(MyConsumer)
    .start_and_wait_any()
    .await?;

Prerequisites run sequentially before any task is spawned. If one fails, startup is aborted immediately.

Restart Policies

Control when tasks restart:

impl SupervisedTask for MyTask {
    fn restart_policy(&self) -> RestartPolicy {
        RestartPolicy::Never           // run once
        RestartPolicy::MaxAttempts(5)  // try up to 5 times, then give up
        RestartPolicy::Always          // keep trying forever (default)
    }
}

Backoff Strategies

Control how long to wait between restarts:

use std::time::Duration;

impl SupervisedTask for MyTask {
    fn backoff_strategy(&self) -> BackoffStrategy {
        BackoffStrategy::fixed(Duration::from_secs(5))   // same wait each time
        BackoffStrategy::exponential()                    // 2s -> 4s -> 8s -> ... 60s (default)
        BackoffStrategy::linear()                         // 5s -> 10s -> 15s -> ...
        BackoffStrategy::fibonacci_with_default()         // 1s -> 1s -> 2s -> 3s -> 5s -> ...
        BackoffStrategy::custom(|attempt| Duration::from_secs(attempt as u64 * 3))
    }
}

Lifecycle Hooks

Get notified about what's happening:

#[async_trait::async_trait]
impl SupervisedTask for MyTask {
    async fn setup(&self) -> anyhow::Result<()> {
        // run once before first attempt — connections, topology, validation
        Ok(())
    }

    async fn cleanup(&self) {
        // run after task stops (success, failure, or panic)
    }

    async fn on_restart(&self, attempt: usize) {
        // called before each restart (not on first attempt)
    }

    async fn on_error(&self, error: &str, attempt: usize) {
        // called when run() returns Err
    }

    async fn on_panic(&self, msg: &str, attempt: usize) {
        // called when run() panics
    }

    async fn should_restart(&self, attempt: usize, error: &str) -> bool {
        !error.contains("Unauthorized") // return false to prevent restart
    }

    async fn on_shutdown(&self) {
        // called during graceful shutdown
    }
}

Managing Multiple Tasks

Use the Supervisor builder or TaskRuntime directly:

// Builder style
Supervisor::new()
    .add(DatabaseWorker)
    .add(ApiServer)
    .add(BackgroundJob)
    .start_and_wait_any()
    .await?;

// Mixed types (boxed or Arc)
Supervisor::new()
    .add_boxed(Box::new(TaskA::new()))
    .add_boxed(Box::new(TaskB::new()))
    .start_and_wait_all()
    .await?;

// Manual runtime control
let mut runtime = TaskRuntime::new();
runtime.register(DatabaseWorker);
runtime.register(ApiServer);
runtime.start_all().await?;

let result = runtime.wait_any().await;
println!("Task '{}' stopped: {:?}", result.task_name, result.final_status);

Graceful Shutdown

Handle SIGTERM/SIGINT properly:

use tokio::signal;

let mut runtime = Supervisor::new()
    .add(MyTask)
    .start()
    .await?;

tokio::select! {
    _ = signal::ctrl_c() => {
        println!("Shutting down...");
        runtime.shutdown().await;
    }
    result = runtime.wait_any() => {
        println!("Task terminated: {:?}", result);
    }
}

Real-World Example

use foxtive_supervisor::*;
use std::time::Duration;

struct DatabasePool;
struct KafkaConsumer { topic: String }

#[async_trait::async_trait]
impl SupervisedTask for DatabasePool {
    fn id(&self) -> &'static str { "database" }

    async fn setup(&self) -> anyhow::Result<()> {
        // establish pool
        Ok(())
    }

    async fn run(&self) -> anyhow::Result<()> {
        // keep pool alive
        Ok(())
    }
}

#[async_trait::async_trait]
impl SupervisedTask for KafkaConsumer {
    fn id(&self) -> &'static str { "kafka-consumer" }

    // won't start until DatabasePool setup() completes
    fn dependencies(&self) -> &'static [&'static str] {
        &["database"]
    }

    fn restart_policy(&self) -> RestartPolicy { RestartPolicy::Always }

    fn backoff_strategy(&self) -> BackoffStrategy {
        BackoffStrategy::exponential_custom(Duration::from_secs(1), Duration::from_secs(30))
    }

    async fn run(&self) -> anyhow::Result<()> {
        loop {
            let message = fetch_message(&self.topic).await?;
            process(message).await?;
        }
    }

    async fn on_error(&self, error: &str, attempt: usize) {
        tracing::error!(topic = %self.topic, attempt, error, "Consumer failed");
    }

    async fn cleanup(&self) {
        // close Kafka connection
    }
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    Supervisor::new()
        .add(DatabasePool)
        .add(KafkaConsumer { topic: "orders".into() })
        .start_and_wait_any()
        .await?;

    Ok(())
}

Error Handling

The supervisor provides structured error handling through the SupervisorError enum:

use foxtive_supervisor::{Supervisor, SupervisorError};

async fn start_supervisor() -> Result<(), SupervisorError> {
    let mut supervisor = Supervisor::new();
    
    // This might fail with structured errors
    supervisor.start().await?;
    
    Ok(())
}

// Pattern matching for specific error handling
match start_supervisor().await {
    Err(SupervisorError::DependencyValidation { task_id, dependency_id, reason }) => {
        eprintln!("Task '{}' has invalid dependency '{}': {:?}", task_id, dependency_id, reason);
    }
    Err(SupervisorError::CircularDependency { task_id, dependency_id }) => {
        eprintln!("Circular dependency detected: '{}' -> '{}'", task_id, dependency_id);
    }
    Err(e) => {
        eprintln!("Supervisor failed: {}", e);
    }
    Ok(()) => println!("Supervisor started successfully"),
}

Error Categories

  • Configuration Errors: DependencyValidation, CircularDependency, InvalidConfiguration
  • Runtime Errors: PrerequisiteFailed, SetupFailed, DependencySetupFailed
  • Execution Errors: TaskExecutionFailed, TaskPanicked, MaxAttemptsReached, RestartPrevented
  • System Errors: RuntimeFailure, InternalError

Supervision Results

Every task returns a SupervisionResult:

pub struct SupervisionResult {
    pub task_name: String,
    pub id: String,
    pub total_attempts: usize,
    pub final_status: SupervisionStatus,
}

pub enum SupervisionStatus {
    CompletedNormally,    // task finished successfully
    MaxAttemptsReached,   // hit restart limit
    RestartPrevented,     // should_restart() returned false
    SetupFailed,          // setup() failed
    DependencyFailed,     // an upstream dependency's setup failed
    ManuallyStopped,      // policy said stop, or task was aborted
}

When to Use This

✅ Great for:

  • Background workers that should keep running
  • Message consumers (Kafka, RabbitMQ, etc.)
  • Database connection pools
  • Health check loops
  • Webhook processors
  • Any "run forever" service

❌ Not for:

  • HTTP request handlers (use your web framework's middleware)
  • One-off scripts
  • Tasks that should fail fast

Philosophy

We believe error handling should be:

  • Explicit: You decide the restart policy
  • Observable: Hooks show you what's happening
  • Flexible: Customize behavior per task
  • Forgiving: Panics don't crash your app

Tasks fail. That's okay. We've got your back.

License

MIT


Built with 🦊 by the Foxtive team. Made for humans who write Rust.