mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-30 01:54:31 -04:00
A worker has no database and cannot NOTIFY, and it does not need to: every
message it sends is produced inside a handler the frontend invoked, so
there is always an open control response to write on. This adds the two
ends of that, and the authorization decision that sits between them, and
nothing that dispatches yet.
workerctl.Envelope gains Subject, a REQUEST and not an instruction. Empty
means the line is for this caller alone, which is what every pre-existing
progress line is, and omitempty keeps those lines byte-identical for an
older reader. It qualifies a progress line and never a reply line: a reply
is the worker's verdict about the work, and there is no version of
"publish my verdict for me" this control plane has to carry.
nodes.MayBroadcast is the replacement for pkg/natsauth's allow list, and
the inversion is the point. NATS read an EMPTY allow list as NO
RESTRICTION, which is why phase 3 refused to delete the backend branch and
spelled it {"_INBOX.>"}. This one reads an empty list, and an absent node
type, as DENY EVERYTHING, and a table-driven spec pins that by emptying
the agent entry and asserting all three of its subjects are then refused.
Matching goes through messaging.SubjectMatches, the one definition in the
tree, so a filter that fires here fires on the carrier.
nodes.Rebroadcaster.Handle returns a bool and never an error. A refused or
failed re-broadcast is logged and the RPC continues, because the RPC's
outcome is the worker's verdict about the work and a publish failure says
nothing about it. The return shape is asserted at compile time in the file
that states the rule, so changing it to an error does not compile.
ControlClient.CallStreaming's progress callback now takes the line's
subject alongside its raw bytes. The client no longer decodes progress at
all: what a line means is a question about the verb and whether a named
broadcast may be made is a question about the node, and it knows neither.
Both moved into installProgressBridge, which every one of the three
streaming call sites in unloader.go goes through. A line naming a subject
is dropped there rather than delivered as install progress, because
backend.install and backend.upgrade are a backend worker's verbs and a
backend worker is allowed no subjects.
agents.StreamPublisher is the producing end, a messaging.Publisher writing
NDJSON envelopes onto an in-flight control response and flushing each one,
so a tick reaches the frontend while the handler is still running.
Serialized, because two concurrent encodes on one http.ResponseWriter
interleave bytes and tear the framing.
Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
554 lines
25 KiB
Go
554 lines
25 KiB
Go
package nodes
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/services/galleryop"
|
|
"github.com/mudler/LocalAI/core/services/messaging"
|
|
"github.com/mudler/LocalAI/core/services/workerctl"
|
|
"github.com/mudler/LocalAI/pkg/model"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
// NodeCommandSender abstracts the control commands a frontend issues to a
|
|
// worker node. They travel over the worker's tunnel as HTTP under
|
|
// workerctl.Prefix; see RemoteUnloaderAdapter.
|
|
//
|
|
// InstallBackend is idempotent: the worker short-circuits if the backend is
|
|
// already running for the requested (modelID, replica) slot. Routine model
|
|
// loads and admin installs both call this.
|
|
//
|
|
// UpgradeBackend is the destructive force-reinstall path: the worker stops
|
|
// every live process for the backend, re-pulls the gallery artifact, and
|
|
// replies. Caller (DistributedBackendManager.UpgradeBackend) handles
|
|
// rolling-update fallback to the legacy install Force=true path on
|
|
// ErrWorkerControlUnsupported, which is what a worker older than the
|
|
// backend.upgrade verb answers.
|
|
type NodeCommandSender interface {
|
|
InstallBackend(nodeID, backendType, modelID, galleriesJSON, uri, name, alias string, replicaIndex int, opID string, onProgress func(messaging.BackendInstallProgressEvent)) (*messaging.BackendInstallReply, error)
|
|
UpgradeBackend(nodeID, backendType, galleriesJSON, uri, name, alias string, replicaIndex int, opID string, onProgress func(messaging.BackendInstallProgressEvent)) (*messaging.BackendUpgradeReply, error)
|
|
DeleteBackend(nodeID, backendName string) (*messaging.BackendDeleteReply, error)
|
|
ListBackends(nodeID string) (*messaging.BackendListReply, error)
|
|
StopBackend(nodeID, backend string) error
|
|
UnloadModelOnNode(nodeID, modelName string) error
|
|
}
|
|
|
|
// RemoteUnloaderAdapter implements NodeCommandSender and
|
|
// model.RemoteModelUnloader by issuing control RPCs to the worker over its
|
|
// tunnel. The worker serves them on the loopback HTTP server it already runs
|
|
// (see core/services/worker/control_routes.go) and handles the actual process
|
|
// start/stop.
|
|
//
|
|
// This mirrors the local ModelLoader's startProcess()/deleteProcess() but for
|
|
// remote nodes.
|
|
//
|
|
// Every verb it sends, for every kind of worker, is a control RPC. It holds no
|
|
// publisher at all, which is why re-routing any of them back onto the bus is a
|
|
// change to this struct and to every caller of the constructor rather than to
|
|
// one branch.
|
|
type RemoteUnloaderAdapter struct {
|
|
registry ModelLocator
|
|
control *ControlClient
|
|
installTimeout time.Duration
|
|
upgradeTimeout time.Duration
|
|
}
|
|
|
|
// NewRemoteUnloaderAdapter creates a new adapter. control carries every verb.
|
|
// installTimeout and upgradeTimeout bound the backend.install and
|
|
// backend.upgrade RPCs respectively; use
|
|
// DistributedConfig.BackendInstallTimeoutOrDefault() /
|
|
// BackendUpgradeTimeoutOrDefault() at construction.
|
|
func NewRemoteUnloaderAdapter(registry ModelLocator, control *ControlClient, installTimeout, upgradeTimeout time.Duration) *RemoteUnloaderAdapter {
|
|
return &RemoteUnloaderAdapter{
|
|
registry: registry,
|
|
control: control,
|
|
installTimeout: installTimeout,
|
|
upgradeTimeout: upgradeTimeout,
|
|
}
|
|
}
|
|
|
|
// InstallTimeout returns the configured backend.install round-trip timeout.
|
|
// Used by DistributedBackendManager to push NextRetryAt out by this duration
|
|
// when a worker times out replying but is still installing in the background.
|
|
func (a *RemoteUnloaderAdapter) InstallTimeout() time.Duration {
|
|
return a.installTimeout
|
|
}
|
|
|
|
// Compile-time proof that the adapter still satisfies the loader's optional
|
|
// extensions. Both are consumed via runtime type assertion in deleteProcess, so
|
|
// a signature drift here would silently downgrade behavior — losing force
|
|
// propagation, or making ShutdownModel unable to tell a cluster-wide miss from
|
|
// a completed unload — rather than failing the build.
|
|
var (
|
|
_ model.RemoteModelUnloader = (*RemoteUnloaderAdapter)(nil)
|
|
_ model.RemoteModelContextUnloader = (*RemoteUnloaderAdapter)(nil)
|
|
_ model.RemoteModelPresenceChecker = (*RemoteUnloaderAdapter)(nil)
|
|
_ ExactModelStopper = (*RemoteUnloaderAdapter)(nil)
|
|
)
|
|
|
|
const exactModelStopTimeout = 10 * time.Second
|
|
|
|
// StopModelReplica stops only the process represented by replica. Configuration
|
|
// cleanup intentionally has no backend.stop fallback: an old worker that does
|
|
// not understand this request leaves the quarantine row for a later retry.
|
|
//
|
|
// The caller's context is carried into the RPC rather than run alongside it on
|
|
// a goroutine, which is what the request/reply carrier needed because it took a
|
|
// timeout and not a context. Abandoning the request is safe: the worker's
|
|
// model.stop handler deliberately drops the caller's context and runs the stop
|
|
// to completion, so a frontend that gives up cannot leave a half-stopped
|
|
// process or an unreturned port behind.
|
|
func (a *RemoteUnloaderAdapter) StopModelReplica(ctx context.Context, nodeID string, replica NodeModel, force bool) (messaging.ModelStopReply, error) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, exactModelStopTimeout)
|
|
defer cancel()
|
|
|
|
var reply messaging.ModelStopReply
|
|
err := a.control.Call(ctx, nodeID, workerctl.PathModelStop, messaging.ModelStopRequest{
|
|
ModelName: replica.ModelName,
|
|
ProcessKey: model.BackendProcessKey(replica.ModelName, replica.ReplicaIndex),
|
|
ExpectedAddress: replica.WorkerLocalAddress,
|
|
Force: force,
|
|
ConfigRevision: replica.ConfigRevision,
|
|
}, &reply)
|
|
if err != nil {
|
|
return messaging.ModelStopReply{}, err
|
|
}
|
|
return reply, nil
|
|
}
|
|
|
|
// UnloadRemoteModel finds the node(s) hosting the given model and tells each
|
|
// to stop its backend process, over that node's own tunnel whatever kind of
|
|
// worker it is.
|
|
// The worker process handles a bounded Free() followed by process termination;
|
|
// forced shutdown skips Free().
|
|
// This is called by ModelLoader.deleteProcess() when process == nil (remote model).
|
|
func (a *RemoteUnloaderAdapter) UnloadRemoteModel(modelName string) error {
|
|
return a.UnloadRemoteModelContext(context.Background(), modelName, false)
|
|
}
|
|
|
|
// HasRemoteModel reports whether any node currently holds the model. It exists
|
|
// because UnloadRemoteModel is idempotent and so cannot signal "there was
|
|
// nothing to stop"; ShutdownModel consults this first so it can answer 404 for
|
|
// a model loaded neither locally nor anywhere in the cluster, instead of the
|
|
// misleading 500 "model not found" that a local-store miss used to produce.
|
|
func (a *RemoteUnloaderAdapter) HasRemoteModel(ctx context.Context, modelName string) (bool, error) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
nodes, err := a.registry.FindNodesWithModel(ctx, modelName)
|
|
if err != nil {
|
|
return false, fmt.Errorf("finding nodes with model %q: %w", modelName, err)
|
|
}
|
|
return len(nodes) > 0, nil
|
|
}
|
|
|
|
// UnloadRemoteModelContext is the cancellation-aware extension used by the
|
|
// model loader to preserve forced shutdown across the distributed boundary.
|
|
func (a *RemoteUnloaderAdapter) UnloadRemoteModelContext(ctx context.Context, modelName string, force bool) error {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
nodes, err := a.registry.FindNodesWithModel(ctx, modelName)
|
|
if err != nil {
|
|
return fmt.Errorf("finding nodes with model %q: %w", modelName, err)
|
|
}
|
|
if len(nodes) == 0 {
|
|
// Unloading is idempotent by contract: cleanup paths (model deletion,
|
|
// config edits, watchdog eviction) legitimately run against an
|
|
// already-unloaded model and must not fail. Callers that need to tell
|
|
// this case apart use HasRemoteModel before unloading.
|
|
xlog.Debug("No remote nodes found with model", "model", modelName)
|
|
return nil
|
|
}
|
|
|
|
var unloadErr error
|
|
seenNodeIDs := make(map[string]struct{}, len(nodes))
|
|
for _, node := range nodes {
|
|
if _, seen := seenNodeIDs[node.ID]; seen {
|
|
continue
|
|
}
|
|
seenNodeIDs[node.ID] = struct{}{}
|
|
xlog.Info("Sending backend.stop to node", "model", modelName, "node", node.Name, "nodeID", node.ID, "force", force)
|
|
if err := a.stopBackend(ctx, node.ID, modelName, force); err != nil {
|
|
xlog.Warn("Failed to send backend.stop", "node", node.Name, "error", err)
|
|
unloadErr = errors.Join(unloadErr, fmt.Errorf("stopping model on node %s: %w", node.ID, err))
|
|
continue
|
|
}
|
|
// Remove every replica of this model on the node — the worker will
|
|
// handle the actual process cleanup.
|
|
if err := a.registry.RemoveAllNodeModelReplicas(ctx, node.ID, modelName); err != nil {
|
|
unloadErr = errors.Join(unloadErr, fmt.Errorf("removing model replicas from node %s: %w", node.ID, err))
|
|
}
|
|
}
|
|
|
|
return unloadErr
|
|
}
|
|
|
|
// installProgressBridge adapts an install or upgrade's progress sink to
|
|
// CallStreaming's line callback, which carries a subject the client does not
|
|
// interpret.
|
|
//
|
|
// It makes two decisions a caller of InstallBackend must not have to make.
|
|
//
|
|
// A line naming a SUBJECT is a re-broadcast request and not install progress,
|
|
// so it is dropped here rather than delivered as a tick. backend.install and
|
|
// backend.upgrade are a BACKEND worker's verbs, and MayBroadcast denies a
|
|
// backend worker every subject, so this path has nothing to publish and no
|
|
// broadcaster to publish it on. Delivering it to onProgress instead would put a
|
|
// worker's arbitrary JSON through a decode into an install-progress event and
|
|
// report whatever fell out as the state of a download.
|
|
//
|
|
// A line this frontend cannot decode costs a tick and never the operation,
|
|
// because progress is transient by contract while the reply is the worker's
|
|
// verdict.
|
|
//
|
|
// It returns nil for a nil sink so CallStreaming keeps its "no callback, no
|
|
// work" path, rather than a non-nil closure wrapping a nil function.
|
|
func installProgressBridge(nodeID, path string, onProgress func(messaging.BackendInstallProgressEvent)) func(string, json.RawMessage) {
|
|
if onProgress == nil {
|
|
return nil
|
|
}
|
|
return func(subject string, raw json.RawMessage) {
|
|
if subject != "" {
|
|
xlog.Warn("refusing a re-broadcast request on a backend control stream",
|
|
"node", nodeID, "path", path, "subject", subject)
|
|
return
|
|
}
|
|
var ev messaging.BackendInstallProgressEvent
|
|
if err := json.Unmarshal(raw, &ev); err != nil {
|
|
xlog.Debug("unreadable control progress line", "node", nodeID, "path", path, "error", err)
|
|
return
|
|
}
|
|
onProgress(ev)
|
|
}
|
|
}
|
|
|
|
// InstallBackend asks a worker node to install a backend and start its process.
|
|
// Idempotent on the worker: if the (modelID, replica) process is already
|
|
// running, the worker short-circuits and returns its address; if the binary
|
|
// is on disk, the worker just spawns a process; only a missing binary
|
|
// triggers a full gallery pull.
|
|
//
|
|
// Timeout: configured via DistributedConfig.BackendInstallTimeoutOrDefault
|
|
// (default 15m). Most calls return in under 2 seconds (process already
|
|
// running). The 15-minute ceiling covers the cold-binary spawn-after-download
|
|
// case on slow links (Jetson Wi-Fi, multi-GB CUDA images) while still
|
|
// failing fast enough to surface real worker hangs.
|
|
//
|
|
// Progress needs no subscription and no window to miss events in: the worker
|
|
// writes its download ticks into THIS response ahead of the terminal reply, so
|
|
// there is nothing to arrange before the request is sent.
|
|
//
|
|
// For force-reinstall (admin-driven Upgrade), use UpgradeBackend instead.
|
|
func (a *RemoteUnloaderAdapter) InstallBackend(
|
|
nodeID, backendType, modelID, galleriesJSON, uri, name, alias string,
|
|
replicaIndex int,
|
|
opID string,
|
|
onProgress func(messaging.BackendInstallProgressEvent),
|
|
) (*messaging.BackendInstallReply, error) {
|
|
xlog.Info("Sending backend.install", "nodeID", nodeID, "backend", backendType, "modelID", modelID, "replica", replicaIndex, "opID", opID)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), a.installTimeout)
|
|
defer cancel()
|
|
|
|
var reply messaging.BackendInstallReply
|
|
err := a.control.CallStreaming(ctx, nodeID, workerctl.PathBackendInstall, messaging.BackendInstallRequest{
|
|
Backend: backendType,
|
|
ModelID: modelID,
|
|
BackendGalleries: galleriesJSON,
|
|
URI: uri,
|
|
Name: name,
|
|
Alias: alias,
|
|
ReplicaIndex: int32(replicaIndex),
|
|
OpID: opID,
|
|
}, &reply, installProgressBridge(nodeID, workerctl.PathBackendInstall, onProgress))
|
|
if err != nil {
|
|
if isRequestTimeout(err) {
|
|
return nil, fmt.Errorf("%w (nodeID=%s backend=%s): %v",
|
|
galleryop.ErrWorkerStillInstalling, nodeID, backendType, err)
|
|
}
|
|
return nil, err
|
|
}
|
|
return &reply, nil
|
|
}
|
|
|
|
// UpgradeBackend asks a worker node to force-reinstall a backend.
|
|
// The worker stops every live process for this backend, force-reinstalls
|
|
// from the gallery (overwriting the on-disk artifact), and replies. The
|
|
// next routine InstallBackend call spawns a fresh process with the new
|
|
// binary - upgrade itself does not start a process.
|
|
//
|
|
// Timeout: configured via DistributedConfig.BackendUpgradeTimeoutOrDefault
|
|
// (default 15m). Real-world worst case observed: 8-10 minutes for large
|
|
// CUDA-l4t backend images on Jetson over WiFi.
|
|
func (a *RemoteUnloaderAdapter) UpgradeBackend(nodeID, backendType, galleriesJSON, uri, name, alias string, replicaIndex int, opID string, onProgress func(messaging.BackendInstallProgressEvent)) (*messaging.BackendUpgradeReply, error) {
|
|
xlog.Info("Sending backend.upgrade", "nodeID", nodeID, "backend", backendType, "replica", replicaIndex, "opID", opID)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), a.upgradeTimeout)
|
|
defer cancel()
|
|
|
|
var reply messaging.BackendUpgradeReply
|
|
err := a.control.CallStreaming(ctx, nodeID, workerctl.PathBackendUpgrade, messaging.BackendUpgradeRequest{
|
|
Backend: backendType,
|
|
BackendGalleries: galleriesJSON,
|
|
URI: uri,
|
|
Name: name,
|
|
Alias: alias,
|
|
ReplicaIndex: int32(replicaIndex),
|
|
OpID: opID,
|
|
}, &reply, installProgressBridge(nodeID, workerctl.PathBackendUpgrade, onProgress))
|
|
if err != nil {
|
|
if isRequestTimeout(err) {
|
|
return nil, fmt.Errorf("%w (nodeID=%s backend=%s): %v",
|
|
galleryop.ErrWorkerStillInstalling, nodeID, backendType, err)
|
|
}
|
|
return nil, err
|
|
}
|
|
a.dropStoppedReplicaRows(nodeID, "backend.upgrade", backendType, reply.StoppedProcessKeys, reply.ReportsStoppedProcesses)
|
|
return &reply, nil
|
|
}
|
|
|
|
// installWithForceFallback is the rolling-update fallback used by
|
|
// DistributedBackendManager.UpgradeBackend when backend.upgrade reports that
|
|
// the worker does not serve that verb (a pre-2026-05-08 build). It re-fires
|
|
// the legacy backend.install with Force=true. Drop this once every worker is
|
|
// on 2026-05-08 or newer.
|
|
func (a *RemoteUnloaderAdapter) installWithForceFallback(nodeID, backendType, galleriesJSON, uri, name, alias string, replicaIndex int, opID string, onProgress func(messaging.BackendInstallProgressEvent)) (*messaging.BackendInstallReply, error) {
|
|
xlog.Warn("Falling back to legacy backend.install Force=true (old worker)", "nodeID", nodeID, "backend", backendType)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), a.upgradeTimeout)
|
|
defer cancel()
|
|
|
|
var reply messaging.BackendInstallReply
|
|
err := a.control.CallStreaming(ctx, nodeID, workerctl.PathBackendInstall, messaging.BackendInstallRequest{
|
|
Backend: backendType,
|
|
BackendGalleries: galleriesJSON,
|
|
URI: uri,
|
|
Name: name,
|
|
Alias: alias,
|
|
ReplicaIndex: int32(replicaIndex),
|
|
Force: true,
|
|
OpID: opID,
|
|
}, &reply, installProgressBridge(nodeID, workerctl.PathBackendInstall, onProgress))
|
|
if err != nil {
|
|
if isRequestTimeout(err) {
|
|
return nil, fmt.Errorf("%w (nodeID=%s backend=%s): %v",
|
|
galleryop.ErrWorkerStillInstalling, nodeID, backendType, err)
|
|
}
|
|
return nil, err
|
|
}
|
|
return &reply, nil
|
|
}
|
|
|
|
// Control-RPC budgets. Each is the deadline the corresponding NATS
|
|
// request/reply carried, kept unchanged so this cutover changes the carrier and
|
|
// not how long the frontend waits.
|
|
const (
|
|
backendListTimeout = 30 * time.Second
|
|
modelsRunningTimeout = 10 * time.Second
|
|
backendStopTimeout = 30 * time.Second
|
|
backendDeleteTimeout = 2 * time.Minute
|
|
modelUnloadTimeout = 30 * time.Second
|
|
modelDeleteTimeout = 30 * time.Second
|
|
nodeStopTimeout = 30 * time.Second
|
|
)
|
|
|
|
// ListBackends queries a worker node for its installed backends.
|
|
func (a *RemoteUnloaderAdapter) ListBackends(nodeID string) (*messaging.BackendListReply, error) {
|
|
xlog.Debug("Sending backend.list", "nodeID", nodeID)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), backendListTimeout)
|
|
defer cancel()
|
|
|
|
var reply messaging.BackendListReply
|
|
if err := a.control.Call(ctx, nodeID, workerctl.PathBackendList, messaging.BackendListRequest{}, &reply); err != nil {
|
|
return nil, err
|
|
}
|
|
return &reply, nil
|
|
}
|
|
|
|
// ListRunningModels asks a worker node which model backend processes it
|
|
// currently has running.
|
|
//
|
|
// The timeout is short on purpose: the worker answers straight out of its
|
|
// in-memory process table, so a slow reply means the worker itself is in
|
|
// trouble, and the caller treats no-answer as "don't know" rather than as
|
|
// "nothing running".
|
|
func (a *RemoteUnloaderAdapter) ListRunningModels(nodeID string) (*messaging.ModelsRunningReply, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), modelsRunningTimeout)
|
|
defer cancel()
|
|
|
|
var reply messaging.ModelsRunningReply
|
|
if err := a.control.Call(ctx, nodeID, workerctl.PathModelsRunning, messaging.ModelsRunningRequest{}, &reply); err != nil {
|
|
return nil, err
|
|
}
|
|
return &reply, nil
|
|
}
|
|
|
|
// StopBackend tells a worker node to stop a specific gRPC backend process.
|
|
// If backend is empty, the worker stops ALL backends.
|
|
// The node stays registered and can receive another InstallBackend later.
|
|
func (a *RemoteUnloaderAdapter) StopBackend(nodeID, backend string) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), backendStopTimeout)
|
|
defer cancel()
|
|
return a.stopBackend(ctx, nodeID, backend, false)
|
|
}
|
|
|
|
// stopBackend sends one backend.stop over the node's own tunnel.
|
|
//
|
|
// It does not ask what KIND of worker the node is, and there is nothing left
|
|
// for the answer to change. A backend worker kills the process and recycles
|
|
// the port; an agent worker runs no backend processes and closes the MCP
|
|
// sessions it cached for that backend. Both serve the verb on the same control
|
|
// path, so the frontend states the fact and the worker decides what it means.
|
|
func (a *RemoteUnloaderAdapter) stopBackend(ctx context.Context, nodeID, backend string, force bool) error {
|
|
// An empty Backend is what the worker reads as "stop everything"; see
|
|
// decodeBackendStopRequest.
|
|
var reply messaging.BackendStopReply
|
|
if err := a.control.Call(ctx, nodeID, workerctl.PathBackendStop,
|
|
messaging.BackendStopRequest{Backend: backend, Force: force}, &reply); err != nil {
|
|
return err
|
|
}
|
|
if !reply.Success {
|
|
return fmt.Errorf("backend.stop on node %s: %s", nodeID, reply.Error)
|
|
}
|
|
if reply.ReportsStoppedProcesses && len(reply.StoppedProcessKeys) == 0 {
|
|
xlog.Warn("backend.stop matched no running process on the worker", "nodeID", nodeID, "backend", backend)
|
|
return nil
|
|
}
|
|
xlog.Info("Worker stopped backend processes", "nodeID", nodeID, "backend", backend, "stopped", reply.StoppedProcessKeys)
|
|
return nil
|
|
}
|
|
|
|
// DeleteBackend tells a worker node to delete a backend (stop + remove files).
|
|
func (a *RemoteUnloaderAdapter) DeleteBackend(nodeID, backendName string) (*messaging.BackendDeleteReply, error) {
|
|
xlog.Info("Sending backend.delete", "nodeID", nodeID, "backend", backendName)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), backendDeleteTimeout)
|
|
defer cancel()
|
|
|
|
var reply messaging.BackendDeleteReply
|
|
if err := a.control.Call(ctx, nodeID, workerctl.PathBackendDelete, messaging.BackendDeleteRequest{Backend: backendName}, &reply); err != nil {
|
|
return nil, err
|
|
}
|
|
a.dropStoppedReplicaRows(nodeID, "backend.delete", backendName, reply.StoppedProcessKeys, reply.ReportsStoppedProcesses)
|
|
return &reply, nil
|
|
}
|
|
|
|
// dropStoppedReplicaRows removes the NodeModel rows addressing processes a
|
|
// worker just terminated.
|
|
//
|
|
// Why eagerly, rather than leaving it to the existing health checks: stopping a
|
|
// process returns its gRPC port to the worker's allocator, and the next backend
|
|
// started there can be handed that same port. Until the row is gone it names a
|
|
// live address, so both SmartRouter.probeHealth and the HealthMonitor per-model
|
|
// probe — which verify liveness, not identity — pass against whatever now
|
|
// occupies the port, and the request is served by the wrong backend rather than
|
|
// failing. Nothing else on the delete/upgrade path tells the controller the
|
|
// address just became invalid, unlike model.unload which drops its rows itself.
|
|
//
|
|
// reported=false means the worker predates this reply field. Its empty list is
|
|
// then indistinguishable from "stopped nothing", so it must NOT be read as a
|
|
// completed cleanup: leave the rows alone and fall back to the probe-based
|
|
// staleness recovery that was the only mechanism before this change.
|
|
func (a *RemoteUnloaderAdapter) dropStoppedReplicaRows(nodeID, op, backendName string, processKeys []string, reported bool) {
|
|
if !reported {
|
|
xlog.Debug("Worker did not report stopped processes; relying on probe-based staleness recovery",
|
|
"nodeID", nodeID, "op", op, "backend", backendName)
|
|
return
|
|
}
|
|
ctx := context.Background()
|
|
for _, key := range processKeys {
|
|
modelName, replicaIndex, ok := model.ParseBackendProcessKey(key)
|
|
if !ok {
|
|
// Acting on a guess could evict the row of a healthy sibling replica.
|
|
xlog.Warn("Ignoring unparseable process key reported by worker",
|
|
"nodeID", nodeID, "op", op, "backend", backendName, "processKey", key)
|
|
continue
|
|
}
|
|
xlog.Info("Dropping replica row for a process the worker stopped",
|
|
"nodeID", nodeID, "op", op, "backend", backendName, "model", modelName, "replica", replicaIndex)
|
|
if err := a.registry.RemoveNodeModel(ctx, nodeID, modelName, replicaIndex); err != nil {
|
|
// Best-effort: probe-based recovery remains the backstop, and failing
|
|
// the operator's delete over a bookkeeping error would be worse than
|
|
// the stale row this prevents.
|
|
xlog.Warn("Failed to drop replica row for a stopped process",
|
|
"nodeID", nodeID, "op", op, "model", modelName, "replica", replicaIndex, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// UnloadModelOnNode sends a model.unload request to a specific node.
|
|
// The worker calls gRPC Free() to release GPU memory.
|
|
func (a *RemoteUnloaderAdapter) UnloadModelOnNode(nodeID, modelName string) error {
|
|
xlog.Info("Sending model.unload", "nodeID", nodeID, "model", modelName)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), modelUnloadTimeout)
|
|
defer cancel()
|
|
|
|
var reply messaging.ModelUnloadReply
|
|
if err := a.control.Call(ctx, nodeID, workerctl.PathModelUnload, messaging.ModelUnloadRequest{ModelName: modelName}, &reply); err != nil {
|
|
return err
|
|
}
|
|
if !reply.Success {
|
|
return fmt.Errorf("model.unload on node %s: %s", nodeID, reply.Error)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteModelFiles sends model.delete to all nodes that have the model cached.
|
|
// This removes model files from worker disks.
|
|
func (a *RemoteUnloaderAdapter) DeleteModelFiles(modelName string) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), modelDeleteTimeout)
|
|
defer cancel()
|
|
|
|
nodes, err := a.registry.FindNodesWithModel(ctx, modelName)
|
|
if err != nil || len(nodes) == 0 {
|
|
xlog.Debug("No nodes with model for file deletion", "model", modelName)
|
|
return nil
|
|
}
|
|
|
|
for _, node := range nodes {
|
|
xlog.Info("Sending model.delete", "nodeID", node.ID, "model", modelName)
|
|
|
|
var reply messaging.ModelDeleteReply
|
|
if err := a.control.Call(ctx, node.ID, workerctl.PathModelDelete, messaging.ModelDeleteRequest{ModelName: modelName}, &reply); err != nil {
|
|
xlog.Warn("model.delete failed on node", "node", node.Name, "error", err)
|
|
continue
|
|
}
|
|
if !reply.Success {
|
|
xlog.Warn("model.delete failed on node", "node", node.Name, "error", reply.Error)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// StopNode tells a worker node to shut down entirely (deregister + exit).
|
|
func (a *RemoteUnloaderAdapter) StopNode(nodeID string) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), nodeStopTimeout)
|
|
defer cancel()
|
|
return a.control.Call(ctx, nodeID, workerctl.PathNodeStop, struct{}{}, nil)
|
|
}
|
|
|
|
// isRequestTimeout reports whether a control RPC ended because its budget ran
|
|
// out rather than because the worker said anything.
|
|
//
|
|
// context.DeadlineExceeded is the ONE signal, and it is matchable because
|
|
// controlFailure wraps the caller's own ctx.Err(). The bus sentinel it used to
|
|
// accept alongside is gone with the bus: every verb this adapter sends now
|
|
// travels over the worker's tunnel, so a nats.ErrTimeout could only arrive from
|
|
// a carrier nothing here uses. The string match that carrier needed is
|
|
// deliberately not reproduced either, in any spelling: a message that merely
|
|
// quotes a timeout is not one, and a worker error that happened to contain the
|
|
// phrase would be reported as still-installing forever.
|
|
func isRequestTimeout(err error) bool {
|
|
return errors.Is(err, context.DeadlineExceeded)
|
|
}
|