Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Supervision

The store task is the one task in minidb that nothing can replace. If it dies, every handle to it answers ERR the store is gone, forever, and the server keeps accepting connections so that it can keep saying so.

A process that is up but cannot do anything is worse than one that is down. Nothing restarts it, and every liveness check that only asks whether the port is open reports success.

The accept loop you wrote in the last exercise watches two things, and the store is not one of them:

#![allow(unused)]
fn main() {
tokio::select! {
    _ = shutdown.cancelled() => /* ... */,
    accepted = listener.accept() => /* ... */,
}
}

StoreHandle now exposes the third thing it could be watching:

#![allow(unused)]
fn main() {
store.closed().await;   // mpsc::Sender::closed: resolves once the Receiver has been dropped
}

The receiver is dropped when the store task ends, whether it returned normally or panicked, so this is one await that answers “is the thing I depend on still alive”. What serve should do when it resolves is the exercise.

Dying on purpose

serve returns an error, main returns it, the process exits non-zero, and systemd or Kubernetes or a shell loop starts it again. That is supervision: not a framework, just a decision about which failures are fatal and something outside the process that notices.

Choosing to die is a real answer and usually the right one when the state that was lost lived in the task that died. minidb could not restart the store task with the data intact even if it tried, because the data was in it. After chapter 9 it could: the log on disk is what makes a restart recover rather than forget.

Restarting instead

When the dead task is stateless or its state is recoverable, supervise it by holding the handle:

#![allow(unused)]
fn main() {
loop {
    match handle.await {
        Ok(()) => break,
        Err(error) if error.is_panic() => {
            warn!(?error, "worker panicked, restarting");
            handle = tokio::spawn(worker());
        }
        Err(_) => break,   // aborted
    }
}
}

JoinError distinguishes a panic from an abort, which is the difference between “something broke” and “we asked it to stop”. Restart loops need a backoff and a give-up count, or a task that panics on startup becomes a busy loop that panics several thousand times a second.

Panics in connection tasks

Those are fine to swallow, and the semaphore permit from chapter 6 is why: it is released by Drop, so a panicking connection gives its permit back on the way out. That is the general defence. Anything that must happen when a task ends should hang off a destructor rather than off the last line of the task body, because the last line is exactly what a panic skips.

If you want a service to be loud about it, std::panic::set_hook at startup gets you one place where every panic in the process is logged, with a backtrace, before unwinding starts.

Exercise

The exercise for this section is located in 07_shutdown/02_supervision