mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-11 08:15:12 -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>
224 lines
8.7 KiB
Go
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
|
|
}
|