Files
2026-07-13 13:39:12 +08:00

140 lines
5.0 KiB
TypeScript

/**
* T02 — pipeline engine circuit-breaker (gaps v3.8.42; opt-in, default OFF).
*
* A lightweight, in-memory, per-engine breaker for the stacked compression pipeline. When an
* engine throws repeatedly ACROSS requests, the breaker opens for that engine id and the
* stacked loops skip it (keeping the body verbatim for that step — fail-open) until a cooldown
* elapses, then probe once (lazy half-open). Success closes it; a failed probe re-opens it.
*
* This is deliberately NOT the provider breaker (`src/shared/utils/circuitBreaker.ts`), which
* is provider-scoped and DB-persisted. This one is engine-scoped, process-local, and adds zero
* DB/IO on the hot path. It composes with — but is independent of — the TV1 per-request bail-out
* (which skips within a single request); the breaker adds cross-request memory.
*
* Default OFF: with `enabled:false` the stacked loops never consult or mutate this state, so
* behavior is byte-identical to the pre-breaker pipeline (a throwing engine still propagates
* unless TV1 bail-out is separately enabled).
*/
export interface PipelineCircuitBreakerConfig {
/** Master switch. Default false — the pipeline never consults the breaker when off. */
enabled: boolean;
/** Consecutive cross-request failures before an engine's breaker opens. Default 3. */
failureThreshold: number;
/** How long the breaker stays open before a half-open probe (ms). Default 30_000. */
cooldownMs: number;
}
export const DEFAULT_PIPELINE_BREAKER: PipelineCircuitBreakerConfig = {
enabled: false,
failureThreshold: 3,
cooldownMs: 30_000,
};
interface EngineBreakerState {
failures: number;
/** Epoch ms until which the engine is OPEN; null = CLOSED. */
openedUntil: number | null;
}
const _state = new Map<string, EngineBreakerState>();
function get(engine: string): EngineBreakerState {
let s = _state.get(engine);
if (!s) {
s = { failures: 0, openedUntil: null };
_state.set(engine, s);
}
return s;
}
function toNonNegativeInt(raw: string | undefined, fallback: number): number {
if (raw === undefined) return fallback;
const n = Number(raw);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
}
/**
* Resolve the breaker config from a partial (per-combo config) with env fallback. Env keys:
* `COMPRESSION_PIPELINE_BREAKER_ENABLED|_THRESHOLD|_COOLDOWN_MS`. The partial wins over env.
*/
export function resolvePipelineBreakerConfig(
partial?: Partial<PipelineCircuitBreakerConfig>,
env: NodeJS.ProcessEnv = process.env
): PipelineCircuitBreakerConfig {
const enabled = partial?.enabled ?? env.COMPRESSION_PIPELINE_BREAKER_ENABLED === "true";
const failureThreshold =
partial?.failureThreshold ??
toNonNegativeInt(
env.COMPRESSION_PIPELINE_BREAKER_THRESHOLD,
DEFAULT_PIPELINE_BREAKER.failureThreshold
);
const cooldownMs =
partial?.cooldownMs ??
toNonNegativeInt(
env.COMPRESSION_PIPELINE_BREAKER_COOLDOWN_MS,
DEFAULT_PIPELINE_BREAKER.cooldownMs
);
return {
enabled: enabled === true,
failureThreshold: Math.max(1, failureThreshold),
cooldownMs: Math.max(0, cooldownMs),
};
}
/**
* Whether an engine may run now. CLOSED → true. OPEN within cooldown → false. OPEN past the
* cooldown → lazily transitions to half-open (one probe allowed; a probe failure re-opens it
* immediately because failures is left one short of the threshold). `now` is injectable for tests.
*/
export function canRunEngine(
engine: string,
config: PipelineCircuitBreakerConfig,
now: number = Date.now()
): boolean {
if (!config.enabled) return true;
const s = get(engine);
if (s.openedUntil == null) return true; // CLOSED (or already half-open)
if (now >= s.openedUntil) {
// Cooldown elapsed → half-open: allow a single probe, but keep failures one below the
// threshold so the very next failure re-opens the breaker without N more round-trips.
s.openedUntil = null;
s.failures = Math.max(0, config.failureThreshold - 1);
return true;
}
return false; // OPEN
}
/** Record a failed engine run; opens the breaker once consecutive failures hit the threshold. */
export function recordEngineFailure(
engine: string,
config: PipelineCircuitBreakerConfig,
now: number = Date.now()
): void {
if (!config.enabled) return;
const s = get(engine);
s.failures += 1;
if (s.failures >= config.failureThreshold) {
s.openedUntil = now + config.cooldownMs;
}
}
/** Record a successful engine run; fully closes the breaker. */
export function recordEngineSuccess(engine: string, config: PipelineCircuitBreakerConfig): void {
if (!config.enabled) return;
const s = get(engine);
s.failures = 0;
s.openedUntil = null;
}
/** Inspect breaker state for telemetry/tests. */
export function getEngineBreakerState(engine: string): { failures: number; open: boolean } {
const s = _state.get(engine);
return { failures: s?.failures ?? 0, open: s?.openedUntil != null };
}
/** Clear all breaker state (tests + operator reset). */
export function resetPipelineEngineBreakers(): void {
_state.clear();
}