Files
LocalAI/core/services/nodes/load_cancel.go
T
mudler-agent cd604b5c5b fix(distributed): bound, stop and cancel model loads with leases and worker operations (#12524)
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>
2026-10-07 16:59:56 +02:00

224 lines
8.7 KiB
Go

package nodes
import (
"context"
"errors"
"fmt"
"time"
"github.com/mudler/LocalAI/core/services/workerctl"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/xlog"
)
// LoadCancelState is what a cancel achieved.
type LoadCancelState string
const (
// LoadCancelStopped: the worker confirmed the load's work ended.
LoadCancelStopped LoadCancelState = "stopped"
// LoadCancelStopping: the cancel is recorded and the stop is pending. The
// model is released at the stop deadline regardless.
LoadCancelStopping LoadCancelState = "stopping"
// LoadCancelGone: no load exists for the model any more.
LoadCancelGone LoadCancelState = "gone"
)
// ErrLoadCancelConflict means another generation holds the model.
// CurrentJobID on the result names it.
var ErrLoadCancelConflict = errors.New("a different load generation is current")
// LoadCancelResult is the outcome of one cancel.
type LoadCancelResult struct {
State LoadCancelState
// RetryAfter is how long until the model is released if the stop never
// confirms. Zero when the state is stopped or gone.
RetryAfter time.Duration
// CurrentJobID is the generation that holds the model, set with
// ErrLoadCancelConflict.
CurrentJobID string
}
// LoadCancelService cancels distributed loads. It is the one path that cancel,
// unload and node deregistration share: it records the cancel on the job, then
// stops the remote work through the single stop call.
type LoadCancelService struct {
Registry *NodeRegistry
// Stopper stops one load operation. It may be nil: the cancel is then
// recorded and the worker's own watchdog and the stop window bound the work.
Stopper LoadAttemptStopper
}
// Cancel cancels the load attempt ref names. It is idempotent: repeating it on
// a failed attempt retries the stop and never extends the stop window.
func (s *LoadCancelService) Cancel(ctx context.Context, ref LoadJobRef) (LoadCancelResult, error) {
return s.cancel(ctx, ref, "cancelled by an administrator", true)
}
func (s *LoadCancelService) cancel(ctx context.Context, ref LoadJobRef, reason string, byAdmin bool) (LoadCancelResult, error) {
outcome, job, err := s.Registry.cancelLoadJob(ctx, ref, reason, byAdmin)
if err != nil {
return LoadCancelResult{}, err
}
switch outcome {
case CancelGone:
return LoadCancelResult{State: LoadCancelGone}, nil
case CancelConflict:
current := ""
if job != nil {
current = job.Generation
}
return LoadCancelResult{CurrentJobID: current}, fmt.Errorf("%w: %s", ErrLoadCancelConflict, current)
}
// The attempt is failed and cancelled. Stop its remote work.
if !job.OpConfirmed && job.NodeID != "" && s.Stopper != nil {
addr := s.Registry.attemptAddress(ctx, job)
if StopLoadAttempt(ctx, s.Registry, s.Stopper, ref, job.NodeID, job.ReplicaIndex, addr, job.LegacyWorker) {
return LoadCancelResult{State: LoadCancelStopped}, nil
}
}
if job.OpConfirmed {
return LoadCancelResult{State: LoadCancelStopped}, nil
}
result := LoadCancelResult{State: LoadCancelStopping}
if fresh, gerr := s.Registry.GetLoadJob(ctx, ref.TrackingKey); gerr == nil && fresh != nil && fresh.StopDeadline != nil {
result.RetryAfter = max(time.Until(*fresh.StopDeadline), time.Second)
}
return result, nil
}
// CancelModelOnNode cancels the load of modelName that runs on nodeID, or has
// no node yet. A load placed on another node is left alone: unloading a replica
// here must not cancel a load there. It reports the attempts it cancelled.
//
// An unload of a loaded replica calls this first and then carries on with the
// normal unload: stopping the load's operation never stops a model that
// finished loading, because the worker refuses a stop whose operation ended.
func (s *LoadCancelService) CancelModelOnNode(ctx context.Context, nodeID, modelName string) ([]LoadJobRef, error) {
job, err := s.Registry.GetLoadJob(ctx, modelName)
if err != nil || job == nil {
return nil, err
}
if job.NodeID != "" && job.NodeID != nodeID {
return nil, nil
}
if _, err := s.Cancel(ctx, job.Ref()); err != nil && !errors.Is(err, ErrLoadCancelConflict) {
return nil, err
}
return []LoadJobRef{job.Ref()}, nil
}
// CancelNodeLoads cancels every load placed on nodeID. Deregistering or
// draining a node calls it before the node's rows are removed.
func (s *LoadCancelService) CancelNodeLoads(ctx context.Context, nodeID string) error {
jobs, err := s.Registry.ListLoadJobsOnNode(ctx, nodeID)
if err != nil {
return err
}
var errs []error
for _, job := range jobs {
if _, err := s.cancel(ctx, job.Ref(), "the worker was removed or is shutting down", false); err != nil && !errors.Is(err, ErrLoadCancelConflict) {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// retryLoadStops retries the stop of failed attempts whose remote work is not
// confirmed ended, once per reconciler pass, until the worker acknowledges or
// the stop deadline releases the job.
func (rc *ReplicaReconciler) retryLoadStops(ctx context.Context) {
var stopper LoadAttemptStopper
switch {
case rc.unloader != nil:
stopper = rc.unloader
case rc.adapter != nil:
stopper = rc.adapter
default:
return
}
jobs, err := rc.registry.ListLoadJobsAwaitingStop(ctx)
if err != nil {
xlog.Warn("Reconciler: failed to list load jobs awaiting stop", "error", err)
return
}
for _, job := range jobs {
StopLoadAttempt(ctx, rc.registry, stopper, job.Ref(), job.NodeID, job.ReplicaIndex, rc.registry.attemptAddress(ctx, &job), job.LegacyWorker)
}
}
// loadAttemptRegistry is what StopLoadAttempt records its outcome on.
type loadAttemptRegistry interface {
ConfirmLoadOp(ctx context.Context, ref LoadJobRef) error
SetLegacyStopWindow(ctx context.Context, ref LoadJobRef, window time.Duration) error
}
// StopLoadAttempt stops the remote work of one failed or cancelled attempt and
// records the outcome. It is the single place a load's work is stopped.
//
// A worker that names operations is sent a stop by operation id, with the
// address when known. A worker that does not (it reported no process instance)
// is sent a stop by exact process address and nothing else, and when the address
// is unknown no stop is claimed. An acknowledged stop confirms the attempt and
// shortens the hold. For a legacy worker that does not acknowledge, the hold is
// the load deadline, because nothing sooner bounds its work. It reports whether
// the stop was acknowledged.
func StopLoadAttempt(ctx context.Context, reg loadAttemptRegistry, stopper LoadAttemptStopper, ref LoadJobRef, nodeID string, replica int, addr string, legacy bool) bool {
acked := false
switch {
case legacy:
if addr != "" {
reply, err := stopper.StopModelReplica(ctx, nodeID, NodeModel{ModelName: ref.TrackingKey, ReplicaIndex: replica, Address: addr}, true)
acked = err == nil && reply.Error == "" && reply.Terminated
}
default:
acked = StopOperationAcked(ctx, stopper, nodeID, ref, replica, addr)
}
switch {
case acked:
if err := reg.ConfirmLoadOp(ctx, ref); err != nil && !errors.Is(err, ErrStaleLoadJob) {
xlog.Warn("Failed to record the stop confirmation", "model", ref.TrackingKey, "error", err)
}
case legacy:
if err := reg.SetLegacyStopWindow(ctx, ref, loadJobLegacyStopWindow); err != nil && !errors.Is(err, ErrStaleLoadJob) {
xlog.Warn("Failed to set the legacy stop window", "model", ref.TrackingKey, "error", err)
}
}
return acked
}
// StopOperationAcked asks the worker to stop the operation of ref and reports
// whether it acknowledged: the process is gone, or was never there. An error, a
// refusal or silence is not an acknowledgement.
func StopOperationAcked(ctx context.Context, stopper LoadOperationStopper, nodeID string, ref LoadJobRef, replica int, addr string) bool {
reply, err := stopper.StopLoadOperation(ctx, nodeID, workerctl.ModelStopRequest{
ModelName: ref.TrackingKey,
ProcessKey: model.BackendProcessKey(ref.TrackingKey, replica),
ExpectedAddress: addr,
OperationID: ref.Generation,
Force: true,
})
if err != nil {
xlog.Warn("Stopping the load operation failed", "node", nodeID, "model", ref.TrackingKey, "error", err)
return false
}
if reply.Error != "" || !reply.Terminated {
xlog.Warn("The worker did not stop the load operation", "node", nodeID, "model", ref.TrackingKey, "error", reply.Error)
return false
}
return true
}
// attemptAddress returns the backend address recorded on the attempt's replica
// row, or "" when the row is gone or never reached a backend.
func (r *NodeRegistry) attemptAddress(ctx context.Context, job *ModelLoadJob) string {
var nm NodeModel
if err := r.db.WithContext(ctx).
Where("node_id = ? AND model_name = ? AND replica_index = ? AND load_generation = ?", job.NodeID, job.TrackingKey, job.ReplicaIndex, job.Generation).
First(&nm).Error; err != nil {
return ""
}
return nm.Address
}