Files
LocalAI/core/services/worker/lifecycle.go
T
Ettore Di Giacinto f9d0d4c5c6 fix(worker): pin the rune cut, answer unload honestly, drop the dead publisher
Review fix round 1. Seven non-blocking findings; the blocking one is a
merge gate for Task 4 rather than anything in this diff, and the report's
concern about it is corrected: until Task 4 lands, PingNode probes two
subjects no serve-backend worker subscribes to any more, so every healthy
worker reads as absent and is marked unhealthy on the scheduling path.

The rune-boundary cut in truncate was true behaviour with nothing
holding it: a byte-wise mutation survived all 201 specs. isRuneStart is
replaced by utf8.RuneStart, the same predicate the cluster package uses
for this rule, and two specs pin it, one with a rune straddling the
bound and one with a rune ending exactly on it so the fix cannot be
"always walk back".

unloadModel answered Success:true whatever Free did. That is the worker
saying "done" about work it did not do, and the frontend's only caller
is EvictLRU, so a false yes told the scheduler VRAM had been released
and let it place the next model on a node still holding the old one. It
now reports the failure, following stopModelExact, which is the honest
pattern already in this package. Still a 200: the worker answered, only
its verdict is negative. An address with nothing loaded still answers
success, which is a true answer rather than a claim about work done.

NewDebouncedInstallProgressPublisher had no production caller after the
last commit, only its own spec. Deleted rather than wired: wiring it
would publish every event on two carriers, which is what the carrier
decision exists to avoid. Its specs now run against the sink, plus one
that pins the identity stamped on each event, since the subject used to
carry the op and node id and now nothing but the body does.

The install progress wiring was exercised by no spec, because with no
gallery nothing ever invokes the download callback. The guard moves into
startProgress, shared by install and upgrade, which also emits one
resolving event before any gallery work. That is worth having on its
own: a cold install spends minutes on a manifest and a progress stream
with nothing on it is indistinguishable from a broken one. It also makes
the wiring observable end to end, and four specs now drive the real
installBackend and upgradeBackend over HTTP with no override.

model/stop and backend/stop keep taking Background rather than the
caller's context, and the sites now say why. model/stop is the
acknowledged stop path: it reserves the process, frees it, kills it,
waits for exit and releases the port, and abandoning that because the
caller hung up would leave a process marked stopping, a port not
returned to the allocator and a row nothing reconciles. In
stopBackendExact the Free is a courtesy before a kill that happens
anyway. model/unload differs because Free IS the operation there.

A route set with no prefix or no registrar is now a startup error rather
than a silent no-op: a server that comes up healthy while every route
the caller registered answers 404 is, through a tunnel, indistinguishable
from a version skew. And the AllPaths spec no longer claims to catch a
constant that was never added to the set, which it cannot; it asserts
the whole set instead, which catches a verb dropped from it.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-02 16:21:41 +00:00

241 lines
9.7 KiB
Go

