6.1 KiB
ReloadDriver
(no submodules)
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: falsefor environments that cannot host two module instances concurrently. RollbackJournalring buffer (default depth 3) layered on the in-memorycreateRollbackJournalfrom@suaveplan/api-loader; persisted subset written to the plugin ledger via theRollbackPersistenceadapter.- Auto-rollback on
start_failedduring reload — the lifecycle driver is fedrolled_backwithout operator intervention. - Manual rollback via
rollback(plugin, toIndex)— Stage 4's admin routePOST /admin/plugins/{name}/rollback?to=Ncalls 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 (themoduleRefis intentionally leftnulluntil a rollback re-imports). FileWatcher(sibling feature) supplies dev-mode--watchtriggers 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
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:
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:
const snap = await reload.rollback("@acme/billing", 0);
// snap.journal[0] is now the active version; emits plugin.rolled_back.
API Reference
class ReloadDriverconstructor(options: ReloadDriverOptions)recordInitialActivation(plugin, registered, handle): voidreload(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.