mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-10 07:47:29 -04:00
Fence model load jobs by generation, lease them on the database clock, bound the work on the worker with operations and a process-group watchdog, and add one stop path with a load-cancel API. See the pull request for the design, the rolling upgrade notes and the test evidence. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
263 lines
14 KiB
Go
263 lines
14 KiB
Go
package nodes
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/services/workerctl"
|
|
grpc "github.com/mudler/LocalAI/pkg/grpc"
|
|
)
|
|
|
|
type ExactModelStopper interface {
|
|
StopModelReplica(ctx context.Context, nodeID string, replica NodeModel, force bool) (workerctl.ModelStopReply, error)
|
|
}
|
|
|
|
type ModelCleanupRegistry interface {
|
|
ClaimModelCleanupRetries(ctx context.Context, now, leaseUntil time.Time, limit int) ([]NodeModel, error)
|
|
RecordModelCleanupFailure(ctx context.Context, nodeID, modelName string, replicaIndex int, cleanupErr string, nextRetry time.Time) error
|
|
RemoveClaimedModelCleanup(ctx context.Context, replica NodeModel) (bool, error)
|
|
}
|
|
|
|
// ModelRouter is used by SmartRouter for routing decisions and model lifecycle.
|
|
type ModelRouter interface {
|
|
FindAndLockNodeWithModel(ctx context.Context, modelName string, candidateNodeIDs []string, pref *RoutePreference) (*BackendNode, *NodeModel, error)
|
|
DecrementInFlight(ctx context.Context, nodeID, modelName string, replicaIndex int) error
|
|
IncrementInFlight(ctx context.Context, nodeID, modelName string, replicaIndex int) error
|
|
RemoveNodeModel(ctx context.Context, nodeID, modelName string, replicaIndex int) error
|
|
RemoveAllNodeModelReplicas(ctx context.Context, nodeID, modelName string) error
|
|
TouchNodeModel(ctx context.Context, nodeID, modelName string, replicaIndex int)
|
|
SetNodeModel(ctx context.Context, nodeID, modelName string, replicaIndex int, state, address string, initialInFlight int) error
|
|
SetNodeModelRevision(ctx context.Context, nodeID, modelName string, replicaIndex int, state, address string, initialInFlight int, revision, effectiveOptionsHash string) error
|
|
SetNodeModelLoadInfo(ctx context.Context, nodeID, modelName string, replicaIndex int, backendType string, optsBlob []byte) error
|
|
SetNodeModelLoadInfoRevision(ctx context.Context, nodeID, modelName string, replicaIndex int, backendType, revision string, optsBlob []byte) error
|
|
UpsertModelLoadInfo(ctx context.Context, modelName, backendType string, optsBlob []byte) error
|
|
UpsertModelLoadInfoRevision(ctx context.Context, modelName, backendType, revision string, optsBlob []byte) error
|
|
GetModelLoadInfo(ctx context.Context, modelName string) (backendType string, optsBlob []byte, err error)
|
|
GetModelLoadInfoRevision(ctx context.Context, modelName string) (backendType, revision string, optsBlob []byte, err error)
|
|
AdvanceModelConfigRevision(ctx context.Context, modelName, revision string) ([]NodeModel, error)
|
|
EstablishModelConfigRevision(ctx context.Context, modelName, revision string) error
|
|
GetModelConfigRevision(ctx context.Context, modelName string) (string, error)
|
|
GetNodeModel(ctx context.Context, nodeID, modelName string, replicaIndex int) (*NodeModel, error)
|
|
RecordModelCleanupFailure(ctx context.Context, nodeID, modelName string, replicaIndex int, cleanupErr string, nextRetry time.Time) error
|
|
ListModelCleanupRetries(ctx context.Context, now time.Time, limit int) ([]NodeModel, error)
|
|
NextFreeReplicaIndex(ctx context.Context, nodeID, modelName string, maxSlots int) (int, error)
|
|
CountReplicasOnNode(ctx context.Context, nodeID, modelName string) (int, error)
|
|
FindNodeWithVRAM(ctx context.Context, minBytes uint64) (*BackendNode, error)
|
|
FindIdleNode(ctx context.Context) (*BackendNode, error)
|
|
FindLeastLoadedNode(ctx context.Context) (*BackendNode, error)
|
|
FindGlobalLRUModelWithZeroInFlight(ctx context.Context) (*NodeModel, error)
|
|
FindLRUModel(ctx context.Context, nodeID string, excludeModels []string) (*NodeModel, error)
|
|
Get(ctx context.Context, nodeID string) (*BackendNode, error)
|
|
GetModelScheduling(ctx context.Context, modelName string) (*ModelSchedulingConfig, error)
|
|
GetGoverningScheduling(ctx context.Context, modelName string) (*ModelSchedulingConfig, error)
|
|
FindNodesBySelector(ctx context.Context, selector map[string]string) ([]BackendNode, error)
|
|
FindNodesWithFreeSlot(ctx context.Context, modelName string, candidateNodeIDs []string) ([]BackendNode, error)
|
|
NarrowByDiskHeadroom(ctx context.Context, candidateNodeIDs []string, required uint64) ([]string, error)
|
|
ReserveVRAM(ctx context.Context, nodeID string, bytes uint64) error
|
|
ReleaseVRAM(ctx context.Context, nodeID string, bytes uint64) error
|
|
FindNodeWithVRAMFromSet(ctx context.Context, minBytes uint64, nodeIDs []string) (*BackendNode, error)
|
|
FindIdleNodeFromSet(ctx context.Context, nodeIDs []string) (*BackendNode, error)
|
|
FindLeastLoadedNodeFromSet(ctx context.Context, nodeIDs []string) (*BackendNode, error)
|
|
GetNodeLabels(ctx context.Context, nodeID string) ([]NodeLabel, error)
|
|
FindNodesWithModel(ctx context.Context, modelName string) ([]BackendNode, error)
|
|
LoadedReplicaStats(ctx context.Context, modelName string, candidateNodeIDs []string) ([]ReplicaCandidate, error)
|
|
MarkUnhealthy(ctx context.Context, nodeID string) error
|
|
LoadJobStore
|
|
}
|
|
|
|
// LoadJobStore is the durable cold-load job record SmartRouter uses to
|
|
// de-duplicate concurrent loaders across replicas without holding the per-model
|
|
// advisory lock for the whole load. See ModelLoadJob.
|
|
type LoadJobStore interface {
|
|
ClaimLoadJob(ctx context.Context, trackingKey, owner string) (*ModelLoadJob, bool, error)
|
|
GetLoadJob(ctx context.Context, trackingKey string) (*ModelLoadJob, error)
|
|
UpdateLoadJob(ctx context.Context, ref LoadJobRef, u LoadJobUpdate) error
|
|
FailLoadJob(ctx context.Context, ref LoadJobRef, msg string, workMayRun bool) error
|
|
DeleteLoadJob(ctx context.Context, ref LoadJobRef) error
|
|
DeleteFailedLoadJob(ctx context.Context, ref LoadJobRef) error
|
|
ConfirmLoadOp(ctx context.Context, ref LoadJobRef) error
|
|
}
|
|
|
|
// ReplicaUnloader unloads one replica by the address its row recorded. The
|
|
// eviction and scale-down paths use it: they delete the row first, so the
|
|
// address must travel with the call.
|
|
type ReplicaUnloader interface {
|
|
UnloadReplica(nodeID string, replica NodeModel) error
|
|
}
|
|
|
|
// LoadOperationInstaller starts a backend as a load operation the worker bounds.
|
|
type LoadOperationInstaller interface {
|
|
InstallBackendOp(nodeID, backendType, modelID, galleriesJSON string, replicaIndex int, opID, operationID string, deadline time.Duration, onProgress func(workerctl.BackendInstallProgressEvent)) (*workerctl.BackendInstallReply, error)
|
|
}
|
|
|
|
// LoadOperationStopper is the only path that kills remote load work. It stops
|
|
// one operation by id and never "any running backend".
|
|
type LoadOperationStopper interface {
|
|
StopLoadOperation(ctx context.Context, nodeID string, req workerctl.ModelStopRequest) (workerctl.ModelStopReply, error)
|
|
}
|
|
|
|
// LoadOperationRenewer renews and completes load operations on a worker node.
|
|
type LoadOperationRenewer interface {
|
|
OperationControl(nodeID string, req workerctl.OperationRequest) (*workerctl.OperationReply, error)
|
|
}
|
|
|
|
// LoadAttemptStopper is what stopping a failed or cancelled attempt needs: the
|
|
// operation stop for a worker that names operations, and the exact-address stop
|
|
// for a worker that does not.
|
|
type LoadAttemptStopper interface {
|
|
LoadOperationStopper
|
|
ExactModelStopper
|
|
}
|
|
|
|
// LoadOperationControl is the part of a carrier that bounds, renews and stops
|
|
// remote load work. Every carrier of NodeCommandSender must provide it; there
|
|
// is no degraded mode in which a sender silently lacks it. A worker that cannot
|
|
// name operations is a fact about that worker, reported by its install reply
|
|
// (BackendInstallReply.ReportsOperations), and is handled per node.
|
|
//
|
|
// What a carrier owes the callers:
|
|
//
|
|
// - Every method is a request and a reply with its own timeout. A call that
|
|
// gets no reply in time returns an error. It must never block for longer
|
|
// than its documented bound, because the owner loop and the reconciler call
|
|
// these from timers.
|
|
//
|
|
// - OperationControl carries renewals and completions. Callers send a renewal
|
|
// every 5 seconds for each running load. The worker kills an operation that
|
|
// goes 90 seconds without one, so a carrier must deliver a renewal within
|
|
// one cadence (5 s, bounded by the 5 s call timeout) or return an error
|
|
// promptly. A lost renewal costs nothing until the kill TTL. A completion is
|
|
// retried by the caller and its loss is not fatal.
|
|
//
|
|
// - StopLoadOperation is idempotent. It addresses one operation by operation
|
|
// id, process key, process instance and address, and the worker refuses
|
|
// unless the ones given match its own records. A repeat after a stop
|
|
// returns a reply with Terminated set, not an error. A reply with Error set
|
|
// is the worker's refusal: the worker is present and said no.
|
|
//
|
|
// - InstallBackendOp is InstallBackend that names the operation and its
|
|
// longest run as a duration. The deadline is relative so clocks do not
|
|
// matter.
|
|
//
|
|
// - UnloadReplica names the replica's address. With no address nothing is
|
|
// sent: a carrier must never ask a worker to pick a process.
|
|
//
|
|
// - Errors keep the four conditions apart (see .agents/distributed-seams.md).
|
|
// ErrNoRoute is a routing fact only: no route from here right now. A
|
|
// timeout is not ErrNoRoute. An unreachable peer is not a verdict about the
|
|
// worker. A worker's own answer, including a refusal, is a nil error with
|
|
// the refusal in the reply. Only the carrier maps its own sentinel onto
|
|
// ErrNoRoute.
|
|
type LoadOperationControl interface {
|
|
LoadOperationInstaller
|
|
LoadOperationStopper
|
|
LoadOperationRenewer
|
|
ReplicaUnloader
|
|
ExactModelStopper
|
|
}
|
|
|
|
// ConcurrencyConflictResolver returns the names of configured models that
|
|
// share at least one concurrency group with the given model. It is satisfied
|
|
// by *config.ModelConfigLoader and lets the SmartRouter make group-aware
|
|
// placement decisions without importing the config package's full surface.
|
|
type ConcurrencyConflictResolver interface {
|
|
GetModelsConflictingWith(modelName string) []string
|
|
}
|
|
|
|
// PinnedModelResolver reports which configured models are pinned. Satisfied
|
|
// by *config.ModelConfigLoader. The router's eviction paths and the
|
|
// reconciler's idle scale-down exclude these models so `pinned: true` holds
|
|
// cluster-wide, not just against the per-node watchdog (#11101). Deliberate
|
|
// teardown (admin unload, model delete, node drain) intentionally bypasses it.
|
|
type PinnedModelResolver interface {
|
|
GetPinnedModelNames() []string
|
|
}
|
|
|
|
// NodeHealthStore is used by HealthMonitor for node status management.
|
|
type NodeHealthStore interface {
|
|
List(ctx context.Context) ([]BackendNode, error)
|
|
GetNodeModels(ctx context.Context, nodeID string) ([]NodeModel, error)
|
|
MarkOffline(ctx context.Context, nodeID string) error
|
|
MarkUnhealthy(ctx context.Context, nodeID string) error
|
|
MarkHealthy(ctx context.Context, nodeID string) error
|
|
Heartbeat(ctx context.Context, nodeID string, update *HeartbeatUpdate) error
|
|
FindStaleNodes(ctx context.Context, threshold time.Duration) ([]BackendNode, error)
|
|
RemoveNodeModel(ctx context.Context, nodeID, modelName string, replicaIndex int) error
|
|
}
|
|
|
|
// ModelLocator is used by RemoteUnloaderAdapter for model discovery.
|
|
type ModelLocator interface {
|
|
FindNodesWithModel(ctx context.Context, modelName string) ([]BackendNode, error)
|
|
RemoveNodeModel(ctx context.Context, nodeID, modelName string, replicaIndex int) error
|
|
RemoveAllNodeModelReplicas(ctx context.Context, nodeID, modelName string) error
|
|
}
|
|
|
|
// ModelLookup is used by DistributedModelStore for model existence queries.
|
|
type ModelLookup interface {
|
|
FindNodeForModel(ctx context.Context, modelName string) (*BackendNode, bool)
|
|
ListAllLoadedModels(ctx context.Context) ([]NodeModel, error)
|
|
Get(ctx context.Context, nodeID string) (*BackendNode, error)
|
|
}
|
|
|
|
// InFlightTracker is used by InFlightTrackingClient for request counting.
|
|
type InFlightTracker interface {
|
|
IncrementInFlight(ctx context.Context, nodeID, modelName string, replicaIndex int) error
|
|
DecrementInFlight(ctx context.Context, nodeID, modelName string, replicaIndex int) error
|
|
// RemoveNodeModel drops a stale replica row so the next request reloads the
|
|
// model instead of routing back to a node where it is no longer loaded.
|
|
RemoveNodeModel(ctx context.Context, nodeID, modelName string, replicaIndex int) error
|
|
}
|
|
|
|
// NodeManager is used by HTTP endpoints for node registration and lifecycle.
|
|
type NodeManager interface {
|
|
Register(ctx context.Context, node *BackendNode, autoApprove bool) error
|
|
Get(ctx context.Context, nodeID string) (*BackendNode, error)
|
|
GetByName(ctx context.Context, name string) (*BackendNode, error)
|
|
List(ctx context.Context) ([]BackendNode, error)
|
|
Deregister(ctx context.Context, nodeID string) error
|
|
ApproveNode(ctx context.Context, nodeID string) error
|
|
MarkOffline(ctx context.Context, nodeID string) error
|
|
MarkDraining(ctx context.Context, nodeID string) error
|
|
MarkHealthy(ctx context.Context, nodeID string) error
|
|
Heartbeat(ctx context.Context, nodeID string, update *HeartbeatUpdate) error
|
|
GetNodeModels(ctx context.Context, nodeID string) ([]NodeModel, error)
|
|
UpdateAuthRefs(ctx context.Context, nodeID, authUserID, apiKeyID string) error
|
|
RemoveNodeModel(ctx context.Context, nodeID, modelName string, replicaIndex int) error
|
|
RemoveAllNodeModelReplicas(ctx context.Context, nodeID, modelName string) error
|
|
}
|
|
|
|
// BackendClientFactory creates gRPC backend clients. It takes the node id
|
|
// because a dialer that must know WHICH node it is reaching, as a tunnel does,
|
|
// cannot recover it from the address; a direct dialer ignores it.
|
|
type BackendClientFactory interface {
|
|
NewClient(nodeID, address string, parallel bool) grpc.Backend
|
|
}
|
|
|
|
// tokenClientFactory is the default BackendClientFactory that creates gRPC
|
|
// clients with an optional bearer token for distributed auth.
|
|
type tokenClientFactory struct {
|
|
token string
|
|
}
|
|
|
|
func (f *tokenClientFactory) NewClient(_, address string, parallel bool) grpc.Backend {
|
|
if f.token != "" {
|
|
return grpc.NewClientWithToken(address, parallel, nil, false, f.token)
|
|
}
|
|
return grpc.NewClient(address, parallel, nil, false)
|
|
}
|
|
|
|
// WorkerNetDialerFor returns the dial function that reaches one worker's own
|
|
// HTTP server, in the shape http.Transport.DialContext and
|
|
// websocket.Dialer.NetDialContext take. It is keyed by node id, not address,
|
|
// because two workers can report the same HTTP address (NAT, loopback) and a
|
|
// tunnel must still reach the right one.
|
|
type WorkerNetDialerFor func(nodeID string) func(ctx context.Context, network, addr string) (net.Conn, error)
|
|
|
|
// DirectWorkerNetDialer dials the address it is handed, whatever the node.
|
|
func DirectWorkerNetDialer() WorkerNetDialerFor {
|
|
// Aggressive keepalive suits the long LAN transfers the file stager makes.
|
|
dial := (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 15 * time.Second}).DialContext
|
|
return func(string) func(context.Context, string, string) (net.Conn, error) { return dial }
|
|
}
|