fix(distributed): configurable remote model-load timeout, and reap the load when it times out#10948
Conversation
The router hardcoded a 5 minute gRPC deadline for the remote LoadModel
call. Staging finishes before the timer starts, so those five minutes
cover only the worker backend's own checkpoint load and pipeline init.
A cold load of meituan-longcat/LongCat-Video-Avatar-1.5 (~83 GB) on an
ARM64 Thor worker fails at exactly 302s with DeadlineExceeded while the
backend process is still making progress (CPU time accumulating, RSS
moving as weights are mapped), so the load was cut short rather than
wedged.
Add LOCALAI_NATS_MODEL_LOAD_TIMEOUT / --model-load-timeout mirroring the
existing backend-install timeout knob, defaulting to 5m so unset
clusters keep today's behaviour.
The cold-load hold ceiling (which bounds how long one load may hold the
per-model advisory lock) was derived from the install timeout alone, so
raising the load deadline past it would have been silently clipped.
Derive it from both budgets via ModelLoadCeilingFor:
max(install + load + 5m staging margin, 25m)
With the defaults that is 15m + 5m + 5m = 25m, identical to the previous
constant, and the 25m floor means shrinking either budget can never
tighten the ceiling below what clusters relied on before.
Assisted-by: Claude:claude-opus-4-8 golangci-lint
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
… out The gRPC deadline on the remote LoadModel call only cancels the client side. A backend blocked in a synchronous weight load never observes its cancelled handler context, so when scheduleAndLoad gave up it left the worker loading with nobody waiting for the result. Observed on an ARM64 Thor worker loading LongCat-Video-Avatar-1.5: the client returned DeadlineExceeded at 302s, and the backend process was still alive 30 minutes later having pulled ~57GB from HuggingFace. Every retry stacked another multi-GB loader on the worker; they had to be reaped by hand via POST /api/nodes/:id/models/unload. Send backend.stop for the exact `modelID#replicaIndex` process key we just abandoned. The exact key matters: a bare model ID stops every replica on that node, including healthy ones serving traffic. Only a deadline or cancellation triggers the reap. Any other LoadModel failure is the backend answering, which means its handler returned and the process is idle - stopping it there would discard a warm process and its downloaded weights. The reap is best-effort and never replaces the load error the caller is waiting on. The `modelID#replicaIndex` format was already hand-rolled in two places (the worker's buildProcessKey and pkg/model's log store). Rather than add a third, export model.BackendProcessKey from pkg/model, the lowest common dependency of both sides. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 golangci-lint
|
Correction to the "Merge-order dependency" section of the description above: it was wrong, and this PR had no unmet dependency. That section claimed That was already fixed on master before this merged, by #10865, which added: const workerBackendFreeTimeout = 5 * time.Secondand applied it at both relevant call sites — So the reap in this PR is effective as merged. No follow-up is required to make it work. The error came from checking a worktree branched off an older master rather than master itself. Leaving this as a comment rather than editing the description, so the original claim and its correction both stay visible. |
Two changes to the distributed cold-load path. They ship together because the first makes large cold loads possible and the second stops a timeout from leaking a multi-GB process every time one still happens.
The incident
An ARM64 Thor worker loading
meituan-longcat/LongCat-Video-Avatar-1.5(~83 GB):DeadlineExceeded, against a hardcoded 5m deadlinePOST /api/nodes/:id/models/unload; we watched it happen twice in one session, each retry stacking another loader on the workerOnce the weights were cached locally the same model loaded in 108 seconds, comfortably inside the 5m budget. The default is not unreasonable for a warm load — the pathology is weight acquisition happening inside the load RPC, which is addressed separately.
1.
LOCALAI_NATS_MODEL_LOAD_TIMEOUT/--model-load-timeoutReplaces the hardcoded
context.WithTimeout(ctx, 5*time.Minute)inscheduleAndLoad. Default stays 5m, so unset clusters are unchanged. Mirrors the existingLOCALAI_NATS_BACKEND_INSTALL_TIMEOUTprecedent, whose help text already says "Increase for slow links pulling multi-GB images" — the install path got this treatment; the load path never did.The advisory-lock ceiling is now derived rather than constant:
ModelLoadCeilingFor(install, load) = max(install + load + 5m, 25m). That matters — the olddefaultModelLoadCeiling = 25 * time.Minutewas explicitly documented as "install (15m) plus staging and the remote LoadModel (5m)", so raising the load budget past it would have been silently clipped and the fix would have appeared not to work. 15m + 5m + 5m = 25m at defaults, identical to before, with a 25m floor so shrinking either budget can never tighten it.2. Reap the abandoned replica on timeout
A gRPC deadline only cancels the client side of the call. A backend blocked in a synchronous weight load never observes its cancelled handler context, and
scheduleAndLoadpreviously just returned the error: noStopBackend, no unload. The worker kept loading forever.On a deadline or cancellation the router now sends
backend.stopfor the exactmodelID#replicaIndexprocess key it just abandoned. The exact key matters: a bare model ID stops every replica on that node, including healthy ones serving traffic.Any other
LoadModelfailure does not reap. An unsupported model or bad option means the backend answered, its handler returned and the process is idle; stopping it would discard a warm process and its downloaded weights before the next attempt. Classification useserrors.Isfor context errors pluserrors.Ason theGRPCStatus()interface (notstatus.Code, which does not unwrap in all grpc-go versions).The reap is best-effort: a failed stop is logged via
xlog.Warnand never replaces the load error the caller is waiting on, with a spec asserting the stop error cannot appear in the returned error.The
modelID#replicaIndexformat was already hand-rolled in two places, so rather than add a third this exportsmodel.BackendProcessKeyfrompkg/model— the lowest common dependency of the router and the worker, with no new import edges.Merge-order dependency
This PR should merge after the worker-side fix that bounds
client.Free(context.Background())instopBackendExact.proc.Stop()itself is sound (go-processmanager defaults: SIGTERM to the process group, 15s grace, then SIGKILL) and will kill a Python loader wedged intorch.load. ButstopBackendExactcallsclient.Free()with an unbounded context first. 37 Python backends default toPYTHON_GRPC_MAX_WORKERS=1— includingbackend/python/longcat-video/backend.py:46, the exact backend from this incident — so a backend wedged inLoadModelhas no thread to serviceFree, and the stop blocks before ever reaching the kill.So the reap here dispatches the right stop for the right replica, but against a fully wedged single-worker Python backend it is inert until that worker-side timeout lands. This matches the field evidence: the manual unload worked, but only once the loader had gone idle enough to answer
Free.Testing
New Ginkgo specs in
core/services/nodes/router_reap_load_test.godriveRoute→scheduleAndLoadwith a stubgrpc.Backendand assert: the stop is dispatched for the exactmodelID#replicaIndex(pinned to replica 2, so a hardcoded#0cannot pass), the originalDeadlineExceededstill propagates when the stop itself fails, a wrappedcontext.DeadlineExceededreaps too, and a cleanInvalidArgumentdoes not.make lint→ 0 issues. Coverage rose to 53.0% (baseline 48.5%).🤖 Generated with Claude Code