218 lines
7.2 KiB
TypeScript
218 lines
7.2 KiB
TypeScript
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);
|
|
});
|
|
}
|
|
}
|