hanki

supervisor

stdlib/extra/supervisor.hk: restart-policy bookkeeping for actor supervision.

The supervision wiring is per-actor worked code and no generic module: a handler cannot be passed as a value and spawn takes a static actor name, which leaves a reusable supervisor unable to spawn an arbitrary child. See the timer and restart patterns in HANKI.md §15. What is reusable is the pure decision and window bookkeeping: after a child has died N times, should its supervisor restart it (and after how long a backoff) or escalate?

RestartPolicy supplies consecutive exponential backoff. RestartIntensity and RestartWindow supply a sliding restart budget. The actor owns one window per logical child or strategy group. After an allowed restart begins, its handler schedules one actor.send_after! expiry after within_ms; handling that expiry calls RestartWindow.expired(epoch). No actor sleeps, and the module itself remains free of actor and clock effects so the bookkeeping is easy to test.

RestartDecision

type RestartDecision
  Restart(i32)
  GiveUp
end

What a supervisor should do after a child's death: restart it after a backoff of the given milliseconds, or stop trying. Non-positive timing configuration means an immediate restart.

impl Eq<RestartDecision>

Hand-written (not @derive) so it sits in the baked stdlib prefix: a stdlib body compares RestartDecision, and a stale-blob chunked lower must resolve Eq<RestartDecision> from the prefix and never an impl synthesised after the user items. Mirrors @derive(Eq); the build_stdlib_bytecode assert pins it there.

eq?

def eq?(self, other: Self) -> bool

Two decisions are equal when they are the same variant and, for Restart, carry the same delay.

Restart(50i32).eq?(Restart(50i32)) => true
Restart(50i32).eq?(Restart(80i32)) => false
(GiveUp == GiveUp)                 => true

RestartAllowance

type RestartAllowance
  RestartAllowed(RestartWindow)
  IntensityExceeded
end

The result of charging one restart to a sliding intensity window. RestartAllowed contains the updated window which the supervisor stores; IntensityExceeded leaves the old window unchanged and tells the actor to perform its configured escalation.

RestartWindow

struct RestartWindow
  restarts: i32
  epoch: i64
end

The live restart charges for one logical child or one strategy group. Each charge ages out independently. This creates a sliding window with no fixed bucket. Start with RestartWindow.empty() and store the value returned by RestartIntensity.next. Carry epoch in each delayed restart and expiry message. A reset makes old timers inert.

impl RestartWindow

empty

def empty() -> RestartWindow

A first-use empty window.

RestartWindow.empty().restarts => 0i32
RestartWindow.empty().epoch    => 0i64

expired

def expired(self, epoch: i64) -> RestartWindow

Age one previously allowed restart out of the sliding window. epoch is the token carried by that restart's delayed expiry. A token from an older reset is ignored; the last live expiry advances the epoch before a future restart can reuse the empty window.

w = RestartWindow(restarts=2i32, epoch=7i64)
w.expired(7i64).restarts => 1i32
w.expired(8i64).restarts => 2i32

reset

def reset(self) -> RestartWindow

Discard every live charge and advance the epoch. Use this only when discarding or reassigning a logical child slot or strategy group. An ordinary respawn preserves the window; its outstanding expiry messages still belong to that slot.

reset = RestartWindow(restarts=2i32, epoch=7i64).reset()
reset.restarts => 0i32
reset.epoch    => 8i64

RestartIntensity

struct RestartIntensity
  max_restarts: i32
  within_ms: i32
end

A sliding restart-intensity limit: permit at most max_restarts restart decisions that have not yet reached their individual within_ms expiry. max_restarts <= 0 permits none. within_ms is an i32 so it can be passed directly to actor.send_after!; as there, a negative interval clamps to zero.

impl RestartIntensity

next

def next(self, window: RestartWindow) -> RestartAllowance

Charge a restart to window. An allowed result contains the incremented window; the caller stores it, performs or schedules the restart, and when that restart begins schedules one expiry after within_ms. Once the active count reaches max_restarts, the result is IntensityExceeded until an expiry is handled.

limit = RestartIntensity(max_restarts=1i32, within_ms=1000i32)
first = match limit.next(RestartWindow.empty())
  RestartAllowed(w) -> w.restarts
  IntensityExceeded -> -1i32
end
first => 1i32
disabled = match RestartIntensity(max_restarts=0i32, within_ms=1i32).next(RestartWindow.empty())
  RestartAllowed(_) -> false
  IntensityExceeded -> true
end
disabled => true

RestartPolicy

struct RestartPolicy
  max_restarts: i32
  base_ms: i32
  max_ms: i32
end

A consecutive-restart policy with exponential backoff. max_restarts is how many restarts to allow before giving up; the backoff delay starts at base_ms and doubles each restart, capped at max_ms.

impl RestartPolicy

next

def next(self, restarts: i32) -> RestartDecision

Decide what to do for a child that has already been restarted restarts times. Restart(delay_ms) once more is allowed, carrying the backoff to wait first; GiveUp once restarts reaches max_restarts.

p = RestartPolicy(max_restarts=3i32, base_ms=10i32, max_ms=1000i32)
p.next(0i32)  => Restart(10i32)
p.next(1i32)  => Restart(20i32)
p.next(2i32)  => Restart(40i32)
p.next(3i32)  => GiveUp

backoff

def backoff(self, restarts: i32) -> i32

The backoff delay before the restarts-th restart: positive base_ms doubled restarts times, saturating at positive max_ms before an i32 multiplication could overflow. A non-positive base or ceiling gives zero. The work is bounded by the width of i32, however large restarts is. Pure; exposed on its own so a supervisor can log or adjust the schedule.

p = RestartPolicy(max_restarts=5i32, base_ms=10i32, max_ms=50i32)
p.backoff(0i32)  => 10i32
p.backoff(2i32)  => 40i32
p.backoff(3i32)  => 50i32