Skip to content

Commit fb4c61d

Browse files
localai-botmudler
andauthored
fix(distributed): configurable remote model-load timeout, and reap the load when it times out (#10948)
* fix(distributed): make the remote LoadModel deadline configurable 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> * fix(distributed): reap the abandoned replica when a remote load times 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 --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 626ae4d commit fb4c61d

10 files changed

Lines changed: 477 additions & 21 deletions

File tree

core/application/distributed.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -356,12 +356,16 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB, configLoade
356356
PrefixConfig: prefixCfg,
357357
Pressure: pressure,
358358
SharedModels: cfg.Distributed.SharedModels,
359-
// Cap how long a cold load may hold the per-model advisory lock: the
360-
// configured backend.install deadline plus a margin for file staging and
361-
// the remote LoadModel. Derived from the install timeout so raising it
362-
// (for slow links pulling multi-GB images) widens the ceiling too,
363-
// instead of letting the static default cut a legitimately slow load.
364-
ModelLoadCeiling: cfg.Distributed.BackendInstallTimeoutOrDefault() + 10*time.Minute,
359+
ModelLoadTimeout: cfg.Distributed.ModelLoadTimeoutOrDefault(),
360+
// Cap how long a cold load may hold the per-model advisory lock. Derived
361+
// from BOTH configured budgets it has to cover, so raising either the
362+
// install timeout (slow links pulling multi-GB images) or the model load
363+
// timeout (very large checkpoints) widens the ceiling too, instead of
364+
// letting a stale bound cut a legitimately slow load short.
365+
ModelLoadCeiling: nodes.ModelLoadCeilingFor(
366+
cfg.Distributed.BackendInstallTimeoutOrDefault(),
367+
cfg.Distributed.ModelLoadTimeoutOrDefault(),
368+
),
365369
})
366370

367371
// Wire staging-progress broadcasting so file-staging shows up on every

