Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions .changeset/plugin-auto-restart-never-reinitialised.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
---
"@objectstack/spec": minor
"@objectstack/core": minor
---

fix(spec,core): `PluginHealthMonitor` stops claiming a restart it never performed; the three `PluginHealthCheck` restart keys retired (#12032, ADR-0049)

<!-- adr-0087: registered plugin-auto-restart-never-reinitialised -->

**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
launch-window convention ships it as `minor`; the prescriptions are registered
under protocol major 18 — three `RETIRED_KEYS_BY_MAJOR[18]` entries plus the D3
semantic entry `plugin-auto-restart-never-reinitialised` — where
`os migrate meta` users will look). Graded `minor` rather than `major` for the
same reason #12340 and #12428 were, the day before, in this same module.

## What was measured

`PluginHealthMonitor.attemptRestart` called `plugin.destroy()` and stopped
there. The comment above the call read *"Call destroy and init to restart"*,
and `init` appeared in `health-monitor.ts` **only inside that comment**. So a
plugin whose health checks crossed `failureThreshold` with `autoRestart: true`
got: `destroy()`, a log line reading `Plugin restarted`, status `recovering`,
and periodic health checks that carried on running against the destroyed
instance. The default check when no `checkMethod` resolves is
`{ name: 'plugin-loaded', status: 'passed' }`, which a destroyed object passes
indefinitely — so the **terminal** report on a torn-down, never-re-initialised
plugin was `healthy`.

Reproduced at `ee3595cefd` before anything was changed, with
`successThreshold: 3`:

```
round 1 (failing): status=failed destroyed=0 alive=true
after backoff: status=recovering destroyed=1 alive=false
recovery round 1: status=recovering destroyed=1 alive=false
recovery round 2: status=recovering destroyed=1 alive=false
recovery round 3: status=healthy destroyed=1 alive=false
```

#11955 made that report *more* convincing rather than less: reaching `healthy`
now costs `successThreshold` consecutive passing rounds, so a destroyed plugin
has to earn a declared number of passes before it is misreported.
`restartAttempts` was incremented as though a restart had occurred, and
`maxRestartAttempts` / `restartBackoff` scheduled further "restarts" of a plugin
that was never brought back up.

## Why REMOVE and not the other two ADR-0049 states

