fix(gateway): serialize local-model calls — fix oMLX "model is busy" chat failures#33
Merged
Merged
Conversation
…hat failures)
A local model server (Ollama / MLX / oMLX) serves ONE request at a time and errors on
any overlap ("model 'gemma-4…' is busy; cannot reload runtime settings variant until
active requests finish"). The app had concurrent callers hitting the same server —
foreground chat, the background auto-reindex embedding every tick, and a user retry after
a premature timeout — so chats failed with "couldn't reach the model" then "busy".
- gateway serializes every local-provider call through a process-wide BoundedSemaphore
(a Semaphore, NOT a Lock/RLock: the streaming path holds it across the SSE generator,
which Starlette drives across different threadpool workers — a Lock raises on the
cross-thread release and would wedge the lock forever; a Semaphore's release is not
owner-bound). Cloud providers are exempt (they parallelize fine).
- the background auto-reindex peeks with gateway.local_available() and skips the tick when
a foreground chat holds the model, so the user's chat is never delayed behind a backfill.
- interactive chat/agent timeout raised 60s -> 180s: a big local model (gemma-4 26B) can
take well over a minute to cold-load + generate, and abandoning the request early made a
retry collide with the still-running one. Cloud models never approach this ceiling.
- chat_stream surfaces a stream timeout as a clean 504 ("model may still be loading") like
the non-streaming paths, instead of a raw "gateway unreachable".
Two adversarial review passes: the first reproduced a critical bug in an initial RLock
version (cross-thread release -> RuntimeError -> permanently wedged lock); the fix here uses
a Semaphore and is verified clean. Tests cover local serialization (1 concurrent local call
vs many cloud), the cross-thread release that RLock could not do, local_available gating, and
the interactive-timeout wiring. Full suite green (656).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug (from your screenshot + logs)
Chat failed with "couldn't reach the model" then, on retry, oMLX's "Model gemma-4… is busy; cannot reload runtime settings variant until active requests finish". Root cause: a local model server (Ollama/MLX/oMLX) serves one request at a time and errors on any overlap, but the app sent it concurrent requests:
The fix
BoundedSemaphore(1)so oMLX never sees overlap. Cloud providers are exempt (they parallelize fine).Lock/RLockraisesRuntimeErroron the cross-thread release and would wedge the lock forever. A Semaphore's release isn't owner-bound.local_available()peek skips the tick while a chat holds the model, so your chat is never delayed behind a backfill.chat_streamnow surfaces a stream timeout as a clean 504 ("model may still be loading") instead of "gateway unreachable".Rigor (this one bit me, so I over-verified)
Two adversarial review passes. The first reproduced a critical bug in my initial
RLockversion — cross-thread release →RuntimeError→ permanently wedged local-model subsystem. I switched to aSemaphoreand re-reviewed: 0 findings (checked for the non-re-entrant deadlock risk, double-release, leaks, and residual collisions). Tests cover: local serialization (1 concurrent local call vs. many cloud), the cross-thread release the old RLock couldn't do (direct regression test),local_availablegating, and the interactive-timeout wiring. Full backend suite green (656), ruff clean.Known, accepted edge (bounded, correct-result)
If you fire a chat at the exact moment a scheduled turn is mid-generation on the same local model, the chat waits for it (up to the scheduled turn's ~300s ceiling) rather than colliding — a correct-but-slower result. Rare for a single user; I can add a "waiting…" stream heartbeat if you ever hit it.