core/cli/run.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ type RunCMD struct {
173173
DistributedPrefixCacheTTL string `env:"LOCALAI_DISTRIBUTED_PREFIX_CACHE_TTL" help:"Idle-timeout for prefix-cache index entries; also drives the background eviction cadence (every TTL/2). Default 5m." group:"distributed"`
174174
BackendInstallTimeout string `env:"LOCALAI_NATS_BACKEND_INSTALL_TIMEOUT" help:"NATS round-trip timeout for backend.install requests sent to worker nodes (default 15m). Increase for slow links pulling multi-GB images." group:"distributed"`
175175
BackendUpgradeTimeout string `env:"LOCALAI_NATS_BACKEND_UPGRADE_TIMEOUT" help:"NATS round-trip timeout for backend.upgrade requests (default 15m)." group:"distributed"`
176+
ModelLoadTimeout string `env:"LOCALAI_NATS_MODEL_LOAD_TIMEOUT" help:"gRPC deadline for the remote LoadModel call sent to a worker node once its backend is installed and model files are staged (default 5m). Increase for very large checkpoints (multi-tens-of-GB diffusion/video models) whose load and pipeline init exceed 5 minutes. Raising it also widens the cold-load lock ceiling." group:"distributed"`
176177
NatsAccountSeed string `env:"LOCALAI_NATS_ACCOUNT_SEED" help:"NATS account signing seed (SU...) used to mint per-node worker JWTs at registration" group:"distributed"`
177178
NatsServiceJWT string `env:"LOCALAI_NATS_SERVICE_JWT" help:"NATS user JWT for the frontend (and agent workers) to publish control-plane messages" group:"distributed"`
178179
NatsServiceSeed string `env:"LOCALAI_NATS_SERVICE_SEED" help:"NATS user signing seed (SU...) paired with LOCALAI_NATS_SERVICE_JWT" group:"distributed"`
@@ -219,6 +220,18 @@ func DefaultUploadPath() string {
219220
return filepath.Join(userScopedTempDir(), "upload")
220221
}
221222

223+
// parseDistributedDuration parses an operator-supplied duration for a
224+
// distributed timeout knob. The env var and the rejected value are both named
225+
// in the error: these knobs are usually set from a compose file or a K8s
226+
// manifest, where a bare "invalid duration" gives the operator nothing to grep.
227+
func parseDistributedDuration(envVar, raw string) (time.Duration, error) {
228+
d, err := time.ParseDuration(raw)
229+
if err != nil {
230+
return 0, fmt.Errorf("invalid %s %q: %w", envVar, raw, err)
231+
}
232+
return d, nil
233+
}
234+
222235
func (r *RunCMD) Run(ctx *cliContext.Context) error {
223236
warnDeprecatedFlags()
224237

@@ -326,19 +339,26 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
326339
opts = append(opts, config.WithStorageSecretKey(r.StorageSecretKey))
327340
}
328341
if r.BackendInstallTimeout != "" {
329-
d, err := time.ParseDuration(r.BackendInstallTimeout)
342+
d, err := parseDistributedDuration("LOCALAI_NATS_BACKEND_INSTALL_TIMEOUT", r.BackendInstallTimeout)
330343
if err != nil {
331-
return fmt.Errorf("invalid LOCALAI_NATS_BACKEND_INSTALL_TIMEOUT %q: %w", r.BackendInstallTimeout, err)
344+
return err
332345
}
333346
opts = append(opts, config.WithBackendInstallTimeout(d))
334347
}
335348
if r.BackendUpgradeTimeout != "" {
336-
d, err := time.ParseDuration(r.BackendUpgradeTimeout)
349+
d, err := parseDistributedDuration("LOCALAI_NATS_BACKEND_UPGRADE_TIMEOUT", r.BackendUpgradeTimeout)
337350
if err != nil {
338-
return fmt.Errorf("invalid LOCALAI_NATS_BACKEND_UPGRADE_TIMEOUT %q: %w", r.BackendUpgradeTimeout, err)
351+
return err
339352
}
340353
opts = append(opts, config.WithBackendUpgradeTimeout(d))
341354
}
355+
if r.ModelLoadTimeout != "" {
356+
d, err := parseDistributedDuration("LOCALAI_NATS_MODEL_LOAD_TIMEOUT", r.ModelLoadTimeout)
357+
if err != nil {
358+
return err
359+
}
360+
opts = append(opts, config.WithModelLoadTimeout(d))
361+
}
342362
if r.RegistrationToken != "" {
343363
opts = append(opts, config.WithRegistrationToken(r.RegistrationToken))
344364
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package cli
2+
3+
import (
4+
"time"
5+
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
8+
)
9+
10+
var _ = Describe("parseDistributedDuration", func() {
11+
It("parses a valid operator-supplied duration", func() {
12+
d, err := parseDistributedDuration("LOCALAI_NATS_MODEL_LOAD_TIMEOUT", "45m")
13+
Expect(err).ToNot(HaveOccurred())
14+
Expect(d).To(Equal(45 * time.Minute))
15+
})
16+
17+
It("names the env var and the bad value so a typo is actionable", func() {
18+
_, err := parseDistributedDuration("LOCALAI_NATS_MODEL_LOAD_TIMEOUT", "45mins")
19+
Expect(err).To(HaveOccurred())
20+
Expect(err.Error()).To(ContainSubstring("LOCALAI_NATS_MODEL_LOAD_TIMEOUT"))
21+
Expect(err.Error()).To(ContainSubstring("45mins"))
22+
})
23+
})

core/config/distributed_config.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ type DistributedConfig struct {
7676

7777
BackendInstallTimeout time.Duration // NATS round-trip timeout for backend.install (default 15m)
7878
BackendUpgradeTimeout time.Duration // NATS round-trip timeout for backend.upgrade (default 15m)
79+
// ModelLoadTimeout is the gRPC deadline for the remote LoadModel call the
80+
// router issues once a worker has the backend installed and the model files
81+
// staged. It therefore covers only the backend's own checkpoint load and
82+
// pipeline init, which for a multi-tens-of-GB diffusion/video checkpoint on
83+
// unified memory can far exceed the 5m default.
84+
ModelLoadTimeout time.Duration // gRPC deadline for remote LoadModel (default 5m)
7985

8086
MaxUploadSize int64 // Maximum upload body size in bytes (default 50 GB)
8187

@@ -142,6 +148,7 @@ func (c DistributedConfig) Validate() error {
142148
FlagMCPCIJobTimeout: c.MCPCIJobTimeout,
143149
FlagBackendInstallTimeout: c.BackendInstallTimeout,
144150
FlagBackendUpgradeTimeout: c.BackendUpgradeTimeout,
151+
FlagModelLoadTimeout: c.ModelLoadTimeout,
145152
} {
146153
if d < 0 {
147154
return fmt.Errorf("%s must not be negative", name)
@@ -286,6 +293,12 @@ func WithBackendUpgradeTimeout(d time.Duration) AppOption {
286293
}
287294
}
288295

296+
func WithModelLoadTimeout(d time.Duration) AppOption {
297+
return func(o *ApplicationConfig) {
298+
o.Distributed.ModelLoadTimeout = d
299+
}
300+
}
301+
289302
var EnableAutoApproveNodes = func(o *ApplicationConfig) {
290303
o.Distributed.AutoApproveNodes = true
291304
}
@@ -342,6 +355,7 @@ const (
342355
FlagMCPCIJobTimeout = "mcp-ci-job-timeout"
343356
FlagBackendInstallTimeout = "backend-install-timeout"
344357
FlagBackendUpgradeTimeout = "backend-upgrade-timeout"
358+
FlagModelLoadTimeout = "model-load-timeout"
345359
)
346360

347361
// Defaults for distributed timeouts.
@@ -355,6 +369,7 @@ const (
355369
DefaultMCPCIJobTimeout = 10 * time.Minute
356370
DefaultBackendInstallTimeout = 15 * time.Minute
357371
DefaultBackendUpgradeTimeout = 15 * time.Minute
372+
DefaultModelLoadTimeout = 5 * time.Minute
358373
)
359374

360375
// DefaultMaxUploadSize is the default maximum upload body size (50 GB).
@@ -408,6 +423,11 @@ func (c DistributedConfig) BackendUpgradeTimeoutOrDefault() time.Duration {
408423
return cmp.Or(c.BackendUpgradeTimeout, DefaultBackendUpgradeTimeout)
409424
}
410425

426+
// ModelLoadTimeoutOrDefault returns the configured timeout or the default.
427+
func (c DistributedConfig) ModelLoadTimeoutOrDefault() time.Duration {
428+
return cmp.Or(c.ModelLoadTimeout, DefaultModelLoadTimeout)
429+
}
430+
411431
// MCPToolTimeoutOrDefault returns the configured timeout or the default.
412432
func (c DistributedConfig) MCPToolTimeoutOrDefault() time.Duration {
413433
return cmp.Or(c.MCPToolTimeout, DefaultMCPToolTimeout)

core/config/distributed_config_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ var _ = Describe("DistributedConfig backend NATS timeouts", func() {
3333
Expect(c.BackendUpgradeTimeoutOrDefault()).To(Equal(30 * time.Minute))
3434
})
3535
})
36+
37+
Context("ModelLoadTimeoutOrDefault", func() {
38+
It("returns 5 minutes when unset so existing clusters keep today's behaviour", func() {
39+
c := config.DistributedConfig{}
40+
Expect(c.ModelLoadTimeoutOrDefault()).To(Equal(5 * time.Minute))
41+
})
42+
43+
It("returns the configured value when set", func() {
44+
c := config.DistributedConfig{ModelLoadTimeout: 45 * time.Minute}
45+
Expect(c.ModelLoadTimeoutOrDefault()).To(Equal(45 * time.Minute))
46+
})
47+
})
3648
})
3749

3850
var _ = Describe("DistributedConfig flag-name constants", func() {
@@ -53,6 +65,7 @@ var _ = Describe("DistributedConfig flag-name constants", func() {
5365
Entry("MCP CI job timeout", config.FlagMCPCIJobTimeout, "mcp-ci-job-timeout"),
5466
Entry("backend install timeout", config.FlagBackendInstallTimeout, "backend-install-timeout"),
5567
Entry("backend upgrade timeout", config.FlagBackendUpgradeTimeout, "backend-upgrade-timeout"),
68+
Entry("model load timeout", config.FlagModelLoadTimeout, "model-load-timeout"),
5669
)
5770
})
5871

@@ -80,6 +93,18 @@ var _ = Describe("DistributedConfig.Validate negative-duration errors", func() {
8093
Expect(err.Error()).To(ContainSubstring(config.FlagBackendUpgradeTimeout))
8194
})
8295

96+
It("rejects a negative ModelLoadTimeout with the flag name in the error", func() {
97+
c := config.DistributedConfig{
98+
Enabled: true,
99+
NatsURL: "nats://localhost:4222",
100+
ModelLoadTimeout: -1 * time.Second,
101+
}
102+
err := c.Validate()
103+
Expect(err).To(HaveOccurred())
104+
Expect(err.Error()).To(ContainSubstring(config.FlagModelLoadTimeout))
105+
Expect(err.Error()).To(ContainSubstring("must not be negative"))
106+
})
107+
83108
It("accepts all-zero durations as valid (defaults apply)", func() {
84109
c := config.DistributedConfig{
85110
Enabled: true,

core/services/nodes/router.go

Lines changed: 107 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,12 @@ import (
1818
"github.com/mudler/LocalAI/pkg/distributedhdr"
1919
grpc "github.com/mudler/LocalAI/pkg/grpc"
2020
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
21+
"github.com/mudler/LocalAI/pkg/model"
2122
"github.com/mudler/LocalAI/pkg/vram"
2223
"github.com/mudler/xlog"
2324
"golang.org/x/sync/singleflight"
25+
"google.golang.org/grpc/codes"
26+
"google.golang.org/grpc/status"
2427
"google.golang.org/protobuf/proto"
2528
"gorm.io/gorm"
2629
"gorm.io/gorm/clause"
@@ -72,9 +75,46 @@ type SmartRouterOptions struct {
7275
// attempt (node selection -> backend install -> file staging -> LoadModel)
7376
// may run while holding the per-model advisory lock. It backstops every
7477
// sub-step's own timeout so a wedged worker can never pin the lock - and
75-
// every other replica's request for that model - indefinitely. Zero selects
76-
// defaultModelLoadCeiling.
78+
// every other replica's request for that model - indefinitely. Zero derives
79+
// it from the default install budget and ModelLoadTimeout via
80+
// ModelLoadCeilingFor.
7781
ModelLoadCeiling time.Duration
82+
// ModelLoadTimeout is the gRPC deadline for the remote LoadModel call, which
83+
// runs after the backend install and file staging have already completed and
84+
// so covers only the worker backend's checkpoint load and pipeline init.
85+
// Zero selects config.DefaultModelLoadTimeout. Operators raise it via
86+
// LOCALAI_NATS_MODEL_LOAD_TIMEOUT for very large checkpoints; ModelLoadCeiling
87+
// must be widened in step (see ModelLoadCeilingFor) or the ceiling clips the
88+
// longer deadline before it can be used.
89+
ModelLoadTimeout time.Duration
90+
}
91+
92+
// modelLoadStagingMargin is the slack ModelLoadCeilingFor adds on top of the
93+
// install + remote-load budgets to cover the steps that have no deadline of
94+
// their own: node selection, replica allocation and model file staging.
95+
const modelLoadStagingMargin = 5 * time.Minute
96+
97+
// minModelLoadCeiling floors the derived ceiling at the historical 25m constant
98+
// so shrinking the install or load budgets can never tighten the hold ceiling
99+
// below what clusters relied on before it became derived.
100+
const minModelLoadCeiling = 25 * time.Minute
101+
102+
// ModelLoadCeilingFor derives the cold-load hold ceiling from the budgets it has
103+
// to cover. The ceiling bounds how long one cold load may hold the per-model
104+
// advisory lock; it must comfortably exceed the slowest legitimate load (backend
105+
// install + staging + remote LoadModel) so it only ever fires when a step is
106+
// genuinely wedged - e.g. a worker that died mid-install. Deriving it means
107+
// raising LOCALAI_NATS_MODEL_LOAD_TIMEOUT for an 80 GB video checkpoint actually
108+
// takes effect, instead of being cut short by a constant that silently went
109+
// stale. Non-positive inputs fall back to their package defaults.
110+
func ModelLoadCeilingFor(installTimeout, loadTimeout time.Duration) time.Duration {
111+
if installTimeout <= 0 {
112+
installTimeout = config.DefaultBackendInstallTimeout
113+
}
114+
if loadTimeout <= 0 {
115+
loadTimeout = config.DefaultModelLoadTimeout
116+
}
117+
return max(installTimeout+loadTimeout+modelLoadStagingMargin, minModelLoadCeiling)
78118
}
79119

80120
// SmartRouter routes inference requests to the best available backend node.
@@ -111,15 +151,11 @@ type SmartRouter struct {
111151
// modelLoadCeiling bounds how long a cold load may hold the per-model
112152
// advisory lock (see SmartRouterOptions.ModelLoadCeiling).
113153
modelLoadCeiling time.Duration
154+
// modelLoadTimeout is the deadline for the remote LoadModel gRPC call
155+
// (see SmartRouterOptions.ModelLoadTimeout).
156+
modelLoadTimeout time.Duration
114157
}
115158

116-
// defaultModelLoadCeiling is the fallback hold ceiling for a cold model load.
117-
// It must comfortably exceed the slowest legitimate load - a multi-GB backend
118-
// install (DefaultBackendInstallTimeout, 15m) plus staging and the remote
119-
// LoadModel (5m) - so it never cuts a real load short; it only ever fires when
120-
// a step is genuinely wedged (e.g. a worker that died mid-install).
121-
const defaultModelLoadCeiling = 25 * time.Minute
122-
123159
// probeCacheTTL is how long a successful gRPC HealthCheck on a backend is
124160
// trusted before the next request re-probes. Matches healthCheckTTL in
125161
// pkg/model/model.go so the single-process and distributed paths share a
@@ -134,9 +170,13 @@ func NewSmartRouter(registry ModelRouter, opts SmartRouterOptions) *SmartRouter
134170
if factory == nil {
135171
factory = &tokenClientFactory{token: opts.AuthToken}
136172
}
173+
loadTimeout := opts.ModelLoadTimeout
174+
if loadTimeout <= 0 {
175+
loadTimeout = config.DefaultModelLoadTimeout
176+
}
137177
ceiling := opts.ModelLoadCeiling
138178
if ceiling <= 0 {
139-
ceiling = defaultModelLoadCeiling
179+
ceiling = ModelLoadCeilingFor(config.DefaultBackendInstallTimeout, loadTimeout)
140180
}
141181
return &SmartRouter{
142182
registry: registry,
@@ -153,6 +193,7 @@ func NewSmartRouter(registry ModelRouter, opts SmartRouterOptions) *SmartRouter
153193
pressure: opts.Pressure,
154194
sharedModels: opts.SharedModels,
155195
modelLoadCeiling: ceiling,
196+
modelLoadTimeout: loadTimeout,
156197
}
157198
}
158199

@@ -250,11 +291,21 @@ func (r *SmartRouter) scheduleAndLoad(ctx context.Context, backendType, tracking
250291
if loadOpts != nil {
251292
xlog.Info("Loading model on remote node", "node", node.Name, "model", modelName, "addr", backendAddr)
252293

253-
loadCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
294+
loadCtx, cancel := context.WithTimeout(ctx, r.modelLoadTimeout)
254295
defer cancel()
255296

256297
res, err := client.LoadModel(loadCtx, loadOpts)
257298
if err != nil {
299+
// A gRPC deadline only cancels the CLIENT side of the call. A
300+
// backend blocked in a synchronous weight load never observes its
301+
// cancelled handler context, so it keeps loading with nobody
302+
// waiting: an 83GB checkpoint was seen still downloading 30
303+
// minutes past the client timeout, and each retry stacked another
304+
// multi-GB loader process on the worker. Reap the replica we just
305+
// abandoned before handing the failure back.
306+
if loadAbandonedOnWorker(err) {
307+
r.reapAbandonedLoad(node, trackingKey, replicaIndex)
308+
}
258309
return nil, fmt.Errorf("loading model %s on node %s: %w", modelName, node.Name, err)
259310
}
260311
if !res.Success {
@@ -285,6 +336,51 @@ func (r *SmartRouter) scheduleAndLoad(ctx context.Context, backendType, tracking
285336
return &scheduleLoadResult{Node: node, Client: client, BackendAddr: backendAddr, ReplicaIndex: replicaIndex}, nil
286337
}
287338

339+
// loadAbandonedOnWorker reports whether a failed remote LoadModel left the
340+
// worker process still running the load.
341+
//
342+
// Only a deadline or a cancellation qualifies: those are OUR side giving up
343+
// while the backend keeps going. Every other failure (an unsupported model, a
344+
// bad option, an OOM) is the backend answering, which means its handler
345+
// returned and the process is idle — stopping it there would throw away a
346+
// warm process and its downloaded weights for the next attempt.
347+
func loadAbandonedOnWorker(err error) bool {
348+
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
349+
return true
350+
}
351+
// The deadline usually reaches us as a gRPC status rather than a wrapped
352+
// context error. errors.As (not status.Code) so a wrapped status is still
353+
// recognised.
354+
var st interface{ GRPCStatus() *status.Status }
355+
if errors.As(err, &st) {
356+
switch st.GRPCStatus().Code() {
357+
case codes.DeadlineExceeded, codes.Canceled:
358+
return true
359+
}
360+
}
361+
return false
362+
}
363+
364+
// reapAbandonedLoad tells the worker to stop the one replica whose load we
365+
// just abandoned. The exact `modelID#replicaIndex` process key matters: the
366+
// bare model ID would stop every replica of the model on that node, including
367+
// healthy ones serving traffic.
368+
//
369+
// Best-effort by design — the caller is waiting on the load failure, and a
370+
// failed reap must be visible in the logs, never substituted for it.
371+
func (r *SmartRouter) reapAbandonedLoad(node *BackendNode, trackingKey string, replicaIndex int) {
372+
if r.unloader == nil {
373+
return
374+
}
375+
processKey := model.BackendProcessKey(trackingKey, replicaIndex)
376+
xlog.Warn("Reaping abandoned model load on worker",
377+
"node", node.Name, "model", trackingKey, "replica", replicaIndex)
378+
if err := r.unloader.StopBackend(node.ID, processKey); err != nil {
379+
xlog.Warn("Failed to reap abandoned model load; the worker may still be loading",
380+
"node", node.Name, "backend", processKey, "error", err)
381+
}
382+
}
383+
288384
// ScheduleAndLoadModel implements ModelScheduler for the reconciler.
289385
// It retrieves stored model options from an existing replica and performs the
290386
// full load sequence (stage files, LoadModel, SetNodeModel) on a new node.

0 commit comments

Comments
 (0)