mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-11 02:07:27 -04:00
* fix(galleryop): self-evict terminal ops from OpCache.GetStatus The processingBackends map (the UI 'reinstalling' spinner source) only cleared an op when a client polled /api/backends/job/:uid. The Manage-page Reinstall and Upgrade buttons never poll, so completed installs leaked into processingBackends forever and the backend card spun 'reinstalling' even though the install had finished. Evict terminal ops on the list read instead; DeleteUUID already broadcasts the eviction so peer replicas converge. Reproduced on a live 5-node distributed cluster: 5 backends sat in processingBackends with underlying jobs reporting completed:true,progress:100. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(nodes): clear pending backend ops behind offline/draining nodes ListDuePendingBackendOps filters status=healthy, so a backend op queued against a node that went offline (stale heartbeat) or draining (admin action) was never retried, aged out, or deleted - it leaked forever and kept the UI operation spinning. Add DeleteStalePendingBackendOps and run it each reconcile pass: draining nodes are cleared immediately (model rows already purged), offline nodes once their heartbeat is older than a grace window (blip protection). Reproduced on a live cluster: orphaned llama-cpp install rows targeting an offline (nvidia-thor) and a draining (mac-mini-m4) node sat at attempts=0 indefinitely. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(nodes): stream per-node progress during backend upgrade The install dispatch subscribed to a per-op progress subject and streamed per-node download ticks; the upgrade dispatch did a bare 15-minute blocking NATS round-trip with no subscription, so the UI showed progress:0 the whole time (the 'reinstalling but nothing happens' report on a slow node). Thread the op ID through BackendManager.UpgradeBackend -> the distributed manager -> the adapter, and have the adapter subscribe to the per-op progress subject before the request (extracted into a shared subscribeProgress helper reused by install/upgrade/force-fallback). The worker's upgradeBackend now creates the same DebouncedInstallProgressPublisher installBackend uses. An upgrade is a force-reinstall, so it reuses SubjectNodeBackendInstallProgress rather than minting a new subject - no new NATS permission, no new rolling-update compat surface. Reconciler-driven retries pass empty opID/onProgress and stay on the silent path. Reproduced on a live cluster: upgrade of llama-cpp-development on agx-orin-slow sat at progress:0 for 4+ minutes with no per-node feedback. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(galleryop): persist cancellation + periodically reap orphaned ops Two distributed gaps surfaced when a replica was killed mid-upgrade on a live cluster, leaving the backend stuck 'processing' in the UI forever: 1. CancelOperation flipped the in-memory status to cancelled and broadcast a NATS event but never persisted the terminal status. On the next replica restart the still-active row re-hydrated straight back into processingBackends and the UI spun again. It now calls store.Cancel(id) so the cancel survives a restart. 2. CleanStale (which marks abandoned active ops failed) only ran once on startup, so an op orphaned AFTER startup - its owning replica's foreground handler goroutine gone - was never reaped until the next restart. Add GalleryService.ReapStaleOperations and run it on a 15m ticker (CleanStale now returns the reaped count for observability). Neither is covered by the OpCache self-evict fix: an orphaned op never reaches Processed, so it would never self-evict. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(review): address self-review findings on the distributed install fixes Three findings from an adversarial review of this branch: 1. CRITICAL - OpCache.GetStatus crashed under concurrent load. m.Map() returns the live internal map by reference, so deleting from it on the read path was an unsynchronized write to a map four HTTP handlers poll every ~1s -> a 'concurrent map writes' fatal. Rewritten to iterate a Keys() snapshot, build a fresh result map, and apply evictions via the locked DeleteUUID after the loop. Added a -race concurrency regression guard. 2. HIGH - GetStatus evicted failed ops too, hiding them from /api/operations and breaking the dismiss-failed-op flow (the panel keeps Error != nil ops so the admin can read the error and click Dismiss). Eviction now fires only for terminal ops with Error == nil (success/cancelled); failures are retained. 3. MEDIUM - DeleteStalePendingBackendOps missed StatusUnhealthy nodes. A node marked unhealthy on a NATS ErrNoResponders never transitions to offline (health.go skips re-marking it), so its pending ops leaked exactly like the offline case. Unhealthy is now reaped via the same stale-heartbeat grace path (a fresh-heartbeat node is recovering and keeps its op). Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(review-2): don't evict the still-installing soft-path; don't spin on failed ops Second review pass found two issues: 1. MEDIUM (Go) - OpCache.GetStatus evicted the ErrWorkerStillInstalling soft-path op. That op is deliberately Processed=true with no error to show a yellow in-progress state when a worker timed out the NATS round-trip but is still installing in the background; the reconciler confirms the real outcome later. Evicting it (and broadcasting OpEnd + marking the DB completed) hid an install that may still fail. Eviction is now scoped to a clean success (progress 100 + 'completed', matching the job-poll's historical condition) or a cancellation - the soft-path (progress != 100) and failures are kept. 2. MEDIUM (React) - the Backends gallery card rendered ANY operation as an 'Installing...' spinner, so a failed op (now intentionally kept in the list for the OperationsBar error + Dismiss) spun forever. Exclude errored ops from the card spinner, mirroring Models.jsx (isInstalling already excludes op.error). The error + Dismiss still surface in the global OperationsBar. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): refresh Manage backends table when an operation settles The Manage backends table fetched installed backends only on mount/after delete and checked upgrades only on tab activation. After a reinstall/upgrade completed neither re-ran, so the installed-version cell and the 'update available' badge stayed stale until the user switched tabs - the op looked like it 'did nothing'. Watch the operations list (via useOperations) and re-fetch installed backends + available upgrades whenever the count settles, mirroring the operations.length watch Backends.jsx already uses. Consolidates the prior tab-activation upgrades check into the same effect. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
248 lines
7.6 KiB
Go
248 lines
7.6 KiB
Go
package application
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/gallery"
|
|
"github.com/mudler/LocalAI/core/services/advisorylock"
|
|
"github.com/mudler/LocalAI/core/services/galleryop"
|
|
"github.com/mudler/LocalAI/pkg/model"
|
|
"github.com/mudler/LocalAI/pkg/system"
|
|
"github.com/mudler/xlog"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// UpgradeChecker periodically checks for backend upgrades and optionally
|
|
// auto-upgrades them. It caches the last check results for API queries.
|
|
//
|
|
// In standalone mode it runs a simple ticker loop.
|
|
// In distributed mode it uses a PostgreSQL advisory lock so that only one
|
|
// frontend instance performs periodic checks and auto-upgrades at a time.
|
|
type UpgradeChecker struct {
|
|
appConfig *config.ApplicationConfig
|
|
modelLoader *model.ModelLoader
|
|
galleries []config.Gallery
|
|
systemState *system.SystemState
|
|
db *gorm.DB // non-nil in distributed mode
|
|
// backendManagerFn lazily returns the current backend manager (may be
|
|
// swapped from Local to Distributed after startup). Pulled through each
|
|
// check so the UpgradeChecker uses whichever is active. In distributed
|
|
// mode this ensures CheckUpgrades asks workers instead of the (empty)
|
|
// frontend filesystem — fixing the bug where upgrades never surfaced.
|
|
backendManagerFn func() galleryop.BackendManager
|
|
|
|
checkInterval time.Duration
|
|
stop chan struct{}
|
|
done chan struct{}
|
|
triggerCh chan struct{}
|
|
|
|
mu sync.RWMutex
|
|
lastUpgrades map[string]gallery.UpgradeInfo
|
|
lastCheckTime time.Time
|
|
}
|
|
|
|
// NewUpgradeChecker creates a new UpgradeChecker service.
|
|
// Pass db=nil for standalone mode, or a *gorm.DB for distributed mode
|
|
// (uses advisory locks so only one instance runs periodic checks).
|
|
// backendManagerFn is optional; when set, CheckUpgrades is routed through
|
|
// the active backend manager — required in distributed mode so the check
|
|
// aggregates from workers rather than the empty frontend filesystem.
|
|
func NewUpgradeChecker(appConfig *config.ApplicationConfig, ml *model.ModelLoader, db *gorm.DB, backendManagerFn func() galleryop.BackendManager) *UpgradeChecker {
|
|
return &UpgradeChecker{
|
|
appConfig: appConfig,
|
|
modelLoader: ml,
|
|
galleries: appConfig.BackendGalleries,
|
|
systemState: appConfig.SystemState,
|
|
db: db,
|
|
backendManagerFn: backendManagerFn,
|
|
checkInterval: 6 * time.Hour,
|
|
stop: make(chan struct{}),
|
|
done: make(chan struct{}),
|
|
triggerCh: make(chan struct{}, 1),
|
|
lastUpgrades: make(map[string]gallery.UpgradeInfo),
|
|
}
|
|
}
|
|
|
|
// Run starts the upgrade checker loop. It waits 30 seconds after startup,
|
|
// performs an initial check, then re-checks every 6 hours.
|
|
//
|
|
// In distributed mode, periodic checks are guarded by a PostgreSQL advisory
|
|
// lock so only one frontend instance runs them. On-demand triggers (TriggerCheck)
|
|
// and the initial check always run locally for fast API response cache warming.
|
|
func (uc *UpgradeChecker) Run(ctx context.Context) {
|
|
defer close(uc.done)
|
|
|
|
// Initial delay: don't slow down startup. Short enough that operators
|
|
// don't stare at an empty upgrade banner for long; long enough that
|
|
// workers have registered and reported their installed backends.
|
|
initialDelay := 10 * time.Second
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-uc.stop:
|
|
return
|
|
case <-time.After(initialDelay):
|
|
}
|
|
|
|
// First check always runs locally (to warm the cache on this instance)
|
|
uc.runCheck(ctx)
|
|
|
|
if uc.db != nil {
|
|
// Distributed mode: use advisory lock for periodic checks.
|
|
// RunLeaderLoop ticks every checkInterval; only the lock holder executes.
|
|
go advisorylock.RunLeaderLoop(ctx, uc.db, advisorylock.KeyBackendUpgradeCheck, uc.checkInterval, func() {
|
|
uc.runCheck(ctx)
|
|
})
|
|
|
|
// Still listen for on-demand triggers (from API / settings change)
|
|
// and stop signal — these run on every instance.
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-uc.stop:
|
|
return
|
|
case <-uc.triggerCh:
|
|
uc.runCheck(ctx)
|
|
}
|
|
}
|
|
} else {
|
|
// Standalone mode: simple ticker loop
|
|
ticker := time.NewTicker(uc.checkInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-uc.stop:
|
|
return
|
|
case <-ticker.C:
|
|
uc.runCheck(ctx)
|
|
case <-uc.triggerCh:
|
|
uc.runCheck(ctx)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Shutdown stops the upgrade checker loop.
|
|
func (uc *UpgradeChecker) Shutdown() {
|
|
close(uc.stop)
|
|
<-uc.done
|
|
}
|
|
|
|
// TriggerCheck forces an immediate upgrade check on this instance.
|
|
func (uc *UpgradeChecker) TriggerCheck() {
|
|
select {
|
|
case uc.triggerCh <- struct{}{}:
|
|
default:
|
|
// Already triggered, skip
|
|
}
|
|
}
|
|
|
|
// GetAvailableUpgrades returns the cached upgrade check results.
|
|
func (uc *UpgradeChecker) GetAvailableUpgrades() map[string]gallery.UpgradeInfo {
|
|
uc.mu.RLock()
|
|
defer uc.mu.RUnlock()
|
|
|
|
// Return a copy to avoid races
|
|
result := make(map[string]gallery.UpgradeInfo, len(uc.lastUpgrades))
|
|
for k, v := range uc.lastUpgrades {
|
|
result[k] = v
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (uc *UpgradeChecker) runCheck(ctx context.Context) {
|
|
var (
|
|
upgrades map[string]gallery.UpgradeInfo
|
|
err error
|
|
)
|
|
if uc.backendManagerFn != nil {
|
|
if bm := uc.backendManagerFn(); bm != nil {
|
|
upgrades, err = bm.CheckUpgrades(ctx)
|
|
}
|
|
}
|
|
if upgrades == nil && err == nil {
|
|
upgrades, err = gallery.CheckBackendUpgrades(ctx, uc.galleries, uc.systemState)
|
|
}
|
|
|
|
uc.mu.Lock()
|
|
uc.lastCheckTime = time.Now()
|
|
if err != nil {
|
|
xlog.Debug("Backend upgrade check failed", "error", err)
|
|
uc.mu.Unlock()
|
|
return
|
|
}
|
|
uc.lastUpgrades = upgrades
|
|
uc.mu.Unlock()
|
|
|
|
if len(upgrades) == 0 {
|
|
xlog.Debug("All backends up to date")
|
|
return
|
|
}
|
|
|
|
// Log available upgrades
|
|
for name, info := range upgrades {
|
|
if info.AvailableVersion != "" {
|
|
xlog.Info("Backend upgrade available",
|
|
"backend", name,
|
|
"installed", info.InstalledVersion,
|
|
"available", info.AvailableVersion)
|
|
} else {
|
|
xlog.Info("Backend upgrade available (new build)",
|
|
"backend", name)
|
|
}
|
|
}
|
|
|
|
// Auto-upgrade if enabled. Route through the active BackendManager so
|
|
// distributed-mode upgrades fan out to workers via NATS — calling
|
|
// gallery.UpgradeBackend directly would look up the backend on the
|
|
// frontend filesystem, which is empty in distributed mode and produces
|
|
// "backend not found" while the cluster still reports an upgrade.
|
|
if uc.appConfig.AutoUpgradeBackends {
|
|
var bm galleryop.BackendManager
|
|
if uc.backendManagerFn != nil {
|
|
bm = uc.backendManagerFn()
|
|
}
|
|
for name, info := range upgrades {
|
|
xlog.Info("Auto-upgrading backend", "backend", name,
|
|
"from", info.InstalledVersion, "to", info.AvailableVersion)
|
|
var err error
|
|
if bm != nil {
|
|
// Background auto-upgrade: no live admin watching a progress bar,
|
|
// so opID is empty and the distributed path skips progress streaming.
|
|
err = bm.UpgradeBackend(ctx, "", name, nil)
|
|
} else {
|
|
err = gallery.UpgradeBackend(ctx, uc.systemState, uc.modelLoader,
|
|
uc.galleries, name, nil, uc.appConfig.RequireBackendIntegrity)
|
|
}
|
|
if err != nil {
|
|
xlog.Error("Failed to auto-upgrade backend",
|
|
"backend", name, "error", err)
|
|
} else {
|
|
xlog.Info("Backend upgraded successfully", "backend", name,
|
|
"version", info.AvailableVersion)
|
|
}
|
|
}
|
|
// Re-check to update cache after upgrades. Route through the same
|
|
// BackendManager so distributed mode reflects the worker view.
|
|
var freshUpgrades map[string]gallery.UpgradeInfo
|
|
var freshErr error
|
|
if bm != nil {
|
|
freshUpgrades, freshErr = bm.CheckUpgrades(ctx)
|
|
} else {
|
|
freshUpgrades, freshErr = gallery.CheckBackendUpgrades(ctx, uc.galleries, uc.systemState)
|
|
}
|
|
if freshErr == nil {
|
|
uc.mu.Lock()
|
|
uc.lastUpgrades = freshUpgrades
|
|
uc.mu.Unlock()
|
|
}
|
|
}
|
|
}
|