fix(nodes): never schedule a model onto a node that cannot store it#11054
Merged
Conversation
A worker whose models filesystem was 100% full kept advertising
`status: healthy`, stayed a scheduling candidate, was picked to host a
70 GB video model, accepted the staging request, transferred ~17 GB and
only then failed:
staging .../whisper-large-v3/model.fp32-00001-of-00002.safetensors:
upload to node b7bacbf4-... failed with status 500:
writing file: /models/longcat-video-avatar-1.5/...: no space left on device
The node was at 937G/937G/0-avail. Total elapsed before the truth
surfaced: 16 minutes, for a decision that could never have succeeded.
The worker health signal only ever proved liveness. `/readyz`
(WorkerReadiness/NATSReadiness) checks the NATS link; `status: healthy`
in the registry is driven by heartbeat recency. Node capacity carried
VRAM and RAM but no disk figure at all, and the router compared model
size against VRAM only — nothing anywhere looked at free space on the
filesystem that staging actually writes to.
Report it, then use it:
- Workers now measure the filesystem backing their MODELS directory
(not `/` -- staged weights land in the models path, and that mount is
very often separate) and report `total_disk`/`available_disk` on
registration and on every heartbeat. Free disk moves faster than VRAM
under staging traffic, so the per-heartbeat refresh matters.
- The SmartRouter drops nodes that cannot store the model before it
picks one. The requirement comes from `modelPayloadBytes` -- the same
local paths `stageModelFiles` uploads, already computed for the
size-derived load budget -- plus a 5% / 1 GiB margin, rather than a
fixed percentage of the node's disk. A percentage threshold would take
a small-but-usable node out of rotation for models it could hold, and
on a homogeneous cluster would strand every node at once.
- When no node fits, scheduling fails immediately with an error naming
the requirement and each node's free space, instead of picking one and
discovering it mid-transfer.
Two deliberate non-changes. Low disk does not mark a node `unhealthy`:
the check is per model, so a node too small for one model stays a valid
target for smaller ones. And `total_disk == 0` means "does not report
disk" (pre-upgrade worker, or a failed stat), not "full" -- such nodes
pass through untouched so a rolling upgrade never empties the candidate
pool. A genuinely full node is distinguishable: non-zero total, zero
available. Registry read failures are logged and scheduling continues
unfiltered; a database hiccup must not wedge a cluster.
Free space is surfaced on the node detail page next to VRAM, since the
incident's signature was a node that looked entirely healthy.
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]
The admission check added in the previous commit had no off switch. A scheduler-side veto with no escape hatch is a liability: our size estimate can be wrong (deduplicating or compressing filesystems, a backend that fetches its own weights rather than loading the staged copy), and an operator who hits that has no way out but a downgrade. Add one knob with two surfaces that share a single source of truth: - `--distributed-disk-headroom-check` / `LOCALAI_DISTRIBUTED_DISK_HEADROOM_CHECK` (default true), following the `--distributed-prefix-cache` pattern for a default-on distributed feature. - `distributed_disk_headroom_check` in the runtime-settings registry, so it can be flipped without a restart from `POST /api/settings` and from Settings -> Distributed in the WebUI. Both write `DistributedConfig.DiskHeadroomDisabled`, and the SmartRouter reads that member LIVE on every scheduling decision through a closure over the application config rather than a value snapshotted at construction. Env/CLI sets the boot value, the runtime setting overrides it live, last write wins, and there is exactly one member to read. Snapshotting would have made the runtime toggle a no-op until restart. Disabled means WARN, not SKIP. Selection goes back to ignoring free disk -- byte for byte the pre-check behaviour -- but the check still runs, and when it would have rejected every node it says so, naming the knob that suppressed it. Going quiet when switched off would reproduce the exact condition that made the original incident expensive: a cluster doing something that could not work and saying nothing. Disabling is also logged once at startup. Warning only on the total-rejection case keeps it actionable rather than chatty on a heterogeneous cluster. Also fixes a false positive in the check itself: shared-models mode (LOCALAI_DISTRIBUTED_SHARED_MODELS) stages nothing at all -- every node already mounts this models directory at this path -- so demanding the full checkpoint size of free space per node would have rejected a cluster that needs no new bytes. The check is skipped there entirely. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]
mudler
force-pushed
the
fix/node-disk-headroom
branch
from
July 22, 2026 21:12
5caebb7 to
80d5ae0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The incident
An NVIDIA Jetson Thor worker (
nvidia-thor) filled its disk completely:It continued to report
status: healthyvia the nodes API, remained a scheduling candidate, was selected to host a 70 GB video model, accepted the staging request, transferred ~17 GB, and only then failed:Total elapsed before the failure surfaced: 16 minutes. The node had zero bytes free when it accepted the work — it could never have held the model. The error message itself is good (it names the file and the cause, from an earlier fix). The defect is that the request was accepted at all.
What the health signal actually covered
/readyz(WorkerReadiness+NATSReadiness) checks the NATS link — "can this worker receive work", added for Docker HEALTHCHECK probes a frontend endpoint, so every worker container is permanently unhealthy #10987.scripts/build/healthcheck.sh, the containerHEALTHCHECK, checks liveness.status: healthyin the node registry is driven purely by heartbeat recency (LastHeartbeat,MarkOffline/FindStaleNodes).None of these say anything about capacity to accept work.
Node capacity carried
total_vram,available_vram,total_ram,available_ram— no disk figure existed at all, unused or otherwise.SmartRouter.scheduleNewModelcompared an estimated VRAM figure againstavailable_vram - reserved_vramand never looked at free space on the filesystem staging actually writes to.The fix
Report it. Workers now measure the filesystem backing their models directory (
--models-path) — not/, since staged weights land in the models path and that mount is very often separate — and reporttotal_disk/available_diskon registration and on every heartbeat. Free disk moves much faster than VRAM under staging traffic (each accepted model permanently consumes space), so a registration-time-only reading would have missed a node that filled up hours later.disk.Usage().Freerather thanTotal - Used: the ext4 reserved-blocks pool sits between them, and writing into it is exactly what fails with ENOSPC for a non-root process.Use it.
scheduleNewModelnow drops nodes that cannot store the model before picking one, ahead of the replica-slot and VRAM passes. The requirement comes frommodelPayloadBytes— the same local pathsstageModelFilesuploads, already computed for the size-derived load budget — plus a modest margin (5%, at least 1 GiB).Fail clearly. When no node fits, scheduling fails immediately with the numbers behind the decision rather than picking one and discovering it 16 minutes into a transfer:
The operator knob
The check is on by default, with one switch exposed two ways:
--distributed-disk-headroom-check/LOCALAI_DISTRIBUTED_DISK_HEADROOM_CHECK(defaulttrue)distributed_disk_headroom_check—POST /api/settings, and Settings → Distributed in the WebUIBoth write the same
DistributedConfig.DiskHeadroomDisabledmember, and the SmartRouter reads that member live on every scheduling decision via a closure over the application config rather than a value snapshotted at construction. Env/CLI sets the boot value, the runtime setting overrides it live, last write wins, and there is exactly one thing to read — no second source of truth to drift. Snapshotting at construction would have made the runtime toggle a no-op until restart, which is the failure mode worth guarding against here. The registration follows the declarative registry added in #10864 (core/config/runtime_settings_registry.go); its completeness spec fails until both the struct field and the registry row exist, and I confirmed it does.Disabled means warn, not skip
This was the judgement call. Disabling restores selection that ignores free disk — byte for byte the pre-check behaviour — but the check still runs, and when it would have rejected every node it says so, naming the knob that suppressed it:
Skipping outright was rejected because it reproduces the condition that made the original bug expensive: the cluster was doing something that could not work and said nothing about it. The escape hatch exists for setups where our size estimate is wrong (deduplicating or compressing filesystems, a backend that fetches its own weights instead of using the staged copy) — and in exactly those cases the operator needs to see what LocalAI thought was wrong. Warning only on the total-rejection case keeps it actionable rather than chatty on a heterogeneous cluster where some nodes are legitimately too small. Disabling is also logged once at startup, so a silently-disabled safety check cannot hide.
Design choices
Model size, not a fixed percentage. A percentage threshold would take a small-but-usable node out of rotation for models it could comfortably store, and on a homogeneous cluster would strand every node at once. Comparing against the actual payload means a node too small for one model stays a valid target for smaller ones. When the size cannot be determined locally (a bare HF repo id the worker fetches itself), the node only has to clear a 2 GiB floor.
Selection-side, not health-side. Low disk deliberately does not flip a node to
unhealthy. The check is per model; a global health threshold is exactly the hard cutoff that could strand a tight cluster.total_disk == 0means unknown, not full. A pre-upgrade worker (or one whose stat failed) reportstotal_disk == 0and passes through untouched, so a rolling upgrade never empties the candidate pool. A genuinely full node is distinguishable: non-zero total, zero available. Usingavailable_disk == 0as the unknown sentinel would have reinstated the exact bug — that is what a 100%-full node reports.Shared-models mode skips the check entirely.
LOCALAI_DISTRIBUTED_SHARED_MODELSmakesstageModelFilesupload nothing: every node already mounts this models directory at this path. Demanding the full checkpoint size of free space per node would have rejected a cluster that needs no new bytes at all. This was a false positive in the first draft of the check, caught while wiring the knob.Never wedge on a read failure. A registry error from
NarrowByDiskHeadroomis logged and scheduling continues unfiltered rather than failing. Standalone/single-node operation is untouched: none of this runs outside the distributedSmartRouterpath.Scope
Registration and heartbeat are plain JSON over HTTP, so this needed no protobuf change and no ripple through the gRPC surface. The additions are purely additive fields; existing React UI, e2e, and MCP consumers read node fields additively and are unaffected.
list_nodesinpkg/mcp/localaitoolsdoes not project VRAM either, so disk was left out of it; the runtime-settings registry drives REST + UI only (MCP exposes settings solely for branding), so no MCP tool was added.Testing
TDD, Ginkgo/Gomega, red first and failing on behaviour rather than missing symbols — both for the original check and for the knob.
Original check:
Knob (4 router specs red before the wiring, all "Expected success, but got an error"):
Config-level, verified red by removing the registry row:
The router specs assert the incident shape directly:
RoutereturnsErrInsufficientDiskandunloader.installCallsis empty — no backend install, therefore no staging, therefore no 17 GB transferred before the truth surfaced. The live-toggle spec flips the value after the router is constructed, so a snapshotted read would fail it.All green after the fix:
go build ./core/... ./pkg/...→ exit 0go test ./core/services/nodes/→ exit 0 (260s, full suite)go test ./core/config/→ exit 0go test ./core/cli/... ./core/application/... ./pkg/xsysinfo/ ./core/services/worker/→ exit 0make lint→ exit 0, 0 issuesDocs updated in
docs/content/features/distributed-mode.md: Disk headroom section with the new Turning the check off subsection, the frontend configuration table, and the scheduling-algorithm step list. Free space is surfaced on the node detail page next to VRAM, since the incident's signature was a node that looked entirely healthy.Pre-existing flake, not from this branch:
Backend traces payload boundingincore/http/endpoints/localaifails under load ("Backend trace channel full, dropping trace"). It fails 5/5 on cleanorigin/master(ff299df) as well as on this branch, so it comes from #11056, not from here. Left alone.Not verified: no access to the live cluster, so the end-to-end behaviour against a genuinely full
nvidia-thoris reasoned from the code paths and covered by unit/integration specs rather than observed. The React UI was parse-checked with esbuild but not built or rendered (nonode_modulesin the worktree).🤖 Generated with Claude Code
https://claude.ai/code/session_0156CbKpDh8qbAYzJZJDZ2yk