Initial Commit
This commit is contained in:
217
packages/api-stack/api/api-router/src/slot/slot.ts
Normal file
217
packages/api-stack/api/api-router/src/slot/slot.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { ROUTER_CODES, RouterError } from "../error-codes/error-codes.js";
|
||||
|
||||
/** A per-plugin handler reference held in a slot position. */
|
||||
export type SlotHandler = (...args: readonly unknown[]) => unknown;
|
||||
|
||||
/** Identifies one of the three positions a `BlueGreenSlot` can hold. */
|
||||
export type SlotPosition = "live" | "shadow" | "draining";
|
||||
|
||||
/** Opaque handle representing a draining slot — returned by `flip()`. */
|
||||
export interface DrainingHandle {
|
||||
readonly slotId: string;
|
||||
/** Promise that resolves once all in-flight requests against this slot settle. */
|
||||
readonly settled: Promise<void>;
|
||||
/** Signals abort to the still-pending requests. */
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
/** Per-plugin router mount point. */
|
||||
export interface RouterSlot {
|
||||
readonly slotId: string;
|
||||
readonly handler: SlotHandler;
|
||||
readonly version: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic flip primitive backing hot-reload. Three positions:
|
||||
* - `live` (currently serving)
|
||||
* - `shadow` (imported, not yet serving)
|
||||
* - `draining` (former `live`, finishing in-flight)
|
||||
*
|
||||
* Per design §8.1: the per-plugin version pointer is the single atomic
|
||||
* point that decides which module gets a request. This interface is that
|
||||
* pointer's contract; concrete impls live in `api-host`.
|
||||
*/
|
||||
export interface BlueGreenSlot {
|
||||
/** Current `live` slot. Throws if disposed. */
|
||||
readonly live: RouterSlot;
|
||||
/** Currently-staged shadow, or null. */
|
||||
readonly shadow: RouterSlot | null;
|
||||
/** Set or replace the shadow handler. */
|
||||
setShadow(handler: SlotHandler): void;
|
||||
/**
|
||||
* Atomically promote `shadow` to `live`, returning a draining handle
|
||||
* for the previous live slot. Throws `ROUTER.SLOT_FLIP_FAILED` if no
|
||||
* shadow is set.
|
||||
*/
|
||||
flip(): DrainingHandle;
|
||||
/**
|
||||
* Wait for a draining slot to settle, or abort if the timeout elapses.
|
||||
* Throws `ROUTER.SLOT_DISPOSE_TIMEOUT` on timeout AND signals abort to
|
||||
* still-pending requests.
|
||||
*/
|
||||
dispose(slotId: string, opts: { timeoutMs: number }): Promise<void>;
|
||||
/** Increment pending counter for the named slot — used by request acquire. */
|
||||
acquire(slotId: string): void;
|
||||
/** Decrement pending counter — used after request settles. */
|
||||
release(slotId: string): void;
|
||||
}
|
||||
|
||||
interface SlotState {
|
||||
slot: RouterSlot;
|
||||
pending: number;
|
||||
waiters: Array<() => void>;
|
||||
abortController: AbortController;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference, in-memory implementation of `BlueGreenSlot`.
|
||||
*
|
||||
* `api-host` is expected to compose this primitive with its router
|
||||
* registry; tests and the conformance harness use it directly. Slot IDs
|
||||
* are derived deterministically from `<plugin>.<version>` so log lines
|
||||
* and span tags stay readable.
|
||||
*/
|
||||
export class InMemoryBlueGreenSlot implements BlueGreenSlot {
|
||||
private readonly plugin: string;
|
||||
private liveState: SlotState;
|
||||
private shadowSlot: RouterSlot | null = null;
|
||||
private nextVersion: number;
|
||||
private readonly draining = new Map<string, SlotState>();
|
||||
|
||||
public constructor(plugin: string, initialHandler: SlotHandler) {
|
||||
this.plugin = plugin;
|
||||
this.nextVersion = 1;
|
||||
const slot = this.makeSlot(initialHandler);
|
||||
this.liveState = this.makeState(slot);
|
||||
}
|
||||
|
||||
public get live(): RouterSlot {
|
||||
return this.liveState.slot;
|
||||
}
|
||||
|
||||
public get shadow(): RouterSlot | null {
|
||||
return this.shadowSlot;
|
||||
}
|
||||
|
||||
public setShadow(handler: SlotHandler): void {
|
||||
this.shadowSlot = this.makeSlot(handler);
|
||||
}
|
||||
|
||||
public flip(): DrainingHandle {
|
||||
if (this.shadowSlot === null) {
|
||||
throw new RouterError(
|
||||
ROUTER_CODES.SLOT_FLIP_FAILED,
|
||||
`BlueGreenSlot(${this.plugin}).flip() called with no shadow`,
|
||||
{ context: { plugin: this.plugin } }
|
||||
);
|
||||
}
|
||||
const previous = this.liveState;
|
||||
const newLive = this.shadowSlot;
|
||||
this.shadowSlot = null;
|
||||
this.liveState = this.makeState(newLive);
|
||||
this.draining.set(previous.slot.slotId, previous);
|
||||
const handle: DrainingHandle = {
|
||||
slotId: previous.slot.slotId,
|
||||
settled: this.settledPromise(previous),
|
||||
abort: () => previous.abortController.abort(),
|
||||
};
|
||||
return handle;
|
||||
}
|
||||
|
||||
public async dispose(
|
||||
slotId: string,
|
||||
opts: { timeoutMs: number }
|
||||
): Promise<void> {
|
||||
const state = this.draining.get(slotId);
|
||||
if (state === undefined) return;
|
||||
if (state.pending === 0) {
|
||||
this.draining.delete(slotId);
|
||||
return;
|
||||
}
|
||||
const timerRef: { value: ReturnType<typeof setTimeout> | undefined } = {
|
||||
value: undefined,
|
||||
};
|
||||
const timeout = new Promise<"timeout">((resolve) => {
|
||||
timerRef.value = setTimeout(
|
||||
() => resolve("timeout"),
|
||||
opts.timeoutMs
|
||||
);
|
||||
});
|
||||
const settled = this.settledPromise(state).then(
|
||||
() => "settled" as const
|
||||
);
|
||||
const winner = await Promise.race([timeout, settled]);
|
||||
clearTimeout(timerRef.value);
|
||||
if (winner === "timeout") {
|
||||
state.abortController.abort();
|
||||
throw new RouterError(
|
||||
ROUTER_CODES.SLOT_DISPOSE_TIMEOUT,
|
||||
`BlueGreenSlot.dispose(${slotId}) timed out after ${opts.timeoutMs}ms with ${state.pending} pending`,
|
||||
{
|
||||
context: {
|
||||
slotId,
|
||||
pending: state.pending,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
this.draining.delete(slotId);
|
||||
}
|
||||
|
||||
public acquire(slotId: string): void {
|
||||
const state = this.lookup(slotId);
|
||||
if (state === null) return;
|
||||
state.pending += 1;
|
||||
}
|
||||
|
||||
public release(slotId: string): void {
|
||||
const state = this.lookup(slotId);
|
||||
if (state === null) return;
|
||||
state.pending -= 1;
|
||||
if (state.pending <= 0) {
|
||||
state.pending = 0;
|
||||
const drainWaiters = state.waiters.splice(0, state.waiters.length);
|
||||
for (const waiter of drainWaiters) waiter();
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns abort signal callers can wire onto in-flight requests on a draining slot. */
|
||||
public signalFor(slotId: string): AbortSignal | null {
|
||||
const state = this.lookup(slotId);
|
||||
return state === null ? null : state.abortController.signal;
|
||||
}
|
||||
|
||||
private lookup(slotId: string): SlotState | null {
|
||||
if (this.liveState.slot.slotId === slotId) return this.liveState;
|
||||
const draining = this.draining.get(slotId);
|
||||
return draining ?? null;
|
||||
}
|
||||
|
||||
private makeSlot(handler: SlotHandler): RouterSlot {
|
||||
const version = this.nextVersion;
|
||||
this.nextVersion += 1;
|
||||
return {
|
||||
slotId: `${this.plugin}.v${version}`,
|
||||
handler,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
private makeState(slot: RouterSlot): SlotState {
|
||||
return {
|
||||
slot,
|
||||
pending: 0,
|
||||
waiters: [],
|
||||
abortController: new AbortController(),
|
||||
};
|
||||
}
|
||||
|
||||
private settledPromise(state: SlotState): Promise<void> {
|
||||
if (state.pending === 0) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => {
|
||||
state.waiters.push(resolve);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user