mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-12 22:33:54 -04:00
Node placement and replica rules could only name a model, so an operator who pinned "llama3" to the GPU tier had to rewrite the rule whenever a different model took over that job. An alias already gives a stable name for whichever model serves it, and a rule on that name makes it a deployment slot: repoint the alias and the placement follows. A rule keeps the name the operator chose. Reads resolve that name through the config loader to the model the rule governs, so the reconciler counts, schedules and trims replicas of the target, and the router finds an alias-keyed rule from the target it is already routing. An alias that resolves to nothing governs nothing loadable, so the reconciler skips it and the write paths refuse it. A replica is shared by every name that resolves to it, so only one rule can decide where it runs. The REST and MCP write paths reject a rule whose target another rule already governs. A pair that arrives some other way, such as a seed file or an alias repointed onto a model that already has a rule, resolves in favour of the rule named after the model itself and then the oldest, and the rest are listed as shadowed. The eviction guard is the exception: it matches rules to replicas in raw SQL inside a locking transaction and cannot resolve an alias. It reads a stored target that the reconciler refreshes each tick, and falls back to the rule's own name when that target is empty. Assisted-by: Claude:claude-opus-5 golangci-lint eslint Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
231 lines
8.7 KiB
Go
231 lines
8.7 KiB
Go
package nodes
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
// AliasResolver maps a model name to the name of the model that actually
|
|
// serves it: an alias resolves to its target, anything else to itself. The
|
|
// second return reports whether the name was an alias.
|
|
//
|
|
// core/config.ModelConfigLoader implements this. It is an interface here so
|
|
// the registry stays testable without building a full config loader.
|
|
type AliasResolver interface {
|
|
ResolveAliasName(name string) (string, bool)
|
|
}
|
|
|
|
// SetAliasResolver installs the resolver used to map a scheduling rule's model
|
|
// name onto the model the rule actually governs. Called once at startup before
|
|
// serving. Leaving it unset makes every rule govern its own name, which is the
|
|
// behaviour from before rules could be keyed by an alias.
|
|
func (r *NodeRegistry) SetAliasResolver(resolver AliasResolver) {
|
|
r.aliasResolver.Store(&resolver)
|
|
}
|
|
|
|
// resolveAlias maps a name through the installed resolver, or returns it
|
|
// unchanged when no resolver is wired.
|
|
func (r *NodeRegistry) resolveAlias(name string) (string, bool) {
|
|
p := r.aliasResolver.Load()
|
|
if p == nil || *p == nil {
|
|
return name, false
|
|
}
|
|
return (*p).ResolveAliasName(name)
|
|
}
|
|
|
|
// applyTarget fills in the rule's derived TargetModel. Every read path runs a
|
|
// rule through this so callers can tell the rule's key (ModelName, the name
|
|
// the operator chose) apart from the model it governs (TargetModel).
|
|
func (r *NodeRegistry) applyTarget(cfg *ModelSchedulingConfig) {
|
|
if cfg == nil {
|
|
return
|
|
}
|
|
cfg.TargetModel, cfg.ModelIsAlias = r.resolveAlias(cfg.ModelName)
|
|
}
|
|
|
|
// GetGoverningScheduling returns the rule that governs a physical model: the
|
|
// rule keyed by the model's own name when one exists, otherwise the rule of an
|
|
// alias that resolves to it. Returns nil when no rule governs the model.
|
|
//
|
|
// The reverse lookup exists because rules stay keyed by the name the operator
|
|
// chose, so that an alias rule survives repointing the alias, while the router
|
|
// only ever sees the resolved model name (request middleware resolves the
|
|
// alias long before routing).
|
|
func (r *NodeRegistry) GetGoverningScheduling(ctx context.Context, modelName string) (*ModelSchedulingConfig, error) {
|
|
if direct, err := r.GetModelScheduling(ctx, modelName); err != nil || direct != nil {
|
|
return direct, err
|
|
}
|
|
rule, err := r.aliasRuleFor(ctx, modelName, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return rule, nil
|
|
}
|
|
|
|
// aliasRuleFor returns the oldest alias-keyed rule resolving to targetModel,
|
|
// skipping the rule named by exclude. Returns nil when no alias rule resolves
|
|
// there.
|
|
//
|
|
// Several names can resolve to one model, but placement governs a single
|
|
// shared load, so exactly one rule can win. Ordering by creation time (then by
|
|
// name, so rules written in the same transaction still order the same way)
|
|
// makes every frontend and every reconciler tick pick the same rule.
|
|
//
|
|
// This scans the rule table rather than filtering in SQL, because the mapping
|
|
// from a rule's name to the model it governs lives in the config loader, not
|
|
// in the database. The scan is bounded by the number of scheduling rules an
|
|
// operator has written, not by the number of models in the cluster.
|
|
func (r *NodeRegistry) aliasRuleFor(ctx context.Context, targetModel, exclude string) (*ModelSchedulingConfig, error) {
|
|
if targetModel == "" {
|
|
return nil, nil
|
|
}
|
|
var configs []ModelSchedulingConfig
|
|
if err := r.db.WithContext(ctx).Order("created_at ASC, model_name ASC").Find(&configs).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range configs {
|
|
cfg := &configs[i]
|
|
// A rule keyed by the target's own name is the direct rule, not an
|
|
// alias rule; callers handle that case with a precedence of its own.
|
|
if cfg.ModelName == targetModel || cfg.ModelName == exclude {
|
|
continue
|
|
}
|
|
r.applyTarget(cfg)
|
|
if cfg.Target() == targetModel {
|
|
return cfg, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// SchedulingConflict reports the name of an existing rule that already governs
|
|
// targetModel, or "" when the target is free. exclude is the rule being
|
|
// created or edited, which never conflicts with itself.
|
|
//
|
|
// Placement governs one shared load, so two rules resolving to the same model
|
|
// would each claim to decide where it runs. Write paths use this to reject the
|
|
// second one instead of leaving the outcome to the tiebreak in
|
|
// GetGoverningScheduling.
|
|
func (r *NodeRegistry) SchedulingConflict(ctx context.Context, targetModel, exclude string) (string, error) {
|
|
if targetModel == "" {
|
|
return "", nil
|
|
}
|
|
if targetModel != exclude {
|
|
direct, err := r.GetModelScheduling(ctx, targetModel)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if direct != nil {
|
|
return direct.ModelName, nil
|
|
}
|
|
}
|
|
rule, err := r.aliasRuleFor(ctx, targetModel, exclude)
|
|
if err != nil || rule == nil {
|
|
return "", err
|
|
}
|
|
return rule.ModelName, nil
|
|
}
|
|
|
|
// ResolveRuleTarget returns the model a rule keyed by ruleName would govern,
|
|
// and whether ruleName is an alias. A name that is an alias but comes back
|
|
// unchanged is one that does not resolve.
|
|
func (r *NodeRegistry) ResolveRuleTarget(ruleName string) (string, bool) {
|
|
return r.resolveAlias(ruleName)
|
|
}
|
|
|
|
// markShadowed flags every rule that resolves to a model some other rule
|
|
// already governs. Placement decides where one shared load runs, so only one
|
|
// rule per target can take effect.
|
|
//
|
|
// The precedence matches GetGoverningScheduling: a rule keyed by the target's
|
|
// own name wins, otherwise the oldest rule does. configs is modified in place.
|
|
func markShadowed(configs []ModelSchedulingConfig) {
|
|
governing := make(map[string]int, len(configs))
|
|
for i := range configs {
|
|
target := configs[i].Target()
|
|
best, seen := governing[target]
|
|
if !seen {
|
|
governing[target] = i
|
|
continue
|
|
}
|
|
if rulePrecedes(configs[i], configs[best], target) {
|
|
governing[target] = i
|
|
}
|
|
}
|
|
for i := range configs {
|
|
configs[i].Shadowed = governing[configs[i].Target()] != i
|
|
}
|
|
}
|
|
|
|
// rulePrecedes reports whether rule a governs target instead of rule b.
|
|
func rulePrecedes(a, b ModelSchedulingConfig, target string) bool {
|
|
if (a.ModelName == target) != (b.ModelName == target) {
|
|
return a.ModelName == target
|
|
}
|
|
if !a.CreatedAt.Equal(b.CreatedAt) {
|
|
return a.CreatedAt.Before(b.CreatedAt)
|
|
}
|
|
return a.ModelName < b.ModelName
|
|
}
|
|
|
|
// ErrSchedulingConflict is returned when a rule would govern a model that
|
|
// another rule already governs. Callers map it onto a conflict status.
|
|
var ErrSchedulingConflict = errors.New("model already has a scheduling rule")
|
|
|
|
// ValidateSchedulingTarget checks that a rule keyed by ruleName can be written,
|
|
// and returns the model it will govern.
|
|
//
|
|
// It rejects two cases. An alias that does not resolve governs nothing
|
|
// loadable, so a rule on it would sit inert forever. And a model that another
|
|
// rule already governs cannot take a second one, because placement decides
|
|
// where a single shared load runs: two rules would each claim to decide, and
|
|
// only one could win.
|
|
func (r *NodeRegistry) ValidateSchedulingTarget(ctx context.Context, ruleName string) (string, error) {
|
|
target, isAlias := r.resolveAlias(ruleName)
|
|
if isAlias && target == ruleName {
|
|
return "", fmt.Errorf("%q is an alias that does not resolve to a model: point it at an existing model before giving it a scheduling rule", ruleName)
|
|
}
|
|
conflict, err := r.SchedulingConflict(ctx, target, ruleName)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if conflict != "" {
|
|
return "", fmt.Errorf("%w: rule %q already governs model %q, so edit or delete that rule instead of adding a second one", ErrSchedulingConflict, conflict, target)
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
// RefreshSchedulingTargets rewrites each rule's stored target_model to match
|
|
// the current alias mapping, and returns the number of rows it changed.
|
|
//
|
|
// Go callers resolve aliases live and never read the stored copy. It exists for
|
|
// the eviction guard, which matches rules to loaded replicas in raw SQL inside
|
|
// a locking transaction and so cannot resolve an alias itself. Repointing an
|
|
// alias therefore reaches that guard one reconciler tick later, which is early
|
|
// enough: until then the guard protects the previous target, and the reconciler
|
|
// is already reloading the new one.
|
|
func (r *NodeRegistry) RefreshSchedulingTargets(ctx context.Context) error {
|
|
var configs []ModelSchedulingConfig
|
|
if err := r.db.WithContext(ctx).Find(&configs).Error; err != nil {
|
|
return err
|
|
}
|
|
for i := range configs {
|
|
stored := configs[i].TargetModel
|
|
live, _ := r.resolveAlias(configs[i].ModelName)
|
|
if stored == live {
|
|
continue
|
|
}
|
|
if err := r.db.WithContext(ctx).Model(&ModelSchedulingConfig{}).
|
|
Where("id = ?", configs[i].ID).
|
|
Update("target_model", live).Error; err != nil {
|
|
return err
|
|
}
|
|
xlog.Info("Scheduling rule now governs a different model",
|
|
"rule", configs[i].ModelName, "was", stored, "now", live)
|
|
}
|
|
return nil
|
|
}
|