mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 19:02:17 +03:00
fix(sse): scale chat admission by ingest byte budget instead of a fixed request count (#11548)
Merged via /merge-batch (lote 2026-08-26, v3.8.51). Boarded no worktree combinado junto com outras ~30 PRs; validação única: typecheck/complexity/cognitive-complexity/changelog-integrity verdes, file-size rebaseado onde necessário (crescimento legítimo), lint com os mesmos 228 achados pré-existentes confirmados via sonda contra o tip puro (não introduzidos por este lote), e ~370 testes focados (unit + vitest) passando. Obrigado pela contribuição.
This commit is contained in:
116
src/shared/middleware/admissionBudget.ts
Normal file
116
src/shared/middleware/admissionBudget.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Auto-derived ingest byte budget for chat admission (#503-fanout).
|
||||
*
|
||||
* The legacy `chatBodyAdmission.ts` gate counted *requests* (default cap: 1)
|
||||
* instead of *bytes*, so any deployment serving coding-agent traffic (bodies
|
||||
* routinely > 256 KB) collapsed to an effective concurrency of 1-2 regardless
|
||||
* of how much RAM the host actually has. This module derives a byte budget
|
||||
* from the process's real memory ceiling — mirroring the auto-calibration
|
||||
* pattern already proven by `open-sse/utils/heapPressure.ts::computeHeapPressureThresholdMb`
|
||||
* (the v3.8.8 "resource pressure" outage was a fixed-number version of this
|
||||
* same mistake) — so the gate scales itself on a 512 MB container and a
|
||||
* 32 GB desktop alike, with no env tuning required.
|
||||
*/
|
||||
import v8 from "node:v8";
|
||||
|
||||
/** Fraction of the effective memory ceiling reserved for raw, not-yet-dispatched request bytes. */
|
||||
export const INGEST_HEAP_FRACTION = 0.25;
|
||||
/**
|
||||
* Transient heap multiplier per raw ingest byte during buffer → parse →
|
||||
* translate → dispatch (UTF-8 buffer + JS string + parsed object graph +
|
||||
* translated graph coexist briefly). Folded into the budget itself so a
|
||||
* request is charged its exact raw byte count, never a guessed multiple.
|
||||
*/
|
||||
export const INGEST_AMPLIFICATION = 8;
|
||||
/**
|
||||
* Preserve liveness for ordinary agent requests on tiny hosts, accepting that
|
||||
* the floor can reserve a large share of a sub-128 MiB container.
|
||||
*/
|
||||
export const MIN_INGEST_BUDGET_BYTES = 8 * 1024 * 1024;
|
||||
export const MAX_INGEST_BUDGET_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
export type IngestBudgetSource = "override" | "cgroup" | "v8_heap";
|
||||
|
||||
export interface IngestBudget {
|
||||
bytes: number;
|
||||
source: IngestBudgetSource;
|
||||
effectiveCeilingBytes: number;
|
||||
}
|
||||
|
||||
function parsePositiveFinite(value: string | number | null | undefined): number | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure budget calculation — no I/O, no env reads. Callers supply the live
|
||||
* V8 heap ceiling and (optionally) a cgroup-derived constrained-memory
|
||||
* figure; the tighter of the two wins. `override` (when positive) always wins.
|
||||
*/
|
||||
export function computeIngestByteBudget(input: {
|
||||
heapSizeLimitBytes: number;
|
||||
constrainedMemoryBytes?: number | null;
|
||||
override?: string | number | null;
|
||||
}): IngestBudget {
|
||||
const override = parsePositiveFinite(input.override);
|
||||
if (override !== null) {
|
||||
const bytes = Math.min(
|
||||
MAX_INGEST_BUDGET_BYTES,
|
||||
Math.max(MIN_INGEST_BUDGET_BYTES, Math.floor(override))
|
||||
);
|
||||
return { bytes, source: "override", effectiveCeilingBytes: bytes };
|
||||
}
|
||||
|
||||
const heapLimit = parsePositiveFinite(input.heapSizeLimitBytes) ?? MIN_INGEST_BUDGET_BYTES;
|
||||
const constrained = parsePositiveFinite(input.constrainedMemoryBytes ?? null);
|
||||
const ceiling = constrained !== null ? Math.min(heapLimit, constrained) : heapLimit;
|
||||
const source: IngestBudgetSource =
|
||||
constrained !== null && constrained <= heapLimit ? "cgroup" : "v8_heap";
|
||||
|
||||
const raw = Math.floor((ceiling * INGEST_HEAP_FRACTION) / INGEST_AMPLIFICATION);
|
||||
const bytes = Math.min(MAX_INGEST_BUDGET_BYTES, Math.max(MIN_INGEST_BUDGET_BYTES, raw));
|
||||
|
||||
return { bytes, source, effectiveCeilingBytes: Math.floor(ceiling) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort cgroup/container memory ceiling. `process.constrainedMemory()`
|
||||
* (Node >=19.6/20.13) returns the cgroup limit on Linux containers and is
|
||||
* absent/undefined elsewhere (e.g. plain Windows/macOS hosts) — treated the
|
||||
* same as "no cgroup limit" so the V8 heap ceiling is used instead.
|
||||
*/
|
||||
function readConstrainedMemoryBytes(): number | null {
|
||||
try {
|
||||
const proc = process as NodeJS.Process & { constrainedMemory?: () => number };
|
||||
if (typeof proc.constrainedMemory !== "function") return null;
|
||||
const value = proc.constrainedMemory();
|
||||
return Number.isFinite(value) && value > 0 ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let cachedBudget: IngestBudget | null = null;
|
||||
|
||||
/**
|
||||
* Resolve the process-wide ingest byte budget, cached after first call (like
|
||||
* `HEAP_PRESSURE_THRESHOLD_MB`) so the gate never re-reads cgroup/V8 state on
|
||||
* the hot path. `override` defaults to `OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES`.
|
||||
*/
|
||||
export function resolveIngestByteBudget(
|
||||
override: string | number | null | undefined = process.env.OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES
|
||||
): IngestBudget {
|
||||
if (cachedBudget) return cachedBudget;
|
||||
cachedBudget = computeIngestByteBudget({
|
||||
heapSizeLimitBytes: v8.getHeapStatistics().heap_size_limit,
|
||||
constrainedMemoryBytes: readConstrainedMemoryBytes(),
|
||||
override,
|
||||
});
|
||||
return cachedBudget;
|
||||
}
|
||||
|
||||
/** Test seam: force the next `resolveIngestByteBudget()` call to recompute. */
|
||||
export function reloadIngestBudgetForTests(): void {
|
||||
cachedBudget = null;
|
||||
}
|
||||
40
src/shared/middleware/chatAdmissionIdentity.ts
Normal file
40
src/shared/middleware/chatAdmissionIdentity.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createHmac } from "crypto";
|
||||
|
||||
const ADMISSION_BYPASS_VALUE = "internal";
|
||||
const SELF_LOOP_KEY = "sk_omniroute";
|
||||
const FINGERPRINT_KEY = "omniroute-admission-fingerprint-v1";
|
||||
|
||||
export const ADMISSION_BYPASS_HEADER = "x-omniroute-admission-bypass";
|
||||
|
||||
export function resolveSessionId(request: Request): string {
|
||||
const authHeader = request.headers.get("authorization") || "";
|
||||
const bearerMatch = /^bearer\s+(\S+)$/i.exec(authHeader.trim());
|
||||
if (bearerMatch) return fingerprint(bearerMatch[1]);
|
||||
|
||||
const xApiKey = request.headers.get("x-api-key")?.trim();
|
||||
if (xApiKey) return fingerprint(xApiKey);
|
||||
|
||||
const xGoogApiKey = request.headers.get("x-goog-api-key")?.trim();
|
||||
return xGoogApiKey ? fingerprint(xGoogApiKey) : "anonymous";
|
||||
}
|
||||
|
||||
export function resolveSelfLoopBearer(): string {
|
||||
return (
|
||||
process.env.OMNIROUTE_API_KEY?.trim() || process.env.ROUTER_API_KEY?.trim() || SELF_LOOP_KEY
|
||||
);
|
||||
}
|
||||
|
||||
export function isInternalAdmissionBypass(request: Request): boolean {
|
||||
const bypass =
|
||||
request.headers.get(ADMISSION_BYPASS_HEADER)?.trim().toLowerCase() === ADMISSION_BYPASS_VALUE;
|
||||
if (!bypass) return false;
|
||||
|
||||
const auth = request.headers.get("authorization") || "";
|
||||
const match = /^bearer\s+(\S+)$/i.exec(auth.trim());
|
||||
return Boolean(match && match[1].trim().toLowerCase() === resolveSelfLoopBearer().toLowerCase());
|
||||
}
|
||||
|
||||
function fingerprint(value: string): string {
|
||||
// Deterministic admission-lane fingerprint, never password verification.
|
||||
return `key_${createHmac("sha256", FINGERPRINT_KEY).update(value).digest("hex").slice(0, 16)}`;
|
||||
}
|
||||
73
src/shared/middleware/chatAdmissionResponses.ts
Normal file
73
src/shared/middleware/chatAdmissionResponses.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
|
||||
|
||||
import { CORS_HEADERS } from "../utils/cors";
|
||||
|
||||
const JSON_HEADERS = { ...CORS_HEADERS, "Content-Type": "application/json" };
|
||||
|
||||
export function chatAdmissionRejectionResponse(status: 413 | 503, hardMaxBytes: number): Response {
|
||||
const isPayload = status === 413;
|
||||
const headers: Record<string, string> = { ...JSON_HEADERS };
|
||||
if (!isPayload) headers["Retry-After"] = "2";
|
||||
const message = isPayload
|
||||
? `Request body too large for chat completions (max ${Math.floor(
|
||||
hardMaxBytes / (1024 * 1024)
|
||||
)} MB).`
|
||||
: "Chat admission capacity is temporarily unavailable. Retry shortly.";
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
buildErrorBody(status, message, undefined, {
|
||||
type: isPayload ? "payload_too_large" : "server_error",
|
||||
code: isPayload ? "PAYLOAD_TOO_LARGE" : "chat_admission_busy",
|
||||
})
|
||||
),
|
||||
{ status, headers }
|
||||
);
|
||||
}
|
||||
|
||||
export function bodyExceedsBudgetResponse(maxInflightBytes: number): Response {
|
||||
const maxMiB = Math.max(1, Math.floor(maxInflightBytes / (1024 * 1024)));
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
buildErrorBody(
|
||||
413,
|
||||
`Request body exceeds the chat ingest budget (max ${maxMiB} MB).`,
|
||||
undefined,
|
||||
{ type: "payload_too_large", code: "body_exceeds_budget" }
|
||||
)
|
||||
),
|
||||
{ status: 413, headers: JSON_HEADERS }
|
||||
);
|
||||
}
|
||||
|
||||
export function resourcePressureRejectionResponse(): Response {
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
buildErrorBody(
|
||||
503,
|
||||
"Service temporarily unavailable due to resource pressure. Retry shortly.",
|
||||
undefined,
|
||||
{ type: "server_error", code: "resource_pressure" }
|
||||
)
|
||||
),
|
||||
{ status: 503, headers: { ...JSON_HEADERS, "Retry-After": "2" } }
|
||||
);
|
||||
}
|
||||
|
||||
export function structuralRejectionResponse(status: 413 | 503, maxMessages: number): Response {
|
||||
const historyLimit = status === 413;
|
||||
const headers: Record<string, string> = { ...JSON_HEADERS };
|
||||
if (!historyLimit) headers["Retry-After"] = "1";
|
||||
const body = buildErrorBody(
|
||||
status,
|
||||
historyLimit
|
||||
? `Chat history exceeds the ${maxMessages}-message limit; compact the conversation and retry.`
|
||||
: "Structurally heavy chat request capacity is busy; retry shortly.",
|
||||
undefined,
|
||||
{
|
||||
type: historyLimit ? "payload_too_large" : "server_error",
|
||||
code: historyLimit ? "chat_history_too_large" : "chat_admission_busy",
|
||||
}
|
||||
);
|
||||
body.error.reason = historyLimit ? "message_limit" : "structure_limit";
|
||||
return new Response(JSON.stringify(body), { status, headers });
|
||||
}
|
||||
54
src/shared/middleware/chatAdmissionStructureEstimate.ts
Normal file
54
src/shared/middleware/chatAdmissionStructureEstimate.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export interface TokenEstimate {
|
||||
tokens: number;
|
||||
exhausted: boolean;
|
||||
}
|
||||
|
||||
export function estimateStructureTokens(value: unknown, limit: number): TokenEstimate {
|
||||
let tokens = 0;
|
||||
let visited = 0;
|
||||
const maxNodes = 10_000;
|
||||
const stack: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }];
|
||||
while (stack.length > 0 && tokens < limit && visited < maxNodes) {
|
||||
const current = stack.pop();
|
||||
if (!current) break;
|
||||
visited += 1;
|
||||
if (typeof current.value === "string") {
|
||||
tokens += conservativeStringTokens(current.value, limit - tokens);
|
||||
continue;
|
||||
}
|
||||
if (!current.value || typeof current.value !== "object") continue;
|
||||
if (current.depth >= 12) return { tokens, exhausted: true };
|
||||
|
||||
const remainingNodes = maxNodes - visited - stack.length;
|
||||
if (Array.isArray(current.value)) {
|
||||
if (current.value.length > remainingNodes) return { tokens, exhausted: true };
|
||||
for (let index = current.value.length - 1; index >= 0; index -= 1) {
|
||||
stack.push({ value: current.value[index], depth: current.depth + 1 });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let children = 0;
|
||||
for (const key in current.value) {
|
||||
if (!Object.hasOwn(current.value, key)) continue;
|
||||
children += 1;
|
||||
if (children > remainingNodes) return { tokens, exhausted: true };
|
||||
tokens += conservativeStringTokens(key, limit - tokens);
|
||||
if (tokens >= limit) return { tokens: limit, exhausted: false };
|
||||
stack.push({
|
||||
value: (current.value as Record<string, unknown>)[key],
|
||||
depth: current.depth + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { tokens, exhausted: stack.length > 0 && tokens < limit };
|
||||
}
|
||||
|
||||
function conservativeStringTokens(value: string, remaining: number): number {
|
||||
let tokens = 0;
|
||||
for (const character of value) {
|
||||
tokens += character.codePointAt(0)! < 0x80 ? 0.25 : 1;
|
||||
if (tokens >= remaining) return remaining;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
@@ -15,11 +15,32 @@
|
||||
* connection's burst cannot starve others (#9654).
|
||||
*/
|
||||
|
||||
import { CORS_HEADERS } from "../utils/cors";
|
||||
import { createLogger } from "../utils/logger";
|
||||
import { createHmac } from "crypto";
|
||||
import v8 from "node:v8";
|
||||
import { trackRequest } from "../../lib/gracefulShutdown";
|
||||
import { resolveIngestByteBudget, type IngestBudgetSource } from "./admissionBudget";
|
||||
import {
|
||||
ADMISSION_BYPASS_HEADER,
|
||||
isInternalAdmissionBypass,
|
||||
resolveSelfLoopBearer,
|
||||
resolveSessionId,
|
||||
} from "./chatAdmissionIdentity";
|
||||
import {
|
||||
bodyExceedsBudgetResponse,
|
||||
chatAdmissionRejectionResponse,
|
||||
resourcePressureRejectionResponse,
|
||||
structuralRejectionResponse,
|
||||
} from "./chatAdmissionResponses";
|
||||
import { estimateStructureTokens } from "./chatAdmissionStructureEstimate";
|
||||
import {
|
||||
composeAdmissionLease,
|
||||
IngestByteAdmissionController,
|
||||
type IngestBudgetAcquireResult,
|
||||
} from "./ingestByteAdmission";
|
||||
import {
|
||||
getResourcePressureObservation,
|
||||
type PressureSeverity,
|
||||
} from "@omniroute/open-sse/utils/resourcePressure.ts";
|
||||
|
||||
function parsePositiveInt(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
@@ -180,7 +201,21 @@ interface AdmissionWaiter {
|
||||
* A client abort mid-wait is deliberately NOT a shed: capacity was never denied,
|
||||
* the caller simply left (its 503 is dropped on the dead connection).
|
||||
*/
|
||||
export type ChatAdmissionShedReason = "queue_timeout" | "queued_bytes_budget";
|
||||
export type ChatAdmissionShedReason =
|
||||
| "queue_timeout"
|
||||
| "queued_bytes_budget"
|
||||
| "body_exceeds_budget"
|
||||
| "inflight_bytes_budget"
|
||||
| "resource_pressure";
|
||||
|
||||
/** Read cached pressure severity; sampling failures must not cause false sheds. */
|
||||
export function defaultPressureSeverity(): PressureSeverity {
|
||||
try {
|
||||
return getResourcePressureObservation().state.severity;
|
||||
} catch {
|
||||
return "normal";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One structural-shed observation, emitted to the shed sink at warn level.
|
||||
@@ -239,6 +274,8 @@ export class ChatAdmissionController {
|
||||
#shedsByReason = new Map<string, number>();
|
||||
readonly #onShed: ChatAdmissionShedSink;
|
||||
|
||||
readonly #ingestBudget: IngestByteAdmissionController;
|
||||
|
||||
constructor(
|
||||
readonly maxHeavyInFlight = 1,
|
||||
readonly maxQueuedBytes = CHAT_ADMISSION_MAX_QUEUED_BYTES,
|
||||
@@ -248,7 +285,13 @@ export class ChatAdmissionController {
|
||||
readonly healthyHeadroom = CHAT_ADMISSION_HEALTHY_HEADROOM,
|
||||
/** #11244: sink notified once per structural shed. Defaults to the shared pino
|
||||
* logger (warn); tests inject a capture/no-op sink. */
|
||||
onShed: ChatAdmissionShedSink = defaultChatAdmissionShedSink
|
||||
onShed: ChatAdmissionShedSink = defaultChatAdmissionShedSink,
|
||||
/** #503-fanout: see the field-level comment above `#inflightBytes`. */
|
||||
budgetOptions: {
|
||||
maxInflightBytes?: number;
|
||||
budgetSource?: IngestBudgetSource;
|
||||
checkPressureSeverity?: () => PressureSeverity;
|
||||
} = {}
|
||||
) {
|
||||
if (!Number.isSafeInteger(maxHeavyInFlight) || maxHeavyInFlight < 1) {
|
||||
throw new RangeError("maxHeavyInFlight must be a positive integer");
|
||||
@@ -260,6 +303,10 @@ export class ChatAdmissionController {
|
||||
throw new RangeError("healthyHeadroom must be a non-negative integer");
|
||||
}
|
||||
this.#onShed = onShed;
|
||||
this.#ingestBudget = new IngestByteAdmissionController({
|
||||
...budgetOptions,
|
||||
onShed: (reason, lane) => this.recordShed(reason, lane),
|
||||
});
|
||||
}
|
||||
|
||||
get activeHeavy(): number {
|
||||
@@ -505,6 +552,39 @@ export class ChatAdmissionController {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
get inflightBytes(): number {
|
||||
return this.#ingestBudget.inflightBytes;
|
||||
}
|
||||
|
||||
get maxInflightBytes(): number {
|
||||
return this.#ingestBudget.maxInflightBytes;
|
||||
}
|
||||
|
||||
get budgetSource(): IngestBudgetSource {
|
||||
return this.#ingestBudget.budgetSource;
|
||||
}
|
||||
|
||||
pressureSeverity(): PressureSeverity {
|
||||
return this.#ingestBudget.pressureSeverity();
|
||||
}
|
||||
|
||||
canFitBudget(bytes: number): boolean {
|
||||
return this.#ingestBudget.canFit(bytes);
|
||||
}
|
||||
|
||||
tryAcquireBudget(bytes: number): ChatAdmissionLease | null {
|
||||
return this.#ingestBudget.tryAcquire(bytes);
|
||||
}
|
||||
|
||||
acquireBudgetWithin(
|
||||
bytes: number,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
sessionKey = "default"
|
||||
): Promise<IngestBudgetAcquireResult> {
|
||||
return this.#ingestBudget.acquireWithin(bytes, timeoutMs, signal, sessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultAdmissionController = new ChatAdmissionController(CHAT_MAX_HEAVY_IN_FLIGHT);
|
||||
@@ -526,53 +606,12 @@ const defaultAdmissionController = new ChatAdmissionController(CHAT_MAX_HEAVY_IN
|
||||
* per-key capacity being allocated.
|
||||
*/
|
||||
|
||||
export function resolveSessionId(request: Request): string {
|
||||
// Fairness scheduling key ONLY (never a capacity shard): hashed so raw key
|
||||
// material never appears in diagnostics. Reuses the internal-bypass auth
|
||||
// extraction: bearer token from Authorization, x-api-key (Anthropic-style),
|
||||
// or Google API key header.
|
||||
// CodeQL: Intentionally HMAC-SHA256 with a fixed context key, NOT password hashing. The
|
||||
// digest is a deterministic, non-reversible per-key fairness key for the shared admission
|
||||
// budget — never stored or used for password-style verification.
|
||||
const authHeader = request.headers.get("authorization") || "";
|
||||
const bearerMatch = /^bearer\s+(\S+)$/i.exec(authHeader.trim());
|
||||
if (bearerMatch) {
|
||||
// Fingerprint for the admission-budget bucket key, not a password/credential hash — keyed
|
||||
// with a fixed context label so it reads as a domain-separated digest, not a bare hash.
|
||||
return (
|
||||
"key_" +
|
||||
createHmac("sha256", "omniroute-admission-fingerprint-v1")
|
||||
.update(bearerMatch[1])
|
||||
.digest("hex")
|
||||
.slice(0, 16)
|
||||
);
|
||||
}
|
||||
const xApiKey = request.headers.get("x-api-key") || "";
|
||||
if (xApiKey.trim().length > 0) {
|
||||
// Fingerprint for the admission-budget bucket key, not a password/credential hash — keyed
|
||||
// with a fixed context label so it reads as a domain-separated digest, not a bare hash.
|
||||
return (
|
||||
"key_" +
|
||||
createHmac("sha256", "omniroute-admission-fingerprint-v1")
|
||||
.update(xApiKey.trim())
|
||||
.digest("hex")
|
||||
.slice(0, 16)
|
||||
);
|
||||
}
|
||||
const xGoogApiKey = request.headers.get("x-goog-api-key") || "";
|
||||
if (xGoogApiKey.trim().length > 0) {
|
||||
// Fingerprint for the admission-budget bucket key, not a password/credential hash — keyed
|
||||
// with a fixed context label so it reads as a domain-separated digest, not a bare hash.
|
||||
return (
|
||||
"key_" +
|
||||
createHmac("sha256", "omniroute-admission-fingerprint-v1")
|
||||
.update(xGoogApiKey.trim())
|
||||
.digest("hex")
|
||||
.slice(0, 16)
|
||||
);
|
||||
}
|
||||
return "anonymous";
|
||||
}
|
||||
export { ADMISSION_BYPASS_HEADER, resolveSelfLoopBearer, resolveSessionId };
|
||||
|
||||
const NULL_LEASE: ChatAdmissionLease = {
|
||||
released: true,
|
||||
release() {},
|
||||
};
|
||||
|
||||
export class PerConnectionAdmissionController {
|
||||
readonly #controller: ChatAdmissionController;
|
||||
@@ -583,13 +622,26 @@ export class PerConnectionAdmissionController {
|
||||
// accepted for API compatibility and ignored — there are no per-session lanes
|
||||
// to evict. `onShed` (#11244) is live: it replaces the shed sink of the shared
|
||||
// controller (tests inject a capture/no-op sink; production keeps the pino warn).
|
||||
_opts?: { maxSessions?: number; sessionTtlMs?: number; onShed?: ChatAdmissionShedSink }
|
||||
// `budget` (#503-fanout) is live: the additive ingest byte-budget gate — see
|
||||
// `ChatAdmissionController`'s constructor comment. Absent for every caller
|
||||
// except the production singleton below.
|
||||
_opts?: {
|
||||
maxSessions?: number;
|
||||
sessionTtlMs?: number;
|
||||
onShed?: ChatAdmissionShedSink;
|
||||
budget?: {
|
||||
maxInflightBytes?: number;
|
||||
budgetSource?: IngestBudgetSource;
|
||||
checkPressureSeverity?: () => PressureSeverity;
|
||||
};
|
||||
}
|
||||
) {
|
||||
this.#controller = new ChatAdmissionController(
|
||||
maxHeavyInFlight,
|
||||
undefined,
|
||||
undefined,
|
||||
_opts?.onShed
|
||||
_opts?.onShed,
|
||||
_opts?.budget
|
||||
);
|
||||
}
|
||||
|
||||
@@ -611,6 +663,17 @@ export class PerConnectionAdmissionController {
|
||||
lanes: ReadonlyArray<{ key: string; waiting: number }>;
|
||||
shedTotal: number;
|
||||
shedsByReason: Record<string, number>;
|
||||
/** #503-fanout: live ingest bytes reserved through the byte-budget gate. */
|
||||
inflightBytes: number;
|
||||
/** #503-fanout: the auto-derived (or overridden) budget ceiling. */
|
||||
maxInflightBytes: number;
|
||||
/** #503-fanout: which signal the budget was derived from. */
|
||||
budgetSource: IngestBudgetSource;
|
||||
/** #503-fanout: live multi-signal resource-pressure severity. */
|
||||
pressureSeverity: PressureSeverity;
|
||||
/** #503-fanout: false on a default deployment — the legacy count cap only
|
||||
* binds when the operator explicitly set OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT. */
|
||||
countCapEnabled: boolean;
|
||||
} {
|
||||
return {
|
||||
activeHeavy: this.#controller.activeHeavy,
|
||||
@@ -620,6 +683,11 @@ export class PerConnectionAdmissionController {
|
||||
lanes: this.#controller.waitersByKey,
|
||||
shedTotal: this.#controller.shedTotal,
|
||||
shedsByReason: this.#controller.shedsByReason,
|
||||
inflightBytes: this.#controller.inflightBytes,
|
||||
maxInflightBytes: this.#controller.maxInflightBytes,
|
||||
budgetSource: this.#controller.budgetSource,
|
||||
pressureSeverity: this.#controller.pressureSeverity(),
|
||||
countCapEnabled: this.#controller.maxHeavyInFlight < Number.MAX_SAFE_INTEGER,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -641,8 +709,32 @@ export class PerConnectionAdmissionController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The legacy count cap (#503-fanout) now binds ONLY when the operator has
|
||||
* explicitly set `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT`. Left unset — the
|
||||
* default on every deployment that produced the multi-subagent 503 storm —
|
||||
* it resolves to effectively unlimited, so the auto-derived ingest byte
|
||||
* budget below (`resolveIngestByteBudget()`) is the gate that actually binds.
|
||||
* A deployment that already tuned this env var (e.g. `infra/app.env.example`
|
||||
* setting `=5`) keeps its exact prior behavior layered on top of the budget.
|
||||
*/
|
||||
function resolveLegacyCountCap(): number {
|
||||
const raw = process.env.OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT;
|
||||
if (raw === undefined || raw.trim() === "") return Number.MAX_SAFE_INTEGER;
|
||||
return CHAT_MAX_HEAVY_IN_FLIGHT;
|
||||
}
|
||||
|
||||
const productionIngestBudget = resolveIngestByteBudget();
|
||||
|
||||
export const perConnectionAdmissionController = new PerConnectionAdmissionController(
|
||||
CHAT_MAX_HEAVY_IN_FLIGHT
|
||||
resolveLegacyCountCap(),
|
||||
{
|
||||
budget: {
|
||||
maxInflightBytes: productionIngestBudget.bytes,
|
||||
budgetSource: productionIngestBudget.source,
|
||||
checkPressureSeverity: defaultPressureSeverity,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type ChatRequestAdmission =
|
||||
@@ -652,101 +744,7 @@ export type ChatRequestAdmission =
|
||||
export type ChatStructureAdmission =
|
||||
{ admit: true; lease: ChatAdmissionLease | null } | { admit: false; response: Response };
|
||||
|
||||
function rejectionResponse(status: 413 | 503, hardMaxBytes: number): Response {
|
||||
const isPayload = status === 413;
|
||||
const headers: Record<string, string> = {
|
||||
...CORS_HEADERS,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (!isPayload) headers["Retry-After"] = "2";
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: isPayload
|
||||
? `Request body too large for chat completions (max ${Math.floor(
|
||||
hardMaxBytes / (1024 * 1024)
|
||||
)} MB).`
|
||||
: "Chat admission capacity is temporarily unavailable. Retry shortly.",
|
||||
type: isPayload ? "payload_too_large" : "server_error",
|
||||
code: isPayload ? "PAYLOAD_TOO_LARGE" : "chat_admission_busy",
|
||||
},
|
||||
}),
|
||||
{ status, headers }
|
||||
);
|
||||
}
|
||||
|
||||
function structuralRejectionResponse(status: 413 | 503, maxMessages: number): Response {
|
||||
const historyLimit = status === 413;
|
||||
const headers: Record<string, string> = {
|
||||
...CORS_HEADERS,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (!historyLimit) headers["Retry-After"] = "1";
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: historyLimit
|
||||
? `Chat history exceeds the ${maxMessages}-message limit; compact the conversation and retry.`
|
||||
: "Structurally heavy chat request capacity is busy; retry shortly.",
|
||||
type: historyLimit ? "payload_too_large" : "server_error",
|
||||
code: historyLimit ? "chat_history_too_large" : "chat_admission_busy",
|
||||
reason: historyLimit ? "message_limit" : "structure_limit",
|
||||
},
|
||||
}),
|
||||
{ status, headers }
|
||||
);
|
||||
}
|
||||
|
||||
type TokenEstimate = { tokens: number; exhausted: boolean };
|
||||
|
||||
function conservativeStringTokens(value: string, remaining: number): number {
|
||||
let tokens = 0;
|
||||
for (const character of value) {
|
||||
tokens += character.codePointAt(0)! < 0x80 ? 0.25 : 1;
|
||||
if (tokens >= remaining) return remaining;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function estimateStructureTokens(value: unknown, limit: number): TokenEstimate {
|
||||
let tokens = 0;
|
||||
let visited = 0;
|
||||
const maxNodes = 10_000;
|
||||
const stack: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }];
|
||||
while (stack.length > 0 && tokens < limit && visited < maxNodes) {
|
||||
const current = stack.pop();
|
||||
if (!current) break;
|
||||
visited += 1;
|
||||
if (typeof current.value === "string") {
|
||||
tokens += conservativeStringTokens(current.value, limit - tokens);
|
||||
continue;
|
||||
}
|
||||
if (!current.value || typeof current.value !== "object") continue;
|
||||
if (current.depth >= 12) return { tokens, exhausted: true };
|
||||
|
||||
const remainingNodes = maxNodes - visited - stack.length;
|
||||
if (Array.isArray(current.value)) {
|
||||
if (current.value.length > remainingNodes) return { tokens, exhausted: true };
|
||||
for (const child of current.value) stack.push({ value: child, depth: current.depth + 1 });
|
||||
continue;
|
||||
}
|
||||
|
||||
let children = 0;
|
||||
for (const key in current.value) {
|
||||
if (!Object.hasOwn(current.value, key)) continue;
|
||||
children += 1;
|
||||
if (children > remainingNodes) return { tokens, exhausted: true };
|
||||
tokens += conservativeStringTokens(key, limit - tokens);
|
||||
if (tokens >= limit) return { tokens: limit, exhausted: false };
|
||||
stack.push({
|
||||
value: (current.value as Record<string, unknown>)[key],
|
||||
depth: current.depth + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { tokens, exhausted: stack.length > 0 && tokens < limit };
|
||||
}
|
||||
const INGEST_NORMAL_MAX_WAIT_MS = 250;
|
||||
|
||||
export async function admitChatStructure(
|
||||
body: unknown,
|
||||
@@ -803,9 +801,21 @@ export async function admitChatStructure(
|
||||
? perConnectionAdmissionController.getController(options.sessionId)
|
||||
: defaultAdmissionController);
|
||||
|
||||
// Uncontended fast path: capacity is free, no need to consult heap pressure at all.
|
||||
const immediate = controller.tryAcquireHeavy();
|
||||
if (immediate) return { admit: true, lease: immediate };
|
||||
// Uncontended fast path: capacity is free on BOTH the legacy count gate and
|
||||
// the byte-budget gate (#503-fanout) — mirrors admitChatRequest's composed
|
||||
// reserve(). When the count cap is unlimited (the production default since
|
||||
// this fix), the byte-budget gate is what actually decides "uncontended":
|
||||
// without composing both here, a structurally-heavy-but-byte-light request
|
||||
// would always take this fast path and the heap-pressure-conditional shed
|
||||
// below would never be reachable in production.
|
||||
const immediateCount = controller.tryAcquireHeavy();
|
||||
if (immediateCount) {
|
||||
const immediateBudget = controller.tryAcquireBudget(CHAT_LARGE_BODY_BYTES);
|
||||
if (immediateBudget) {
|
||||
return { admit: true, lease: composeAdmissionLease(immediateCount, immediateBudget) };
|
||||
}
|
||||
immediateCount.release();
|
||||
}
|
||||
|
||||
// Heavyweight capacity is momentarily busy (a concurrent heavy request holds the
|
||||
// lease). #10183 / #10268: only enter the bounded-wait / shed path — with its
|
||||
@@ -833,15 +843,31 @@ export async function admitChatStructure(
|
||||
// Structural-only waits happen on byte-light bodies (a byte-heavy body already
|
||||
// holds the byte-stage lease), so the conservative 256KB weight bounds the
|
||||
// parsed JSON the waiter keeps resident while parked.
|
||||
const acquired = await controller.acquireHeavyWithin(
|
||||
const acquiredCount = await controller.acquireHeavyWithin(
|
||||
options.queueMs ?? 0,
|
||||
options.signal,
|
||||
CHAT_LARGE_BODY_BYTES,
|
||||
options.sessionId
|
||||
);
|
||||
return acquired
|
||||
? { admit: true, lease: acquired }
|
||||
: { admit: false, response: structuralRejectionResponse(503, maxMessages) };
|
||||
if (!acquiredCount) {
|
||||
return { admit: false, response: structuralRejectionResponse(503, maxMessages) };
|
||||
}
|
||||
|
||||
// #503-fanout: same composed count+budget gate as the fast path above.
|
||||
const acquiredBudget = await controller.acquireBudgetWithin(
|
||||
CHAT_LARGE_BODY_BYTES,
|
||||
options.queueMs ?? 0,
|
||||
options.signal,
|
||||
options.sessionId
|
||||
);
|
||||
if (acquiredBudget.status !== "acquired") {
|
||||
acquiredCount.release();
|
||||
return { admit: false, response: structuralRejectionResponse(503, maxMessages) };
|
||||
}
|
||||
return {
|
||||
admit: true,
|
||||
lease: composeAdmissionLease(acquiredCount, acquiredBudget.lease),
|
||||
};
|
||||
}
|
||||
|
||||
function parseContentLength(header: string | null): number | null {
|
||||
@@ -863,83 +889,6 @@ function rebuildRequest(request: Request, body: Uint8Array): Request {
|
||||
} as RequestInit & { duplex: "half" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal self-loop bypass marker for the vision-bridge describe call (and any
|
||||
* other trusted in-process sub-request). An external client cannot spoof it:
|
||||
* it is honored ONLY when combined with a trusted self-loop credential — the
|
||||
* local-mode `sk_omniroute` sentinel or the operator-configured env key
|
||||
* (`OMNIROUTE_API_KEY` / `ROUTER_API_KEY`, #1350) so REQUIRE_API_KEY=true
|
||||
* deployments can run the describe sub-request.
|
||||
*/
|
||||
export const ADMISSION_BYPASS_HEADER = "x-omniroute-admission-bypass";
|
||||
const ADMISSION_BYPASS_VALUE = "internal";
|
||||
const SELF_LOOP_KEY = "sk_omniroute";
|
||||
|
||||
/**
|
||||
* Resolve the bearer credential used by trusted in-process self-loop
|
||||
* sub-requests (the vision-bridge describe call).
|
||||
*
|
||||
* Local mode uses the `sk_omniroute` sentinel. Deployments that force API key
|
||||
* auth (`REQUIRE_API_KEY=true`) reject that sentinel with 401, so they must use
|
||||
* a real key — the persistent env-var key (#1350, `OMNIROUTE_API_KEY` /
|
||||
* `ROUTER_API_KEY`) is the natural choice because it always validates and
|
||||
* survives restarts. Falls back to the sentinel when no env key is configured
|
||||
* so local-mode behavior is unchanged.
|
||||
*/
|
||||
export function resolveSelfLoopBearer(): string {
|
||||
return (
|
||||
process.env.OMNIROUTE_API_KEY?.trim() || process.env.ROUTER_API_KEY?.trim() || SELF_LOOP_KEY
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel lease returned by the admission byte stage for an internal self-loop
|
||||
* sub-request (the vision-bridge describe call). The parent request already holds
|
||||
* the single heavyweight lease, so the describe call must never reserve again —
|
||||
* but a NON-NULL lease is still required so the route's later structural stage
|
||||
* (`admitChatStructure`) treats the body as covered. With `lease: null` the
|
||||
* structural stage classifies the base64-heavy describe body as "heavy" and tries
|
||||
* to acquire the busy capacity, returning 503 `chat_admission_busy` anyway — the
|
||||
* gap that kept the Zoo Code / api-key describe call failing even after the byte
|
||||
* stage was bypassed. Release is a no-op; capacity was never reserved.
|
||||
*/
|
||||
function createNoopLease(): ChatAdmissionLease {
|
||||
return {
|
||||
get released() {
|
||||
return true;
|
||||
},
|
||||
release() {
|
||||
// No-op: this sentinel never reserved heavyweight capacity.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const NULL_LEASE: ChatAdmissionLease = createNoopLease();
|
||||
|
||||
/**
|
||||
* True when the request is a trusted in-process self-loop sub-request that must
|
||||
* not consume a heavyweight admission lease. The describe call runs WHILE the
|
||||
* parent request already holds the single heavyweight lease (`CHAT_MAX_HEAVY_IN_FLIGHT=1`),
|
||||
* so without this bypass it is rejected with 503 `chat_admission_busy` and the
|
||||
* image is never described (#vision-bridge self-loop).
|
||||
*/
|
||||
function isInternalAdmissionBypass(request: Request): boolean {
|
||||
const bypass =
|
||||
request.headers.get(ADMISSION_BYPASS_HEADER)?.trim().toLowerCase() === ADMISSION_BYPASS_VALUE;
|
||||
if (!bypass) return false;
|
||||
|
||||
// Credential gate: the bypass only applies to trusted self-loop credentials —
|
||||
// the local `sk_omniroute` sentinel OR the operator-configured env key
|
||||
// (`OMNIROUTE_API_KEY` / `ROUTER_API_KEY`, #1350) so REQUIRE_API_KEY=true
|
||||
// deployments can still run the vision-bridge describe sub-request. The env
|
||||
// key is a secret like any other API key, so honoring it here does not widen
|
||||
// the attack surface: a third-party that holds it can already call every API.
|
||||
const auth = request.headers.get("authorization") || "";
|
||||
const match = /^bearer\s+(\S+)$/i.exec(auth.trim());
|
||||
if (!match) return false;
|
||||
return match[1].trim().toLowerCase() === resolveSelfLoopBearer().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve heavyweight capacity and ingest the body with a hard byte bound before JSON
|
||||
* parsing. Missing/invalid Content-Length is sniffed only up to the heavyweight threshold;
|
||||
@@ -970,9 +919,8 @@ export async function admitChatRequest(
|
||||
// Internal self-loop: skip the heavyweight reservation entirely (the parent
|
||||
// request already holds the single lease) but still enforce the hard byte bound.
|
||||
if (internalBypass) {
|
||||
const contentLengthHeader = request.headers.get("content-length");
|
||||
if (contentLength !== null && contentLength > hardMaxBytes) {
|
||||
return { admit: false, response: rejectionResponse(413, hardMaxBytes) };
|
||||
return { admit: false, response: chatAdmissionRejectionResponse(413, hardMaxBytes) };
|
||||
}
|
||||
// Sniff bytes for the hard bound without reserving a lease.
|
||||
const reader = request.body?.getReader();
|
||||
@@ -986,7 +934,7 @@ export async function admitChatRequest(
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > hardMaxBytes) {
|
||||
await reader.cancel("chat request exceeds hard body limit").catch(() => undefined);
|
||||
return { admit: false, response: rejectionResponse(413, hardMaxBytes) };
|
||||
return { admit: false, response: chatAdmissionRejectionResponse(413, hardMaxBytes) };
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
@@ -1004,15 +952,58 @@ export async function admitChatRequest(
|
||||
return { admit: true, request: rebuildRequest(request, body), lease: NULL_LEASE };
|
||||
}
|
||||
|
||||
// #503-fanout: shed before spending any bytes on ingestion when the process
|
||||
// is under genuine critical resource pressure. No-op for every controller a
|
||||
// test constructs directly (default severity is always "normal").
|
||||
if (controller.pressureSeverity() === "critical") {
|
||||
controller.recordShed("resource_pressure", sessionId);
|
||||
return { admit: false, response: resourcePressureRejectionResponse() };
|
||||
}
|
||||
|
||||
if (contentLength !== null && contentLength > hardMaxBytes) {
|
||||
return { admit: false, response: rejectionResponse(413, hardMaxBytes) };
|
||||
return { admit: false, response: chatAdmissionRejectionResponse(413, hardMaxBytes) };
|
||||
}
|
||||
if (
|
||||
contentLength !== null &&
|
||||
contentLength >= largeBodyBytes &&
|
||||
!controller.canFitBudget(contentLength)
|
||||
) {
|
||||
controller.recordShed("body_exceeds_budget", sessionId);
|
||||
return { admit: false, response: bodyExceedsBudgetResponse(controller.maxInflightBytes) };
|
||||
}
|
||||
|
||||
let lease: ChatAdmissionLease | null = null;
|
||||
const reserve = async (bytes = 0): Promise<boolean> => {
|
||||
if (lease) return true;
|
||||
lease = await controller.acquireHeavyWithin(queueMs, request.signal, bytes, sessionId);
|
||||
return lease !== null;
|
||||
const countLease = await controller.acquireHeavyWithin(
|
||||
queueMs,
|
||||
request.signal,
|
||||
bytes,
|
||||
sessionId
|
||||
);
|
||||
if (!countLease) return false;
|
||||
|
||||
// Additive ingest byte-budget gate (#503-fanout), layered on top of the
|
||||
// legacy count gate above. `maxInflightBytes` defaults to unlimited for
|
||||
// every controller a test constructs directly, so this resolves
|
||||
// synchronously true there — only the production singleton (built with a
|
||||
// real host-derived budget) is ever actually gated by it.
|
||||
const severity = controller.pressureSeverity();
|
||||
const budgetWaitMs =
|
||||
severity === "high" ? queueMs : Math.min(queueMs, INGEST_NORMAL_MAX_WAIT_MS);
|
||||
const budgetResult = await controller.acquireBudgetWithin(
|
||||
bytes,
|
||||
budgetWaitMs,
|
||||
request.signal,
|
||||
sessionId
|
||||
);
|
||||
if (budgetResult.status !== "acquired") {
|
||||
countLease.release();
|
||||
return false;
|
||||
}
|
||||
|
||||
lease = composeAdmissionLease(countLease, budgetResult.lease);
|
||||
return true;
|
||||
};
|
||||
|
||||
// A known-large declaration can reserve before ingestion. Unknown lengths are boundedly
|
||||
@@ -1022,7 +1013,7 @@ export async function admitChatRequest(
|
||||
contentLength >= largeBodyBytes &&
|
||||
!(await reserve(Math.min(contentLength, hardMaxBytes)))
|
||||
) {
|
||||
return { admit: false, response: rejectionResponse(503, hardMaxBytes) };
|
||||
return { admit: false, response: chatAdmissionRejectionResponse(503, hardMaxBytes) };
|
||||
}
|
||||
|
||||
const reader = request.body?.getReader();
|
||||
@@ -1038,11 +1029,17 @@ export async function admitChatRequest(
|
||||
if (totalBytes > hardMaxBytes) {
|
||||
await reader.cancel("chat request exceeds hard body limit").catch(() => undefined);
|
||||
lease?.release();
|
||||
return { admit: false, response: rejectionResponse(413, hardMaxBytes) };
|
||||
return { admit: false, response: chatAdmissionRejectionResponse(413, hardMaxBytes) };
|
||||
}
|
||||
if (totalBytes >= largeBodyBytes && !controller.canFitBudget(totalBytes)) {
|
||||
controller.recordShed("body_exceeds_budget", sessionId);
|
||||
await reader.cancel("chat request exceeds ingest budget").catch(() => undefined);
|
||||
lease?.release();
|
||||
return { admit: false, response: bodyExceedsBudgetResponse(controller.maxInflightBytes) };
|
||||
}
|
||||
if (totalBytes >= largeBodyBytes && !(await reserve(totalBytes))) {
|
||||
await reader.cancel("chat admission capacity unavailable").catch(() => undefined);
|
||||
return { admit: false, response: rejectionResponse(503, hardMaxBytes) };
|
||||
return { admit: false, response: chatAdmissionRejectionResponse(503, hardMaxBytes) };
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
184
src/shared/middleware/ingestByteAdmission.ts
Normal file
184
src/shared/middleware/ingestByteAdmission.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { PressureSeverity } from "@omniroute/open-sse/utils/resourcePressure.ts";
|
||||
|
||||
import type { IngestBudgetSource } from "./admissionBudget";
|
||||
|
||||
export interface IngestByteLease {
|
||||
readonly released: boolean;
|
||||
release(): void;
|
||||
}
|
||||
|
||||
export function composeAdmissionLease(...leases: IngestByteLease[]): IngestByteLease {
|
||||
let released = false;
|
||||
return {
|
||||
get released() {
|
||||
return released;
|
||||
},
|
||||
release: () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
for (const lease of leases) lease.release();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type IngestBudgetAcquireResult =
|
||||
| { status: "acquired"; lease: IngestByteLease }
|
||||
| { status: "body_exceeds_budget" }
|
||||
| { status: "unavailable" };
|
||||
|
||||
export interface IngestByteAdmissionOptions {
|
||||
maxInflightBytes?: number;
|
||||
budgetSource?: IngestBudgetSource;
|
||||
checkPressureSeverity?: () => PressureSeverity;
|
||||
onShed: (reason: "body_exceeds_budget" | "inflight_bytes_budget", lane: string) => void;
|
||||
}
|
||||
|
||||
interface BudgetWaiter {
|
||||
resolve: () => void;
|
||||
}
|
||||
|
||||
export class IngestByteAdmissionController {
|
||||
#inflightBytes = 0;
|
||||
readonly maxInflightBytes: number;
|
||||
readonly budgetSource: IngestBudgetSource;
|
||||
readonly #checkPressureSeverity: () => PressureSeverity;
|
||||
readonly #onShed: IngestByteAdmissionOptions["onShed"];
|
||||
#queues = new Map<string, BudgetWaiter[]>();
|
||||
#fairKeys: string[] = [];
|
||||
#fairCursor = 0;
|
||||
|
||||
constructor(options: IngestByteAdmissionOptions) {
|
||||
this.maxInflightBytes = options.maxInflightBytes ?? Number.MAX_SAFE_INTEGER;
|
||||
this.budgetSource = options.budgetSource ?? "v8_heap";
|
||||
this.#checkPressureSeverity = options.checkPressureSeverity ?? (() => "normal");
|
||||
this.#onShed = options.onShed;
|
||||
}
|
||||
|
||||
get inflightBytes(): number {
|
||||
return this.#inflightBytes;
|
||||
}
|
||||
|
||||
pressureSeverity(): PressureSeverity {
|
||||
return this.#checkPressureSeverity();
|
||||
}
|
||||
|
||||
canFit(bytes: number): boolean {
|
||||
return normalizeCharge(bytes) <= this.maxInflightBytes;
|
||||
}
|
||||
|
||||
tryAcquire(bytes: number): IngestByteLease | null {
|
||||
const charge = normalizeCharge(bytes);
|
||||
if (this.#inflightBytes + charge > this.maxInflightBytes) return null;
|
||||
this.#inflightBytes += charge;
|
||||
let released = false;
|
||||
return {
|
||||
get released() {
|
||||
return released;
|
||||
},
|
||||
release: () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
this.#inflightBytes = Math.max(0, this.#inflightBytes - charge);
|
||||
this.#dispatchFair();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async acquireWithin(
|
||||
bytes: number,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
sessionKey = "default"
|
||||
): Promise<IngestBudgetAcquireResult> {
|
||||
if (!this.canFit(bytes)) {
|
||||
this.#onShed("body_exceeds_budget", sessionKey);
|
||||
return { status: "body_exceeds_budget" };
|
||||
}
|
||||
const deadline = Date.now() + Math.max(0, Math.floor(timeoutMs));
|
||||
for (;;) {
|
||||
if (signal?.aborted) return { status: "unavailable" };
|
||||
const lease = this.tryAcquire(bytes);
|
||||
if (lease) return { status: "acquired", lease };
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) return this.#timeout(sessionKey);
|
||||
|
||||
let queue = this.#queues.get(sessionKey);
|
||||
if (!queue) {
|
||||
queue = [];
|
||||
this.#queues.set(sessionKey, queue);
|
||||
this.#fairKeys.push(sessionKey);
|
||||
}
|
||||
let resolveParked: (() => void) | null = null;
|
||||
const waiter = { resolve: () => resolveParked?.() };
|
||||
const parked = new Promise<void>((resolve) => {
|
||||
resolveParked = resolve;
|
||||
queue.push(waiter);
|
||||
});
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const races: Array<Promise<boolean>> = [
|
||||
parked.then(() => false),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(true), remaining);
|
||||
}),
|
||||
];
|
||||
let onAbort: (() => void) | null = null;
|
||||
if (signal) {
|
||||
races.push(
|
||||
new Promise<boolean>((resolve) => {
|
||||
onAbort = () => resolve(true);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal.aborted) resolve(true);
|
||||
})
|
||||
);
|
||||
}
|
||||
const timedOut = await Promise.race(races);
|
||||
this.#removeWaiter(sessionKey, waiter);
|
||||
if (timer) clearTimeout(timer);
|
||||
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
||||
if (timedOut) {
|
||||
if (signal?.aborted) return { status: "unavailable" };
|
||||
return this.#timeout(sessionKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#timeout(sessionKey: string): IngestBudgetAcquireResult {
|
||||
this.#onShed("inflight_bytes_budget", sessionKey);
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
|
||||
#removeWaiter(key: string, waiter: BudgetWaiter): void {
|
||||
const queue = this.#queues.get(key);
|
||||
if (!queue) return;
|
||||
const index = queue.indexOf(waiter);
|
||||
if (index >= 0) queue.splice(index, 1);
|
||||
if (queue.length === 0) this.#removeFairKey(key);
|
||||
}
|
||||
|
||||
#removeFairKey(key: string): void {
|
||||
this.#queues.delete(key);
|
||||
const index = this.#fairKeys.indexOf(key);
|
||||
if (index < 0) return;
|
||||
this.#fairKeys.splice(index, 1);
|
||||
if (index < this.#fairCursor) this.#fairCursor -= 1;
|
||||
if (this.#fairKeys.length === 0) this.#fairCursor = 0;
|
||||
}
|
||||
|
||||
#dispatchFair(): void {
|
||||
if (this.#fairKeys.length === 0) return;
|
||||
for (let i = 0; i < this.#fairKeys.length; i++) {
|
||||
const key = this.#fairKeys[this.#fairCursor % this.#fairKeys.length];
|
||||
this.#fairCursor += 1;
|
||||
const queue = this.#queues.get(key);
|
||||
if (!queue || queue.length === 0) continue;
|
||||
const waiter = queue.shift() as BudgetWaiter;
|
||||
if (queue.length === 0) this.#removeFairKey(key);
|
||||
waiter.resolve();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCharge(bytes: number): number {
|
||||
return Math.max(0, Math.floor(bytes));
|
||||
}
|
||||
Reference in New Issue
Block a user