Skip to content

fix(upgrade-check): don't filter upgrade candidates by controller capability#11024

Merged
mudler merged 1 commit into
masterfrom
fix/upgrade-check-distributed
Jul 21, 2026
Merged

fix(upgrade-check): don't filter upgrade candidates by controller capability#11024
mudler merged 1 commit into
masterfrom
fix/upgrade-check-distributed

Conversation

@localai-bot

Copy link
Copy Markdown
Collaborator

Problem

On a live distributed cluster, GET /backends reported 48 installed backends while POST /backends/upgrades/check evaluated only 5:

cpu-insightface-development, cpu-sherpa-onnx-development,
cpu-speaker-recognition-development, local-store-development, piper

All 5 are plain/CPU backends. All 43 skipped ones are hardware-specific builds (cuda13-nvidia-l4t-arm64-*, etc). No GPU backend on the fleet was ever checked for upgrades.

Concrete harm: cuda13-nvidia-l4t-arm64-longcat-video-development was installed at digest sha256:0b8dc851... while the registry tag held sha256:38dae6ff.... The upgrade check never reported it, and a cuDNN packaging fix sat unnoticed on a GPU worker node for two days until the registry API was queried by hand.

Root cause

Not the node inventory. DistributedBackendManager.CheckUpgrades already aggregates the cluster-wide installed set (with per-node versions/digests) and feeds it to gallery.CheckUpgradesAgainst. The installed side was correct.

The gallery side was filtered. CheckUpgradesAgainst resolved candidates through AvailableBackends, which applies GalleryBackend.IsCompatibleWith(systemState) — the controller's system state. On a CPU-only controller, IsBackendCompatible rejects every name containing l4t / cuda / nvidia / rocm / sycl, so those entries never appear in the candidate list, FindGalleryElement returns nil, and the loop hits continue. Only names that fall through to "CPU backends are always compatible" survive — exactly the 5 observed.

The exclusion is accidental, not deliberate. There is no guard or comment justifying it; the existing tests even work around it (// Names are kept generic ("my-backend") so the capability filter in AvailableBackends doesn't drop them on a CPU-only test host).

AvailableBackendsForCapabilities was added earlier to fix the same class of bug for the backend listing endpoint. The upgrade checker never got the equivalent treatment.

Fix

Use AvailableBackendsUnfiltered in CheckUpgradesAgainst and in UpgradeBackend. Every name looked up in either place is already installed somewhere, so hardware compatibility was decided at install time; re-deciding it against whichever host happens to run the check is wrong. Unfiltered is preferred over the capability-union variant because it needs no cluster capability plumbing and cannot under-report.

Test

New Ginkgo spec in core/gallery/upgrade_test.go pins a deterministic CPU-only controller via system.NewCapabilityState("default", ...) and asserts an l4t backend installed on a worker at 1.0.0 against a gallery at 2.0.0 is reported. Red before the change:

[FAILED] Expected
    <map[string]gallery.UpgradeInfo | len:0>: {}
to have key
    <string>: cuda13-nvidia-l4t-arm64-longcat-video-development

Green after. go build ./core/... ./pkg/... exit 0, go test ./core/gallery/ ./core/services/nodes/... ./core/application/... exit 0, make lint exit 0.

🤖 Generated with Claude Code

https://claude.ai/code/session_0156CbKpDh8qbAYzJZJDZ2yk

…ability

CheckUpgradesAgainst resolved gallery entries through AvailableBackends,
which drops every entry the *local* host cannot run. In distributed mode
the host running the check is a CPU-only controller while the GPU
backends live on worker nodes, so FindGalleryElement returned nil for
every cuda/rocm/l4t entry and those backends were silently skipped.

Measured on a live cluster: GET /backends reported 48 installed
backends, POST /backends/upgrades/check evaluated 5 — all of them plain
or cpu-prefixed. The 43 skipped were all hardware-specific builds. As a
result cuda13-nvidia-l4t-arm64-longcat-video-development stayed at
sha256:0b8dc851 while the registry tag held sha256:38dae6ff, and a cuDNN
packaging fix sat unnoticed on a GPU worker for two days.

Every name looked up here is already installed somewhere in the cluster,
so hardware compatibility was decided at install time; re-deciding it
against the controller is wrong. Switch both CheckUpgradesAgainst and
UpgradeBackend to AvailableBackendsUnfiltered.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]
@mudler
mudler merged commit 2b61e4b into master Jul 21, 2026
21 of 22 checks passed
@mudler
mudler deleted the fix/upgrade-check-distributed branch July 21, 2026 17:28
mudler added a commit that referenced this pull request Jul 22, 2026
…l replaced (#11029)

A backend reinstall could poison every subsequent model load on a worker
node until the worker process was restarted.

gallery.InstallBackend (and gallery.UpgradeBackend) replace a backend by
renaming the live directory to `<name>.install-backup`, moving the staged
directory into place, then deleting the backup. A working directory
follows the inode across a rename, so a backend process that outlives
that swap ends up with a deleted inode as its CWD, and every getcwd(2)
in it fails with ENOENT.

Observed on a Jetson Thor worker in distributed mode after two
successive reinstalls of cuda13-nvidia-l4t-arm64-longcat-video-development.
A later model load failed with:

    rpc error: code = Internal desc = failed to load LongCat model: [Errno 2] No such file or directory

The backend's own traceback shows it dying while importing torch, before
touching any model file:

    backend.py line 142 in LoadModel
    backend.py line 300 in _import_torch
      torch/_library/custom_ops.py  lib._register_fake(...)
      torch/library.py:183          caller_module = inspect.getmodule(frame)
      inspect.py:1013               f = getabsfile(module)
      inspect.py:983                return os.path.normcase(os.path.abspath(_filename))
      <frozen posixpath>, line 415, in abspath
    FileNotFoundError: [Errno 2] No such file or directory

os.path.abspath calls os.getcwd() for a relative path. Scanning /proc
inside the worker container found the deleted CWD directly:

    pid 23467 CWD DELETED: /backends/cuda13-nvidia-l4t-arm64-longcat-video-development.install-backup (deleted)

Restarting the worker container cleared it (dead CWD count 1 -> 0).

Python backends import torch lazily inside LoadModel, so such a survivor
still answers HealthCheck and keeps its gRPC port. It looks healthy and
only detonates when a model is actually loaded through it.

The install paths already stop running processes before replacing the
directory (installBackend's force branch, upgradeBackend, backend.delete),
but they resolve them by name. That bookkeeping reaps nothing whenever
the recorded name no longer resolves into the install's identity set: a
legacy entry with an empty backendName, backendIdentity degraded to
name-only matching after a ListSystemBackends failure, or an earlier
reinstall having already rewritten the metadata.json that carries the
alias. Any of those leaves a live process whose directory is about to be
unlinked, and nothing downstream notices, because the reuse gate checks
liveness and name -- and the name is precisely what does not change
across a reinstall.

Record the directory each supervised process runs out of, plus that
directory's identity at spawn time, and compare with os.SameFile before
reusing the process. This needs none of the name bookkeeping to have
been correct. Both reuse gates are covered: processMatchesBackend (the
install fast path) and startBackend's own already-running branch, which
now force-stops such a survivor so the fresh spawn chdirs into the newly
installed directory. Processes with no recorded directory are accepted,
so a rollout does not restart every running backend once.

This matters more with #11024 pending: making GPU backends visible to
the upgrade checker will have AutoUpgradeBackends fan upgrades out to
worker nodes at scale, and every one of those is a reinstall. Left as
is, a rare manual-upgrade footgun becomes a fleet-wide one.


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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants