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

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

  • BlueGreenSlot interface — abstract contract every adapter pins to.
  • RouterSlot — { slotId, handler, version } triple; ids are deterministic (<plugin>.v<n>).
  • DrainingHandle — { slotId, settled, abort() } returned from flip().
  • 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; fires ROUTER.SLOT_DISPOSE_TIMEOUT and wires AbortSignal on timeout.
  • flip() with shadow === null throws ROUTER.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 with live, 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.