first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View 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.