Skip to content

Commit de83b72

Browse files
localai-botmudler
andauthored
fix(distributed): orchestrator resilience — auto-upgrade routing, worker bind-wait, RAG-init crash, log spam (#9657)
* fix(nodes/health): skip stale-marking already-offline nodes The health monitor re-emitted "Node heartbeat stale" + "Marking stale node offline" + MarkOffline on every cycle for nodes that were already in the offline (or unhealthy) state. For an operator-stopped node this flooded the logs with the same WARN+INFO pair every check interval. Skip the staleness branch when the node is already StatusOffline / StatusUnhealthy — the state is already what we'd write, so neither the log lines nor the DB update carry information. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(worker): wait for backend gRPC bind before replying to backend.install The backend supervisor used to wait up to 4s (20 × 200ms) for the backend's gRPC server to answer a HealthCheck, then log a warning and reply Success with the bind address anyway. On slower nodes (a Jetson Orin doing first-boot CUDA init, large CGO library load) the gRPC listener wasn't up yet, so the frontend's first LoadModel dial returned "connect: connection refused" and the operator chased a phantom network issue instead of a startup-timing one. Two changes: - Bump the readiness window to 30s. CUDA init on Orin/Thor first boot measures in seconds, not milliseconds. - On deadline-exceeded, stop the half-started process, recycle the port, and return an error with the backend's stderr tail. The frontend now gets a real failure with diagnostic context instead of a misleading ECONNREFUSED on a downstream dial. Process death during the wait window keeps its existing fast-fail path. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(distributed): route auto-upgrade through BackendManager + bump LocalAGI/LocalRecall Two distributed-mode bugs that surfaced together in the orchestrator logs: 1. Auto-upgrade always failed with "backend not found". UpgradeChecker correctly routed CheckUpgrades through the active BackendManager (so the frontend aggregates worker state), but the auto-upgrade branch right below called gallery.UpgradeBackend directly with the frontend's SystemState. In distributed mode the frontend has no backends installed locally, so ListSystemBackends returned empty and Get(name) failed for every reported upgrade. Auto-upgrade now also goes through BackendManager.UpgradeBackend, which fans out to workers via NATS. 2. Embedding-load failure on a remote node crashed the orchestrator. When RAG init lazily called NewPersistentPostgresCollection and the remote embedding worker was unreachable, LocalRecall called os.Exit(1) inside the constructor, killing the orchestrator pod. LocalRecall now returns errors instead, LocalAGI surfaces them as a nil collection, and the existing RAGProviderFromState path returns (nil, nil, false) — the same code path the agent pool already takes when no RAG is configured. The orchestrator stays up; chat requests degrade to "no RAG available" until the embedding worker recovers. Bumps: github.com/mudler/LocalAGI → e83bf515d010 github.com/mudler/localrecall → 6138c1f535ab Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 1aeb4d7 commit de83b72

5 files changed

Lines changed: 68 additions & 17 deletions

File tree

core/application/upgrade_checker.go

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -199,22 +199,44 @@ func (uc *UpgradeChecker) runCheck(ctx context.Context) {
199199
}
200200
}
201201

