SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
3.7 KB · 113 lines typescript
Raw Blame History
1/**2 * Per-host fault isolation (SPEC §26): a concurrency gate, a politeness interval and a circuit3 * breaker. One broken or hostile domain must never stall the whole ingestion pipeline — after4 * `failures` consecutive failures the circuit opens and requests fail fast until the cooldown ends.5 * A single trial request is allowed when the cooldown elapses (half-open); success closes it.6 */7export interface HostGateOptions {8  concurrency: number;9  minIntervalMs: number;10  circuitFailures: number;11  circuitCooldownMs: number;12}1314export type CircuitState = 'closed' | 'open' | 'half_open';1516interface HostState {17  inFlight: number;18  waiters: Array<() => void>;19  lastStart: number;20  consecutiveFailures: number;21  openedAt: number | null;22  state: CircuitState;23  trialInFlight: boolean;24}2526export class CircuitOpenError extends Error {27  constructor(public host: string, public retryAt: Date) {28    super(`circuit open for ${host} until ${retryAt.toISOString()}`);29  }30}3132export class HostGates {33  private hosts = new Map<string, HostState>();34  constructor(private resolve: (host: string) => HostGateOptions) {}3536  private get(host: string): HostState {37    let s = this.hosts.get(host);38    if (!s) {39      s = { inFlight: 0, waiters: [], lastStart: 0, consecutiveFailures: 0, openedAt: null, state: 'closed', trialInFlight: false };40      this.hosts.set(host, s);41    }42    return s;43  }4445  snapshot(host: string): { state: CircuitState; consecutiveFailures: number; inFlight: number; retryAt: Date | null } {46    const s = this.get(host);47    const o = this.resolve(host);48    return { state: s.state, consecutiveFailures: s.consecutiveFailures, inFlight: s.inFlight, retryAt: s.openedAt ? new Date(s.openedAt + o.circuitCooldownMs) : null };49  }5051  /** Acquire a slot; throws CircuitOpenError immediately when the circuit is open. */52  async acquire(host: string): Promise<() => void> {53    const s = this.get(host);54    const o = this.resolve(host);55    if (s.state === 'open') {56      if (s.openedAt !== null && Date.now() - s.openedAt >= o.circuitCooldownMs) {57        s.state = 'half_open';58      } else {59        throw new CircuitOpenError(host, new Date((s.openedAt ?? Date.now()) + o.circuitCooldownMs));60      }61    }62    if (s.state === 'half_open') {63      if (s.trialInFlight) throw new CircuitOpenError(host, new Date(Date.now() + 5_000));64      s.trialInFlight = true;65    }66    while (s.inFlight >= o.concurrency) await new Promise<void>((r) => s.waiters.push(r));67    s.inFlight++;68    const wait = s.lastStart + o.minIntervalMs - Date.now();69    if (wait > 0) await new Promise((r) => setTimeout(r, wait));70    s.lastStart = Date.now();71    let released = false;72    return () => {73      if (released) return;74      released = true;75      s.inFlight--;76      s.waiters.shift()?.();77    };78  }7980  /** Report the outcome of a request made under `acquire`. */81  report(host: string, ok: boolean): void {82    const s = this.get(host);83    const o = this.resolve(host);84    if (ok) {85      s.consecutiveFailures = 0;86      s.openedAt = null;87      s.state = 'closed';88      s.trialInFlight = false;89      return;90    }91    s.consecutiveFailures++;92    if (s.state === 'half_open') {93      s.trialInFlight = false;94      s.state = 'open';95      s.openedAt = Date.now();96      return;97    }98    if (s.consecutiveFailures >= o.circuitFailures) {99      s.state = 'open';100      s.openedAt = Date.now();101    }102  }103104  /** Manually reset a host (admin "resume"). */105  reset(host: string): void {106    this.hosts.delete(host);107  }108109  hostsSnapshot(): Array<{ host: string; state: CircuitState; consecutiveFailures: number; inFlight: number }> {110    return [...this.hosts.entries()].map(([host, s]) => ({ host, state: s.state, consecutiveFailures: s.consecutiveFailures, inFlight: s.inFlight }));111  }112}113