Files
skills/docs/api-stack/api/api-host/src/hot-reload/hot-reload.md
2026-05-29 08:20:56 +01:00

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: 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

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