package worker
import (
"context"
"encoding/json"
"fmt"
"maps"
"slices"
"syscall"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/services/messaging"
grpc "github.com/mudler/LocalAI/pkg/grpc"
"github.com/mudler/xlog"
)
// The worker's lifecycle verbs.
//
// Each takes a decoded request and returns a reply value. They carry no
// carrier: the HTTP control plane in control_routes.go is what decodes the
// request, calls one of these, and encodes what comes back. Keeping the verb
// free of its transport is what let the ten NATS subscriptions these replaced
// be deleted without touching a line of what they actually do.
// backendList answers backend.list with the backends installed in this node's
// gallery.
func (s *backendSupervisor) backendList() messaging.BackendListReply {
xlog.Info("Serving backend.list")
backends, err := gallery.ListSystemBackends(s.systemState)
if err != nil {
return messaging.BackendListReply{Error: err.Error()}
}
var infos []messaging.NodeBackendInfo
for name, b := range backends {
// Drop synthetic alias rows: ListSystemBackends emits an entry
// keyed by the alias name that re-uses the chosen concrete's
// metadata. The frontend can't reconstruct that aliasing
// faithfully from a flat NodeBackendInfo, and for upgrade
// detection it would surface as a phantom `<alias>` install
// pointing at the dev concrete's URI/digest — tricking the
// upgrade check into flagging the non-dev gallery entry of the
// same alias. Concrete and meta entries always have
// `name == b.Metadata.Name`, so this drops aliases only.
if b.Metadata != nil && b.Metadata.Name != "" && name != b.Metadata.Name {
continue
}
info := messaging.NodeBackendInfo{
Name: name,
IsSystem: b.IsSystem,
IsMeta: b.IsMeta,
}
if b.Metadata != nil {
info.InstalledAt = b.Metadata.InstalledAt
info.GalleryURL = b.Metadata.GalleryURL
info.Version = b.Metadata.Version
info.URI = b.Metadata.URI
info.Digest = b.Metadata.Digest
}
infos = append(infos, info)
}
return messaging.BackendListReply{Backends: infos}
}
// stopBackends serves backend.stop: it terminates the processes the request
// names, or every process when it names none.
//
// It reports nothing. The verb has always been fire-and-forget, and a stop that
// could report per-process failure would be a different contract than the one
// the frontend was written against.
func (s *backendSupervisor) stopBackends(req messaging.BackendStopRequest, stopAll bool) {
if stopAll {
xlog.Info("Serving backend.stop (all)", "force", req.Force)
s.stopAllBackends(req.Force)
return
}
xlog.Info("Serving backend.stop", "backend", req.Backend, "force", req.Force)
// The identifier may be a backend name, a model name, or an exact
// modelID#replica key depending on the caller; resolveStopTargets
// handles all three. stopBackend alone resolves only the model meanings.
for _, key := range s.resolveStopTargets(req.Backend) {
if err := s.stopBackendExact(key, req.Force); err != nil {
xlog.Error("Failed to stop backend process", "backend", req.Backend, "processKey", key, "error", err)
}
}
}
// decodeBackendStopRequest reads a backend.stop body. An EMPTY body means "stop
// everything", which is the shape the frontend uses to drain a node, so it is
// not a decode failure.
func decodeBackendStopRequest(data []byte) (messaging.BackendStopRequest, bool, error) {
if len(data) == 0 {
return messaging.BackendStopRequest{}, true, nil
}
var req messaging.BackendStopRequest
if err := json.Unmarshal(data, &req); err != nil {
return messaging.BackendStopRequest{}, false, fmt.Errorf("decoding backend stop request: %w", err)
}
return req, req.Backend == "", nil
}
// deleteBackend serves backend.delete: stop the backend's processes if running,
// then remove its files from disk.
func (s *backendSupervisor) deleteBackend(req messaging.BackendDeleteRequest) messaging.BackendDeleteReply {
xlog.Info("Serving backend.delete", "backend", req.Backend)
// Resolve the backend's identity (concrete name + alias) BEFORE touching
// the filesystem: DeleteBackendFromSystem removes the metadata.json that
// carries the alias, and a model loaded via the alias records the alias as
// its process's backend name.
identity := s.backendIdentity(req.Backend)
// Stop every process started for this backend. Processes are keyed by
// modelID#replica, so the lookup must match the recorded backend name — a
// lookup by backend name alone resolved to nothing and left the process
// running with its directory deleted underneath it.
keys := s.resolveProcessKeysForBackend(identity)
if len(keys) == 0 {
// Not an error: deleting a backend that was never loaded is routine.
// But log it — silence here is what made the orphan case invisible.
xlog.Info("Deleting backend with no matching running process",
"backend", req.Backend, "identity", slices.Sorted(maps.Keys(identity)))
}
// Accumulate the processes we actually terminate. Every stop hands a gRPC
// port back to this worker's allocator while the controller still holds a
// NodeModel row for that address, so the controller needs these keys to
// drop those rows before the port is re-bound by an unrelated backend. A
// key is appended only after its process is confirmed gone, which is what
// lets the controller trust the list on the partial-failure replies below.
stopped := make([]string, 0, len(keys))
deleteReply := func(success bool, errMsg string) messaging.BackendDeleteReply {
return messaging.BackendDeleteReply{
Success: success,
Error: errMsg,
StoppedProcessKeys: stopped,
ReportsStoppedProcesses: true,
}
}
for _, key := range keys {
if err := s.stopBackendExact(key, false); err != nil {
// We knew about this process and could not kill it. Replying
// success would repeat the original defect: the operator is told
// "backend deleted" while the process keeps serving requests.
xlog.Error("Failed to stop backend process during delete; aborting delete",
"backend", req.Backend, "processKey", key, "error", err)
return deleteReply(false, fmt.Sprintf("could not stop running process %s: %v", key, err))
}
stopped = append(stopped, key)
}
// Delete the backend files
if err := gallery.DeleteBackendFromSystem(s.systemState, req.Backend); err != nil {
xlog.Warn("Failed to delete backend files", "backend", req.Backend, "error", err)
return deleteReply(false, err.Error())
}
// Re-register backends after deletion
if err := gallery.RegisterBackends(s.systemState, s.ml); err != nil {
xlog.Error("Failed to refresh registered backends after deletion", "backend", req.Backend, "error", err)
return deleteReply(false, err.Error())
}
return deleteReply(true, "")
}
// unloadModel serves model.unload: a gRPC Free() that releases GPU memory
// without killing the backend process.
func (s *backendSupervisor) unloadModel(ctx context.Context, req messaging.ModelUnloadRequest) messaging.ModelUnloadReply {
xlog.Info("Serving model.unload")
// Find the backend address for this model's backend type
// The request includes an Address field if the router knows which process to target
targetAddr := req.Address
if targetAddr == "" {
// Fallback: try all running backends
s.mu.Lock()
for _, bp := range s.processes {
targetAddr = bp.addr
break
}
s.mu.Unlock()
}
if targetAddr == "" {
// Nothing is loaded here, so there is nothing to free. That is the
// worker's own answer and it is a true one, not a claim about work it
// performed.
return messaging.ModelUnloadReply{Success: true}
}
// Bounded gRPC Free(). A model.unload request must not occupy the handler
// forever when a backend is wedged. The bound is derived from the caller's
// own budget, so a caller that allowed less than workerBackendFreeTimeout
// is not made to wait longer than it asked for.
client := grpc.NewClientWithToken(targetAddr, false, nil, false, s.cfg.RegistrationToken)
freeCtx, cancel := context.WithTimeout(ctx, workerBackendFreeTimeout)
err := client.Free(freeCtx)
cancel()
if err != nil {
// Reported, not swallowed. This used to answer Success:true whatever
// Free did, which is the worker saying "done" about something it did
// not do: the frontend's only caller is EvictLRU, so a false yes told
// the scheduler VRAM had been released and let it place the next model
// on a node still holding the old one. A failure here is the worker's
// own verdict about one Free call and nothing more; it is not a
// statement that the node or the model is gone.
xlog.Warn("Free() failed during model.unload", "error", err, "addr", targetAddr)
return messaging.ModelUnloadReply{
Success: false,
Error: fmt.Sprintf("freeing model on %s: %v", targetAddr, err),
}
}
return messaging.ModelUnloadReply{Success: true}
}
// deleteModel serves model.delete: remove a model's staged files from disk.
func (s *backendSupervisor) deleteModel(req messaging.ModelDeleteRequest) messaging.ModelDeleteReply {
xlog.Info("Serving model.delete", "model", req.ModelName)
if err := gallery.DeleteStagedModelFiles(s.cfg.ModelsPath, req.ModelName); err != nil {
xlog.Warn("Failed to delete model files", "model", req.ModelName, "error", err)
return messaging.ModelDeleteReply{Success: false, Error: err.Error()}
}
return messaging.ModelDeleteReply{Success: true}
}
// signalNodeStop serves node.stop: it triggers the normal shutdown path via
// sigCh so deferred cleanup runs, rather than exiting the process here.
func (s *backendSupervisor) signalNodeStop() {
xlog.Info("Serving node.stop, signaling shutdown")
select {
case s.sigCh <- syscall.SIGTERM:
default:
xlog.Debug("Shutdown already signaled, ignoring duplicate stop")
}
}