@zakkster/lite-di-supervisor
v1.0.0
Published
OTP-style supervision tree over a @zakkster/lite-di-container topology: declared children restarted on fault by one-for-one / one-for-all / rest-for-one strategy via the container's invalidate/rebind primitives, with a restart-intensity budget that escala
Downloads
31
Maintainers
Readme
@zakkster/lite-di-supervisor
OTP-style supervision tree over a @zakkster/lite-di-container topology. A Supervisor owns a declared, ordered set of container TOKENS and restarts them on an EXPLICITLY-PUSHED fault by one-for-one / one-for-all / rest-for-one strategy, via the container's invalidate + getAsync primitives, with a restart-intensity budget that escalates instead of hot-looping. The healthy poll path -- check() -- allocates 0 bytes/op, hard-gated.
The supervision tree the DI line was missing
@zakkster/lite-di-container already builds, wires, and tears down your object
graph, and (since 2.2.0, decision 0005) exposes invalidate(token) -- flush one
cached instance so the next resolve rebuilds it from the SAME registration. What
it deliberately does NOT own is the POLICY on top of that primitive: when a
subsystem faults, WHICH children restart, in WHAT order, and how many times
before you stop trying. The container gives you the atomic per-token restart; it
stays out of the ordering decision on purpose.
lite-di-supervisor is that missing policy layer -- an OTP-style supervision
tree. It owns a declared, ordered set of container tokens (its children). When a
fault is PUSHED to it, it restarts the affected children by one of three
strategies via invalidate + an eager getAsync re-resolve, so no live instance
is ever left holding a torn-down dependency. A restart-intensity budget
(maxRestarts within windowMs, against an INJECTED now()) escalates instead
of hot-looping. This is the GAP-1 keystone that turns a wired container into a
self-healing service kernel.
npm install @zakkster/lite-di-supervisorPeer dependency (not bundled, install it alongside):
npm install @zakkster/lite-di-container # >=2.2.0 (the invalidate primitive)import { Container } from '@zakkster/lite-di-container';
import { Supervisor, STRATEGIES } from '@zakkster/lite-di-supervisor';
class Db { async connect() { /* open pool */ } }
class Cache { constructor(db) { this.db = db; } }
class Api { constructor(cache) { this.cache = cache; } handle(req) { /* ... */ } }
const c = new Container();
c.singleton('db', Db);
c.singleton('cache', Cache, ['db']);
c.singleton('api', Api, ['cache']);
c.boot();
const sup = new Supervisor(c, {
children: ['db', 'cache', 'api'], // declared START order
strategy: STRATEGIES.REST_FOR_ONE, // faulting 'cache' rebuilds cache + api
});
await sup.start(); // resolve children in order, arm supervision
// A subsystem method threw? Push the fault -- the supervisor heals the subtree:
await sup.reportFault('cache', new Error('redis dropped'));
// cache and api are rebuilt from their registrations; db is untouched.
sup.check(); // 0 -- all children healthy again (0 B/op readout)Table of contents
- Why this exists
- What you get
- The fault contract
- API reference
- Composability with the DI line
- Zero-GC design notes
- Design decisions worth knowing
- Testing
- What this is not
- Ecosystem
- License
Why this exists
A DI container and a self-healing supervision tree solve two halves of the same
problem and, until now, never met. The container constructs and wires your
subsystems and can flush and rebuild any single one of them (invalidate) -- but
a thrown error inside a subsystem's own method is INVISIBLE to it, and it has no
opinion on which OTHER subsystems a fault should take down with it. It ships the
atomic per-token primitive and stops there, by design: container decision 0005
states plainly that transitive restart ordering (one-for-one, one-for-all,
rest-for-one) is the future supervisor's responsibility.
This package IS that responsibility. It adds the four things the container deferred: a fault-reporting CONTRACT (faults are pushed, never guessed), a restart-ordering POLICY (the three strategies), a restart-intensity BUDGET (stop hot-looping and escalate), and an escalation SEAM (surface a dead subtree to whatever sits above). The container gives the healing primitive; the supervisor owns when and how far the healing spreads.
What you get
- A
Supervisorover a container and a declared, ordered set of child TOKENS -- first-class container citizens, resolved in start order atstart(), restarted through the container's own publicinvalidate+getAsync. - Three restart strategies --
one-for-one(heal just the faulted token),one-for-all(shared-fate: rebuild every child),rest-for-one(a dependency chain: rebuild the faulted token and everything started after it). - An EXPLICIT-PUSH fault contract:
reportFault(token, err?)is the primitive;run(token, fn)wraps a call so a throw self-reports and then re-throws. No polling, no reflection, no timers, no magic method-wrapping. - A restart-intensity budget with escalation:
maxRestartswithinwindowMs, measured against an INJECTEDnow(), so the budget is fully deterministic in a test. Exhaustion firesonEscalateonce -- it never hot-loops. - A 0-allocation readiness poll:
check()returns the count of non-healthy children in a single loop over an SoAUint8Array-- nonow(), no closure, no array. Hard-gated at 0.000 B/op. This is the tick a long-running server calls. - Fail-closed behavior end to end: an unknown option throws with a did-you-mean
hint, a not-booted or under-registered container throws at
start()and stays INIT (atomic), and a dead supervisor (ESCALATED / SHUT_DOWN) never restarts.
Full types ship in Supervisor.d.ts.
The fault contract
The container cannot see an error thrown inside a subsystem's own method: a
rejected db.query() or a throwing cache.get() is invisible to the graph that
built db and cache. So the supervisor never tries to GUESS a fault -- it is
TOLD. There are exactly two ways in, and both are explicit:
reportFault(token, err?)is the primitive. You caught (or observed) a fault in a subsystem; you name the token that failed. The supervisor marks that child non-healthy SYNCHRONOUSLY (socheck()reflects it on the very next call), then applies the restart policy on a serialized cold lane.run(token, fn)is the convenience wrapper. It runsfn; on success it returns the value; on throw it callsreportFault(token, e)and then RE-THROWS the original error. The caller ALWAYS sees its own error -- the supervisor heals in the background but never swallows. Wrap a request handler or a work-loop body with it and a throw becomes a self-report for free.
// Primitive: you own the try/catch, you name the token.
try {
await cache.warm();
} catch (e) {
await sup.reportFault('cache', e); // heal, then decide what to tell the caller
}
// Wrapper: the throw self-reports, and the caller still gets the error.
const rows = await sup.run('db', () => db.query(sql)); // on throw: restart db, re-throwWhat is NOT the fault channel: check(). It is a health READOUT -- the count of
children currently marked non-healthy -- and it never restarts, never reads
now(), and never allocates. A poll cannot see a fault the subsystem did not
surface, so polling is not how faults arrive; it is only how you ask "is
everything healthy right now?" on a hot loop. Faults come in through the two
push doors above, and nowhere else.
API reference
Constructor
new Supervisor(container: Container, spec: {
children: (string | symbol)[]; // declared START order; non-empty, unique
strategy?: 'one-for-one' | 'one-for-all' | 'rest-for-one'; // default 'one-for-one'
maxRestarts?: number; // integer >= 1; default 3
windowMs?: number; // finite number > 0; default 5000
now?: () => number; // default monotonic real clock
onRestart?: (info) => void; // fired after a successful restart
onEscalate?: (info) => void; // fired once (terminally) on budget exhaustion
})container-- a booted-by-start()@zakkster/lite-di-containerinstance (>=2.2.0). Anull/ non-object container is aTypeError.children-- the declared START order, a non-empty array of uniquestring | symboltokens, each registered on the container by the timestart()runs. Empty, duplicate, or non-token children throw.strategy-- one of the threeSTRATEGIESvalues (default'one-for-one'). An unknown strategy throws, naming the three valid values.maxRestarts-- the intensity budget's count, an integer>= 1(default3). Anything else is aTypeError.windowMs-- the intensity window in milliseconds, a finite number> 0(default5000).now-- the clock the budget is measured against,() => number(default a monotonic real clock:performance.now()when present, elseDate.now()). Inject a scriptable clock for a fully deterministic budget in tests. Read ONLY on the cold restart path, never incheck().onRestart(info)-- optional; fired AFTER each successful restart with{ token, index, strategy, error }(errorisnullwhen none was passed).onEscalate(info)-- optional; fired ONCE, terminally, when the budget is exhausted, with{ token, restarts, windowMs }.
An UNKNOWN option key throws with a did-you-mean hint (unknown option 'stratergy'.
Did you mean 'strategy'?) -- it is never silently ignored. Every bad type throws
in the constructor; nothing is armed until start().
Methods
start(): Promise<void>
check(): number
reportFault(token: string | symbol, err?: unknown): Promise<void>
run<T>(token: string | symbol, fn: () => T | Promise<T>): Promise<T>
shutdown(): Promise<void>
get state(): numberstart()-- resolve every child in declared start order (viagetAsync, so async singletons are supported) and arm supervision (INIT -> RUNNING). Only from INIT; the container must be booted (isBooted) and every child registered (has), else it throws and STAYS INIT. Validation is atomic -- all checks run BEFORE any resolution, so a bad topology never leaves you half-armed.check(): number-- HOT. The count of non-healthy children (0== all healthy). A single loop over an SoAUint8Array; nonow(), no closure, no array, no allocating branch. Allowed in RUNNING and ESCALATED (a readout is always safe); returns0in INIT / SHUT_DOWN (nothing armed). 0.000 B/op.reportFault(token, err?): Promise<void>-- record a fault and apply the restart policy. Marks the child non-healthy synchronously (socheck()reflects it immediately), then restarts SERIALIZED behind any in-flight restart. Only while RUNNING; an unknown token throws; after ESCALATED or SHUT_DOWN it throws (a dead supervisor never restarts). The not-running and unknown-token guards throw synchronously, before the method returns a promise -- so a barereportFault(t).catch(h)does NOT catch a guard error; it escapes the.catch. Wrap the call intry/catch, or userun()(which reports from inside its owntry). Only the restart itself settles through the returnedPromise<void>.run(token, fn): Promise<T>-- runfn; on success return its value; on throw callreportFault(token, e)and then RE-THROW the original error (fail-closed: the caller always sees its own error). Only while RUNNING; an unknown token, or a non-functionfn, throws beforefnruns. If the supervisor escalated betweenfnthrowing and the self-report, that is observable viaonEscalate/check()and never masks the caller's error.shutdown(): Promise<void>-- stop supervising (terminal, idempotent). An already-in-flight restart is DRAINED to completion first (aborting mid-restart would leave children half-invalidated); only QUEUED faults are skipped. After SHUT_DOWN,reportFault/runthrow.state(getter) -- the current lifecycle state, one ofSTATES.
Constants
STRATEGIES = { ONE_FOR_ONE, ONE_FOR_ALL, REST_FOR_ONE } // frozen string enum
STATES = { INIT, RUNNING, ESCALATED, SHUT_DOWN } // frozen int enum
VERSION // version string, three-place-synced with package.json + CHANGELOG| Export | Type | Value / meaning |
| ------------------ | ------------------- | --------------------------------------------------------- |
| STRATEGIES.ONE_FOR_ONE | string | 'one-for-one' -- restart only the faulted token. |
| STRATEGIES.ONE_FOR_ALL | string | 'one-for-all' -- restart every child (shared fate). |
| STRATEGIES.REST_FOR_ONE | string | 'rest-for-one' -- restart the token and all after it. |
| STATES.INIT | number (0) | Constructed, not yet started. check() returns 0. |
| STATES.RUNNING | number (1) | Armed and supervising. reportFault / run allowed. |
| STATES.ESCALATED | number (2) | Budget exhausted; terminal. reportFault / run throw. |
| STATES.SHUT_DOWN | number (3) | Stopped; terminal. reportFault / run throw. |
| VERSION | string | '1.0.0'. |
Restart ordering, for a faulted token T at start-index i in children =
[c0..cn]:
- one-for-one:
invalidate(T), then re-resolve T. Only T. - one-for-all: tear down every child in REVERSE-start order (
cn..c0) viainvalidate, then re-resolve in START order (c0..cn), so every dependent rebuilds against fresh dependencies. - rest-for-one: tear down
cn..ciin reverse, re-resolveci..cnin start order. Children beforeiare untouched -- in a valid start order they cannot depend on T. Eager re-resolution is what resolves the container's 0005 stale-dependent hazard: no live instance is left holding a torn-down dependency.
Composability with the DI line
A full service kernel: subsystems wired in the container, a supervisor armed over
them with rest-for-one, a work loop wrapped in run() so a throw self-heals,
and an onEscalate that surfaces a dead subtree to an orchestrator.
import { Container } from '@zakkster/lite-di-container';
import { Supervisor, STRATEGIES, STATES } from '@zakkster/lite-di-supervisor';
// --- Subsystems, a dependency chain: pool -> repo -> worker. ---------------
class Pool { async open() { /* connect */ } async q(sql) { /* ... */ } }
class Repo { constructor(pool) { this.pool = pool; } find(id) { return this.pool.q('...'); } }
class Worker { constructor(repo) { this.repo = repo; } step(job) { return this.repo.find(job.id); } }
const c = new Container();
c.singleton('pool', Pool);
c.singleton('repo', Repo, ['pool']);
c.singleton('worker', Worker, ['repo']);
c.boot();
// --- Arm supervision over the chain. rest-for-one: a pool fault rebuilds ----
// pool -> repo -> worker in order; a worker fault rebuilds only worker.
const sup = new Supervisor(c, {
children: ['pool', 'repo', 'worker'],
strategy: STRATEGIES.REST_FOR_ONE,
maxRestarts: 5,
windowMs: 10000,
onRestart: (info) => log.warn('healed', info.token, 'via', info.strategy),
onEscalate: (info) => orchestrator.subtreeDead('jobs', info), // the seam upward
});
await sup.start();
// --- A work loop. run() wraps each step: a throw self-reports to 'worker' ---
// (healing the chain) and then re-throws, so the loop can back off.
async function pump(job) {
try {
return await sup.run('worker', () => c.get('worker').step(job));
} catch (e) {
if (sup.state === STATES.ESCALATED) throw e; // subtree is dead; stop pumping
await backoff(); // healed; retry shortly
}
}
// --- A readiness endpoint polls the 0 B/op readout every request. ----------
function readiness() { return sup.check() === 0 ? 200 : 503; }
// --- Terminal teardown drains any in-flight restart. -----------------------
await sup.shutdown();Every stage snaps onto the DI line: the supervisor touches only the container's
PUBLIC surface (isBooted, has, getAsync, invalidate) -- no internals, no
second copy of the wiring. The container owns the instances and the restart
primitive; the supervisor owns the ordering, the budget, and the escalation seam.
Zero-GC design notes
check() is the only hot lane -- the readiness poll a long-running server calls
every tick -- so it is gated at exactly zero, not "small". It is a single loop
over a pre-allocated SoA Uint8Array of child states, returning the count of
non-healthy entries. It reads no wall clock, builds no message, captures no
closure, and takes no branch that allocates. Everything else -- reportFault,
restart, escalate, shutdown -- is COLD by design: faults are rare, and the
serialized restart chain, the injected-now() budget read, and the hook payload
objects live there where allocation is fine.
| Path | Steady-state allocations |
| -------------------------------------- | ---------------------------------------------- |
| check() (poll, N children) | 0.000 B/op (HARD gate, 1e6 ops, 64 children) |
| reportFault / restart / escalate | cold by design (rare; allocation is fine) |
| shutdown | cold by design (terminal, once) |
The intensity budget itself is 0-alloc to MAINTAIN across a soak: restart
timestamps live in a FIXED Float64Array ring of size maxRestarts, so a
million fault/restart cycles cannot grow it unbounded. The torture gate
(@zakkster/lite-leak + @zakkster/lite-gc-profiler, under --expose-gc)
proves both the poll and the soak:
- check(): 0.000 B/op over 1e6 calls on a 64-child fan-out,
maxMajor0. - T7 soak: 100000 fault/restart cycles + 1e6
check()polls, major GCs = 0, retained leak size = 0.
Numbers reproduce with node --expose-gc test/torture.mjs. No gate output is a
FAIL.
Design decisions worth knowing
The four load-bearing forks were ratified in
decisions/0001-supervision-model.md;
each conceptually builds on container decision 0005 (invalidate / rebind).
- Fault detection is EXPLICIT PUSH (Fork 1). The supervisor learns of a fault
only when told, via
reportFault(primitive) orrun(a wrapper that reports then re-throws). WHY: a poll cannot see a fault a subsystem did not surface, and magic wrapping of every method is neither zero-dep nor deterministic. Push is the only zero-magic, testable contract.check()is a readout, not the channel. - The window clock is an INJECTED
now()(Fork 2). The budget is measured against anow: () => number(default a monotonic real clock), read ONLY on the cold restart path. WHY: a scriptable clock makes the intensity budget fully deterministic in a test, and no lane ever reads a wall clock directly. - Nesting is SINGLE-LEVEL + an escalate hook (Fork 3). Budget exhaustion fires
onEscalate(info); it does NOT restart a parent supervisor. WHY: ship the primitive with a clean seam. Multi-level supervisor-of-supervisors is deferred; the escalation hook is where a parent will one day subscribe to a child's death. - Restart ==
invalidate-only (Fork 4). A restart isinvalidate(token)(flush + rebuild from the SAME registration) followed by an eagergetAsync(token)re-resolve. WHY: the supervisor's job is self-HEALING, not re-wiring. Registration SWAP (rebind, blue/green reconfiguration) is a distinct concern and out of alpha scope. Keeping restart == invalidate makes the stale-dependent handling a pure ORDERING problem -- the three strategies rebuild the affected suffix/set in order, so no live instance is left holding a torn-down dependency (the container's 0005 hazard, resolved here as policy).
Every unverified state fails closed: a not-booted or under-registered container
throws at start() and stays INIT (atomic); a budget-exceeded attempt escalates
rather than silently restarting once more or hot-looping; a fault arriving
mid-restart is chained AFTER it, never recursed, so overlapping invalidate
storms on one token cannot occur and the restart order stays deterministic.
Testing
npm test-- 103node:testcases across construction, start/check lifecycle, the three strategies, the intensity budget + escalation, and therunwrapper (including the boundary and fail-closed matrix).npm run torture--node --expose-gc test/torture.mjs: the 0.000 B/opcheck()gate over 1e6 calls on a 64-child fan-out (maxMajor0), and the T7 soak (100000 fault/restart cycles + 1e6 polls, major GCs 0, leak size 0).npm run verify--npm testthennpm run torture, the publish gate.
What this is not
- Not a multi-level supervisor-of-supervisors. A supervisor's children are
container tokens, one level deep. Budget exhaustion surfaces through
onEscalate-- that hook is the seam a supervision tree will one day hang off of, but the tree itself is deferred to a later minor. - Not a reconfiguration tool. Restart ==
invalidate(rebuild from the same registration). Registration SWAP / blue-green (rebind) is a separate concern and out of alpha scope. The supervisor heals; it does not re-wire. - Not a liveness prober. There are no timers and no polling for health. Faults
are PUSHED (
reportFault/run);check()is a readout you call, not a probe the supervisor runs. - Not a transitive cascade beyond the declared child set. A supervisor restarts ITS OWN children by the chosen ordering; it does not reach across to another supervisor's tokens. Cross-supervisor ordering is the future tree's job.
- Not a process manager. This restarts DI-constructed subsystems inside one process. For OS processes / containers use pm2, systemd, or kubernetes; for retry/backoff of individual CALLS use a retry library -- this restarts SUBSYSTEMS, not calls.
- Not the container. Wiring, lifetimes, and the
invalidateprimitive live in@zakkster/lite-di-container(the peer dependency); the supervisor is the policy on top.
Ecosystem
Part of the @zakkster/lite-di-* line -- a self-healing zero-GC backend service
kernel:
@zakkster/lite-di-container-- the SPINE: DI wiring, lifetimes, and theinvalidate/rebindrestart primitive (peer dependency).@zakkster/lite-di-event-bus-- zero-GC DI fan-out / pub-sub (sibling).@zakkster/lite-di-cron-- wall-clock scheduling over the container (sibling).@zakkster/lite-di-ticker-- the per-frame system loop, 0 B/frame (sibling).@zakkster/lite-di-graph-- topology visualization /describe()formatting (sibling).@zakkster/lite-di-supervisor-- this package: the GAP-1 self-healing KEYSTONE that makes the line a service kernel.@zakkster/lite-gc-profiler/@zakkster/lite-leak-- the allocation gate and retention witness that prove the 0 B/op poll and the soak.
License
MIT (c) Zahary Shinikchiev [email protected]
