taskvisor 0.5.0

Task supervisor for Tokio: restarts background tasks on failure with exponential backoff and jitter, graceful shutdown, and lifecycle events
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
# Taskvisor

[![Crates.io](https://img.shields.io/crates/v/taskvisor.svg)](https://crates.io/crates/taskvisor)
[![docs.rs](https://docs.rs/taskvisor/badge.svg)](https://docs.rs/taskvisor)
[![Minimum Rust 1.90](https://img.shields.io/badge/rust-1.90%2B-orange.svg)](https://rust-lang.org)
[![Apache 2.0](https://img.shields.io/badge/license-Apache2.0-blue.svg)](./LICENSE)

> Task supervisor for Tokio with backoff, graceful shutdown, and lifecycle events.

You write the task as a plain async fn. Taskvisor keeps it alive: it restarts tasks on failure with backoff, stops everything cleanly on shutdown, and emits typed lifecycle events for observability.

## The loop you stop writing

Every long-running service has this code somewhere:

```rust,ignore
// The hand-rolled way: tokio::spawn + retry loop.
tokio::spawn(async move {
    loop {
        match run_worker().await {
            Ok(()) => break,
            Err(e) => {
                eprintln!("worker failed: {e}; retrying in 1s");
                tokio::time::sleep(Duration::from_secs(1)).await;
            }
        }
    }
});
// No backoff. No jitter. No graceful shutdown. No visibility.
```

With taskvisor, the same worker needs one line:

```rust,ignore
sup.run(vec![TaskSpec::restartable(worker)]).await?;
// Restart on failure, backoff, Ctrl+C handling: included.
```

## Quick start

```toml
[dependencies]
taskvisor = "0.5"
tokio = { version = "1", features = ["full"] }
```

A worker that polls every 5 seconds and stops cleanly on Ctrl+C. If it returns a retryable error, taskvisor restarts it:

```rust,no_run
use std::time::Duration;
use taskvisor::prelude::*;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let worker = TaskFn::arc("worker", |ctx| async move {
        loop {
            // Resolves to Err(TaskError::Canceled) on shutdown.
            // Canceled is a clean stop, not a failure.
            ctx.run_until_cancelled(tokio::time::sleep(Duration::from_secs(5)))
                .await?;
            println!("[worker] polling...");
        }
    });

    let sup = Supervisor::new(SupervisorConfig::default(), vec![]);
    sup.run(vec![TaskSpec::restartable(worker)]).await?;
    Ok(())
}
```

No signal handling, no retry loop, no type annotations on the closure.

## See it recover from failure

A task fails twice, then succeeds. Taskvisor retries it with backoff and publishes an event for every step. A small subscriber prints each event:

```rust,no_run
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use taskvisor::prelude::*;

/// Prints every lifecycle event, using stable machine-readable labels.
struct Printer;
impl Subscribe for Printer {
    fn on_event(&self, ev: &Event) {
        if let Some(task) = ev.task.as_deref() {
            println!("  {} (task={task})", ev.kind.as_label());
        }
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let attempts = Arc::new(AtomicU32::new(0));
    let flaky = TaskFn::arc("flaky", move |_ctx| {
        let attempts = Arc::clone(&attempts);
        async move {
            let n = attempts.fetch_add(1, Ordering::Relaxed) + 1;
            if n < 3 {
                return Err(TaskError::fail(format!("boom #{n}")));
            }
            Ok(()) // third attempt succeeds
        }
    });

    let spec = TaskSpec::restartable(flaky)
        .with_backoff(BackoffPolicy::constant(Duration::from_millis(50)));

    let subs: Vec<Arc<dyn Subscribe>> = vec![Arc::new(Printer)];
    Supervisor::new(SupervisorConfig::default(), subs)
        .run(vec![spec])
        .await?;
    Ok(())
}
```

```text
# output:
  task_add_requested (task=flaky)
  task_added (task=flaky)
  task_starting (task=flaky)        # attempt 1
  task_failed (task=flaky)
  backoff_scheduled (task=flaky)    # wait 50ms, then retry
  task_starting (task=flaky)        # attempt 2
  task_failed (task=flaky)
  backoff_scheduled (task=flaky)
  task_starting (task=flaky)        # attempt 3
  task_stopped (task=flaky)         # success
  actor_exhausted (task=flaky)      # task succeeded, no more restarts
  task_removed (task=flaky)
```

In the output, `actor` means taskvisor's internal per-task runner, not an actor-model actor.

## Why taskvisor?

Restart and backoff are the baseline. Taskvisor also gives you:

- **A final answer for watched tasks.** Await one result: done, failed, or canceled. Delivered even when events are dropped under load.
- **Typed lifecycle events.** Lifecycle transitions are emitted to a best-effort event bus. A `tracing` bridge is built in.
- **Admission control.** Tasks can share a named slot. A rule decides what happens when the slot is busy: queue, replace, or drop the new task.
- **Task modes and restart policies.** Run once, on failure, always, or periodically.
- **Backoff with jitter.** Exponential or constant, with a cap and a floor.
- **Limits.** Per-attempt timeout, retry budget, global concurrency cap.

Taskvisor is not a replacement for Tokio or Tower.
It works one level higher: you write the task, taskvisor runs it and tells you what happened.

It is also not an actor framework: no addressable actors, no mailboxes, no message passing.
Your existing `async fn` is the unit of supervision.

## When to use taskvisor

Use it when you have **resident background tasks** that must stay up for the life of the process: queue consumers, pollers, sync loops, connection keepers, periodic jobs.
Taskvisor restarts them on failure and emits lifecycle events for observability. You can add, remove, and await tasks at runtime.

**Not the right tool if:**

| You want…                                  | Use instead                                                                                     |
|--------------------------------------------|-------------------------------------------------------------------------------------------------|
| To retry a single future                   | [backon]https://crates.io/crates/backon / [tokio-retry]https://crates.io/crates/tokio-retry |
| Durable, distributed jobs with storage     | [apalis]https://crates.io/crates/apalis                                                       |
| The actor model (addresses, mailboxes)     | [ractor]https://crates.io/crates/ractor / [kameo]https://crates.io/crates/kameo             |
| Structured subsystem shutdown, no restarts | [tokio-graceful-shutdown]https://crates.io/crates/tokio-graceful-shutdown                     |

## Two modes

| Mode             | When to use            | Lifecycle                                        |
|------------------|------------------------|--------------------------------------------------|
| `sup.run(specs)` | Tasks known upfront    | Blocks until all done or Ctrl+C                  |
| `sup.serve()`    | Tasks added at runtime | Returns a handle. You control shutdown           |

```rust,ignore
// Dynamic mode
let handle = sup.serve();

let id = handle.add(spec).await?;                 // registry accepted this TaskId
handle.cancel(id).await?;                         // cancel by identity and wait for cleanup
// Alternative: handle.cancel_by_label("task-name").await?;
let tasks = handle.list().await;                  // Vec<(TaskId, name)>

handle.shutdown().await?;
```

## How it works

<img src="https://raw.githubusercontent.com/soltiHQ/.github/main/assets/schema/taskvisor-process.png" alt="How taskvisor works: TaskSpec wraps your async fn, the Supervisor runs and restarts it, lifecycle events go to the event bus, the final outcome is delivered once per task" width="640">

Two separate channels report what happened. The event bus is best-effort (dashed lines).
The final outcome is delivered once per task (bold line). It does not go through the bus.

Each subscriber gets its own bounded queue. Callbacks run on Tokio's blocking pool, a slow callback does not occupy async worker threads or block event publishers.
Module-level concepts are documented in depth on [docs.rs](https://docs.rs/taskvisor).

## Error handling

Return these from your task to control what happens next:

| Return                           | Retryable | What happens                                                                |
|----------------------------------|-----------|-----------------------------------------------------------------------------|
| `Ok(())`                         || Task completed. `RestartPolicy` decides the next step.                      |
| `Err(TaskError::fail(reason))`   | Yes       | Retryable failure. Backoff, then retry. Wrap a cause with `fail_from(err)`. |
| `panic!` in the task body        | Yes       | Caught and converted to a retryable failure.                                |
| `Err(TaskError::Timeout { .. })` | Yes       | Set automatically when the per-attempt timeout is exceeded.                 |
| `Err(TaskError::fatal(reason))`  | No        | Permanent failure. The task stops and will not restart.                     |
| `Err(TaskError::Canceled)`       | No        | Clean stop on shutdown. Not an error.                                       |

### Cancellation

Long-running tasks must observe cancellation through their `TaskContext`:

```rust,ignore
// Pattern 1: wrap fallible work (recommended)
let work_result = ctx.run_until_cancelled(do_work()).await?;
work_result?;

// Pattern 2: select! for manual control
tokio::select! {
    _ = ctx.cancelled() => Err(TaskError::Canceled),
    result = do_work() => result,
}
```

## Recipes

### Restart tasks on failure with exponential backoff

```rust,no_run
use std::time::Duration;
use taskvisor::{BackoffPolicy, JitterPolicy};

// 200ms -> 400ms -> 800ms -> ... capped at 30s, with jitter.
let backoff = BackoffPolicy::exponential(Duration::from_millis(200))
    .with_max(Duration::from_secs(30))
    .with_jitter(JitterPolicy::Equal);
```

Jitter spreads retries in time. It prevents many tasks from retrying at the same moment.
`JitterPolicy::Equal` keeps each delay within `[base/2, base]` and is the recommended choice for most services.

### Run a periodic task

```rust,ignore
// Run, wait 30 seconds, run again. Forever.
// The interval starts after each completion (not a wall-clock schedule).
let spec = TaskSpec::periodic(task, Duration::from_secs(30));
```

### Set a timeout and a retry limit

```rust,ignore
// Each attempt gets 5s; the task gives up after 3 failure-driven retries.
let spec = TaskSpec::restartable(task)
    .with_timeout(Some(Duration::from_secs(5)))
    .with_max_retries(std::num::NonZeroU32::new(3).unwrap());
```

### Configure the supervisor

```rust,no_run
use std::time::Duration;
use taskvisor::{Supervisor, SupervisorConfig};

let sup = Supervisor::builder(SupervisorConfig::default())
    .with_grace(Duration::from_secs(30))                      // task shutdown grace period (default 60s)
    .with_subscriber_shutdown_timeout(Duration::from_secs(5)) // shared subscriber drain timeout
    .with_registry_queue_capacity(256)                        // bounded management queue (default 1024)
    .with_max_concurrent(4)                                   // global concurrency limit (default: unlimited)
    .build();
```

### Graceful shutdown on Ctrl+C

Static mode (`run`) installs OS shutdown signal handlers for you.
In dynamic mode, you stop things yourself: `handle.shutdown()` cancels all tasks and waits up to `grace`.
Tasks that ignore cancellation are force-aborted after the grace period.
Subscriber queues then get a separate shared drain timeout (default 5s).
At that deadline queued events are dropped; a callback already running on Tokio's blocking pool may continue.
Tokio runtime shutdown may still wait for that running callback.
Subscriber cleanup is best-effort, so reaching its timeout does not replace the task shutdown result.
The outcome is reported either way.

### Run CPU-heavy work without blocking Tokio

Offload the computation to rayon and await the result through a oneshot channel. All of this runs inside a supervised task.
Tokio threads stay unblocked. Taskvisor adds restart with backoff:

```rust,ignore
let (tx, rx) = oneshot::channel();
rayon::spawn(move || {
    let _ = tx.send(heavy_compute());
});

match ctx.run_until_cancelled(rx).await? {
    Ok(Ok(value)) => Ok(()),                 // use the value
    Ok(Err(e)) => Err(TaskError::fail(e)),   // supervisor retries with backoff
    Err(_) => Err(TaskError::fail("compute thread dropped the channel")),
}
```

Full program: [`examples/cpu_job.rs`](examples/cpu_job.rs).

### Send supervisor events to `tracing`

Your application must initialize `tracing` first (for example with `tracing-subscriber`).
Then add `TracingBridge` as a subscriber:

```rust,ignore
// features = ["tracing"]
let subs: Vec<Arc<dyn Subscribe>> = vec![Arc::new(TracingBridge)];
let sup = Supervisor::new(SupervisorConfig::default(), subs);
// Failures arrive as ERROR, retries as DEBUG in your existing log pipeline.
// Filter with RUST_LOG=taskvisor=warn.
```

A failing task recovering:

![taskvisor retries a failing task with backoff and recovers, shown as colored tracing logs](https://raw.githubusercontent.com/soltiHQ/.github/main/assets/demo/taskvisor-recovery.gif)

See [`examples/tracing.rs`](examples/tracing.rs) for the full program and [`examples/metrics.rs`](examples/metrics.rs) for Prometheus counters.

### Await a task's final result

```rust,ignore
// add_and_watch returns a TaskWaiter resolving to the final TaskOutcome.
let (id, waiter) = handle
    .add_and_watch(TaskSpec::once(job))
    .await?;

match waiter.wait().await? {
    TaskOutcome::Completed => println!("{id} done"),
    TaskOutcome::Failed { reason, .. } => eprintln!("{id} failed: {reason}"),
    other => eprintln!("{id} ended with {other:?}"),
}
```

## Admission control *(feature `controller`)*

You submit tasks to named slots. A policy decides what happens when a slot is busy:

| Policy          | Behavior                                               | Use case               |
|-----------------|--------------------------------------------------------|------------------------|
| `Queue`         | Bounded FIFO queue. Extra submissions are rejected.    | Job queue              |
| `Replace`       | Cancels the running task, starts the new one.          | Search-as-you-type     |
| `DropIfRunning` | Rejects the submission if the slot is busy.            | "One deploy at a time" |

```rust,ignore
let sup = Supervisor::builder(cfg)
    .with_controller(ControllerConfig::default())
    .build();
let handle = sup.serve();

// Await the final task outcome. A submission that is never admitted
// resolves to TaskOutcome::Rejected.
let (id, waiter) = handle.submit_and_watch(ControllerSpec::queue(spec)).await?;
let outcome = waiter.wait().await?;
```

See [`examples/slots.rs`](examples/slots.rs) and [`examples/admission.rs`](examples/admission.rs).

## Production notes

- **No unsafe.** The crate is `#![forbid(unsafe_code)]`.
- **Tested.** Unit and integration suites cover concurrency, shutdown, timeouts, and task identity. CI covers formatting, linting, tests, docs, and example builds.
- **Small footprint.** Depends on `tokio`, `tokio-util`, `thiserror`, `fastrand`. Optional: `dashmap` (controller), `tracing`.

## Examples

All examples run as-is:

```bash
cargo run --example basic
```

Add `--features tracing` or `--features controller` where the table notes it.

| Example                                         | What it shows                                                                           |
|-------------------------------------------------|-----------------------------------------------------------------------------------------|
| [basic.rs]examples/basic.rs                   | Run one task and exit: the minimal wiring                                               |
| [worker.rs]examples/worker.rs                 | A long-running worker that stops cleanly on Ctrl+C                                      |
| [periodic.rs]examples/periodic.rs             | Run a job every N seconds, forever                                                      |
| [multiple.rs]examples/multiple.rs             | Several tasks with different restart rules under one supervisor                         |
| [queue_consumer.rs]examples/queue_consumer.rs | A message consumer that reconnects after failures                                       |
| [cpu_job.rs]examples/cpu_job.rs               | Run CPU-heavy work on rayon, supervised, without blocking Tokio                         |
| [subscriber.rs]examples/subscriber.rs         | React to lifecycle events with your own handler                                         |
| [tracing.rs]examples/tracing.rs               | Send supervisor events into your logs (feature `tracing`)                               |
| [metrics.rs]examples/metrics.rs               | Count lifecycle events as Prometheus metrics                                            |
| [dynamic.rs]examples/dynamic.rs               | Add, cancel, and remove tasks while the app is running                                  |
| [outcomes.rs]examples/outcomes.rs             | Wait for a task's final result: done, failed, or canceled                               |
| [slots.rs]examples/slots.rs                   | Limit concurrency per slot: queue, replace, or drop the newcomer (feature `controller`) |
| [admission.rs]examples/admission.rs           | Find out if your submission ran or was rejected (feature `controller`)                  |

## Performance

Run the benchmarks on your own hardware:

```bash
cargo bench                                          # default-feature suites
cargo bench --bench lifecycle                        # task lifecycle overhead
cargo bench --bench controller --features controller # admission control
```

## Feature flags

| Feature              | What it enables                                                                       |
|----------------------|---------------------------------------------------------------------------------------|
| `controller`         | Slot-based admission control: `ControllerSpec`, `ControllerConfig`, `AdmissionPolicy` |
| `tracing`            | Built-in `TracingBridge` subscriber: events flow into your `tracing` log pipeline     |
| `logging`            | Built-in `LogWriter` subscriber: event output to stdout (demo/reference)              |
| `tokio-util-interop` | Access to the raw `CancellationToken` behind `TaskContext`, for APIs that need one    |
| `test-util`          | Test helpers: `TaskContext::detached()`, `TaskId::for_tests()`, and more              |

```toml
taskvisor = { version = "0.5", features = ["controller", "tracing"] }
```

## Contributing

Issues and pull requests are welcome. Start with the [contributing guide](https://github.com/soltiHQ/.github/blob/main/CONTRIBUTING.md).

<br>

##

<p align="center">
  <a href="https://github.com/soltiHQ">
    <picture>
      <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/soltiHQ/.github/main/assets/word/solti-word-light.svg">
      <img src="https://raw.githubusercontent.com/soltiHQ/.github/main/assets/logo/solti-logo-dark.svg" alt="soltiHQ" height="128">
    </picture>
  </a>
</p>