STT cost/load control: bounded concurrency, rate limits, scope gating (LC-592) #579
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/lc592-stt-load-control"
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 LC-592, and with it the LC-589 transcription-hardening epic (LC-590 reliability, LC-591 accuracy, LC-593 providers, LC-592 cost/load).
What changed
Three controls, deliberately separate because they bound different things:
LETS_CHAT_STT_WORKERSpermits (default 2), held across the read and the transcribe. Protects a CPU-bound self-hosted engine, where the common deployment is whisper.cpp on the same box as lets-chat.LETS_CHAT_STT_RATE_GLOBAL(30/min) andLETS_CHAT_STT_RATE_ROOM(10/min). Concurrency alone does not bound cost on a metered engine (two workers can bill all day), so this is a separate axis. Reuses the existingRateLimitsonAppStatevia two newRateLimitKindvariants, so it needed no new shared state.LETS_CHAT_STT_SCOPE=both(default) /voice/clips/none, so an operator can keep cheap voice notes and drop the expensive video-clip path. Gates stored attachments only;nonestill leaves live call captions working.The main deviation, and why
The ticket specced an mpsc worker pool with over-cap jobs left
pendingfor a sweeper to re-enqueue. I asked and we went with a semaphore instead. It needs noAppStatefield - which would have meant a mechanical one-line edit to the ~117 sites that build anAppStateby hand, for a value identical at every one of them - and it has no overflow case at all, so thependingfallback and its polling sweeper both disappear rather than needing to be written, tested, and maintained.Refusal behaviour differs by path, on purpose
A rate-limited voice message is marked
failed, which surfaces LC-590's Retry control once the window rolls over: visible and recoverable rather than silently absent. It is deliberately not leftpending- with no queue behind it, nothing would ever drain that, and the UI would show "Transcribing..." forever.A call clip sheds instead: it takes the permit with
try_acquirerather than waiting. A caption is latency-bound and worthless once late, the next clip is already 5 seconds away, and queueing behind a batch of long voice notes would deliver stale captions and hold the engine longer. Both refusals answer non-2xx, which LC-590 already renders as the caption warning.A gated-out attachment is left completely untouched - no status, so no Retry control offering to do the thing the operator just switched off. Policy is not failure.
Testing
just checkclean; full suite green at 185 test binaries (184 plus the new one).The LC-592 tests live in their own test binary. The gating tests mutate
LETS_CHAT_STT_SCOPE, a process-global thatscope()reads on every transcription, so sharing a process withtranscripts.rswould let a gating test switch transcription off underneath a concurrently-running test that expects it on. Within the file every test takes the lock, including the ones that only read - the rate and concurrency tests failed outright until they did, which is worth knowing if you add one.MockSttClientgained an optional per-call delay plus in-flight tracking. Without it an instant mock can never have two calls overlap, somax_concurrent() <= WORKERSwould have been vacuously true and the concurrency bound would have had no test at all.All three controls verified non-vacuous: I removed the scope gate, the rate check, and the permit in one pass and confirmed exactly the four intended tests failed - with
default_scope_transcribes_both_kindscorrectly still passing, since removing the gate should not change default behaviour.🤖 Generated with Claude Code
https://claude.ai/code/session_019xQaQQJyeJX7ZS9aVECXwF