fix(distributed): make the cold-load hold scale with progress, not wall-clock#11019
Merged
Conversation
…ll-clock
A 70 GB video checkpoint (longcat-video-avatar-1.5) could not be loaded on a
distributed cluster. The request failed with HTTP 500 after 1499.98s - exactly
the 25m00s cold-load ceiling - while staging was demonstrably healthy: 26 of 57
files and 39 GB transferred at a sustained ~26 MB/s, zero errors, no stalls. It
was not wedged, it was killed by a timer.
ModelLoadCeilingFor covers node selection, backend install, file staging and the
remote LoadModel. Install and load carry their own budgets; staging was covered
only by a FIXED 5-minute margin. But staging time is bytes over bandwidth, not a
constant: 70 GB at 26 MB/s needs ~45m against a 25m ceiling, so the failure is
deterministic for any sufficiently large model rather than a flake. Simply
raising the constant moves the cliff to the next model size - the deployment
target here is checkpoints of 600 GB and beyond.
The ceiling's real purpose is that "a wedged worker can never pin the lock
indefinitely". Progress, not elapsed time, is what distinguishes a wedged worker
from a large one. The hold is now a deadline that extends whenever the transfer
reports bytes and expires a 5-minute stall window after they stop:
- A large model transferring fine continues, for hours if needed.
- A worker that died mid-transfer still fails within the stall window.
Progress is observed at byte level on the transfer itself, via the existing
staging progress callback. Per-file completion would be too coarse - a single
600 GB shard would be indistinguishable from a stall for hours. The observation
point is back-pressured by the socket, so it reflects the network rather than
local disk reads. Observation is coarsened to one timer touch per stall/20 so
the per-read callback stays cheap.
The base budget (unchanged, and still derived from the install and load
timeouts) continues to cover the steps that report no progress, so
LOCALAI_NATS_MODEL_LOAD_TIMEOUT keeps working exactly as before. An absolute
cap of 24h bounds the hold even while progress keeps arriving, so a peer
trickling bytes forever cannot pin the advisory lock; 600 GB at the measured
26 MB/s is ~6.5h, so the cap sits far above any legitimate transfer.
Also fixes the incoherent layering the same error exposed: the resumable upload
carried a 1h retry budget nested inside the 25m ceiling, so the inner budget was
unreachable and the message still blamed it ("failed after 1 attempts within
1h0m0s budget") while the 25m parent was the actual killer. The upload now
adopts the caller's deadline when there is one, and applies its fixed budget
only when nothing above bounded it - which also stops a fixed 1h from
reintroducing the size cliff under the now-extendable parent.
This is the successor to #10968, where a hardcoded 5-minute LoadModel gRPC
timeout was replaced by this derived ceiling. Fixing the inner timeout exposed
the outer ceiling as the new binding constraint.
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]
This was referenced Jul 21, 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 failure
A 70 GB video checkpoint (
longcat-video-avatar-1.5) could not be loaded on a distributed cluster. Measured in production:Staging was healthy and steady the whole time: 26 of 57 files, 39 GB transferred at a sustained ~26 MB/s, zero errors, no stalls. It did not wedge. It was killed by a timer, at exactly the
minModelLoadCeilingvalue.Root cause
ModelLoadCeilingForincore/services/nodes/router.gobounds how long one cold load may hold the per-model advisory lock. It covers node selection, backend install, file staging, and the remoteLoadModel. Install and load have their own budgets; staging was covered only by a fixed 5-minute margin:Staging duration is bytes over available bandwidth, not a constant. 70 GB at 26 MB/s needs ~45 minutes against a 25-minute ceiling, so this failure is deterministic for any sufficiently large model, not a flake.
Verified call path:
Route()buildsloadCtxwith the ceiling, takes the advisory lock, thenscheduleAndLoad->stageModelFiles->fileStager.EnsureRemote, whoseresumeCtxis a child of that context. The ceiling is the binding constraint — the parent's expiry propagatesDeadlineExceededinto the upload, which is why the error reads the way it does.That also explains the corroborating incoherence in the same message: the upload carries a 1h retry budget nested inside the 25m ceiling. The inner budget is unreachable, and
failed after 1 attemptsshows the retry never got a second try before the parent died.Why not just raise the constant
The deployment target is checkpoints of 600 GB and larger. A bigger fixed number just moves the cliff to the next model size and we rediscover this later.
The fix: a progress-based deadline
The ceiling's own comment states its purpose — so "a wedged worker can never pin the lock indefinitely". Progress, not elapsed time, is what distinguishes a wedged worker from a large one. The hold now extends whenever the transfer reports bytes and expires a stall window after they stop:
Design decisions
What counts as progress, and where it is observed. Byte-level movement on the transfer, via the existing staging progress callback (
withStagingCallback), which both the HTTP and S3 stagers already drive. Per-file completion would be far too coarse: a single 600 GB shard would be indistinguishable from a stall for hours. The observation point is the upload body reader, which the transport only reads as fast as it can write to the socket, so it reflects genuine end-to-end movement rather than local disk reads. Observation is coarsened to one timer touch perstall/20so the per-read (~32 KB) callback stays cheap.Stall window: 5 minutes. The old
modelLoadStagingMargin, reused as a rolling window rather than a one-shot total, so it bounds silence instead of bounding transfer size. It is well beyond any legitimate gap in a healthy stream (a worker fsyncing a multi-GB shard, a LAN blip inside the resumable-upload retry loop, a GC pause) while still releasing the advisory lock fast enough that a dead worker is not felt as an outage. Clamped to the base ceiling so an operator who deliberately tightens the ceiling for fast failure does not silently get it widened back out.Absolute cap: 24h. The advisory lock is held throughout, so a progress-based hold still has to be bounded against a peer that trickles a few bytes per window forever. 600 GB at the measured 26 MB/s is ~6.5h, and ~17h at a pessimistic 10 MB/s, so 24h sits far above any legitimate transfer while still guaranteeing the lock is eventually released. Progress can never move it.
Operator overrides stay coherent. The base budget is unchanged and still derived from the install and load timeouts, and still covers the steps that report no progress (node selection, install,
LoadModel).LOCALAI_NATS_MODEL_LOAD_TIMEOUTtherefore behaves exactly as before.ModelLoadCeilingForkeeps its signature and all its existing specs.The 1h-inside-25m layering. The resumable upload now adopts the caller's deadline when there is one, and applies its fixed budget only when nothing above bounded it. This removes the misleading error text and stops a fixed 1h from reintroducing the size cliff under the now-extendable parent.
The expiry reports
context.DeadlineExceededrather thanCanceled, because the upload retry loop branches on that distinction.Deadline()reports the absolute cap, not the rolling expiry, so gRPC children (LoadModel) budget correctly instead of inheriting a few milliseconds.Tests
TDD, Ginkgo/Gomega, in
core/services/nodes/router_staging_deadline_test.go. Red first, on behaviour — both failures were against existing symbols only:The first reproduces the production error text; the second shows the hold ending at the base ceiling (201ms) instead of extending. Specs cover: a slow-but-progressing transfer surviving past the base ceiling, a transfer that progresses then goes silent still being killed promptly, the absolute cap bounding a forever-trickling peer, the stall window not outliving a tight ceiling, and
Deadline()exposing the cap.Verification
go build ./core/... ./pkg/...go test ./core/services/nodes/(full suite)go test ./core/application/...make lint0 issues.)Not verified: the 70 GB cluster transfer itself was not reproduced locally — that requires the multi-node cluster and the real checkpoint. The specs simulate the transfer shape (scaled-down durations) at the router level against a fake stager; the production evidence above is from the incident, not from this branch.
Context
Successor to #10968, where a hardcoded 5-minute
LoadModelgRPC timeout was replaced by this derived ceiling. Fixing the inner timeout exposed the outer ceiling as the new binding constraint. The framing for reviewers: the ceiling must scale with work, not wall-clock.Docs updated in
docs/content/features/distributed-mode.mdper the docs-with-code rule.