Skip to content

Commit b700a78

Browse files
localai-botmudler
andauthored
fix(distributed): make the cold-load hold scale with progress, not wall-clock (#11019)
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. Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 0d21248 commit b700a78

5 files changed

Lines changed: 541 additions & 13 deletions

File tree

core/services/nodes/file_stager_http.go

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,8 @@ func (h *HTTPFileStager) EnsureRemote(ctx context.Context, nodeID, localPath, ke
117117
xlog.Info("Uploading file to remote node", "node", nodeID, "file", filepath.Base(localPath), "size", humanFileSize(fileSize), "url", url)
118118

119119
// Outer time budget: bound the total resumable-upload duration so a
120-
// permanently-unreachable worker doesn't hold the request forever. Default
121-
// matches the existing per-response timeout.
122-
outerBudget := h.resumeBudget()
123-
124-
resumeCtx, cancel := context.WithTimeout(ctx, outerBudget)
120+
// permanently-unreachable worker doesn't hold the request forever.
121+
resumeCtx, cancel, outerBudget := h.resumeContext(ctx)
125122
defer cancel()
126123

127124
var lastErr error
@@ -178,14 +175,34 @@ func (h *HTTPFileStager) EnsureRemote(ctx context.Context, nodeID, localPath, ke
178175
}
179176
}
180177

181-
// resumeBudget returns the maximum total time the resumable upload loop will
182-
// spend retrying transient failures end-to-end. Past this budget the upload
183-
// fails rather than spinning forever — 1h covers multi-GB transfers on
184-
// pathological links without letting a wedged server jam the master.
185-
func (h *HTTPFileStager) resumeBudget() time.Duration {
186-
return 1 * time.Hour
178+
// resumeContext bounds the resumable upload loop so it can't spin forever, and
179+
// returns the budget it ended up with for error reporting.
180+
//
181+
// When the caller already imposed a deadline, that deadline IS the budget:
182+
// nesting a second one under it was actively misleading. In production a 1h
183+
// resume budget sat inside a 25m cold-load ceiling, so the inner budget was
184+
// unreachable and the failure still reported "failed after 1 attempts within
185+
// 1h0m0s budget" while the real killer was the 25m parent. Worse, the parent's
186+
// cold-load hold is now progress-extended (see load_deadline.go) precisely so a
187+
// 600 GB transfer can run for hours — a fixed 1h nested inside it would
188+
// reintroduce the very size cliff that change removes.
189+
//
190+
// The fixed fallback only applies when nobody above bounded the transfer.
191+
func (h *HTTPFileStager) resumeContext(ctx context.Context) (context.Context, context.CancelFunc, time.Duration) {
192+
if deadline, ok := ctx.Deadline(); ok {
193+
resumeCtx, cancel := context.WithCancel(ctx)
194+
return resumeCtx, cancel, time.Until(deadline)
195+
}
196+
resumeCtx, cancel := context.WithTimeout(ctx, defaultResumeBudget)
197+
return resumeCtx, cancel, defaultResumeBudget
187198
}
188199

200+
// defaultResumeBudget bounds an unparented resumable upload. 1h covers multi-GB
201+
// transfers on pathological links without letting a wedged server jam the
202+
// master. Callers that need longer (a cold load staging a 600 GB checkpoint)
203+
// impose their own, progress-extended deadline instead.
204+
const defaultResumeBudget = 1 * time.Hour
205+
189206
// nextBackoff returns the sleep before retry #attempt: 1s, 2s, 4s, ..., capped
190207
// at 30s, with the first sleep (attempt=2) being 1s.
191208
func nextBackoff(attempt int) time.Duration {
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
package nodes
2+
3+
import (
4+
"context"
5+
"errors"
6+
"sync"
7+
"time"
8+
9+
"github.com/mudler/xlog"
10+
)
11+
12+
// The cold-load hold has to satisfy two requirements that a single wall-clock
13+
// timeout cannot express at once:
14+
//
15+
// 1. A legitimately huge checkpoint must be allowed to finish. Staging time is
16+
// bytes/bandwidth, not a constant: a 70 GB model at 26 MB/s needs ~45m and a
17+
// 600 GB checkpoint needs hours. Any fixed ceiling is therefore a model-size
18+
// cliff — raise it and the cliff simply moves to the next larger model.
19+
// 2. A worker that died mid-transfer must still release the per-model advisory
20+
// lock promptly, or it pins every other replica's request for that model.
21+
//
22+
// The discriminator between the two is progress: bytes moving means healthy,
23+
// silence means wedged. So the hold is a deadline that is *pushed forward* every
24+
// time the transfer reports bytes, rather than a countdown from when the load
25+
// began. Duration then scales with the work instead of with the clock.
26+
27+
const (
28+
// stagingStallWindow is how long the transfer may report zero bytes before
29+
// the load is declared wedged. It replaces the old fixed
30+
// modelLoadStagingMargin: the same 5 minutes, but rolling rather than
31+
// one-shot, so it bounds silence instead of bounding total transfer size.
32+
//
33+
// 5m is well beyond any legitimate gap in a healthy stream (the receiving
34+
// worker fsyncing a multi-GB shard, a transient LAN blip inside the
35+
// resumable-upload retry loop, a GC pause) while still releasing the
36+
// advisory lock quickly enough that a dead worker is not felt as an outage.
37+
stagingStallWindow = 5 * time.Minute
38+
39+
// modelLoadAbsoluteMax bounds the hold even while progress keeps arriving,
40+
// so a degenerate peer that trickles a few bytes per stall window forever
41+
// cannot pin the advisory lock for all time. It is deliberately far above
42+
// any legitimate transfer: 600 GB at the 26 MB/s measured in production is
43+
// ~6.5h, and at a pessimistic 10 MB/s ~17h, so 24h leaves real headroom
44+
// while still guaranteeing the lock is eventually released.
45+
modelLoadAbsoluteMax = 24 * time.Hour
46+
47+
// loadProgressQuantumDivisor coarsens progress observation. The byte-level
48+
// callback fires on every read of the upload body (~32 KB), which is far too
49+
// often to touch a timer; extending at most once per stall/20 keeps the hot
50+
// path cheap while costing at most 5% of the stall window in precision.
51+
loadProgressQuantumDivisor = 20
52+
53+
// loadProgressQuantumMax caps the coarsening so a long stall window doesn't
54+
// make progress observation itself laggy.
55+
loadProgressQuantumMax = time.Second
56+
)
57+
58+
// errLoadDeadlineExpired is the cancellation cause recorded when the cold-load
59+
// hold expires, so loadDeadlineContext can report context.DeadlineExceeded and
60+
// stay distinguishable from a caller-driven cancel.
61+
var errLoadDeadlineExpired = errors.New("cold-load deadline expired")
62+
63+
// loadDeadline is the mutable expiry behind a loadDeadlineContext. Observe()
64+
// pushes the expiry forward; the absolute cap and the parent context still
65+
// bound it.
66+
type loadDeadline struct {
67+
mu sync.Mutex
68+
timer *time.Timer
69+
stall time.Duration
70+
quantum time.Duration
71+
expiry time.Time
72+
hardExpiry time.Time
73+
lastObserve time.Time
74+
extensions int
75+
stopped bool
76+
cancel context.CancelCauseFunc
77+
}
78+
79+
// loadDeadlineKey retrieves the active loadDeadline from a context chain.
80+
type loadDeadlineKey struct{}
81+
82+
// loadDeadlineContext is a context whose expiry moves forward while the work it
83+
// covers reports progress.
84+
//
85+
// It reports context.DeadlineExceeded (not context.Canceled) on expiry, because
86+
// downstream code — notably the resumable upload loop in file_stager_http.go —
87+
// branches on that distinction to tell "we ran out of budget" apart from "the
88+
// caller gave up", and the two lead to different retry decisions.
89+
type loadDeadlineContext struct {
90+
context.Context
91+
d *loadDeadline
92+
}
93+
94+
// Deadline reports the ABSOLUTE cap rather than the current rolling expiry.
95+
// Children derive their own budgets from the parent deadline (a gRPC
96+
// context.WithTimeout takes the earlier of the two), and the rolling expiry is
97+
// a floor that moves, not a promise that the work stops then. Reporting the
98+
// rolling value would let a child silently inherit a few seconds of budget.
99+
func (c *loadDeadlineContext) Deadline() (time.Time, bool) {
100+
c.d.mu.Lock()
101+
defer c.d.mu.Unlock()
102+
return c.d.hardExpiry, true
103+
}
104+
105+
func (c *loadDeadlineContext) Err() error {
106+
err := c.Context.Err()
107+
if err == nil {
108+
return nil
109+
}
110+
if errors.Is(context.Cause(c.Context), errLoadDeadlineExpired) {
111+
return context.DeadlineExceeded
112+
}
113+
return err
114+
}
115+
116+
func (c *loadDeadlineContext) Value(key any) any {
117+
if _, ok := key.(loadDeadlineKey); ok {
118+
return c.d
119+
}
120+
return c.Context.Value(key)
121+
}
122+
123+
// newLoadDeadlineContext builds a cold-load hold context. base is the initial
124+
// budget granted before any progress is seen (it covers the steps that report
125+
// none: node selection, backend install, the remote LoadModel). stall is how
126+
// long zero progress is tolerated once the transfer has started, and absoluteMax
127+
// bounds the whole hold regardless of progress.
128+
//
129+
// Non-positive base/stall/absoluteMax fall back to their package defaults, and
130+
// stall is clamped to base so a caller with a deliberately tight ceiling (tests,
131+
// or an operator who wants fast failure) doesn't get a stall window wider than
132+
// the ceiling it was derived from.
133+
func newLoadDeadlineContext(parent context.Context, base, stall, absoluteMax time.Duration) (context.Context, context.CancelFunc) {
134+
if base <= 0 {
135+
base = minModelLoadCeiling
136+
}
137+
if stall <= 0 {
138+
stall = stagingStallWindow
139+
}
140+
if stall > base {
141+
stall = base
142+
}
143+
if absoluteMax <= 0 {
144+
absoluteMax = modelLoadAbsoluteMax
145+
}
146+
if absoluteMax < base {
147+
absoluteMax = base
148+
}
149+
150+
ctx, cancel := context.WithCancelCause(parent)
151+
now := time.Now()
152+
153+
quantum := stall / loadProgressQuantumDivisor
154+
if quantum > loadProgressQuantumMax {
155+
quantum = loadProgressQuantumMax
156+
}
157+
158+
d := &loadDeadline{
159+
stall: stall,
160+
quantum: quantum,
161+
expiry: now.Add(base),
162+
hardExpiry: now.Add(absoluteMax),
163+
cancel: cancel,
164+
}
165+
d.timer = time.AfterFunc(base, d.fire)
166+
167+
// The absolute cap is a plain one-shot: progress can never move it.
168+
hardTimer := time.AfterFunc(absoluteMax, func() {
169+
xlog.Warn("Cold-load hold hit its absolute cap while still reporting progress; releasing the model lock",
170+
"cap", absoluteMax)
171+
d.stop()
172+
cancel(errLoadDeadlineExpired)
173+
})
174+
175+
stopAll := func() {
176+
hardTimer.Stop()
177+
d.stop()
178+
cancel(context.Canceled)
179+
}
180+
return &loadDeadlineContext{Context: ctx, d: d}, stopAll
181+
}
182+
183+
func (d *loadDeadline) fire() {
184+
d.mu.Lock()
185+
if d.stopped {
186+
d.mu.Unlock()
187+
return
188+
}
189+
// A late-arriving Observe may have pushed the expiry out after this timer
190+
// was already scheduled to fire; re-arm instead of cancelling.
191+
if remaining := time.Until(d.expiry); remaining > 0 {
192+
d.timer.Reset(remaining)
193+
d.mu.Unlock()
194+
return
195+
}
196+
d.stopped = true
197+
extensions := d.extensions
198+
stall := d.stall
199+
d.mu.Unlock()
200+
201+
if extensions > 0 {
202+
xlog.Warn("Cold load stalled: no staging progress within the stall window, releasing the model lock",
203+
"stallWindow", stall, "progressExtensions", extensions)
204+
}
205+
d.cancel(errLoadDeadlineExpired)
206+
}
207+
208+
func (d *loadDeadline) stop() {
209+
d.mu.Lock()
210+
defer d.mu.Unlock()
211+
d.stopped = true
212+
if d.timer != nil {
213+
d.timer.Stop()
214+
}
215+
}
216+
217+
// Observe records that real work happened, pushing the expiry out by a full
218+
// stall window. It never pulls the expiry in, so it cannot shorten the base
219+
// budget, and it never pushes past the absolute cap.
220+
func (d *loadDeadline) Observe() {
221+
if d == nil {
222+
return
223+
}
224+
now := time.Now()
225+
226+
d.mu.Lock()
227+
defer d.mu.Unlock()
228+
if d.stopped {
229+
return
230+
}
231+
// Coarsen: the byte callback fires per read, we only need per-quantum.
232+
if !d.lastObserve.IsZero() && now.Sub(d.lastObserve) < d.quantum {
233+
return
234+
}
235+
d.lastObserve = now
236+
237+
next := now.Add(d.stall)
238+
if next.After(d.hardExpiry) {
239+
next = d.hardExpiry
240+
}
241+
if !next.After(d.expiry) {
242+
return
243+
}
244+
d.expiry = next
245+
d.extensions++
246+
d.timer.Reset(time.Until(next))
247+
}
248+
249+
// observeLoadProgress pushes out the cold-load deadline attached to ctx, if any.
250+
// Callers report byte-level movement here; a context without a load deadline
251+
// (single-host paths, tests constructing stagers directly) is a no-op.
252+
func observeLoadProgress(ctx context.Context) {
253+
if d, ok := ctx.Value(loadDeadlineKey{}).(*loadDeadline); ok {
254+
d.Observe()
255+
}
256+
}

core/services/nodes/router.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,15 @@ type SmartRouterOptions struct {
8787
// must be widened in step (see ModelLoadCeilingFor) or the ceiling clips the
8888
// longer deadline before it can be used.
8989
ModelLoadTimeout time.Duration
90+
// StagingStallWindow is how long file staging may report zero bytes before
91+
// the cold load is declared wedged and the advisory lock released. While
92+
// bytes keep moving the hold extends, so transfer size no longer bounds the
93+
// load. Zero selects stagingStallWindow. It is clamped to ModelLoadCeiling.
94+
StagingStallWindow time.Duration
95+
// ModelLoadAbsoluteMax bounds the cold-load hold even while progress keeps
96+
// arriving, so a peer trickling bytes forever cannot pin the advisory lock
97+
// indefinitely. Zero selects modelLoadAbsoluteMax (24h).
98+
ModelLoadAbsoluteMax time.Duration
9099
}
91100

92101
// modelLoadStagingMargin is the slack ModelLoadCeilingFor adds on top of the
@@ -154,6 +163,10 @@ type SmartRouter struct {
154163
// modelLoadTimeout is the deadline for the remote LoadModel gRPC call
155164
// (see SmartRouterOptions.ModelLoadTimeout).
156165
modelLoadTimeout time.Duration
166+
// stagingStallWindow and modelLoadAbsoluteMax turn modelLoadCeiling from a
167+
// hard countdown into a progress-extended hold (see load_deadline.go).
168+
stagingStallWindow time.Duration
169+
modelLoadAbsoluteMax time.Duration
157170
}
158171

159172
// probeCacheTTL is how long a successful gRPC HealthCheck on a backend is
@@ -194,6 +207,11 @@ func NewSmartRouter(registry ModelRouter, opts SmartRouterOptions) *SmartRouter
194207
sharedModels: opts.SharedModels,
195208
modelLoadCeiling: ceiling,
196209
modelLoadTimeout: loadTimeout,
210+
// Zero values are resolved to their defaults inside
211+
// newLoadDeadlineContext, which also clamps the stall window to the
212+
// ceiling, so nothing to normalize here.
213+
stagingStallWindow: opts.StagingStallWindow,
214+
modelLoadAbsoluteMax: opts.ModelLoadAbsoluteMax,
197215
}
198216
}
199217

@@ -526,7 +544,13 @@ func (r *SmartRouter) Route(ctx context.Context, modelID, modelName, backendType
526544
// even if a sub-step wedges. Each long step still has its own (tighter)
527545
// bound; this only backstops them. The per-model advisory lock below
528546
// de-dupes concurrent loaders across replicas.
529-
loadCtx, cancelLoad := context.WithTimeout(context.WithoutCancel(ctx), r.modelLoadCeiling)
547+
// The backstop is progress-based, not wall-clock: staging time is bytes over
548+
// bandwidth, so a fixed ceiling is a model-size cliff (a 70 GB checkpoint
549+
// transferring healthily at 26 MB/s needs ~45m and was killed at exactly
550+
// 25m00s). The hold instead extends while the transfer reports bytes and
551+
// expires a stall window after they stop. See load_deadline.go.
552+
loadCtx, cancelLoad := newLoadDeadlineContext(context.WithoutCancel(ctx),
553+
r.modelLoadCeiling, r.stagingStallWindow, r.modelLoadAbsoluteMax)
530554
defer cancelLoad()
531555
loadModel := func(ctx context.Context) (*RouteResult, error) {
532556
// Re-check after acquiring lock — another request may have loaded it
@@ -1328,6 +1352,12 @@ func (r *SmartRouter) stageModelFiles(ctx context.Context, node *BackendNode, op
13281352
func (r *SmartRouter) withStagingCallback(ctx context.Context, trackingKey, fileName string, fileIdx, totalFiles int) context.Context {
13291353
start := time.Now()
13301354
return WithStagingProgress(ctx, func(fn string, bytesSent, totalBytes int64) {
1355+
// Byte-level movement is what keeps the cold-load hold alive. Observing
1356+
// here (rather than at per-file completion) is what makes a single
1357+
// 600 GB shard distinguishable from a wedged worker: file-granular
1358+
// progress would look identical to a stall for hours.
1359+
observeLoadProgress(ctx)
1360+
13311361
var speed string
13321362
elapsed := time.Since(start)
13331363
if elapsed > 0 {

0 commit comments

Comments
 (0)