std/supervisors
Import with import std/supervisors.
Exports
Section titled “Exports”struct RestartInfo
Section titled “struct RestartInfo”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.
RestartInfo.inspect
Section titled “RestartInfo.inspect”fn inspect(value: RestartInfo): String
impl Debug.inspect
enum Restart
Section titled “enum Restart”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
-
TemporaryNever 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.
-
TransientRestarted 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.
-
PermanentRestarted 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
bootstill lists it.
Restart.inspect
Section titled “Restart.inspect”fn inspect(value: Restart): String
impl Debug.inspect
enum Backoff
Section titled “enum Backoff”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
-
ExponentialExponential with jitter, bounded two ways: give up after
max_restartsattempts, or aftermax_elapsedspent 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
Customrather than a knob for the ceiling.max_restartscounts restarts rather than runs, somax_restarts: 3runs 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_restartsafter ten months and stay dead — the budgets are for a run of trouble, not for the lifetime of the process. -
CustomAnything else. Handed a
RestartInfoafter each failure, returningSome(delay)to try again orNoneto 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
Noneand is never retried — retrying a broken decision function is how you build a second crash loop on top of the first.
Backoff.inspect
Section titled “Backoff.inspect”fn inspect(value: Backoff): String
impl Debug.inspect
enum GiveUp
Section titled “enum GiveUp”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
-
ReportThe default: report the failure and carry on. The rest of the supervisor’s work keeps running.
-
ExitEnd 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
Reportplus your own monitoring is all that is on offer.
GiveUp.inspect
Section titled “GiveUp.inspect”fn inspect(value: GiveUp): String
impl Debug.inspect
enum Wait
Section titled “enum Wait”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
-
ForeverBlock until the supervisor’s work finishes, however long that takes.
-
UpToBlock 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.
Wait.inspect
Section titled “Wait.inspect”fn inspect(value: Wait): String
impl Debug.inspect
enum FlushOutcome
Section titled “enum FlushOutcome”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
-
FlushedEvery task outstanding when you asked has finished.
-
TimedOutThe bound ran out with work still outstanding. Nothing was cancelled — the work carries on, and shutdown’s drain is still what eventually bounds it.
FlushOutcome.inspect
Section titled “FlushOutcome.inspect”fn inspect(value: FlushOutcome): String
impl Debug.inspect
type Supervisor
Section titled “type Supervisor”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.
Supervisor.new
Section titled “Supervisor.new”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.
Supervisor.spawn
Section titled “Supervisor.spawn”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.
Supervisor.spawn_all
Section titled “Supervisor.spawn_all”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.
Supervisor.flush
Section titled “Supervisor.flush”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.
Supervisor.inspect
Section titled “Supervisor.inspect”fn inspect(value: Supervisor): String
impl Debug.inspect