Skip to content

Concurrent local-provider inference: fair pool coordinator - #1

Merged
e3ndr merged 5 commits into
mainfrom
ash/pool-concurrency
Aug 30, 2026
Merged

e3ndr merged 5 commits into
mainfrom
ash/pool-concurrency

Conversation

@e3ndr

@e3ndr e3ndr commented Aug 29, 2026

Copy link
Copy Markdown
Owner

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 ReentrantLock per 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:

  • keeps a single FIFO queue, strictly by arrival — a resource hog can't
    stomp out requests already in line (no skipping the line);
  • lets the queue head start when the pool is empty, or when it's on the
    active provider with a free concurrency() slot (and, if
    concurrencyMatchesModel(), all in-flight requests share its model);
  • enforces at most one provider with in-flight requests per pool (the
    VRAM invariant). A provider switch sleeps the previous provider and
    wakes the new one — done outside the pool lock;
  • keeps providers warm: a provider is only slept on a switch, never
    because its queue went empty (load times can be penalizing);
  • makes pools independent — requests on pool A never sleep or block
    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-model
    requests 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; InferenceProvider carries the defaults
so all other provider types are unaffected.

Routes

Both /inference/v1/* and /proxy/:provider/* inference endpoints go
through 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). Scheduler
logic verified with a standalone harness driving the real Pool against
stub providers — 5/5 pass:

  1. same provider, concurrency=2 → two requests in flight at once (peak 2)
  2. different provider waits for a full drain, then runs
  3. model-match serializes different models on the same provider
  4. no line-cutting: a queued other-provider request blocks even a free slot
  5. (S1–S4 above; the first-request-on-empty-pool path is exercised by all)

The harness also caught a real deadlock on the first run — acquire()
enqueued then blocked, but dispatch() was only called from release() /
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

~Ash 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
e3ndr force-pushed the ash/pool-concurrency branch from 715e22c to fd3ec72 Compare August 30, 2026 04:50
@e3ndr e3ndr closed this Aug 30, 2026
@e3ndr
e3ndr deleted the ash/pool-concurrency branch August 30, 2026 04:53
@e3ndr
e3ndr restored the ash/pool-concurrency branch August 30, 2026 04:53
@e3ndr e3ndr reopened this Aug 30, 2026
@e3ndr
e3ndr merged commit 0436d33 into main Aug 30, 2026
3 checks passed
@e3ndr
e3ndr deleted the ash/pool-concurrency branch August 30, 2026 05:00
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