Skip to content

std/supervisors

Import with import std/supervisors.

struct RestartInfo {
    attempt: Int
    failure: Failure
    ran_for: Duration
}

What the runtime learned from one failed run, handed to a Backoff.Custom policy so it can decide what to do next.

fn inspect(value: RestartInfo): String

impl Debug.inspect

enum Restart {
    Temporary
    Transient
    Permanent
}

Which endings put a task back to work.

A disposition, not a schedule: this says whether a stopped task runs again, and Backoff says when and how many times. Keeping them apart is what lets each be a short list, and it is the split BEAM settled on — these three are its :temporary / :transient / :permanent.

Restart re-invokes the closure the spawn was given, so it rebuilds whatever state that closure sets up. There is nothing else it could do: the closure is the only description of the work.

Being cancelled is never a restart trigger under any disposition. Shutdown cancels, and a policy that fought its own shutdown would never let the program exit.

Variants

  • Temporary

    Never restarted. The default, because a supervisor holds whatever you spawn into it and most of that is work meant to finish.

    Failures are still reported: declining to retry is not declining to look.

  • Transient

    Restarted if it fails — a panic or a runtime error. A task that returned is done.

    For work with an end: send this email, write this audit row. Retry it if it breaks, and let it finish when it finishes.

  • Permanent

    Restarted if it stops at all, whether it failed or returned.

    For work with no end: a worker draining a queue, a server reading an inbox. Such a task returning on its own is a bug — the loop hit a branch it should not have, or someone closed the channel it was reading — and this is what turns that from silence into a report and a restart. Without it a worker with a bug that exits early simply vanishes, while boot still lists it.

fn inspect(value: Restart): String

impl Debug.inspect

enum Backoff {
    Exponential { max_restarts: Int, max_elapsed: Duration }
    Custom (RestartInfo) -> Maybe<Duration>
}

When to try again, and how many times before giving up.

Separate from Restart because whether to restart and how patiently are different questions; answering them together would make every caller write a schedule even when the default suits them.

Variants

  • Exponential

    Exponential with jitter, bounded two ways: give up after max_restarts attempts, or after max_elapsed spent trying, whichever comes first.

    Both bounds have defaults, so .Exponential{} is the built-in schedule and what you get by choosing a disposition and nothing else. Naming one field keeps the other — .Exponential{max_restarts: 3} still gives up after fifteen minutes.

    Those are the two questions worth answering — how many times, and for how long. The delay curve is not one of them: it starts at a second, doubles, and levels off at a minute, and a caller who genuinely wants a different shape wants Custom rather than a knob for the ceiling.

    max_restarts counts restarts rather than runs, so max_restarts: 3 runs the body at most four times.

    Jitter is always applied and is not optional. Without it a supervisor restarting fifty identical tasks retries them all at the same instant, reproducing the thundering herd the backoff exists to damp.

    A healthy run resets both budgets. A run lasting longer than the delay ceiling counts as recovered, so the next failure starts over from the first delay with a full count and clock. Without that a worker failing once a month would exhaust max_restarts after ten months and stay dead — the budgets are for a run of trouble, not for the lifetime of the process.

  • Custom

    Anything else. Handed a RestartInfo after each failure, returning Some(delay) to try again or None to give up.

    The space of retry policies is open — Fibonacci delays, circuit breakers, rules that differ by failure kind — and enumerating them is a losing game. One function closes it. Note that a delay you return is used as given: jitter is part of the built-in schedule, not of this one.

    Keep it quick: return a delay rather than sleeping in it, and do not spawn from it. A failure inside it is a failure of the restart machinery rather than of the task, so it counts as None and is never retried — retrying a broken decision function is how you build a second crash loop on top of the first.

fn inspect(value: Backoff): String

impl Debug.inspect

enum GiveUp {
    Report
    Exit
}

What happens at the moment the runtime decides a task will not run again — under Restart.Temporary its first failure, under Backoff.Exponential once max_restarts or max_elapsed runs out, and under Backoff.Custom when the policy returns None.

