feat: provider-aware concurrency caps — queue behind a busy provider instead of failing against it (#356) #495
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/356-provider-concurrency"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #356.
A burst of Friday-night sessions now costs latency, not failures. Each provider declares what it will actually serve at once; the Celery layer enforces it; a GM whose session lands in a busy window sees "queued behind N, about M minutes" instead of silence.
What
--parallel 2), bundled WhisperX 1 (its v2 advertises exactly that), Anthropic/OpenAINone(rely on 429 +Retry-After)..envoverridesQB_LLM_CONCURRENCY_LIMIT/QB_ASR_CONCURRENCY_LIMITwin over the declaration.app/services/provider_slots.py, DB 0 only): leases with TTL so an OOM-killed worker cannot pin a GPU; rank taken from a monotonic admission counter rather than expiry (renewing a lease must not re-sort it behind a newcomer); rolling hold durations for the estimate; fails open (logged,degraded) if Redis is unreachable.process_audioacquires the ASR slot wherewhisper_cfgis read and, if none is free,self.retry(countdown=20–40 s jittered, max_retries=240)so the worker returns to the pool; released before the LLM phase. Every LLM path acquires through onerun_with_slotaround the transport dispatch ingenerate_structured_textand_dispatch_prosewith a bounded in-task wait (ceiling 900 s), because retrying a whole task after transcription would repeat the ASR. Request-path callers (/ask, admin Test, the two Workbench generators that run inside an HTTP request) never wait: aRequestScopeMiddlewarecontextvar marks request scope so a browser cannot hang on the queue — the rule is a property of where code runs, not of each call site.Retry-After(clamped to 10 min), bounded backoff, typed error afterwards. An ASR 429 re-queues the session rather than failing it (_AsrShouldRequeue), and a re-queued run writes nosession_usagerow — a charge for work that never happened, rewritten every 20 s, would be wrong. An LLM 429 after transcription still fails after three attempts, deliberately: the ASR half was really paid for and re-running would repeat it.GET /api/admin/ai/queueper{kind, provider, host}→ limit, in use, waiting, estimated wait;processing_waiton the session response;ProcessingQueueNoteon the session page.ProviderRateLimitedis now an alias ofapp.providers.errors.ProviderRateLimitError; #359'sxfailplaceholder is replaced by a real conformance case asserting peak == limit for every adapter (a cap that never admits its full quota would pass<=while halving throughput). The gate is in the caller by design and the test docstring says so.Rebased over #357: the usage meter,
_prose_usageand the ASR timer coexist with the slot try/finally; three import/phase conflicts resolved.Verification
🤖 Generated with Claude Code
Two views of the same Redis state, aimed at the two people who ask about it. `GET /api/admin/ai/queue` answers the operator's question during a burst: is anything stuck, or is it just busy? Those looked identical from outside — every session on "Processing…" either way — and the only way to tell them apart was to read worker logs. Per endpoint it reports the cap, what is in use, what is waiting and how long a new arrival should expect to wait. It reads Redis only and never contacts a provider, so it stays honest and instant when a provider is exactly what has gone wrong. `processing_wait` on the session detail response answers the GM's: an additive field, null whenever nothing is queued, shaped {kind, position, estimated_seconds, since}. Read from Redis rather than the session row, so there is no migration and nothing to clean up when the wait ends. Only the detail endpoint fills it — the list endpoints would need one Redis read per session for a note nobody reads in a list. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>