Skip to content

fix(sse): configurable round-robin combo queue depth for faster failover (#3872) - #4390

Merged
diegosouzapw merged 1 commit into
release/v3.8.32from
fix/3872-combo-queue-depth
Jun 20, 2026
Merged

diegosouzapw merged 1 commit into
release/v3.8.32from
fix/3872-combo-queue-depth

Conversation

@diegosouzapw

Copy link
Copy Markdown
Owner

Closes #3872

Problem

When a round-robin combo member is saturated, requests sit in the per-model rate-limit semaphore's unbounded queue and only fail over to the next member after the full queueTimeoutMs (default 30s) elapses. A burst of agentic requests therefore deep-queues one hot member (e.g. minimax at concurrency 3) instead of spilling to healthy members — the client often dies mid-task before failover.

The combo already cascades on SEMAPHORE_QUEUE_FULL/SEMAPHORE_TIMEOUT (combo.ts round-robin loop), but the combo's own semaphore (rateLimitSemaphore.ts) never emitted SEMAPHORE_QUEUE_FULL — its queue was unbounded, so that fast-cascade path was unreachable.

Fix

  • rateLimitSemaphore.acquire accepts an optional maxQueueSize; once the queue is that deep it rejects immediately with SEMAPHORE_QUEUE_FULL instead of waiting. Omitted/negative keeps the historical unbounded behavior (backward-compatible).
  • New queueDepth combo-config knob, resolved through the full 3-layer cascade (global default → provider override → per-combo) via a pure resolveComboQueueDepth helper (mirrors the existing resolveComboTargetTimeoutMs pattern). Default 20 (backward-compatible; matches the number reporters saw); 0 = never queue → fail over to the next member immediately.
  • Plumbed into the round-robin semaphore.acquire(...) call, surfaced in Settings → Combo Defaults, and validated by the combo Zod schema (min(0).max(100), accepted on create + global defaults + provider overrides).

Why this is the right lever (investigation note)

The user's exact Semaphore queue full (20) for minimax:<connId> 429 comes from the per-account accountSemaphore deeper in chatCore, but the combo already cascades on a 429 result. The actionable lever for faster combo failover is bounding the combo's own per-model queue so it cascades before the 30s timeout — which also relieves pressure on the per-account semaphore (fewer requests pile in concurrently).

Validation (Hard Rule #18 — TDD)

  • tests/unit/rateLimitSemaphore.test.ts (new): unbounded default unchanged; bounded queue emits SEMAPHORE_QUEUE_FULL; maxQueueSize: 0 fails over immediately. RED before the maxQueueSize cap, GREEN after.
  • tests/unit/combo-config.test.ts: queueDepth default 20, 3-layer cascade, resolveComboQueueDepth clamps (default/0/negative/NaN/>100/fractional), schema accepts 0 + rejects out-of-range. RED before the config field.
  • Local: combo + semaphore suite 376/376, typecheck:core clean, lint clean, check:test-discovery / check:file-size / check:any-budget:t11 / i18n-ui-coverage (--threshold=65) green.

Default is unchanged behavior for existing installs (20-deep then cascade vs. previously unbounded-then-30s-timeout — strictly better under burst, no-op under light load).

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a configurable pre-cascade semaphore queue depth (queueDepth) for round-robin combos to allow faster failover under concurrency saturation. The changes span backend configuration resolution, validation schemas, rate-limiting semaphores, frontend settings components, and unit tests. The review feedback identifies a coercion bug in resolveComboQueueDepth where empty or null values incorrectly resolve to 0 instead of the default fallback, a UI issue where clearing the queue depth input field prevents restoring the default value, and a recommendation to add unit tests covering these edge cases.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +145 to +149
export function resolveComboQueueDepth(config: Record<string, unknown> | null | undefined): number {
const raw = isRecord(config) ? Number(config.queueDepth) : Number.NaN;
if (!Number.isFinite(raw) || raw < 0) return DEFAULT_COMBO_QUEUE_DEPTH;
return Math.min(Math.floor(raw), MAX_COMBO_QUEUE_DEPTH);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In JavaScript, Number(null) and Number("") both evaluate to 0. Because 0 is a valid and meaningful value for queueDepth (meaning "never queue, fail over immediately"), passing null or "" will bypass the fallback check raw < 0 and incorrectly resolve the queue depth to 0 instead of falling back to DEFAULT_COMBO_QUEUE_DEPTH (20). Explicitly checking for null and "" ensures robust fallback behavior.

References
  1. Ensure robust input handling and fallback logic for configuration parameters. (link)

Comment on lines +492 to +497
onChange={(e) =>
setComboDefaults((prev) => ({
...prev,
queueDepth: parseInt(e.target.value) || 0,
}))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using parseInt(e.target.value) || 0 means that if the user clears the input field (resulting in an empty string ""), parseInt("") evaluates to NaN, which falls back to 0. Since 0 is a valid and active setting ("never queue"), the user can never clear the field to restore the default behavior (which would show the placeholder 20 and fall back to DEFAULT_COMBO_QUEUE_DEPTH on the backend). Setting it to undefined when empty allows the placeholder to be displayed and the backend to correctly fall back.

References
  1. Allow clearing optional numeric fields to restore default values. (link)

Comment on lines +626 to +639
test("resolveComboQueueDepth defaults to 20, honors configured values, and clamps the range", () => {
assert.equal(resolveComboQueueDepth(null), 20);
assert.equal(resolveComboQueueDepth({}), 20);
assert.equal(resolveComboQueueDepth({ queueDepth: 5 }), 5);
// 0 is a valid, meaningful value: queue nothing → fail over to the next member immediately.
assert.equal(resolveComboQueueDepth({ queueDepth: 0 }), 0);
// Invalid / negative inputs fall back to the safe default.
assert.equal(resolveComboQueueDepth({ queueDepth: -3 }), 20);
assert.equal(resolveComboQueueDepth({ queueDepth: Number.NaN }), 20);
// Out-of-range high values are clamped, not trusted.
assert.equal(resolveComboQueueDepth({ queueDepth: 99999 }), 100);
// Fractional values floor to an integer queue slot count.
assert.equal(resolveComboQueueDepth({ queueDepth: 3.9 }), 3);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add assertions to verify that null and "" (empty string) values for queueDepth correctly fall back to the default value of 20 instead of being coerced to 0.

References
  1. Always include comprehensive unit tests covering edge cases and boundary conditions. (link)

@diegosouzapw
diegosouzapw force-pushed the fix/3872-combo-queue-depth branch from e2f3a09 to cb5bfe5 Compare June 20, 2026 15:08
@diegosouzapw
diegosouzapw changed the base branch from release/v3.8.30 to release/v3.8.31 June 20, 2026 15:08
@diegosouzapw
diegosouzapw force-pushed the fix/3872-combo-queue-depth branch 2 times, most recently from 851926b to 38d47d4 Compare June 20, 2026 17:37
@diegosouzapw
diegosouzapw changed the base branch from release/v3.8.31 to release/v3.8.32 June 20, 2026 18:31
…ver (#3872)

Round-robin combo members deep-queued under concurrency saturation: the
per-model rate-limit semaphore had an unbounded queue and only emitted
SEMAPHORE_TIMEOUT after the full queueTimeoutMs (default 30s), so failover to
the next combo member happened far too late (or the client died first).

The per-model semaphore now accepts a bounded queue depth and rejects with
SEMAPHORE_QUEUE_FULL once the queue is full — the round-robin loop already
cascades to the next member on that code, so a low depth fails over immediately.
A new `queueDepth` combo-config knob (global default / provider override /
per-combo; default 20 for backward compatibility, 0 = never queue) is plumbed
via a resolveComboQueueDepth helper and surfaced in Settings → Combo Defaults.

TDD: rateLimitSemaphore.test.ts (bounded queue + SEMAPHORE_QUEUE_FULL, RED
before the maxQueueSize cap) and combo-config.test.ts (queueDepth cascade,
helper clamps, schema range).

Co-authored-by: KooshaPari <KooshaPari@users.noreply.github.com>
@diegosouzapw
diegosouzapw force-pushed the fix/3872-combo-queue-depth branch from 38d47d4 to 5e2ed34 Compare June 20, 2026 19:30
@diegosouzapw
diegosouzapw merged commit fc57530 into release/v3.8.32 Jun 20, 2026
6 checks passed
@diegosouzapw
diegosouzapw deleted the fix/3872-combo-queue-depth branch June 20, 2026 19:56
tkgo11 pushed a commit to tkgo11/OmniRoute that referenced this pull request Sep 23, 2026
…ver (diegosouzapw#3872) (diegosouzapw#4390)

Round-robin combo members deep-queued under concurrency saturation: the
per-model rate-limit semaphore had an unbounded queue and only emitted
SEMAPHORE_TIMEOUT after the full queueTimeoutMs (default 30s), so failover to
the next combo member happened far too late (or the client died first).

The per-model semaphore now accepts a bounded queue depth and rejects with
SEMAPHORE_QUEUE_FULL once the queue is full — the round-robin loop already
cascades to the next member on that code, so a low depth fails over immediately.
A new `queueDepth` combo-config knob (global default / provider override /
per-combo; default 20 for backward compatibility, 0 = never queue) is plumbed
via a resolveComboQueueDepth helper and surfaced in Settings → Combo Defaults.

TDD: rateLimitSemaphore.test.ts (bounded queue + SEMAPHORE_QUEUE_FULL, RED
before the maxQueueSize cap) and combo-config.test.ts (queueDepth cascade,
helper clamps, schema range).

Co-authored-by: KooshaPari <KooshaPari@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant