ADR 0107 — A rollback spends a declared budget, and the budget is a bound
Context
ADR 0102 lists the kernel’s obligations as separate bullets: “rollback of every
resource whose start was attempted” and “bounded drain, reverse-order close
and optional force under two absolute process-wide deadlines”. The deadlines
belong to the phase list of shutdown(). Nothing said what a rollback
spends, and the gap was not noticed because it does not look like a gap: the
rollback calls close, close takes a context, and the context type declares
both deadlines optional.
Optional turned out to mean two different things at the two ends. The kernel
read it as “this path has no deadlines to give”. managedServerResource read
it as context.deadlineAt ?? now, and then computed now - now — which is to
say it answered “your budget is zero”. So a failed startup handed its server
{ gracePeriodMs: 0, forceTimeoutMs: 0 }, and three things followed:
- requests the server had already accepted died at the socket, unanswered;
- the rollback itself failed, because
withTimeout(forceStop(), 0)cannot succeed; - and that failure replaced the diagnosis.
start()rejected with anAggregateErrorreading “application startup and rollback failed”, so the first line an operator read was shutdown machinery and the resource that actually broke was one entry down.
Measured, with one request in flight: the request was severed in 39ms and the
startup cause was buried. The path became ordinary rather than rare when
reportHealth during start began to be honoured — a required resource that
starts unhealthy now fails the startup, so rollback is a normal outcome.
There is a second defect underneath, and it predates all of this: the rollback
loop awaited each close with nothing watching the clock. Every other phase in
shutdown() is wrapped in untilDeadline. A resource whose close never
returns — a poller awaiting its own completion, a consumer resource with a hung
upstream — therefore kept a failed startup from ever reporting why it
failed. Handing that loop deadlines without watching them would have moved the
hang from “forever” to “forever” while reading as though it were fixed.
Decision
The budget is declared on the application. ApplicationConfig.shutdown
(ApplicationShutdownBudgetSchema: gracePeriodMs, forceTimeoutMs, no
signal) is the budget the rollback spends, and the default for shutdown()
called with no options. A rollback happens inside start(), so there is no call
for a caller to pass options to; the budget has to live where the application
does. Making it also the shutdown() default keeps “how long may this
application take to stop” one number instead of two that can disagree.
The budget is enforced. The rollback’s close sweep runs under a timer at
the force deadline. A close that does not finish is abandoned, reported as a
close failure, and the startup error remains the AggregateError’s cause.
An absent deadline is not a spent one. managedServerResource no longer
collapses absence into now. It omits the field and lets
ShutdownOptionsSchema — which already carries .default(30_000) /
.default(5_000) — apply its own defaults. This is the Zod-first rule applied
to a boundary: the schema owns these numbers, and a second copy in an adapter is
a copy that can drift. It also fixes the class of bug rather than one arrangement
of it, since a consumer or the conformance kit may legitimately build a context
with no deadlines.
Consequences
A failed start() can now take up to the budget to reject. This is the
trade, and it is the reason this is an ADR and a ### ⚠️ Breaking changes
entry rather than a bug fix. With nothing in flight the rollback still returns
at once — a grace period is a ceiling, not a sleep. With a request that never
finishes, the default 30s+5s turns a rejection that took milliseconds into one
that can take 35 seconds. An application that would rather fail fast declares a
smaller budget; that knob is why the ceiling is not a supervisor policy chosen
by the kernel, which ADR 0102 says it does not choose.
It is also what makes the bound testable. A bound that can only be exercised by
waiting out the default budget is a bound nobody can afford to test, and an
untested bound is not one anybody should rely on;
packages/core/tests/application-reported-health.test.ts proves it in
milliseconds by declaring a 20ms budget.
The rollback remains one phase, not five. It calls close and only close
— no stopAdmission, no drain, no force loop. forceDeadlineAt is still
passed because an adapter that self-forces inside its own close (the managed
server does) needs both ends to compute a budget from. The guide says this
plainly rather than describing the rollback as “a real shutdown”, which it is
not.
One deadline is shared by the whole sweep, as ADR 0102 requires of the shutdown loops: “it cannot create a fresh timeout for each resource”. A resource reached after the budget is spent therefore sees a deadline in the past and gets a zero grace — which is now the correct answer, because the budget really is spent, rather than the previous answer to a question nobody had asked.