202-
// Auto-upgrade if enabled
202+
// Auto-upgrade if enabled. Route through the active BackendManager so
203+
// distributed-mode upgrades fan out to workers via NATS — calling
204+
// gallery.UpgradeBackend directly would look up the backend on the
205+
// frontend filesystem, which is empty in distributed mode and produces
206+
// "backend not found" while the cluster still reports an upgrade.
203207
if uc.appConfig.AutoUpgradeBackends {
208+
var bm galleryop.BackendManager
209+
if uc.backendManagerFn != nil {
210+
bm = uc.backendManagerFn()
211+
}
204212
for name, info := range upgrades {
205213
xlog.Info("Auto-upgrading backend", "backend", name,
206214
"from", info.InstalledVersion, "to", info.AvailableVersion)
207-
if err := gallery.UpgradeBackend(ctx, uc.systemState, uc.modelLoader,
208-
uc.galleries, name, nil); err != nil {
215+
var err error
216+
if bm != nil {
217+
err = bm.UpgradeBackend(ctx, name, nil)
218+
} else {
219+
err = gallery.UpgradeBackend(ctx, uc.systemState, uc.modelLoader,
220+
uc.galleries, name, nil)
221+
}
222+
if err != nil {
209223
xlog.Error("Failed to auto-upgrade backend",
210224
"backend", name, "error", err)
211225
} else {
212226
xlog.Info("Backend upgraded successfully", "backend", name,
213227
"version", info.AvailableVersion)
214228
}
215229
}
216-
// Re-check to update cache after upgrades
217-
if freshUpgrades, err := gallery.CheckBackendUpgrades(ctx, uc.galleries, uc.systemState); err == nil {
230+
// Re-check to update cache after upgrades. Route through the same
231+
// BackendManager so distributed mode reflects the worker view.
232+
var freshUpgrades map[string]gallery.UpgradeInfo
233+
var freshErr error
234+
if bm != nil {
235+
freshUpgrades, freshErr = bm.CheckUpgrades(ctx)
236+
} else {
237+
freshUpgrades, freshErr = gallery.CheckBackendUpgrades(ctx, uc.galleries, uc.systemState)
238+
}
239+
if freshErr == nil {
218240
uc.mu.Lock()
219241
uc.lastUpgrades = freshUpgrades
220242
uc.mu.Unlock()

core/cli/worker.go

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -465,10 +465,20 @@ func (s *backendSupervisor) startBackend(backend, backendPath string) (string, e
465465
bp := s.processes[backend]
466466
s.mu.Unlock()
467467

468-
// Wait for the gRPC server to be ready
468+
// Wait for the gRPC server to be ready before reporting success.
469+
// Slow nodes (Jetson Orin doing first-boot CUDA init, large CGO libs)
470+
// can take 10-15s before the gRPC port accepts connections; the previous
471+
// 4s window made the worker reply Success on a not-yet-listening port,
472+
// which manifested upstream as "connect: connection refused" on the
473+
// frontend's first LoadModel dial.
469474
client := grpc.NewClientWithToken(clientAddr, false, nil, false, s.cmd.RegistrationToken)
470-
for range 20 {
471-
time.Sleep(200 * time.Millisecond)
475+
const (
476+
readinessPollInterval = 200 * time.Millisecond
477+
readinessTimeout = 30 * time.Second
478+
)
479+
deadline := time.Now().Add(readinessTimeout)
480+
for time.Now().Before(deadline) {
481+
time.Sleep(readinessPollInterval)
472482
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
473483
if ok, _ := client.HealthCheck(ctx); ok {
474484
cancel()
@@ -496,10 +506,23 @@ func (s *backendSupervisor) startBackend(backend, backendPath string) (string, e
496506
}
497507
}
498508

499-
// Log stderr to help diagnose why the backend isn't responding
509+
// Readiness deadline exceeded. Returning success here would leave the
510+
// frontend with an unbound address (it dials, gets ECONNREFUSED, and
511+
// the operator sees a misleading "connection refused" instead of the
512+
// real cause). Stop the half-started process, recycle the port, and
513+
// surface the failure to the caller with the backend's stderr tail.
500514
stderrTail := readLastLinesFromFile(proc.StderrPath(), 20)
501-
xlog.Warn("Backend gRPC server not ready after waiting, proceeding anyway", "backend", backend, "addr", clientAddr, "stderr", stderrTail)
502-
return clientAddr, nil
515+
xlog.Error("Backend gRPC server not ready before deadline; aborting install", "backend", backend, "addr", clientAddr, "timeout", readinessTimeout, "stderr", stderrTail)
516+
if killErr := proc.Stop(); killErr != nil {
517+
xlog.Warn("Failed to stop unready backend process", "backend", backend, "error", killErr)
518+
}
519+
s.mu.Lock()
520+
if cur, ok := s.processes[backend]; ok && cur == bp {
521+
delete(s.processes, backend)
522+
s.freePorts = append(s.freePorts, port)
523+
}
524+
s.mu.Unlock()
525+
return "", fmt.Errorf("backend %s did not become ready within %s. Last stderr:\n%s", backend, readinessTimeout, stderrTail)
503526
}
504527

505528
// resolveProcessKeys turns a caller-supplied identifier into the set of

core/services/nodes/health.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ func (hm *HealthMonitor) doCheckAll(ctx context.Context) {
126126
// Workers (both backend and agent) send HTTP heartbeats to the frontend.
127127
// If the heartbeat is stale, the worker is presumed down.
128128
if time.Since(node.LastHeartbeat) > hm.staleThreshold {
129+
// Skip nodes already marked offline/unhealthy — re-marking them
130+
// every cycle floods the log with the same WARN+INFO pair for
131+
// nodes the operator has intentionally taken down.
132+
if node.Status == StatusOffline || node.Status == StatusUnhealthy {
133+
continue
134+
}
129135
xlog.Warn("Node heartbeat stale", "node", node.Name, "lastHeartbeat", node.LastHeartbeat)
130136
if hm.autoOffline {
131137
xlog.Info("Marking stale node offline", "node", node.Name)

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,8 @@ require (
167167
github.com/kevinburke/ssh_config v1.2.0 // indirect
168168
github.com/labstack/gommon v0.4.2 // indirect
169169
github.com/mschoch/smat v0.2.0 // indirect
170-
github.com/mudler/LocalAGI v0.0.0-20260415165142-3369136c7380
171-
github.com/mudler/localrecall v0.5.9-0.20260415164846-8ad831f840fc // indirect
170+
github.com/mudler/LocalAGI v0.0.0-20260504165100-e83bf515d010
171+
github.com/mudler/localrecall v0.5.10-0.20260504162944-6138c1f535ab // indirect
172172
github.com/mudler/skillserver v0.0.6
173173
github.com/olekukonko/tablewriter v0.0.5 // indirect
174174
github.com/oxffaa/gopher-parse-sitemap v0.0.0-20191021113419-005d2eb1def4 // indirect

go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -703,8 +703,8 @@ github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
703703
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
704704
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
705705
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
706-
github.com/mudler/LocalAGI v0.0.0-20260415165142-3369136c7380 h1:gSS535c1MO3IRSUIWJT1xzZjT4lZBsqtHpptXvrEsmw=
707-
github.com/mudler/LocalAGI v0.0.0-20260415165142-3369136c7380/go.mod h1:rD7G70wl+5zlpvNF13iZBpAuat8LsiJFn678z3Kxleo=
706+
github.com/mudler/LocalAGI v0.0.0-20260504165100-e83bf515d010 h1:b5MBD3gq+H/tN2dVFqkFI6CvSrBUnmvdGPl6ivtSrSc=
707+
github.com/mudler/LocalAGI v0.0.0-20260504165100-e83bf515d010/go.mod h1:QOB+zg2jARzslqhy2c/59CW2Kcp0JEHOiNIDeCRFP2s=
708708
github.com/mudler/cogito v0.9.5-0.20260315222927-63abdec7189b h1:A74T2Lauvg61KodYqsjTYDY05kPLcW+efVZjd23dghU=
709709
github.com/mudler/cogito v0.9.5-0.20260315222927-63abdec7189b/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
710710
github.com/mudler/edgevpn v0.31.1 h1:7qegiDWd0kAg6ljhNHxqvp8hbo/6BbzSdbb7/2WZfiY=
@@ -713,8 +713,8 @@ github.com/mudler/go-piper v0.0.0-20241023091659-2494246fd9fc h1:RxwneJl1VgvikiX
713713
github.com/mudler/go-piper v0.0.0-20241023091659-2494246fd9fc/go.mod h1:O7SwdSWMilAWhBZMK9N9Y/oBDyMMzshE3ju8Xkexwig=
714714
github.com/mudler/go-processmanager v0.1.1 h1:c/1NRZOZpW8HuFv9RhBG57nQu1oDMRomEHedwBFMlrw=
715715
github.com/mudler/go-processmanager v0.1.1/go.mod h1:h6kmHUZeafr+k5hRYpGLMzJFH4hItHffgpRo2QIkP+o=
716-
github.com/mudler/localrecall v0.5.9-0.20260415164846-8ad831f840fc h1:p1ucQ2rbU4mhG2Xl1Emg5Q6QCYCjI+fvMF9KTek/+sY=
717-
github.com/mudler/localrecall v0.5.9-0.20260415164846-8ad831f840fc/go.mod h1:xuPtgL9zUyiQLmspYzO3kaboYrGbWmwi8BQPt1aCAcs=
716+
github.com/mudler/localrecall v0.5.10-0.20260504162944-6138c1f535ab h1:U6MWVv9Xgb56JTIL4DfsZftSig/LeJA+yizlyw8fq24=
717+
github.com/mudler/localrecall v0.5.10-0.20260504162944-6138c1f535ab/go.mod h1:xuPtgL9zUyiQLmspYzO3kaboYrGbWmwi8BQPt1aCAcs=
718718
github.com/mudler/memory v0.0.0-20260406210934-424c1ecf2cf8 h1:Ry8RiWy8fZ6Ff4E7dPmjRsBrnHOnPeOOj2LhCgyjQu0=
719719
github.com/mudler/memory v0.0.0-20260406210934-424c1ecf2cf8/go.mod h1:EA8Ashhd56o32qN7ouPKFSRUs/Z+LrRCF4v6R2Oarm8=
720720
github.com/mudler/skillserver v0.0.6 h1:ixz6wUekLdTmbnpAavCkTydDF6UdXAG3ncYufSPK9G0=

0 commit comments

Comments
 (0)