docs(website): settle boot/shutdown dispatch and what a boot writes down
Records phase 2 PR 5 of the module system: the lifecycle hooks a module registers, how they are dispatched, and what a boot does to installed_modules. MODULE_API.md 2.5 gains the reconcile's four steps in order, the rules that fall out of them (the operator's `disabled` wins over any outcome; a bookkeeping failure is not a boot failure; a module with no onBoot still reaches `started`; a module whose onBoot threw gets no onShutdown), and why onBoot has no timeout while onShutdown has a five-second budget -- shutdown races the process being killed and boot does not. 4.4 gains the failure_stage table: every failure is recorded against the 4.3 step that produced it, so the admin panel can say where a module broke and not only what the message was. MODULE_SYSTEM.md 2.4 records the new rule for a row whose directory is gone, and 2.7 the PR 5 progress entry with its four decisions. BACKEND_DESIGN.md's installed_modules section gains the write path now that one exists. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -258,13 +258,59 @@ It runs **after** `ensureSchema()` (so the module's own tables exist) and after
|
||||
and **before** the HTTP listener binds — a module that must not serve traffic before it has warmed
|
||||
its cache gets that for free.
|
||||
|
||||
`onShutdown` runs before the server closes, in reverse registration order, with a 5-second budget
|
||||
per module; exceeding it is logged and skipped rather than hanging the process.
|
||||
`onShutdown` runs before anything core owns is closed — the database pool, the push dispatcher and
|
||||
the SSE fan-out are all still open, because a module's `onShutdown` is the only chance it gets to
|
||||
flush through them. Reverse registration order, with a 5-second budget per module; exceeding it is
|
||||
logged and the hook abandoned rather than hanging the process. Abandoned, not cancelled: nothing can
|
||||
stop a promise that is still running, but the process is exiting anyway and the alternative is a host
|
||||
where `systemctl stop` waits for SIGKILL.
|
||||
|
||||
Both are individually try/caught. An `onBoot` that throws marks that module `startup_failed`
|
||||
(§4.4) and the site still comes up — its routes stay mounted but its dispatch guard rejects them
|
||||
with 503, because a module that failed to warm up serving half-initialised data is worse than a
|
||||
module that says it is down.
|
||||
**`onBoot` has no budget, deliberately.** Shutdown races the process being killed; boot does not. A
|
||||
slow `onBoot` delays the listener binding, which is the guarantee two paragraphs up rather than a
|
||||
problem to be timed out, and core's own boot steps are awaited exactly the same way.
|
||||
|
||||
Both hooks are optional, and both are individually try/caught. An `onBoot` that throws marks that
|
||||
module `startup_failed` (§4.4) and the site still comes up — its routes stay mounted but its dispatch
|
||||
guard rejects them with 503, because a module that failed to warm up serving half-initialised data is
|
||||
worse than a module that says it is down. A module with no `onBoot` at all still reaches `started`:
|
||||
having nothing to warm up is not the same as never having started, and the row has to agree with the
|
||||
guard about whether the module is serving. A module whose `onBoot` threw gets **no** `onShutdown` — it
|
||||
is part-way through a warm-up it never finished, and handing it a half-built world to tear down is
|
||||
worse than not closing cleanly.
|
||||
|
||||
`onBoot` receives the same frozen `ctx` object `register()` was given, not a second one built to look
|
||||
like it.
|
||||
|
||||
**What a boot does to `installed_modules`** (`MODULE_SYSTEM.md` §2.4). The dispatch is the second half
|
||||
of a reconcile, and the order of its four steps is the design:
|
||||
|
||||
1. Clear the last boot's outcomes, so what is on display afterwards is what *this* boot did.
|
||||
`disabled` rows are left alone — that is an operator decision, not an outcome.
|
||||
2. Write a row for every module found on the volume, with null provenance if it has none. A directory
|
||||
placed on the volume by hand is a supported install (§2.5 of the design of record) and without a
|
||||
row it could be neither disabled nor reported.
|
||||
3. Mark any row whose directory is **not** on the volume `startup_failed` (stage `require`). Step 1
|
||||
has just reset it to `enabled`, and a row claiming to be enabled for a module that is not there is
|
||||
the one state that is simply untrue. A plain uninstall leaves `disabled`, which step 1 never
|
||||
touches, so this catches only a directory deleted by hand.
|
||||
4. Write down the outcome each module already carries — disabled by the operator, or failed during
|
||||
load or schema replay, both of which happen before the database is reachable — and only then
|
||||
dispatch `onBoot`.
|
||||
|
||||
**The operator's switch wins over everything, including a failure.** A module whose row says
|
||||
`disabled` is guarded (§4.5), is not booted, and does **not** have its failure re-recorded:
|
||||
overwriting a deliberate `disabled` with an outcome would silently switch it back on at the next
|
||||
boot.
|
||||
|
||||
**A bookkeeping failure is not a boot failure.** Every database write in the reconcile is individually
|
||||
caught. A row that will not update is bad — the admin panel shows the wrong thing — but it is
|
||||
strictly less bad than a site that will not start, and it must not stop the modules behind it from
|
||||
booting.
|
||||
|
||||
Dispatch and reconcile live in `server/src/modules/lifecycle.js`, not in the loader: `routeManifest.js`
|
||||
and `swagger.js` both require `app.js` against a dead pool (§4.1), so the loader may not reach the
|
||||
database. The two halves meet at exactly one place — `loader.setState()` — so the in-memory record
|
||||
the dispatch guard reads and the row the admin panel reads are moved together and cannot disagree.
|
||||
|
||||
### 2.6 Schema fragments
|
||||
|
||||
@@ -584,6 +630,26 @@ Two sub-cases differ, and the difference matters:
|
||||
The second is what keeps the URL surface deterministic and generatable: `routes.manifest.json` must
|
||||
not depend on whether a module's boot hook happened to succeed on the machine that generated it.
|
||||
|
||||
**Every failure is recorded against the step that produced it**, in `failure_stage`, so the admin
|
||||
panel can say *where* a module broke and not only what the message was. The stages are §4.3's seven
|
||||
validation steps plus `boot`:
|
||||
|
||||
| Stage | The step that failed |
|
||||
| --- | --- |
|
||||
| `manifest` | `module.json` unparseable, an unknown key, a bad or mismatched `id`, no `version` |
|
||||
| `core_api` | `coreApi` missing, or not satisfied by `MODULE_API_VERSION` |
|
||||
| `mounts` | a malformed prefix, or one already owned by core or another module |
|
||||
| `extensions` | a declared slot that does not exist |
|
||||
| `schema` | a fragment breaking a §2.6 rule at load, or a statement the database rejected at replay |
|
||||
| `require` | the entry point threw, or did not export a function — also a row whose directory is gone |
|
||||
| `register` | `register()` threw, a claim was malformed, or what it registered ≠ what it declared |
|
||||
| `boot` | `onBoot` threw |
|
||||
|
||||
The four steps that share one function label themselves; the rest are inferred from how far the load
|
||||
had got, and an unlabelled throw is recorded against the step that was running rather than guessed
|
||||
at. Every non-failing transition clears both the stage and the reason, so a running module can never
|
||||
show the failure it had two boots ago.
|
||||
|
||||
### 4.5 The disabled guard
|
||||
|
||||
A module disabled in `installed_modules` is *mounted and guarded*, never unmounted — a one-line
|
||||
|
||||
Reference in New Issue
Block a user