Skip to content

Commit 682fb27

Browse files
localai-botmudler
andauthored
fix(distributed): detach cold-load staging from the request context (#10438)
A model not yet loaded on a worker is staged lazily on the inference request path. Staging a multi-GB model takes minutes - far longer than any client keeps its HTTP request open - so a browser refresh, an ingress/LB idle-timeout, or a round-robined retry landing on another frontend replica cancels the request context and aborts the upload with "context canceled" mid-transfer. Large models then never finish staging, so they never load (observed in a 2-replica deployment: both frontends repeatedly failed to stage a 15.7 GB GGUF, each attempt dying at a different offset). Bind the cold load (staging + LoadModel + the per-model advisory lock) to context.WithoutCancel(ctx): it keeps the request's values (prefix chain) but drops cancellation/deadline. Each long step keeps its own bound (the file stager's resume budget, LoadModel's 5m timeout), and the advisory lock still de-dupes concurrent loaders across replicas. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 20c643e commit 682fb27

2 files changed

Lines changed: 98 additions & 5 deletions

File tree

core/services/nodes/router.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -359,8 +359,21 @@ func (r *SmartRouter) Route(ctx context.Context, modelID, modelName, backendType
359359
}
360360
}
361361

362-
// Step 2: Model not loaded — schedule loading with distributed lock to prevent duplicates
363-
loadModel := func() (*RouteResult, error) {
362+
// Step 2: Model not loaded — schedule loading with distributed lock to prevent duplicates.
363+
//
364+
// Detach the cold-load from the caller's context. Staging a model can
365+
// transfer multiple GB to a worker, which takes far longer than any client
366+
// keeps its HTTP request open — a browser refresh, an ingress/LB idle
367+
// timeout, or a round-robined retry landing on another replica all cancel
368+
// the request context. If staging were bound to it, the multi-GB upload
369+
// aborts with "context canceled" mid-transfer and large models can never
370+
// finish staging (the model-load outage). WithoutCancel keeps the request's
371+
// values (prefix chain, etc.) but drops its cancellation/deadline. Each
372+
// long step still has its own bound (the file stager's resume budget,
373+
// LoadModel's 5m timeout), and the per-model advisory lock below de-dupes
374+
// concurrent loaders across replicas.
375+
loadCtx := context.WithoutCancel(ctx)
376+
loadModel := func(ctx context.Context) (*RouteResult, error) {
364377
// Re-check after acquiring lock — another request may have loaded it
365378
node, nm, err := r.registry.FindAndLockNodeWithModel(ctx, trackingKey, candidateNodeIDs, pref)
366379
if err == nil && node != nil {
@@ -433,9 +446,9 @@ func (r *SmartRouter) Route(ctx context.Context, modelID, modelName, backendType
433446
if r.db != nil {
434447
lockKey := advisorylock.KeyFromString("model-load:" + trackingKey)
435448
var result *RouteResult
436-
lockErr := advisorylock.WithLockCtx(ctx, r.db, lockKey, func() error {
449+
lockErr := advisorylock.WithLockCtx(loadCtx, r.db, lockKey, func() error {
437450
var err error
438-
result, err = loadModel()
451+
result, err = loadModel(loadCtx)
439452
return err
440453
})
441454
if lockErr != nil {
@@ -444,7 +457,7 @@ func (r *SmartRouter) Route(ctx context.Context, modelID, modelName, backendType
444457
return result, nil
445458
}
446459
// No DB (non-distributed) — proceed without lock
447-
return loadModel()
460+
return loadModel(loadCtx)
448461
}
449462

450463
// parseSelectorJSON decodes a JSON node selector string into a map.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package nodes
2+
3+
import (
4+
"context"
5+
"errors"
6+
"os"
7+
"path/filepath"
8+
9+
. "github.com/onsi/ginkgo/v2"
10+
. "github.com/onsi/gomega"
11+
12+
"github.com/mudler/LocalAI/core/services/messaging"
13+
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
14+
)
15+
16+
// cancelOnStageStager simulates the triggering HTTP request being abandoned
17+
// (client disconnect, ingress idle-timeout) the moment a multi-GB file starts
18+
// staging. It cancels the request context and records whether the context the
19+
// stager itself received was cancelled as a result.
20+
type cancelOnStageStager struct {
21+
fakeFileStager
22+
cancelRequest context.CancelFunc
23+
staged bool
24+
ctxErrOnStage error
25+
}
26+
27+
func (s *cancelOnStageStager) EnsureRemote(ctx context.Context, _, _, key string) (string, error) {
28+
s.staged = true
29+
// Mid-transfer: the client gives up on the (minutes-long) request.
30+
if s.cancelRequest != nil {
31+
s.cancelRequest()
32+
}
33+
// A multi-GB upload must survive this. If staging were bound to the
34+
// request context, ctx is now cancelled and the real HTTP stager would
35+
// abort with "context canceled" — exactly the production outage.
36+
s.ctxErrOnStage = ctx.Err()
37+
return "/remote/" + key, nil
38+
}
39+
40+
var _ = Describe("Route cold-load staging context", func() {
41+
It("detaches staging from the request context so a client disconnect cannot abort a multi-GB transfer", func() {
42+
// A real model file so stageModelFiles actually calls the stager
43+
// (non-existent paths are skipped).
44+
tmp := GinkgoT().TempDir()
45+
modelFile := filepath.Join(tmp, "big.gguf")
46+
Expect(os.WriteFile(modelFile, []byte("weights"), 0o644)).To(Succeed())
47+
48+
reg := &fakeModelRouter{
49+
findAndLockErr: errors.New("not loaded"),
50+
findIdleNode: &BackendNode{ID: "n1", Name: "worker-1", Address: "10.0.0.1:50051"},
51+
}
52+
backend := &stubBackend{loadResult: &pb.Result{Success: true}}
53+
factory := &stubClientFactory{client: backend}
54+
unloader := &fakeUnloader{installReply: &messaging.BackendInstallReply{
55+
Success: true,
56+
Address: "10.0.0.1:9001",
57+
}}
58+
stager := &cancelOnStageStager{}
59+
60+
router := NewSmartRouter(reg, SmartRouterOptions{
61+
Unloader: unloader,
62+
ClientFactory: factory,
63+
FileStager: stager,
64+
// DB nil: no advisory lock, exercises the same detached load ctx.
65+
})
66+
67+
ctx, cancel := context.WithCancel(context.Background())
68+
stager.cancelRequest = cancel
69+
defer cancel()
70+
71+
result, err := router.Route(ctx, "big-model", filepath.Join("models", "big.gguf"), "llama-cpp",
72+
&pb.ModelOptions{Model: "big.gguf", ModelFile: modelFile}, false)
73+
74+
Expect(err).ToNot(HaveOccurred())
75+
Expect(result).ToNot(BeNil())
76+
Expect(stager.staged).To(BeTrue(), "staging must have been attempted")
77+
Expect(stager.ctxErrOnStage).ToNot(HaveOccurred(),
78+
"staging context must survive cancellation of the triggering request")
79+
})
80+
})

0 commit comments

Comments
 (0)