These two are the whole space rather than a simplification. With no child specs there is no subtree to restart and nothing to escalate to, so a supervisor that gives up can only report locally or hand off to whatever is above the process.

Note that handing off means exiting. Nothing here restarts the program — the runtime has no self-restart, so Exit ends the process and whatever runs it decides whether anything comes back.

Variants

  • Report

    The default: report the failure and carry on. The rest of the supervisor’s work keeps running.

  • Exit

    End the process: every supervisor is drained, then the program exits non-zero.

    It exits rather than restarts. Nothing in Nomi brings the program back — that is the job of whatever runs it, a service manager or an orchestrator. What makes handing off worthwhile is that a new process re-runs boot, so it starts with fresh connections, fresh state, and a rebuilt supervisor set: far more cleared than restarting one task could manage.

    Drastic, and therefore not the default. It is also a poor fit for a long-running process with nothing above it — a desktop application, an embedded runtime — where nothing will restart it and Report plus your own monitoring is all that is on offer.

fn inspect(value: GiveUp): String

impl Debug.inspect

enum Wait {
    Forever
    UpTo Duration
}

How long Supervisor.flush is willing to block.

A union rather than a Duration with a magic “forever” value: an unbounded wait is a different thing from a long one, and giving Duration a sentinel would leak into every arithmetic and comparison that touches it.

Variants

  • Forever

    Block until the supervisor’s work finishes, however long that takes.

  • UpTo

    Block until the work finishes or this much time passes, whichever comes first. Returns as soon as the work is done — the bound is a ceiling, not a duration to spend.

fn inspect(value: Wait): String

impl Debug.inspect

enum FlushOutcome {
    Flushed
    TimedOut
}

How a Supervisor.flush ended.

Distinct from an ambient Context deadline, which aborts the work that exceeded it. A bound given here returns a value instead, so the caller decides what a slow flush means.

Variants

  • Flushed

    Every task outstanding when you asked has finished.

  • TimedOut

    The bound ran out with work still outstanding. Nothing was cancelled — the work carries on, and shutdown’s drain is still what eventually bounds it.

fn inspect(value: FlushOutcome): String

impl Debug.inspect

type Supervisor

An owner for work that has to outlive the call that started it.

A concurrent { } block is an anonymous owner, and it cannot return until every task inside it has been awaited — which is exactly wrong for a signup that responds now and mails later. A named Supervisor is the same idea with a longer life: your function returns while the work keeps running, and the supervisor decides how long shutdown waits for it.

Either way the task has an owner. There is nowhere to spawn that is not already owned by something with a shutdown policy.

A supervisor is a set of tasks that share a policy. The policy decides membership — work belongs together because it should be treated the same way at shutdown and on failure, not because it serves the same feature — and the set is what the policy acts on: its tasks share one max_running semaphore and contend with each other for slots, Supervisor.flush waits for all of them, and the drain cancels all of them on one budget. Tasks under different supervisors do none of that to each other.

Supervisors live on the app struct and are created in boot — one per shutdown policy the app needs, not one per feature:

struct Config {
  context: Context
  audit: Supervisor
  notify: Supervisor
}

impl App for Config

fn boot(): App {
  Config{
    context: Context.root(),
    audit: Supervisor.new(shutdown_timeout: Duration.seconds(30), max_running: 4),
    notify: Supervisor.new(shutdown_timeout: Duration.milliseconds(200), max_running: 50),
  }
}

Background work is in-process, not durable: a hard crash between “response sent” and “email sent” loses the email. Fine for a welcome message, not for a receipt.

fn new(max_running: Int, shutdown_timeout: Duration, restart: Restart, backoff: Backoff, on_give_up: GiveUp): Supervisor

type function on Supervisor

Create a named supervisor with its own shutdown budget.

shutdown_timeout: is how long shutdown waits for this supervisor’s work after cancelling it. Supervisors drain concurrently and their budgets are unrelated, so a 30-second budget and a 200-millisecond one do not queue behind each other. When the budget expires the runtime abandons whatever is left rather than waiting — otherwise a task that never observes cancellation could hang shutdown forever, and every budget would be decorative.

