mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 05:32:19 +03:00
* fix(quality): clears two release/v3.8.50 base-red gates Unblocks Merge integrity and Docs Gates for every PR against release/v3.8.50, not just this branch: - changelog.d/features/9415-newapi-sub2api-aggregator-balance.md had a non-standard YAML frontmatter header that no other fragment in the tree uses. check-changelog-integrity.mjs reads a fragment's first non-blank line to validate it starts with a markdown bullet; the frontmatter's leading `---` made that check fail regardless of the actual bullet content further down. Removed the frontmatter and reformatted the body to match the documented changelog.d/README.md bullet convention. - docs/ops/VM_DEPLOYMENT_GUIDE.md documented OMNIROUTE_MAX_POOL_SIZE and OMNIROUTE_DB_POOL_SIZE as tunable env vars, but neither is read anywhere in the codebase (confirmed via full-repo grep) — this repo uses SQLite, which has no connection-pool concept these vars could plausibly control. check:fabricated-docs --strict correctly flags fabricated env-var claims; removed the bullet rather than implementing a feature to match invented documentation. * fix(i18n): completes Vietnamese parity, fixes empty migration query Two more release/v3.8.50 base-red items, both surfaced while chasing CI failures on unrelated PRs: - vi.json was missing 8 keys that #9539 (NewAPI/Sub2API aggregator balance) added to en.json without a matching i18n:sync-ui run — pt-BR.json already had all 8, only Vietnamese drifted. Added translations for the 6 provider-settings strings, the feature-flag description, and the quota tooltip; verified against tests/unit/i18n-vi-completeness.test.ts (parity, placeholder preservation, ICU parse — all 5 assertions pass). - src/lib/db/migrations/120_interception_rules.sql was pure comments documenting a no-schema-change key_value namespace, with no executable SQL statement — the migration runner logged "FAILED: 120_interception_rules — Query contained no valid SQL statement" on every fresh DB init. 118_provider_param_filters.sql (same pattern, two migrations earlier) already ends with a bare `SELECT 1;` no-op for exactly this reason; 120 was just missing it. Verified directly against better-sqlite3 that the file now executes without error. * fix(types): clears 6 pre-existing release/v3.8.50 typecheck errors typecheck:core is its own blocking CI job (quality.yml), separate from Docs Gates/Merge integrity. Confirmed pre-existing and unrelated to any current work by branching this worktree directly from upstream/release/v3.8.50 with no other merges applied. - accountSemaphore.ts: isBypassed() already excludes null/<=0 maxConcurrency before ensureGate() is called, but a boolean- returning helper isn't a type predicate TS can narrow through. Added a targeted `as number` at the one call site, with a comment explaining why it's safe. - combo/comboStructure.ts: two module-scope `const HARD_COMPAT_REASONS` declarations with different values — a genuine "can't redeclare" compile error, not a narrowing gap. The first (4-item set including "output_tokens") had zero usages between its own declaration and the second; the second (3-item set, matching the CompatFilterOptions doc comment exactly) is what hasHardCapabilityFailure/ describeCapabilityFilterExhaustion/the third call site all actually use. Removed the dead first declaration. - combo/comboStructure.ts + combo/fusionPanel.ts: both accessed `.prompt`/`.model` on a `ComboModelStep | ComboProviderWildcardStep` union after only excluding `combo-ref`, but `ComboProviderWildcardStep` has neither field — a real latent bug (fusionPanel would have pushed `undefined` into a fusion panel for a wildcard step). Narrowed to `step.kind === "model"` in comboStructure, and switched to the already-existing `getComboModelString()` helper in fusionPanel (which correctly resolves to null for unsupported step kinds, mirroring how combo-ref is already skipped there). Verified directly via a standalone script exercising both branches (wildcard vs. model step). - combo/quotaStrategies.ts: imported `preferAntigravityConnectionsWithStoredProject` from a module that never existed (`../antigravityProjectPersistence.ts`, distinct from the real `antigravityProjectPersist.ts`) — the function itself was referenced nowhere else in the codebase. Wrote the missing implementation: prefers Antigravity connections with a discovered `projectId` for reset-aware routing, failing open to the full list when none have one yet (per the file's own "Exclude... from reset-aware pool" changelog note, softened to a preference — strict exclusion would empty the pool entirely for a fleet of freshly-added accounts). Verified directly via a standalone script. - compression/engines/ccr/index.ts: `enforceGlobalBudget(owner, bytes)` was called with only `bytes` at one of its two call sites, missing the `owner` argument the other call site (and the function's own doc comment on preferring the calling principal's LRU eviction) already uses correctly. Added the missing `entry.principalId` argument. - firecrawlQuotaFetcher.ts: `fetchFirecrawlQuota` was annotated to return `Promise<QuotaInfo | null>` but every return path constructs a `FirecrawlQuota` (QuotaInfo extended with remainingCredits/planCredits/ extraCreditsInferred/overPlan) — the type the file already defines and the type `parseFirecrawlCreditUsage` already correctly returns. Widened the annotation to match; `FirecrawlQuota extends QuotaInfo` so this stays compatible with the `QuotaFetcher` contract. npm run typecheck:core and npm run check:dashboard-typecheck both pass cleanly. A subset of DB-backed tests in this area also fail, but 100% attributably to an already-tracked, unrelated migration version collision (134 -> [ccr_blocks, proxy_logs_egress_ip], see _tasks/features-v3.8.4/9route/POST-MERGE-AUDIT.md) — confirmed by every failure's stack trace bottoming out at that exact error, not at anything touched here. * fix(sse): update stale ALL_ACCOUNTS_INACTIVE test assertions to ALL_TARGETS_SKIPPED Two combo-routing-engine.test.ts cases assert the pre-dispatch-skip scenario (isModelAvailable always false, zero dispatch attempts) returns ALL_ACCOUNTS_INACTIVE. Production code already distinguishes this case via the recordedAttempts === 0 branch and returns the more precise ALL_TARGETS_SKIPPED -- the tests were never updated when that branch shipped upstream, so they fail on a clean release/v3.8.50 checkout independent of this PR's changes. * fix(sse): update second stale ALL_ACCOUNTS_INACTIVE assertion (T24) Same pre-existing upstream test-drift as038035f93: t23-t24-fallback-resilience.test.ts's T24 case asserts the pre-dispatch-skip scenario returns ALL_ACCOUNTS_INACTIVE, but production code returns the more precise ALL_TARGETS_SKIPPED when recordedAttempts === 0. Caught by this PR's own fresh CI run after the dirty-mergeable-state fix. * fix(quality): rebaseline combo-routing-engine.test.ts own-comment growth The ALL_ACCOUNTS_INACTIVE->ALL_TARGETS_SKIPPED fix (58ab721fe) added explanatory comments (+7 lines), pushing the file past its frozen 3457 cap. CI's PR-mode check:file-size caught it; local check-file-size.mjs was not re-run after that specific commit. * chore(tests): drop explanatory comments on ALL_TARGETS_SKIPPED assertions Kept the assertion value fix (ALL_ACCOUNTS_INACTIVE -> ALL_TARGETS_SKIPPED); the comments were unnecessary. Reverts the file-size baseline bump these comments caused (combo-routing-engine.test.ts back to its original 3457). --------- Co-authored-by: Will Gordon <wgordon@redhat.com>
388 lines
9.8 KiB
TypeScript
388 lines
9.8 KiB
TypeScript
/**
|
|
* Account Semaphore
|
|
*
|
|
* In-memory provider/account concurrency limiter keyed by provider and account.
|
|
* Requests beyond the configured concurrency cap wait in a FIFO queue until a slot opens,
|
|
* the gate is unblocked, or the queue timeout expires.
|
|
*/
|
|
|
|
export interface AccountSemaphoreKeyParts {
|
|
provider: string;
|
|
accountKey: string;
|
|
}
|
|
|
|
interface QueuedAcquire {
|
|
resolve: (release: () => void) => void;
|
|
reject: (error: Error) => void;
|
|
timer: ReturnType<typeof setTimeout>;
|
|
}
|
|
|
|
interface AccountGate {
|
|
running: number;
|
|
maxConcurrency: number;
|
|
queue: QueuedAcquire[];
|
|
blockedUntil: number | null;
|
|
cleanupTimer: ReturnType<typeof setTimeout> | null;
|
|
}
|
|
|
|
export interface AcquireAccountSemaphoreOptions {
|
|
maxConcurrency?: number | null;
|
|
timeoutMs?: number;
|
|
signal?: AbortSignal | null;
|
|
maxQueueSize?: number;
|
|
}
|
|
|
|
export interface AccountSemaphoreStatsEntry {
|
|
running: number;
|
|
queued: number;
|
|
maxConcurrency: number;
|
|
blockedUntil: string | null;
|
|
}
|
|
|
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
const DEFAULT_MAX_QUEUE_SIZE = 20;
|
|
|
|
const gates = new Map<string, AccountGate>();
|
|
|
|
/**
|
|
* Build the canonical account semaphore key.
|
|
*/
|
|
export function buildAccountSemaphoreKey({
|
|
provider,
|
|
accountKey,
|
|
}: AccountSemaphoreKeyParts): string {
|
|
return `${String(provider)}:${String(accountKey)}`;
|
|
}
|
|
|
|
function isBypassed(maxConcurrency?: number | null): boolean {
|
|
return maxConcurrency == null || maxConcurrency <= 0;
|
|
}
|
|
|
|
function createNoopReleaseFn(): () => void {
|
|
let released = false;
|
|
|
|
return () => {
|
|
if (released) return;
|
|
released = true;
|
|
};
|
|
}
|
|
|
|
function ensureGate(semaphoreKey: string, maxConcurrency: number): AccountGate {
|
|
const existing = gates.get(semaphoreKey);
|
|
if (existing) {
|
|
existing.maxConcurrency = maxConcurrency;
|
|
return existing;
|
|
}
|
|
|
|
const created: AccountGate = {
|
|
running: 0,
|
|
maxConcurrency,
|
|
queue: [],
|
|
blockedUntil: null,
|
|
cleanupTimer: null,
|
|
};
|
|
gates.set(semaphoreKey, created);
|
|
return created;
|
|
}
|
|
|
|
function isBlocked(gate: AccountGate): boolean {
|
|
if (!gate.blockedUntil) return false;
|
|
if (Date.now() >= gate.blockedUntil) {
|
|
gate.blockedUntil = null;
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function clearCleanupTimer(gate: AccountGate): void {
|
|
if (!gate.cleanupTimer) return;
|
|
clearTimeout(gate.cleanupTimer);
|
|
gate.cleanupTimer = null;
|
|
}
|
|
|
|
function cleanupGateIfIdle(semaphoreKey: string): void {
|
|
const gate = gates.get(semaphoreKey);
|
|
if (!gate) return;
|
|
if (gate.running > 0 || gate.queue.length > 0 || isBlocked(gate)) return;
|
|
clearCleanupTimer(gate);
|
|
gates.delete(semaphoreKey);
|
|
}
|
|
|
|
function scheduleCleanup(semaphoreKey: string): void {
|
|
const gate = gates.get(semaphoreKey);
|
|
if (!gate) return;
|
|
clearCleanupTimer(gate);
|
|
|
|
gate.cleanupTimer = setTimeout(() => {
|
|
gate.cleanupTimer = null;
|
|
cleanupGateIfIdle(semaphoreKey);
|
|
}, 0);
|
|
|
|
gate.cleanupTimer.unref?.();
|
|
}
|
|
|
|
function drainQueue(semaphoreKey: string): void {
|
|
const gate = gates.get(semaphoreKey);
|
|
if (!gate) return;
|
|
|
|
while (gate.queue.length > 0 && gate.running < gate.maxConcurrency && !isBlocked(gate)) {
|
|
const next = gate.queue.shift();
|
|
if (!next) break;
|
|
clearTimeout(next.timer);
|
|
gate.running++;
|
|
next.resolve(createReleaseFn(semaphoreKey));
|
|
}
|
|
|
|
if (gate.running === 0 && gate.queue.length === 0) {
|
|
scheduleCleanup(semaphoreKey);
|
|
}
|
|
}
|
|
|
|
function createReleaseFn(semaphoreKey: string): () => void {
|
|
let released = false;
|
|
|
|
return () => {
|
|
if (released) return;
|
|
released = true;
|
|
|
|
const gate = gates.get(semaphoreKey);
|
|
if (!gate) return;
|
|
if (gate.running > 0) {
|
|
gate.running--;
|
|
}
|
|
|
|
if (gate.queue.length > 0) {
|
|
drainQueue(semaphoreKey);
|
|
return;
|
|
}
|
|
|
|
scheduleCleanup(semaphoreKey);
|
|
};
|
|
}
|
|
|
|
function createSemaphoreTimeoutError(
|
|
semaphoreKey: string,
|
|
timeoutMs: number
|
|
): Error & { code: string } {
|
|
const error = new Error(`Semaphore timeout after ${timeoutMs}ms for ${semaphoreKey}`) as Error & {
|
|
code: string;
|
|
};
|
|
error.code = "SEMAPHORE_TIMEOUT";
|
|
return error;
|
|
}
|
|
|
|
function makeAbortError(signal: AbortSignal): Error {
|
|
const reason = signal.reason;
|
|
if (reason instanceof Error) return reason;
|
|
const err = new Error(typeof reason === "string" ? reason : "The operation was aborted");
|
|
err.name = "AbortError";
|
|
return err;
|
|
}
|
|
|
|
/**
|
|
* Acquire a slot for a provider/model/account tuple.
|
|
* Returns an idempotent release function that is safe to call in finally blocks.
|
|
*/
|
|
export function acquire(
|
|
semaphoreKey: string,
|
|
{
|
|
maxConcurrency = null,
|
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
signal = null,
|
|
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
|
|
}: AcquireAccountSemaphoreOptions = {}
|
|
): Promise<() => void> {
|
|
if (isBypassed(maxConcurrency)) {
|
|
return Promise.resolve(createNoopReleaseFn());
|
|
}
|
|
|
|
if (signal?.aborted) {
|
|
return Promise.reject(makeAbortError(signal));
|
|
}
|
|
|
|
// isBypassed() above already excluded null/<=0 — ensureGate requires a plain
|
|
// number, but a boolean-returning helper isn't a type predicate TS can narrow on.
|
|
const gate = ensureGate(semaphoreKey, maxConcurrency as number);
|
|
clearCleanupTimer(gate);
|
|
|
|
if (gate.running < gate.maxConcurrency && !isBlocked(gate)) {
|
|
gate.running++;
|
|
return Promise.resolve(createReleaseFn(semaphoreKey));
|
|
}
|
|
|
|
if (gate.queue.length >= maxQueueSize) {
|
|
const err = new Error(`Semaphore queue full (${maxQueueSize}) for ${semaphoreKey}`) as Error & {
|
|
code: string;
|
|
};
|
|
err.code = "SEMAPHORE_QUEUE_FULL";
|
|
return Promise.reject(err);
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let abortListener: (() => void) | null = null;
|
|
|
|
const cleanup = () => {
|
|
if (abortListener && signal) {
|
|
signal.removeEventListener("abort", abortListener);
|
|
}
|
|
};
|
|
|
|
const timer = setTimeout(() => {
|
|
cleanup();
|
|
const nextGate = gates.get(semaphoreKey);
|
|
if (!nextGate) {
|
|
reject(createSemaphoreTimeoutError(semaphoreKey, timeoutMs));
|
|
return;
|
|
}
|
|
|
|
const queueIndex = nextGate.queue.findIndex((item) => item.timer === timer);
|
|
if (queueIndex !== -1) {
|
|
nextGate.queue.splice(queueIndex, 1);
|
|
}
|
|
|
|
if (nextGate.running === 0 && nextGate.queue.length === 0) {
|
|
scheduleCleanup(semaphoreKey);
|
|
}
|
|
|
|
reject(createSemaphoreTimeoutError(semaphoreKey, timeoutMs));
|
|
}, timeoutMs);
|
|
|
|
timer.unref?.();
|
|
|
|
const queueItem: QueuedAcquire = {
|
|
resolve: (release) => {
|
|
cleanup();
|
|
resolve(release);
|
|
},
|
|
reject: (error) => {
|
|
cleanup();
|
|
reject(error);
|
|
},
|
|
timer,
|
|
};
|
|
|
|
gate.queue.push(queueItem);
|
|
|
|
if (signal) {
|
|
abortListener = () => {
|
|
cleanup();
|
|
clearTimeout(timer);
|
|
|
|
const nextGate = gates.get(semaphoreKey);
|
|
if (!nextGate) {
|
|
reject(makeAbortError(signal));
|
|
return;
|
|
}
|
|
|
|
const queueIndex = nextGate.queue.findIndex((item) => item.timer === timer);
|
|
if (queueIndex !== -1) {
|
|
nextGate.queue.splice(queueIndex, 1);
|
|
}
|
|
|
|
if (nextGate.running === 0 && nextGate.queue.length === 0) {
|
|
scheduleCleanup(semaphoreKey);
|
|
}
|
|
|
|
reject(makeAbortError(signal));
|
|
};
|
|
if (signal.aborted) {
|
|
abortListener();
|
|
} else {
|
|
signal.addEventListener("abort", abortListener);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Temporarily block new acquisitions for a key while allowing in-flight requests to finish.
|
|
*/
|
|
export function markBlocked(semaphoreKey: string, cooldownMs: number): void {
|
|
const safeCooldownMs = Number.isFinite(cooldownMs) && cooldownMs > 0 ? cooldownMs : 0;
|
|
if (safeCooldownMs <= 0) {
|
|
const gate = gates.get(semaphoreKey);
|
|
if (!gate) return;
|
|
gate.blockedUntil = null;
|
|
drainQueue(semaphoreKey);
|
|
return;
|
|
}
|
|
|
|
const gate = gates.get(semaphoreKey) ?? ensureGate(semaphoreKey, 1);
|
|
clearCleanupTimer(gate);
|
|
gate.blockedUntil = Date.now() + safeCooldownMs;
|
|
|
|
const timer = setTimeout(() => {
|
|
const nextGate = gates.get(semaphoreKey);
|
|
if (!nextGate) return;
|
|
if (nextGate.blockedUntil && Date.now() >= nextGate.blockedUntil) {
|
|
nextGate.blockedUntil = null;
|
|
drainQueue(semaphoreKey);
|
|
if (nextGate.running === 0 && nextGate.queue.length === 0) {
|
|
scheduleCleanup(semaphoreKey);
|
|
}
|
|
}
|
|
}, safeCooldownMs + 50);
|
|
|
|
timer.unref?.();
|
|
}
|
|
|
|
/**
|
|
* Return the current in-memory semaphore snapshot.
|
|
*/
|
|
export function getStats(): Record<string, AccountSemaphoreStatsEntry> {
|
|
const stats: Record<string, AccountSemaphoreStatsEntry> = {};
|
|
|
|
for (const [key, gate] of gates) {
|
|
stats[key] = {
|
|
running: gate.running,
|
|
queued: gate.queue.length,
|
|
maxConcurrency: gate.maxConcurrency,
|
|
blockedUntil: gate.blockedUntil ? new Date(gate.blockedUntil).toISOString() : null,
|
|
};
|
|
}
|
|
|
|
return stats;
|
|
}
|
|
|
|
/**
|
|
* Check if an account semaphore key is currently at or over its max concurrency limit.
|
|
* Returns true if running >= maxConcurrency or blocked.
|
|
*/
|
|
export function isAccountSemaphoreFull(
|
|
provider: string,
|
|
accountKey: string,
|
|
maxConcurrency?: number | null
|
|
): boolean {
|
|
if (isBypassed(maxConcurrency)) return false;
|
|
const key = buildAccountSemaphoreKey({ provider, accountKey });
|
|
const gate = gates.get(key);
|
|
if (!gate) return false;
|
|
const effectiveCap = maxConcurrency ?? gate.maxConcurrency;
|
|
if (isBypassed(effectiveCap)) return false;
|
|
return gate.running >= effectiveCap || isBlocked(gate);
|
|
}
|
|
|
|
/**
|
|
* Reset a single key and reject queued waiters.
|
|
*/
|
|
export function reset(semaphoreKey: string): void {
|
|
const gate = gates.get(semaphoreKey);
|
|
if (!gate) return;
|
|
|
|
clearCleanupTimer(gate);
|
|
for (const entry of gate.queue) {
|
|
clearTimeout(entry.timer);
|
|
entry.reject(new Error("Semaphore reset"));
|
|
}
|
|
gates.delete(semaphoreKey);
|
|
}
|
|
|
|
/**
|
|
* Reset all keys and reject queued waiters.
|
|
*/
|
|
export function resetAll(): void {
|
|
for (const key of gates.keys()) {
|
|
reset(key);
|
|
}
|
|
}
|