@zakkster/lite-di-health
v1.0.0
Published
Fail-closed readiness/liveness aggregator: composes plain health probes (and any object exposing check()) into one verdict, with a 0-allocation readyz()/livez() poll and a drain() latch for graceful shutdown. Pairs with @zakkster/lite-di-supervisor but ne
Maintainers
Readme
@zakkster/lite-di-health
Fail-closed readiness/liveness aggregator. Composes N health SOURCES -- plain pull probes, and any object exposing check() -- into one verdict. readyz()/livez() return a NUMBER (count of not-ready / not-live; 0 == healthy) on a single loop that allocates 0 bytes/op, hard-gated. report() is the structured /healthz body. drain() bleeds traffic for graceful shutdown; livez() is never touched. A source is healthy IFF its probe returns === true or === 0 -- everything else, including a THROW, is UNHEALTHY.
The readiness surface the DI line was missing
Your service has subsystems -- a pool, a cache, a supervisor healing them -- and
an orchestrator above it that needs two yes/no answers on a hot loop: is this
process ALIVE (GET /livez), and is it READY to take traffic (GET /readyz)?
Wiring those by hand drifts: a probe returns undefined on a bad branch and a
naive handler reads it as "not falsey enough to fail", a subsystem dies silently
and nothing flips, a SIGTERM fails the liveness probe and earns a SIGKILL
mid-shutdown. The answers a load balancer trusts are exactly the answers that must
fail CLOSED.
lite-di-health is that readiness surface. It aggregates N lane-tagged SOURCES
into one verdict under a single rule: a source is healthy IFF its probe returns
=== true or === 0 -- false, undefined, null, NaN, a non-zero number, a
string, a boxed Number(0), or a THROW are all UNHEALTHY, and a throwing probe
never blinds the sweep to the rest. readyz() and livez() return a count (0 ==
healthy) on a loop that allocates exactly zero bytes; report() is the human
/healthz body; drain() bleeds readiness for a graceful shutdown while keeping
liveness up. It is the GAP-4 surface that closes the loop under the supervisor:
the supervisor HEALS, this package REPORTS.
npm install @zakkster/lite-di-healthNo dependency -- not even a peer. A source is a plain probe.
import { Health, LANES } from '@zakkster/lite-di-health';
const health = new Health();
health.source('db', () => pool.connected, LANES.READY); // ready when the pool is up
health.source('proc', () => true, LANES.LIVE); // the event loop is turning
health.source('cache', () => cache.ok ? 0 : 1, LANES.BOTH); // 0 == healthy, non-zero == not
// Two 0-allocation polls the load balancer hits every few seconds:
health.readyz(); // 0 -> ready (200), non-zero -> not (503)
health.livez(); // 0 -> live (200), non-zero -> not (503)
// Graceful shutdown: bleed traffic, keep liveness up so you are not SIGKILLed.
health.drain();
health.readyz(); // non-zero -- the load balancer stops sending new work
health.livez(); // unchanged -- the process is still alive, draining in-flight workTable of contents
- Why this exists
- What you get
- The coercion rule
- 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 readiness verdict is only as trustworthy as its worst branch. The failure mode
that bites in production is not a probe that throws -- it is a probe that returns
something ALMOST truthy (undefined, NaN, an empty string, a boxed number) that
a hand-rolled if (probe()) waves through, or a subsystem that dies without
anyone flipping a flag. A readiness surface has to treat every unverified state as
NOT healthy, or it lies to the load balancer at the worst possible moment.
This package makes that rule the ONLY rule and pins it with torture assertions: a
source is healthy IFF === true || === 0; everything else is unhealthy; a throw is
caught, counted unhealthy, and never aborts the sweep. It adds the three things a
raw if cannot: LANES (one source, tagged for readiness, liveness, or both, so
one topology drives two verdicts), a fail-closed EMPTY-lane verdict (nothing has
vouched -> not ready, never a vacuous pass), and a DRAIN latch (bleed readiness on
SIGTERM without failing liveness). "null is not zero," applied to observability.
What you get
- A
Healthaggregator over N lane-tagged pull SOURCES -- register a probe under a unique name in theLIVE,READY, orBOTHlane;readyz()sweeps READY,livez()sweeps LIVE. One topology, two verdicts. - The fail-closed coercion rule as the single source of truth: healthy IFF
=== true || === 0;false/undefined/null/NaN/ any non-zero number / any string / a boxedNumber(0)/ a THROW are all UNHEALTHY. A throwing probe is counted unhealthy and the sweep continues -- one sick source cannot blind the readout to the rest. - Two 0-allocation poll lanes:
readyz()andlivez()each return a count of not-ready / not-live sources in a single indexed loop -- no closure, no array, no string, no allocating branch. Hard-gated at 0.000 B/op over 1e6 calls. - A fail-closed EMPTY-lane verdict: an empty lane returns
1(NOT ready / NOT live). Nothing has vouched, so nothing is claimed -- pinned by a torture assertion so it can never regress to a vacuous0. - A
drain()/undrain()latch:drain()marks the WHOLE ready lane not-ready (a load balancer bleeds traffic) whilelivez()stays untouched; reversible for a rolling deploy, or left latched on theSIGTERMpath. watchSupervisor(name, sup)-- duck-type any object exposingcheck()(e.g.@zakkster/lite-di-supervisor) into a source, 0 B/op per poll. No dependency.- Fail-closed registration: an unknown option, an empty/duplicate name, a
non-function probe, an unknown lane, or a
watchSupervisoron a check-less object all throw.
Full types ship in Health.d.ts.
The coercion rule
A health readout is a security boundary in miniature: when in doubt, say NO. So the rule is deliberately narrow -- a source is healthy for EXACTLY two return values, and unhealthy for every other thing a probe can produce:
| Probe returns | Verdict | Why |
| -------------------- | ----------- | ------------------------------------------------------ |
| true | healthy | an explicit boolean yes |
| 0 | healthy | the "zero problems" count convention (supervisor's check()) |
| -0 | healthy | -0 === 0 is true -- numerically zero, not special-cased |
| false | unhealthy | an explicit no |
| undefined | unhealthy | a probe that fell off the end -- unverified |
| null | unhealthy | "null is not zero" |
| NaN | unhealthy | a computation that went wrong |
| 'ok' (any string) | unhealthy | not a verdict this API accepts |
| 1, 2, -1 (non-zero) | unhealthy | a non-zero problem count |
| new Number(0) | unhealthy | a boxed object -- === 0 is false; fail closed |
| a THROW | unhealthy | caught, counted, and the sweep CONTINUES |
The last row is the load-bearing one: a probe that throws does not crash
readyz() and does not stop it from reaching the sources after it. It is counted
unhealthy and the loop moves on, so a single sick subsystem can never blind the
readout to a healthy majority. This is the supervisor's "escalate flush" lesson
in a new coat: never coerce an unverified state toward healthy.
API reference
Constructor
new Health(options?: {
sources?: { name: string | symbol; probe: () => boolean | number; lane?: 1 | 2 | 3 }[];
})options-- optional. The ONLY known key issources; any other key throws with a did-you-mean hint (unknown option 'source'. Did you mean 'sources'?).sources-- optional initial sources, each an object{ name, probe, lane? }, validated and appended via the SAME path as.source()(so every fail-closed rule below applies identically).lanedefaults toBOTH.
Methods
source(name: string | symbol, probe: () => boolean | number, lane?: 1 | 2 | 3): void
watchSupervisor(name: string | symbol, sup: { check(): number }, lane?: 1 | 2 | 3): void
readyz(): number
livez(): number
report(): { ready: number; live: number; state: number; sources: { name; lane; status }[] }
drain(): void
undrain(): void
get state(): numbersource(name, probe, lane?)-- COLD. Register a pull probe under a unique name in a lane (defaultBOTH). A non-empty-string / non-symbol name, a duplicate name, a non-function probe, or an unknown lane each throw. Append-only -- there is no remove in alpha (topology is boot-locked, like the sibling bricks).watchSupervisor(name, sup, lane?)-- COLD. Duck-typessup.check: ifsupis null/undefined orsup.checkis not a function, it THROWS (a phantom source -- silently never-healthy or silently skipped -- is a fail-open readout). It registers a 0-allocation probe callingsup.check(), whose count composes directly (0healthy, non-zero unhealthy).readyz(): number-- HOT. The count of not-ready sources among READY-lane sources (0== ready). A single indexed loop; no closure, no array, no allocating branch. An empty READY lane returns1(fail-closed). While DRAINING it returns the READY-source count (the whole lane reads not-ready). 0.000 B/op.livez(): number-- HOT. The count of not-live sources among LIVE-lane sources (0== live). An empty LIVE lane returns1(fail-closed, symmetric -- see the footgun below). NEVER affected by drain. 0.000 B/op.report(): object-- COLD.{ ready, live, state, sources }for the human/healthzbody.ready/livecome fromreadyz()/livez()(soreadyreflects the drain latch);sourcesis[{ name, lane, status }]wherestatusis the RAW coercion verdict ('healthy'|'unhealthy'), NOT forced by drain -- an operator draining a still-passing node should see it passing. Re-probes each source; allocates freely.drain(): void-- latchOK -> DRAINING. Idempotent.readyz()flips to all-not-ready;livez()is untouched. The seam an orchestrator pulls before teardown.undrain(): void-- latchDRAINING -> OK. Idempotent. Reversesdrain()for a rolling deploy / maintenance toggle; theSIGTERMpath simply never calls it.state(getter) -- the current drain state, one ofSTATES.
The empty-LIVE-lane footgun. The empty-lane rule is SYMMETRIC and fail- closed: an empty LIVE lane returns
1, so a process that exposes/livezyet registers NOLIVE(orBOTH) source reports NOT-live -- and a Kubernetes liveness probe would RESTART it. This is by design (nothing has vouched for liveness). Register at least one LIVE source if you wire/livez. The code stays fail-closed; this warning carries the nuance.
Constants
LANES = { LIVE: 1, READY: 2, BOTH: 3 } // frozen bitmask
STATES = { OK: 0, DRAINING: 1 } // frozen int enum
VERSION // version string, three-place-synced| Export | Type | Value / meaning |
| --------------- | --------------- | ------------------------------------------------------------ |
| LANES.LIVE | number (1) | Source counts toward livez() only. |
| LANES.READY | number (2) | Source counts toward readyz() only. |
| LANES.BOTH | number (3) | Source counts toward both LIVE and READY (bit 1 + bit 2). The default. |
| STATES.OK | number (0) | Normal operation. readyz() reflects the probes. |
| STATES.DRAINING | number (1) | Draining. readyz() reports all-not-ready; livez() unchanged. |
| VERSION | string | '1.0.0'. |
Composability with the DI line
A full readiness surface: subsystems wired in a container, a supervisor healing
them, and a Health aggregator folding the supervisor's check() plus a couple
of raw probes into the two answers the orchestrator polls -- with a drain() on
SIGTERM that bleeds traffic before teardown.
import { Container } from '@zakkster/lite-di-container';
import { Supervisor, STRATEGIES } from '@zakkster/lite-di-supervisor';
import { Health, LANES } from '@zakkster/lite-di-health';
// --- Subsystems, wired + supervised. ---------------------------------------
const c = new Container();
c.singleton('pool', Pool);
c.singleton('repo', Repo, ['pool']);
c.singleton('worker', Worker, ['repo']);
c.boot();
const sup = new Supervisor(c, {
children: ['pool', 'repo', 'worker'],
strategy: STRATEGIES.REST_FOR_ONE,
});
await sup.start();
// --- The readiness surface. -------------------------------------------------
const health = new Health();
health.watchSupervisor('kernel', sup, LANES.READY); // supervisor healthy -> ready
health.source('proc', () => true, LANES.LIVE); // the process is alive
health.source('deps', () => pingUpstream() ? 0 : 1, LANES.READY);
// --- The k8s probe endpoints -- 0 B/op each. --------------------------------
function readyz() { return health.readyz() === 0 ? 200 : 503; }
function livez() { return health.livez() === 0 ? 200 : 503; }
function healthz() { return health.report(); }
// --- Graceful shutdown. Bleed readiness, keep liveness up. ------------------
process.on('SIGTERM', async () => {
health.drain(); // readyz -> 503; livez stays 200 (no SIGKILL)
await drainInFlight(); // let the load balancer stop sending, finish work
await sup.shutdown();
await c.shutdown();
process.exit(0);
});Every stage snaps onto the DI line, but nothing is REQUIRED: watchSupervisor
duck-types check(), so any object with that method composes and this package
carries no dependency. The container owns the instances, the supervisor owns the
healing, and this package owns the verdict the orchestrator reads.
Zero-GC design notes
readyz() and livez() are the only hot lanes -- the readiness/liveness poll a
long-running server hits every few seconds -- so they are gated at exactly zero,
not "small". Each is a single indexed loop over the lane-tagged parallel arrays
(_probes / _lane), masking the lane bit, calling the probe, and counting the
not-healthy verdicts. It captures no closure, builds no message, allocates no
array or object, and takes no allocating branch. The try/catch around each probe
allocates NOTHING on the healthy path (no throw = no Error); a throw is the rare
unhealthy path where allocation is fine. Everything else -- source,
watchSupervisor, report -- is COLD: registration is rare and report() is the
human path, so their allocation is fine.
| Path | Steady-state allocations |
| --------------------------------- | ----------------------------------------------- |
| readyz() (poll, N sources) | 0.000 B/op (HARD gate, 1e6 ops, 64 sources) |
| livez() (poll, N sources) | 0.000 B/op (HARD gate, 1e6 ops, 64 sources) |
| watchSupervisor-backed readyz() | 0.000 B/op (sup.check() is 0-alloc) |
| source / watchSupervisor | cold by design (registration, rare) |
| report() | cold by design (the human /healthz body) |
The torture gate (@zakkster/lite-leak + @zakkster/lite-gc-profiler, under
--expose-gc) proves both the poll and the soak:
- readyz() / livez(): 0.000 B/op over 1e6 calls on a 64-source fan-out,
maxMajor0. - T7 soak: 100000 source-set churn cycles (retention returns to 0) + 1e6
readyz/livezpolls interleaved withdrain/undraincycles, major GCs = 0, the probe array never grows and the lane SoA is never reallocated.
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-health-model.md.
- Source contract is PULL (Fork 1). A source is
() => boolean | number, swept on demand. WHY: the probe path stays 0-alloc and stateless, and a source can never go stale. PUSH would hold mutable last-known state and fail OPEN the instant one source forgot to report a transition. - One lane-tagged aggregator (Fork 2). Each source carries a lane mask
(
LIVE | READY | BOTH); one topology drives bothreadyz()andlivez(). WHY: two separate aggregators drift -- a source on one but not the other is a silent gap. The empty-lane verdict is fail-closed (Fork 2a): an empty lane returns1(not-ready), pinned by a torture assertion so it can never regress to the vacuous0("nothing has vouched, yet I claim ready"). Symmetric for both lanes -- with the LIVE footgun documented above, not softened in code. - Number-hot + object-cold (Fork 3).
readyz()/livez()return a NUMBER (the 0-alloc poll lane);report()returns the structured object (the cold, human path). WHY: a single rich return would allocate a{ ... }and asourcesarray on the poll lane, several times a second, forever. This mirrors the supervisor'scheck()(number) vsdescribe()(object) split exactly. - drain() flips readyz only, and is reversible (Fork 4). While DRAINING,
readyz()reports the whole ready lane not-ready;livez()is UNAFFECTED, andundrain()reverses the latch. WHY: flippinglivez()on drain would fail the k8s liveness probe mid-shutdown and invite aSIGKILLbefore graceful teardown finishes. The process is ALIVE during a drain, just not accepting new work.
Every unverified state fails closed: an unknown option throws with a did-you-mean
hint, an empty/duplicate name or non-function probe throws, a watchSupervisor on
a check-less object throws, an absent/throwing/undefined probe is UNHEALTHY (never
skipped, never coerced toward 0), an empty lane is not-ready, and the drain latch
marks the WHOLE ready lane not-ready, not one token.
Testing
npm test--node:testcases across construction, registration, the coercion matrix, the lane routing, the empty-lane + drain pins, andwatchSupervisor.npm run torture--node --expose-gc test/torture.mjs: the 0.000 B/opreadyz()+livez()gate over 1e6 calls on a 64-source fan-out (maxMajor0, plus awatchSupervisor-backed variant), the coercion differential vs a naive oracle aggregator, and the T7 soak (100000 churn cycles + 1e6 polls, major GCs 0, leak size 0).npm run verify--npm testthennpm run torture, the publish gate.
What this is not
- Not an HTTP server or router. You wire
readyz()/livez()/report()into your own framework's routes (see the COOKBOOK); this produces the verdict, not the transport. - Not a process / container health manager. Kubernetes, systemd, and pm2 READ a readiness verdict; this package PRODUCES one. It is the surface their probes hit, not the orchestrator.
- Not a metrics or tracing library. A source is a binary healthy/not verdict, not a gauge, a histogram, or a span.
- Not a liveness prober with timers. Sources are PULLED on demand when you call
readyz()/livez(); this package runs no timers and polls nothing on its own. - Not a supervisor. Healing subsystems on a fault is
@zakkster/lite-di-supervisor; this package folds that supervisor'scheck()(and any other probe) into a readiness verdict, and needs no dependency to do it.
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.@zakkster/lite-di-supervisor-- the GAP-1 self-healing keystone; itscheck()is whatwatchSupervisorfolds in.@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-health-- this package: the GAP-4 readiness/liveness surface that closes the loop under the supervisor.@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]