max_running: is how many of its tasks run at once, and it is not optional. Other languages default this to the CPU count, which answers “how much parallelism can this machine use?” — the right question for CPU-bound work and the wrong one here, where the limit exists to protect a connection pool or a rate limit. Leaving it off would not mean “unbounded for this call”: work reaches a supervisor from call sites all over the program, so it would mean unbounded forever. A supervisor holding one long-lived worker is max_running: 1.

Creatable only during boot, because a supervisor is a fixed cost and a fixed bound. Every one is retained for the life of the program — nothing releases it — and carries its own max_running semaphore. Built in a request handler, it would be a fresh one per request, each with its own bound: max_running: 8 under a thousand concurrent requests is eight thousand tasks at the far end, which is the opposite of what the number is for. Creating it once is what keeps it a limit on a downstream rather than a limit on a call.

Note what the rule does not do: a supervisor made anywhere would still be drained at shutdown, since it registers on creation. The restriction is about how many exist, not whether they are known.

Not only inside boot: a constructor boot calls may create its own, which is how a server type owns its supervisor and its restart disposition. Calling such a constructor from anywhere boot does not reach is a compile error, so the set of supervisors is still fixed by the time the program runs.

fn spawn(supervisor: Supervisor, body: () -> Unit): Task<Unit>

type function on Supervisor

Put body under supervisor, and return immediately.

The task is enrolled when this returns, not finished — so a spawn past the supervisor’s max_running never blocks the caller; it waits for a slot on its own.

Returns a Task like Task.spawn does, but imposes no await obligation, because the supervisor drains it at shutdown. The handle is an extra way to observe or cancel one task, not an obligation to. Discard it with _ = when you want neither.

The body must return Unit. Nobody is obliged to await grouped work, so a returned value has nowhere to go — and for a Result that means a silently discarded Err, the most common fire-and-forget bug in every language that has the feature. Wrap the call in a function that decides what failure means, and report it somewhere.

fn spawn_all<T>(supervisor: Supervisor, source: Iter<T>, f: (T) -> Unit): List<Task<Unit>>

type function on Supervisor

Spawn one task per item of source under supervisor.

No max_running here — the supervisor’s own limit is the only one. A limit on this call would protect only the traffic that happened to arrive as a batch, leaving every ordinary Supervisor.spawn spawn unbounded, which is not what anyone means by bounding a supervisor.

Bodies return Unit, as all supervised work does, so there is nothing to collect. The handles come back for cancellation, or for Task.await_all when you want to wait for this batch rather than the supervisor’s whole workload.

fn flush(supervisor: Supervisor, bound: Wait): FlushOutcome

type function on Supervisor

Block until every task outstanding under supervisor has finished.

It does not cancel, and that is the whole distinction from the shutdown_timeout: a few lines up — shutdown cancels what is left when it expires, and this never cancels anything. It waits and reports. Reach for it at a checkpoint, to be sure queued work went out before reporting success, or in a test, to let the background work a call kicked off finish before asserting on it.

“Outstanding” means enrolled by the time you call: spawn enrols before it returns, so work started on an earlier line is always included, and work a flushed task spawns is waited for too. Work that arrives afterwards is not — this is a checkpoint, not a barrier that closes the supervisor.

Unbounded by default. Pass Wait.UpTo(d) to cap it, which returns FlushOutcome.TimedOut rather than unwinding:

case Supervisor.flush(Config.notify, Wait.UpTo(Duration.seconds(5))) {
  FlushOutcome.Flushed -> report_sent()
  FlushOutcome.TimedOut -> carry_on()
}

That is deliberately unlike an ambient Context deadline, which aborts the work that exceeds it. Use the deadline to put a ceiling on a whole region; use this to ask one question and get an answer back either way.

fn inspect(value: Supervisor): String

impl Debug.inspect