Repository navigation
Concurrent local-provider inference: fair pool coordinator - #1
Merged
Merged
Conversation
added 5 commits
August 29, 2026 23:34
Concurrency (max parallel requests, default 1) and concurrencyMatchesModel (only same-model requests run in parallel, default false) are parsed and serialized by the Ollama and vLLM providers, exposed on the provider list API, and editable in the UI. InferenceProvider carries the defaults (1 / false) so all other provider types are unaffected.
The old design took one ReentrantLock per resource pool per request, so exactly one request across ALL providers in a pool could run at a time, and it slept every provider it did not belong to — including providers on other pools. Pools/Pool now coordinate per pool: - a single FIFO queue, strictly by arrival (the fairness guarantee: a resource hog cannot stomp out requests already in line); - the head starts when the pool is empty, or when it is on the active provider with a free concurrency() slot (and, if concurrencyMatchesModel() is set, all in-flight requests share its model); - at most one provider holds in-flight requests at a time (the VRAM invariant); a provider switch sleeps the previous provider and wakes the new one, outside the pool lock; - providers are kept warm: sleeping only happens on a switch, never because a queue went empty; - pools are independent: requests on pool A never sleep or block providers on pool B. LR.lockLocalModels and the LocalModelUnlocker interface are removed; _RequestCleanup releases the pool ticket instead of a model lock.
Both routes now join the pool's fair queue via Pool.acquire instead of the old lock-and-sleep-all, and neither performs its own wakeUp (the coordinator does the switch work). Cloud providers stay ungated. A proxy request with an unparseable body has no model to route and is not gated (nothing can load a model from it).
acquire() enqueued the ticket and then blocked on the drained condition, but dispatch() was only ever called from release() and interruptQueued(). The first request on an empty pool was therefore never started and the thread parked forever (caught live: the pool sat with enqueued-but-not- running requests). Call dispatch() immediately after enqueue, still under the lock, so a ticket that can start now (empty pool, or a free slot on the active provider with the model constraint satisfied) is dispatched in the same critical section. The await loop below only ever blocks when the ticket genuinely has to wait for another request to release.
e3ndr
force-pushed
the
ash/pool-concurrency
branch
from
August 30, 2026 04:50
715e22c to
fd3ec72
Compare
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.
Concurrency for local providers — fair pool coordinator
Local providers (Ollama, vLLM) can now satisfy multiple requests without
blocking each other, while the VRAM invariant is preserved.
What changed
Before: one fair
ReentrantLockper resource pool, taken per request.Exactly one request across all providers in a pool could run at a time,
and each request slept every other provider in the pool — including
providers on other pools (a real cross-pool bug).
After: a per-pool coordinator (
Pools/Pool) that:stomp out requests already in line (no skipping the line);
active provider with a free
concurrency()slot (and, ifconcurrencyMatchesModel(), all in-flight requests share its model);VRAM invariant). A provider switch sleeps the previous provider and
wakes the new one — done outside the pool lock;
because its queue went empty (load times can be penalizing);
providers on pool B (fixes the cross-pool bug).
New config
Per provider (defaults shown):
concurrency(int, default 1) — max parallel requests on this provider.concurrencyMatchesModel(bool, default false) — only same-modelrequests run in parallel; a different model waits for the others to drain.
Exposed on the provider list API and editable in the UI. Parsed/serialized
by the Ollama and vLLM providers;
InferenceProvidercarries the defaultsso all other provider types are unaffected.
Routes
Both
/inference/v1/*and/proxy/:provider/*inference endpoints gothrough the same pool gate — the proxy route can no longer bypass the pool
limits. Cloud providers (
resourcePool: "cloud") stay ungated.Verification
Built with JDK 21 (
mvn package, Svelte UI compiles clean). Schedulerlogic verified with a standalone harness driving the real
Poolagainststub providers — 5/5 pass:
The harness also caught a real deadlock on the first run —
acquire()enqueued then blocked, but
dispatch()was only called fromrelease()/interruptQueued(), so the first request on an empty pool never started.Fixed by dispatching immediately after enqueue (see "Fix deadlock" commit).
Signed off by ~Ash