Initial Commit
This commit is contained in:
98
packages/api-stack/api/api-router/src/slot/slot.md
Normal file
98
packages/api-stack/api/api-router/src/slot/slot.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# `slot`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## 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
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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.
|
||||
Reference in New Issue
Block a user