first commit
This commit is contained in:
154
docs/api-stack/api/api-host/src/hot-reload/hot-reload.md
Normal file
154
docs/api-stack/api/api-host/src/hot-reload/hot-reload.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# `ReloadDriver`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ReloadDriver` implements the §8.1 blue-green hot-reload primitive plus the
|
||||
§8.1a `RollbackJournal` integration. Each `reload(plugin, opts)` call
|
||||
captures the live module + manifest into a per-plugin ring buffer, imports
|
||||
the new module under a cache-busted ESM specifier via the
|
||||
`@suaveplan/api-loader` `ModuleResolver`, refuses to proceed if native
|
||||
bindings (`*.node`) are present in the dep graph, stages the new module in
|
||||
a shadow router slot, drives the lifecycle FSM through `stop → start` against
|
||||
the new module, runs the health probe, atomically flips the router's
|
||||
per-plugin version pointer to the new slot, and drains the old slot. On any
|
||||
failure the driver discards the shadow + auto-transitions the plugin to
|
||||
`rolled_back` by re-activating the most-recent journal entry.
|
||||
|
||||
The driver is paired with `LifecycleDriver` (see `../lifecycle/`): the
|
||||
lifecycle driver owns the FSM transitions; the reload driver orchestrates
|
||||
the blue-green plumbing around them. Both share the same `EventBus` so
|
||||
the `plugin.rolled_back` event is the public boundary between them.
|
||||
|
||||
## Features
|
||||
|
||||
- Blue-green primitive with atomic shadow → live flip + draining slot.
|
||||
- 503-window opt-in via `blueGreen: false` for environments that cannot host
|
||||
two module instances concurrently.
|
||||
- `RollbackJournal` ring buffer (default depth 3) layered on the in-memory
|
||||
`createRollbackJournal` from `@suaveplan/api-loader`; persisted subset
|
||||
written to the plugin ledger via the `RollbackPersistence` adapter.
|
||||
- Auto-rollback on `start_failed` during reload — the lifecycle driver
|
||||
is fed `rolled_back` without operator intervention.
|
||||
- Manual rollback via `rollback(plugin, toIndex)` — Stage 4's admin route
|
||||
`POST /admin/plugins/{name}/rollback?to=N` calls into this.
|
||||
- Native-binding guard via `walkNativeBindings()` + `assertNoNativeBindings()`.
|
||||
- Hydration from the persisted journal store after host restart —
|
||||
`hydrateFromPersistence(plugin)` re-populates the in-memory ring buffer
|
||||
from the ledger (the `moduleRef` is intentionally left `null` until a
|
||||
rollback re-imports).
|
||||
- `FileWatcher` (sibling feature) supplies dev-mode `--watch` triggers with
|
||||
a 500ms debounce, gated off in production.
|
||||
- OTel: `plugin.reload_total{plugin, trigger, outcome}` counter +
|
||||
`plugin.reload_duration_ms{plugin, trigger}` histogram on every reload.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import {
|
||||
createHostInstruments,
|
||||
LifecycleDriver,
|
||||
ReloadDriver,
|
||||
} from "@suaveplan/api-host";
|
||||
|
||||
const reload = new ReloadDriver({
|
||||
adapters: {
|
||||
blueGreen,
|
||||
persistence,
|
||||
resolver,
|
||||
health: { async probe() {} },
|
||||
},
|
||||
lifecycle,
|
||||
events,
|
||||
instruments,
|
||||
});
|
||||
|
||||
reload.recordInitialActivation(plugin, registered, initialHandle);
|
||||
await reload.reload(plugin, {
|
||||
manifest: nextManifest,
|
||||
module: nextModule,
|
||||
manifestHash: nextHash,
|
||||
trigger: "admin",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire the dev-mode file watcher and a custom drain timeout:
|
||||
|
||||
```ts
|
||||
import { FileWatcher, ReloadDriver } from "@suaveplan/api-host/hot-reload";
|
||||
|
||||
const reload = new ReloadDriver({
|
||||
adapters,
|
||||
lifecycle,
|
||||
events,
|
||||
instruments,
|
||||
drainTimeoutMs: 5_000,
|
||||
forceTimeoutMs: 10_000,
|
||||
blueGreen: false, // 503-window mode for native-locks
|
||||
journalDepth: 5,
|
||||
});
|
||||
|
||||
const watcher = new FileWatcher({ fs: nodeFsWatch });
|
||||
watcher.register("@acme/billing", "/opt/plugins/billing/dist", async (pluginName) => {
|
||||
await reload.reload(pluginName, await loadPluginFromDisk(pluginName, "filewatch"));
|
||||
});
|
||||
```
|
||||
|
||||
Operator-initiated rollback:
|
||||
|
||||
```ts
|
||||
const snap = await reload.rollback("@acme/billing", 0);
|
||||
// snap.journal[0] is now the active version; emits plugin.rolled_back.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ReloadDriver`
|
||||
- `constructor(options: ReloadDriverOptions)`
|
||||
- `recordInitialActivation(plugin, registered, handle): void`
|
||||
- `reload(plugin, opts: ReloadOptions): Promise<ReloadSnapshot>`
|
||||
- `rollback(plugin, toIndex: number): Promise<ReloadSnapshot>`
|
||||
- `hydrateFromPersistence(plugin): Promise<void>`
|
||||
- `snapshot(plugin): ReloadSnapshot`
|
||||
- `interface ReloadDriverOptions` — `adapters`, `lifecycle`, `events`,
|
||||
`instruments`, `journalDepth?`, `drainTimeoutMs?`, `forceTimeoutMs?`,
|
||||
`blueGreen?`, `now?`.
|
||||
- `interface ReloadAdapters` — `blueGreen`, `persistence`, `resolver`, `health`.
|
||||
- `interface ReloadOptions` — `manifest`, `module`, `manifestHash`, `trigger`.
|
||||
- `interface ReloadSnapshot` — `plugin`, `journal`, `inFlight`, `lastReloadAt`,
|
||||
`lastReloadTrigger`.
|
||||
- `class FileWatcher` — dev-mode watch primitive with debounced fire.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `BlueGreenAdapter` interface is intentionally narrow — `stageShadow`,
|
||||
`flip`, `discardShadow`, `releaseDraining`. The production impl (Stage 4's
|
||||
`api-router`) backs these with `Atomics.store` on a SharedArrayBuffer; the
|
||||
Chunk-B reference impl used in tests is a plain map of `{ live, draining }`
|
||||
records. The flip is "atomic" in the design because at the router level a
|
||||
single integer write decides which handler runs — but `ReloadDriver` does
|
||||
not care which mechanism the adapter chooses.
|
||||
|
||||
Reinstall criteria (`requiresReinstall(prev, next)`) raises
|
||||
`LIFECYCLE.REINSTALL_REQUIRED_VERSION_DOWNGRADE` on a downgrade — the
|
||||
caller propagates that as the reload's failure code. Forward-compatible
|
||||
upgrades that cross schemaVersion or add new scopes/events/jobs/secrets
|
||||
intentionally fall through to the lifecycle driver's normal start path so
|
||||
the host's audit log shows one reload event, not two.
|
||||
|
||||
Persistence holds only the serialisable subset; `moduleRef` is a `WeakRef`
|
||||
that cannot be serialised. On restart the host re-imports the persisted
|
||||
specifier on demand — the first rollback after a restart pays one extra
|
||||
`import()` call, every subsequent one is free.
|
||||
|
||||
The drain loop currently consults its own `AbortController` but Chunk D
|
||||
wires the lifecycle driver's `InflightTracker` into the resolver-dispose
|
||||
path so requests actually settle against the right module — Chunk B
|
||||
captures the contract + emits the OTel metric so downstream wiring is
|
||||
mechanical.
|
||||
Reference in New Issue
Block a user