mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-01 11:00:24 -04:00
* fix(nodes): never schedule a model onto a node that cannot store it
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]
* feat(nodes): make the disk-headroom check operator-controllable
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]
---------
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
141 lines
5.8 KiB
Go
141 lines
5.8 KiB
Go
package nodes
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// ErrInsufficientDisk reports that no candidate node has enough free space on
|
|
// its models filesystem to store the model being scheduled.
|
|
//
|
|
// This is a scheduling-time verdict on purpose. Before it existed, a worker
|
|
// whose models filesystem was 100% full still advertised `status: healthy`,
|
|
// was picked to host a 70GB model, accepted the staging request, streamed
|
|
// ~17GB and only then failed with "no space left on device" — sixteen minutes
|
|
// after a decision that could never have succeeded.
|
|
var ErrInsufficientDisk = errors.New("no node has enough free disk for the model")
|
|
|
|
const (
|
|
// diskHeadroomMarginRatio is the fraction of the payload kept free on top
|
|
// of the payload itself. Staging is not the only writer on that
|
|
// filesystem (backend installs, logs, the backend's own scratch files),
|
|
// and the payload figure is a floor rather than an exact prediction.
|
|
diskHeadroomMarginRatio = 0.05
|
|
|
|
// diskHeadroomMinMarginBytes floors the proportional margin so small
|
|
// models still leave usable space behind them.
|
|
diskHeadroomMinMarginBytes = uint64(1) << 30 // 1 GiB
|
|
|
|
// diskHeadroomUnknownSizeBytes is what we demand when the model's payload
|
|
// cannot be sized locally (a bare HuggingFace repo id the worker will
|
|
// fetch itself). Deliberately small: it is a "this filesystem is not
|
|
// wedged" floor, not a capacity estimate. Demanding more would strand
|
|
// small-but-healthy nodes on every model whose size we cannot see.
|
|
diskHeadroomUnknownSizeBytes = uint64(2) << 30 // 2 GiB
|
|
)
|
|
|
|
// DiskRequirementFor returns the free bytes a node must have on its models
|
|
// filesystem before it may be handed a model whose staged payload is
|
|
// payloadBytes (as computed by modelPayloadBytes).
|
|
//
|
|
// The requirement is derived from the ACTUAL model size rather than from a
|
|
// fixed percentage of the node's disk. A percentage threshold would take a
|
|
// small node out of rotation for models it could comfortably hold, which on a
|
|
// homogeneous cluster strands every node at once.
|
|
func DiskRequirementFor(payloadBytes int64) uint64 {
|
|
if payloadBytes <= 0 {
|
|
return diskHeadroomUnknownSizeBytes
|
|
}
|
|
payload := uint64(payloadBytes)
|
|
margin := uint64(float64(payload) * diskHeadroomMarginRatio)
|
|
if margin < diskHeadroomMinMarginBytes {
|
|
margin = diskHeadroomMinMarginBytes
|
|
}
|
|
return payload + margin
|
|
}
|
|
|
|
// reportsDisk says whether a node's disk figures are usable.
|
|
//
|
|
// TotalDisk is the "does this worker report disk at all" bit, NOT
|
|
// AvailableDisk: a 100%-full node legitimately reports available == 0, and
|
|
// treating that as unknown would reinstate exactly the bug this guards
|
|
// against. A worker predating the field (or one whose stat failed) reports
|
|
// total == 0 and is passed through untouched, so a rolling upgrade never
|
|
// empties the candidate pool.
|
|
func (n BackendNode) reportsDisk() bool { return n.TotalDisk > 0 }
|
|
|
|
// nodesWithDiskHeadroom filters candidates down to those that can actually
|
|
// store a model needing `required` free bytes on their models filesystem.
|
|
func nodesWithDiskHeadroom(candidates []BackendNode, required uint64) []BackendNode {
|
|
fit := make([]BackendNode, 0, len(candidates))
|
|
for _, n := range candidates {
|
|
if !n.reportsDisk() || n.AvailableDisk >= required {
|
|
fit = append(fit, n)
|
|
}
|
|
}
|
|
return fit
|
|
}
|
|
|
|
// describeDiskShortfall renders the per-node free-space readings that produced
|
|
// a rejection, so the operator sees the numbers behind the decision instead of
|
|
// a bare "no nodes available".
|
|
func describeDiskShortfall(candidates []BackendNode) string {
|
|
parts := make([]string, 0, len(candidates))
|
|
for _, n := range candidates {
|
|
parts = append(parts, fmt.Sprintf("%s has %s free of %s",
|
|
n.Name, humanFileSize(int64(n.AvailableDisk)), humanFileSize(int64(n.TotalDisk))))
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
// NarrowByDiskHeadroom restricts candidateNodeIDs to nodes whose models
|
|
// filesystem can hold `required` more bytes.
|
|
//
|
|
// A nil candidateNodeIDs means "any healthy backend node" (the caller applied
|
|
// no selector); the returned slice is then the narrowed set, never nil, so the
|
|
// caller keeps the constraint. When nothing fits, the error wraps
|
|
// ErrInsufficientDisk and names every candidate's free space.
|
|
//
|
|
// Errors reading the registry are NOT fatal: the caller gets its original
|
|
// candidate set back alongside the error and can carry on. A database hiccup
|
|
// must not stop a cluster from scheduling.
|
|
func (r *NodeRegistry) NarrowByDiskHeadroom(ctx context.Context, candidateNodeIDs []string, required uint64) ([]string, error) {
|
|
candidates, err := r.healthyBackendNodes(ctx, candidateNodeIDs)
|
|
if err != nil {
|
|
return candidateNodeIDs, err
|
|
}
|
|
// No rows at all is not a disk verdict — the pool is empty for some other
|
|
// reason (nothing registered, everything unhealthy). Let the existing
|
|
// "no healthy nodes" paths report that; claiming a disk shortage here
|
|
// would be a misleading diagnosis.
|
|
if len(candidates) == 0 {
|
|
return candidateNodeIDs, nil
|
|
}
|
|
|
|
fit := nodesWithDiskHeadroom(candidates, required)
|
|
if len(fit) == 0 {
|
|
return nil, fmt.Errorf("%w: need %s free on the models filesystem, but %s",
|
|
ErrInsufficientDisk, humanFileSize(int64(required)), describeDiskShortfall(candidates))
|
|
}
|
|
return extractNodeIDs(fit), nil
|
|
}
|
|
|
|
// healthyBackendNodes loads the healthy backend nodes, optionally restricted to
|
|
// an explicit candidate set.
|
|
func (r *NodeRegistry) healthyBackendNodes(ctx context.Context, candidateNodeIDs []string) ([]BackendNode, error) {
|
|
q := r.db.WithContext(ctx).Model(&BackendNode{}).
|
|
Where("status = ? AND node_type = ?", StatusHealthy, NodeTypeBackend)
|
|
if candidateNodeIDs != nil {
|
|
q = q.Where("id IN ?", candidateNodeIDs)
|
|
}
|
|
var out []BackendNode
|
|
if err := q.Find(&out).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, fmt.Errorf("listing healthy backend nodes: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|