3.9 KiB
slot
(no submodules)
Purpose
The slot module defines BlueGreenSlot — the per-plugin atomic mount
point that backs hot-reload. It exposes three positions (live, shadow,
draining) plus a flip() primitive that promotes shadow to live in a
single observable step, returning a DrainingHandle for the previous live
slot so callers can wait for in-flight requests to settle before disposal.
Per design doc §8.1, the per-plugin version pointer is the single atomic
point that determines which module gets a given request. This module is
that pointer's contract. The reference implementation
InMemoryBlueGreenSlot lives here so the conformance harness and Stage-1
testing can exercise the full lifecycle; production api-host is expected
to compose this primitive with its registry.
Features
BlueGreenSlotinterface — abstract contract every adapter pins to.RouterSlot—{ slotId, handler, version }triple; ids are deterministic (<plugin>.v<n>).DrainingHandle—{ slotId, settled, abort() }returned fromflip().flip()is atomic — sets shadow→null, live→shadow, and returns the previous-live slot as draining in one synchronous step.dispose(slotId, { timeoutMs })— Promise.race against settlement; firesROUTER.SLOT_DISPOSE_TIMEOUTand wires AbortSignal on timeout.flip()withshadow === nullthrowsROUTER.SLOT_FLIP_FAILED.- Pending-request bookkeeping via
acquire(slotId)/release(slotId)so the host pipeline can decrement on response settlement.
Usage: Basic
import { InMemoryBlueGreenSlot } from "@suaveplan/api-router/slot";
const slot = new InMemoryBlueGreenSlot("auth", () => "v1");
slot.setShadow(() => "v2");
const draining = slot.flip();
// slot.live is now v2; draining points at v1
await slot.dispose(draining.slotId, { timeoutMs: 5_000 });
Usage: Advanced
import { InMemoryBlueGreenSlot, type DrainingHandle } from "@suaveplan/api-router/slot";
const slot = new InMemoryBlueGreenSlot("billing", () => "v1");
// Two in-flight requests against v1
slot.acquire(slot.live.slotId);
slot.acquire(slot.live.slotId);
slot.setShadow(() => "v2");
const draining: DrainingHandle = slot.flip();
// Race: dispose with short timeout will throw SLOT_DISPOSE_TIMEOUT and
// wire the abort signal onto the still-pending requests.
try {
await slot.dispose(draining.slotId, { timeoutMs: 50 });
} catch (e) {
// RouterError code "ROUTER.SLOT_DISPOSE_TIMEOUT"; in-flight requests
// see draining.settled remain pending, but their abort signal is
// tripped.
}
API Reference
BlueGreenSlot— interface withlive,shadow,setShadow,flip,dispose,acquire,release.RouterSlot—{ slotId: string, handler: SlotHandler, version: number }.SlotPosition—"live" | "shadow" | "draining".DrainingHandle—{ slotId, settled: Promise<void>, abort(): void }.SlotHandler—(...args: unknown[]) => unknown(type-erased).InMemoryBlueGreenSlot— reference impl; constructor takes(plugin: string, initialHandler: SlotHandler).
Implementation Notes
flip() is implemented as a synchronous three-line atomic: read shadow,
clear shadow, swap live. JavaScript's single-threaded execution makes this
observably atomic to all callers within the same realm. The previous-live
slot's state (including its abort controller and pending counter) is
moved into the draining map so subsequent dispose / acquire /
release calls can address it by slot id.
dispose races a timeout against a settlement promise — once all pending
requests release, the promise resolves and dispose returns cleanly. On
timeout, the slot's AbortController is tripped; existing request flows
that wired their fetch onto signalFor(slotId) will observe the abort
and abandon their work. The thrown RouterError carries the slot id and
remaining pending count in context for diagnostic logging.