fix(distributed): run cold model loads as durable jobs instead of holding the advisory lock - #11514
Merged
Merged
Conversation
WithLockCtx already overrides a deployment-wide lock_timeout on its dedicated connection so a blocking pg_advisory_lock() waits its turn instead of failing with 55P03. statement_timeout aborts that exact same statement independently, with SQLSTATE 57014, and was not overridden. Production roles commonly carry statement_timeout=60s. Any guarded section longer than that (a cold model load stages for tens of minutes) therefore killed every concurrent waiter: advisorylock: acquiring lock 9003261067483446873: ERROR: canceling statement due to statement timeout (SQLSTATE 57014) Derive it from the same context budget as lock_timeout, with a matching RESET so the pooled connection is returned clean. Assisted-by: Claude Opus 5 [claude-code]
A cold load in distributed mode is a long-running background job, but it was modelled as a synchronous side effect of an inference request: the whole of it (backend install, multi-GB staging, checkpoint load) ran inside the per-model advisory lock. Loading a 35.7 GB GGUF held that lock for ~20 minutes, so every concurrent request for the same model blocked on pg_advisory_lock and died at the role's 60s statement_timeout. Introduce the row that lets the lock shrink to a decision. Exactly one ModelLoadJob may be active per tracking key; that uniqueness — not the lifetime of a lock — is what de-duplicates concurrent loaders across replicas. ClaimLoadJob does its read-then-write under the advisory lock and nothing else: no network, file or gRPC I/O inside the guarded section, so a claim costs milliseconds no matter how long the resulting load takes. LastProgress is a heartbeat rather than a byte counter. A checkpoint load legitimately moves zero bytes for many minutes, so a reaper keyed on byte movement would reclaim a healthy job mid-load; byte progress stays the concern of load_deadline.go. A job whose heartbeat stops for longer than the orphan window is reclaimable, so a replica killed mid-load cannot wedge a model permanently. Failed jobs keep their row for a short grace so an immediately-following request reports the real cause instead of silently starting a fresh load of a model that just failed. No caller yet — the router moves onto this in the next commit. Assisted-by: Claude Opus 5 [claude-code]
Route wrapped the entire cold load — node selection, backend install, multi-GB staging and the remote LoadModel — in the per-model advisory lock. The lock's job is to de-duplicate concurrent loaders, a decision that takes milliseconds; holding it for the tens of minutes the resulting work takes is what turned a dedup mechanism into a cluster-wide outage for that model. Split it into a claim and a run. The claim is the only thing left inside the lock. The run is a background job owned by the claiming replica and bounded by the same progress-extended deadline as before; every other request for that model — local or on another replica — attaches as a waiter and is served the moment the model is ready, with no duplicate load and no lock contention. Waiters share one broadcast rather than an ordered queue: they all want the identical outcome, so ordering them would add fairness machinery that changes no result. The local channel wakes same-replica waiters instantly and a 2s DB poll is the authority, because a waiter on another replica has no channel to close. On wake a waiter re-runs the warm path rather than trusting the signal — the model may have been evicted in between. A waiter whose client disconnects returns immediately and the job keeps running; it belongs to the job record, not to the request. A failure is recorded on the row so every waiter reports the real cause, and the row survives briefly so the next request does not read "no job" as "not loading" and start a duplicate load of a model that just failed. The runner heartbeats the row on a fixed interval whether or not bytes are moving, which is what keeps a legitimately silent checkpoint load from being reclaimed as an orphan. Phase (installing/staging/loading) and placement ride to the heartbeat on the context, the same seam load_deadline.go already uses, so single-host paths are untouched. Non-distributed mode (no DB) keeps the inline load exactly as it was. Assisted-by: Claude Opus 5 [claude-code]
… progress A request whose model is cold-loading now attaches to the running job and is served the moment the model is ready. That wait has to be bounded: a held HTTP request cannot survive real infrastructure, and an ingress or LB idle timeout kills a twenty-minute request regardless of what LocalAI does. New LOCALAI_MODEL_LOAD_WAIT (default 60s) bounds the CALLER, never the load — the job keeps running either way. On expiry the request gets 503 with Retry-After and a structured body naming the model, the node, the phase, byte progress and an ETA. The `error` envelope keeps OpenAI clients working; `loading` is additive so they ignore it. The ETA comes from the job's own observed rate and is omitted rather than guessed until enough bytes have moved for that rate to mean anything: a confidently wrong ETA on a twenty-minute wait is worse than none. Retry-After is that ETA when known, clamped to [5s, 300s], and the wait budget otherwise. LOCALAI_MODEL_LOAD_WAIT=0 waits unbounded, for deployments with no proxy in front. Zero in the config struct still means "unset, use the default", so the CLI records the operator's zero as ModelLoadWaitUnbounded rather than losing the distinction. The distributed branch of ModelLoader.loadModel wrapped the router's error with %s, which flattened it to a string. Use %w: the typed error is what the HTTP layer keys the 503 off. Assisted-by: Claude Opus 5 [claude-code]
A client that receives 503 while a model stages onto a worker needs somewhere to poll. This returns the same `loading` object the 503 carries — phase, node, byte progress and ETA — or 404 when no load is running. Read-only and observability-shaped, so it is deliberately neither admin-gated nor feature-gated: it explains a 503 the caller just received, and hiding that behind a per-modality feature would make the explanation for a failed image request depend on chat permissions. It also gets no MCP tool, since there is nothing here an admin would manage conversationally. Registered on the surfaces from .agents/api-endpoints-and-auth.md: the swagger block (existing `models` tag, so /api/instructions needs no new area), the endpoint discovery maps in RegisterLocalAIRoutes, regenerated swagger, and the distributed-mode docs page. No FLAG_* usecase is involved, so capabilities.js is unchanged. Assisted-by: Claude Opus 5 [claude-code]
… ready A chat request for a model that is still staging onto a worker now gets a 503 carrying live progress instead of an error. Render it: the composer shows the phase (installing / staging / loading), the node, the percent and the ETA, then polls load-status and re-sends the request the moment the model is ready. Reuses the staging progress idiom the page already had rather than inventing a second one — the two sources are folded into one loadProgress, with the load job winning because it is authoritative across frontend replicas and knows the phase, where the staging operation only knows about a byte transfer this replica happens to be performing. Waiting is bounded (three send attempts, ~30 min of polling each), so a load that never finishes still surfaces as an error rather than as a spinner nobody questions. An aborted generation stops the polling too. Assisted-by: Claude Opus 5 [claude-code]
The router moved legacy cleanup calls onto newly linted lines. Report cleanup failures while preserving the fallback to a cold load. Assisted-by: Codex:gpt-5 [golangci-lint]
mudler
approved these changes
Aug 15, 2026
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 incident
On a
localai-org-developmentfrontend (2 replicas), loading a 35.7 GB GGUF onto a newly added Jetson Thor worker made the model permanently unloadable from the operator's seat, while staging was in fact progressing normally underneath.Replica A acquired the per-model advisory lock
model-load:Qwen3.6-27B-MTP-GGUFand began staging — ~20 minutes of transfer. Replica B received a request for the same model, blocked onpg_advisory_lock, and was killed at 60s by thelocalairole'sstatement_timeout:Every UI retry reproduced it. Three defects sit behind that one symptom:
Routewrapped the whole cold load — backend install, multi-GB staging, checkpoint load — inadvisorylock.WithLockCtx. The lock's job is to de-duplicate concurrent loaders, a decision that takes milliseconds; holding it for tens of minutes turns a dedup mechanism into a cluster-wide outage for that model.WithLockCtxdefended againstlock_timeoutbut notstatement_timeout. Both abort the same blockingpg_advisory_lock($1); only the former was overridden. Latent for every blocking caller, not just model loads.StagingTrackeralready tracked per-file byte progress and broadcast it over NATS, but nothing on the inference path consumed any of it.The change: claim / run split
The cold load becomes a durable job (
model_load_jobs), with waiters attached by broadcast.The advisory lock is kept — it is the right primitive — but its guarded section shrinks to the claim: a
SELECT, possibly aDELETEof an orphan, anINSERT. No network, file or gRPC I/O inside it. Uniqueness of the primary key ontracking_keyis the real guard; the lock only makes the read-then-write non-racy.Waiters share one broadcast, not an ordered queue: every waiter for a model wants the identical outcome, so ordering them would add fairness machinery that changes no result. Local waiters wake on a closed channel; a 2s DB poll is the authority, because a waiter on another replica has no channel to close and NATS broadcasts are fire-and-forget.
Liveness is a heartbeat, not a byte counter. The runner touches
last_progresson a fixed 1s interval whether or not bytes move — a checkpoint load legitimately transfers zero bytes for many minutes, and a reaper keyed on byte movement would reclaim a healthy job mid-load. Byte progress stays the concern ofload_deadline.go, which is untouched. A job whose heartbeat stops past the orphan window is reclaimable, so a replica killed mid-load cannot wedge a model permanently.Caller contract
worker out of disk) to every waiter. The failed job row is retained briefly so a request arriving right after does not read "no job" as "not loading" and start a duplicate load of a model that just failed.Retry-Afterand{ "error": { "message": "model Qwen3.6-27B-MTP-GGUF is staging on node nvidia-thor (41%, ETA ~11m)", "type": "model_loading", "code": "model_loading" }, "loading": { "model": "Qwen3.6-27B-MTP-GGUF", "state": "staging", "node": "nvidia-thor", "progress": 41.2, "bytes_sent": 14730000000, "total_bytes": 35776484480, "file_index": 1, "total_files": 2, "eta_seconds": 660 } }The
errorenvelope keeps OpenAI-client compatibility;loadingis additive, so existing clients ignore it.eta_secondscomes from the job's own observed rate and is omitted, not guessed, until enough bytes have moved for that rate to mean anything — a confidently wrong ETA on a twenty-minute wait is worse than none.Retry-Afteris the ETA when known, clamped to[5s, 300s], and the wait budget otherwise.New config key
LOCALAI_MODEL_LOAD_WAIT(default 60s) bounds how long a request waits for a running cold load. It bounds the caller, never the load — the job runs on either way. A held HTTP request cannot survive real infrastructure: an ingress or LB idle timeout kills a twenty-minute request regardless of what LocalAI does, so the answer should come from LocalAI with progress attached rather than from a proxy dropping the connection.LOCALAI_MODEL_LOAD_WAIT=0restores unbounded waiting for deployments with no proxy in front. (Zero in the config struct still means "unset, use the default", so the CLI records the operator's explicit zero asModelLoadWaitUnboundedrather than losing the distinction.)New endpoint
GET /api/models/{id}/load-status→ the sameloadingobject, or 404 when no load is running. Deliberately neither admin- nor feature-gated: it explains a 503 the caller just received, and hiding that behind a per-modality feature would make the explanation for a failed image request depend on chat permissions. No MCP tool — there is nothing here an admin would manage conversationally.UI
Chat renders a 503 with
type: "model_loading"as inline progress (phase, node, percent, ETA), pollsload-status, and re-sends the request once the model is ready. It reuses the staging progress idiom the page already had rather than inventing a second one; the load job wins over the/api/operationsstaging row because it is authoritative across replicas and names the phase, where the operation only knows about a byte transfer this replica happens to be performing.Commits
fix(advisorylock)— setstatement_timeoutalongsidelock_timeout. Independently backportable; it fixes a latent bug for every blockingWithLockCtxcaller (schema migration, reconciler, health check), not just model loads.feat(distributed)— theModelLoadJobrow +ClaimLoadJob.refactor(distributed)— move staging/load out of the lock into the job runner, with waiter fan-out.feat(distributed)— the wait budget and the 503 contract.feat(api)—load-status+ registration surfaces + docs.feat(ui)— Chat staging state and auto-retry.Each builds and passes on its own.
Testing
New specs (Ginkgo + testcontainers PostgreSQL, and Playwright for the UI):
statement_timeoutinstead of failing 57014 (mirrors the existinglock_timeoutspec);ModelLoadingErrorwithRetry-After, andLOCALAI_MODEL_LOAD_WAIT=0waits unbounded;load-status404s with no job and reports progress with one;Notes
pkg/model/loader.gowrapped the router's error with%s, flattening the chain; changed to%wso the typed loading error survives to the HTTP layer.load_deadline.go(the progress-extended cold-load hold) andfile_stager_http.goare untouched.🤖 Generated with Claude Code