**ENFORCE** would have to build the restart, and the class cannot host one.
`Plugin.init(ctx)` needs a `PluginContext`; the only two `plugin.init(...)` call
sites in the tree are the kernel's own boot loops (`kernel-base.ts:202`,
`kernel.ts:607`), both over the full plugin list, with a context that is
`private` on `ObjectKernel` and `protected` on `KernelBase`. No host can obtain
one, so a host-provided re-init hook would have had nothing to call. (Positive
control for that scan: the same pass resolves five real non-test
`plugin.destroy()` call sites, so it does see lifecycle drivers.) Building a
per-plugin re-init API for a caller that does not exist — no runtime constructs
`PluginHealthMonitor` (#11825) — is the speculation ADR-0049's staged decision
names as the wrong default at this milestone, where the shippable liability is
the false promise and not the missing feature.

**EXPERIMENTAL** requires a roadmap. A scan of the whole `docs/` planning + ADR
corpus returned **zero** mentions of plugin auto-restart, against 118 control
hits for "health" and 13 for "hot reload" in the same corpus.

`maxRestartAttempts` and `restartBackoff` leave with `autoRestart` rather than
as a tidy-up: with no restart, *"Maximum restart attempts before giving up"* and
*"Backoff strategy for restart delays"* have nothing left to be the vocabulary
**of** — the test that took `distributedConfig` out with the `stateStrategy`
value it was documented as requiring (#12340).

## What changes for a host

All three keys are **tombstoned**, not deleted: `PluginHealthCheckSchema` is not
`.strict()`, so a bare deletion would be a silent strip (#3733, ADR-0104) — a
milder form of the defect being retired. A TypeScript host gets a `tsc` error
(the keys are typed `never`); a parse raises the prescription; and
`PluginHealthMonitor.registerPlugin` refuses a hand-built config carrying any of
them with an ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`), thrown
before any state is stored so a refused config leaves no half-registered plugin
behind.

`PluginHealthMonitor` no longer calls `plugin.destroy()` at all. A plugin that
crosses `failureThreshold` is reported `degraded` / `unhealthy` / `failed` and
left running; acting on that is the host's job in this host-driven library
(#11825 route 2). Poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)`
and restart at the level that owns the plugin's lifetime.

Everything else in the monitor is unchanged: registration, periodic checks, the
`timeout` race and its refd-timer guard (#4875), both failure routes sharing the
counters (#11852), and `successThreshold` binding from every status that records
a failure (#11955). `recovering` is now written only by the success branch —
the one writer that ever meant it.
26 changes: 21 additions & 5 deletions content/docs/protocol/kernel/lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -693,8 +693,7 @@ export const salesforcePlugin = {
const monitor = new PluginHealthMonitor(kernel.logger);

// `registerPlugin` takes the PARSED config, so parse it: the schema fills in
// interval 30000, timeout 5000, failureThreshold 3, successThreshold 1,
// autoRestart false, maxRestartAttempts 3, restartBackoff 'exponential'.
// interval 30000, timeout 5000, failureThreshold 3 and successThreshold 1.
monitor.registerPlugin(
salesforcePlugin.name,
PluginHealthCheckSchema.parse({ checkMethod: 'healthCheck' }),
Expand Down Expand Up @@ -734,9 +733,26 @@ failure resets the success count to zero — both routes included — so a throw
part-way through a recovery starts the next attempt at one rather than resuming
where it left off. The symmetry holds the other way too: a passing round resets
the failure count, so `failureThreshold` likewise counts only an unbroken run.
A successful auto-restart lands the plugin in `recovering` with **both**
counters cleared, so a restarted plugin still owes a full `successThreshold` of
passing rounds before it reads `healthy`.

### The monitor reports; it does not act

Nothing above does anything **to** the plugin. A failing plugin is labelled
`degraded`, `unhealthy` or `failed` and left running; the monitor never calls
`destroy()`, and acting on what it reports is the host's job — this is a
host-driven library, and the host is the only party that owns the plugin's
lifetime.

It used to claim otherwise. `PluginHealthCheck` carried `autoRestart`,
`maxRestartAttempts` and `restartBackoff`, and a plugin that crossed
`failureThreshold` with `autoRestart: true` got `plugin.destroy()` called on it
— and nothing else. `init()` was never called, because the monitor has no
`PluginContext` to call it with and no way to obtain one. The plugin was then
logged as `Plugin restarted`, marked `recovering`, and kept under periodic
checks it went on passing from the grave: the default check when no
`checkMethod` resolves is `plugin-loaded`, which a destroyed object satisfies
forever. So the terminal report on a torn-down plugin was `healthy`. The three
keys were removed in `@objectstack/spec` 18 under ADR-0049 enforce-or-remove;
`PluginHealthMonitor.registerPlugin` refuses a config that still carries one.

At the default `successThreshold: 1` none of this is observable: the first
passing round satisfies the count from every status, and `recovering` is never
Expand Down
6 changes: 3 additions & 3 deletions content/docs/references/kernel/plugin-lifecycle-advanced.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ const result = HotReloadConfigSchema.parse(data);
| **failureThreshold** | `integer` | optional (default: `3`) | Consecutive failures needed to mark unhealthy |
| **successThreshold** | `integer` | optional (default: `1`) | Consecutive successes needed to mark healthy |
| **checkMethod** | `string` | optional | Method name to call for health check |
| **autoRestart** | `boolean` | optional (default: `false`) | Automatically restart plugin on health check failure |
| **maxRestartAttempts** | `integer` | optional (default: `3`) | Maximum restart attempts before giving up |
| **restartBackoff** | `Enum<'fixed' \| 'linear' \| 'exponential'>` | optional (default: `"exponential"`) | Backoff strategy for restart delays |
| **autoRestart** | `never` | optional | [REMOVED] `PluginHealthCheck.autoRestart` was removed in @objectstack/spec 18 (#12032, ADR-0049 enforce-or-remove) — it never restarted a plugin. A `PluginHealthMonitor` never restarted anything. `attemptRestart` called `plugin.destroy()` and stopped there — the in-source comment said "Call destroy and init to restart", but `init` appeared in `health-monitor.ts` ONLY inside that comment. What a plugin actually got was: destroy, a log line reading 'Plugin restarted', status `recovering`, and periodic health checks continuing against the destroyed instance — which the default check (`{ name: 'plugin-loaded', status: 'passed' }`, used whenever no `checkMethod` resolves) passes forever, so the terminal report on a destroyed, never-re-initialised plugin was `healthy`. Delete the key. Restarting a plugin is the HOST's job in this host-driven library, and the monitor could not do it even in principle: `Plugin.init(ctx)` needs a `PluginContext`, which only the kernel constructs and which it exposes to nobody (`ObjectKernel.context` is private; `KernelBase.createContext` is protected). Poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)` and act on `unhealthy` / `failed` at the level that owns the plugin's lifetime — recreate the kernel, or let your supervisor restart the process. The monitor reports; it does not act. |
| **maxRestartAttempts** | `never` | optional | [REMOVED] `PluginHealthCheck.maxRestartAttempts` was removed in @objectstack/spec 18 (#12032, ADR-0049 enforce-or-remove) — it capped a restart that never happened. A `PluginHealthMonitor` never restarted anything. `attemptRestart` called `plugin.destroy()` and stopped there — the in-source comment said "Call destroy and init to restart", but `init` appeared in `health-monitor.ts` ONLY inside that comment. What a plugin actually got was: destroy, a log line reading 'Plugin restarted', status `recovering`, and periodic health checks continuing against the destroyed instance — which the default check (`{ name: 'plugin-loaded', status: 'passed' }`, used whenever no `checkMethod` resolves) passes forever, so the terminal report on a destroyed, never-re-initialised plugin was `healthy`. The cap counted destroy calls, so raising it only scheduled further "restarts" of a plugin that was never brought back up. Delete the key. Restarting a plugin is the HOST's job in this host-driven library, and the monitor could not do it even in principle: `Plugin.init(ctx)` needs a `PluginContext`, which only the kernel constructs and which it exposes to nobody (`ObjectKernel.context` is private; `KernelBase.createContext` is protected). Poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)` and act on `unhealthy` / `failed` at the level that owns the plugin's lifetime — recreate the kernel, or let your supervisor restart the process. The monitor reports; it does not act. |
| **restartBackoff** | `never` | optional | [REMOVED] `PluginHealthCheck.restartBackoff` was removed in @objectstack/spec 18 (#12032, ADR-0049 enforce-or-remove) — it delayed a restart that never happened. A `PluginHealthMonitor` never restarted anything. `attemptRestart` called `plugin.destroy()` and stopped there — the in-source comment said "Call destroy and init to restart", but `init` appeared in `health-monitor.ts` ONLY inside that comment. What a plugin actually got was: destroy, a log line reading 'Plugin restarted', status `recovering`, and periodic health checks continuing against the destroyed instance — which the default check (`{ name: 'plugin-loaded', status: 'passed' }`, used whenever no `checkMethod` resolves) passes forever, so the terminal report on a destroyed, never-re-initialised plugin was `healthy`. The chosen strategy only moved when the destroy landed. Delete the key. Restarting a plugin is the HOST's job in this host-driven library, and the monitor could not do it even in principle: `Plugin.init(ctx)` needs a `PluginContext`, which only the kernel constructs and which it exposes to nobody (`ObjectKernel.context` is private; `KernelBase.createContext` is protected). Poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)` and act on `unhealthy` / `failed` at the level that owns the plugin's lifetime — recreate the kernel, or let your supervisor restart the process. The monitor reports; it does not act. |


---
Expand Down
7 changes: 4 additions & 3 deletions packages/core/examples/phase2-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,9 +270,10 @@ async function example() {
timeout: 5000,
failureThreshold: 3,
successThreshold: 1,
autoRestart: true,
maxRestartAttempts: 3,
restartBackoff: 'exponential',
// [#12032] `autoRestart` / `maxRestartAttempts` / `restartBackoff`
// removed: the monitor never restarted anything (it called
// `plugin.destroy()` and reported the corpse `healthy`), so the keys
// were retired under ADR-0049. Act on `getHealthStatus()` in the host.
},

// Hot reload
Expand Down
Loading
Loading