mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-01 11:56:57 -04:00
fix(watchdog): persist a UI-saved Check Interval across restarts (#10601) The watchdog Check Interval saved via /api/settings reverted to 500ms on every restart, while the idle/busy timeouts persisted correctly. Root cause: NewApplicationConfig baseline-defaulted WatchDogInterval to 500ms, whereas the idle/busy timeouts default to 0. The startup loader (loadRuntimeSettingsFromFile) applies a persisted runtime_settings.json value only when the field is still at its zero default - its heuristic for "this wasn't set by an env var". Because the interval was always 500ms at that point, the loader never read the persisted value back, so the saved interval was silently discarded on each boot. Fix: drop the non-zero baseline default so the interval behaves like the sibling timeouts (0 = unset). The effective 500ms default is now supplied at the watchdog layer: WithWatchdogInterval ignores a non-positive value so DefaultWatchDogOptions' 500ms is preserved (and a 0 interval can never turn the watchdog loop into a busy spin). Also mirror the interval in the live config file watcher alongside idle/busy, and report the real 500ms default (not the stale "2s") from ToRuntimeSettings. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
388 lines
15 KiB
Go
388 lines
15 KiB
Go
package application
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"slices"
|
|
"time"
|
|
|
|
"dario.cat/mergo"
|
|
"github.com/fsnotify/fsnotify"
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
type fileHandler func(fileContent []byte, appConfig *config.ApplicationConfig) error
|
|
|
|
type configFileHandler struct {
|
|
handlers map[string]fileHandler
|
|
|
|
watcher *fsnotify.Watcher
|
|
|
|
appConfig *config.ApplicationConfig
|
|
}
|
|
|
|
// TODO: This should be a singleton eventually so other parts of the code can register config file handlers,
|
|
// then we can export it to other packages
|
|
func newConfigFileHandler(appConfig *config.ApplicationConfig) configFileHandler {
|
|
c := configFileHandler{
|
|
handlers: make(map[string]fileHandler),
|
|
appConfig: appConfig,
|
|
}
|
|
err := c.Register("api_keys.json", readApiKeysJson(*appConfig), true)
|
|
if err != nil {
|
|
xlog.Error("unable to register config file handler", "error", err, "file", "api_keys.json")
|
|
}
|
|
err = c.Register("external_backends.json", readExternalBackendsJson(*appConfig), true)
|
|
if err != nil {
|
|
xlog.Error("unable to register config file handler", "error", err, "file", "external_backends.json")
|
|
}
|
|
err = c.Register("runtime_settings.json", readRuntimeSettingsJson(*appConfig), true)
|
|
if err != nil {
|
|
xlog.Error("unable to register config file handler", "error", err, "file", "runtime_settings.json")
|
|
}
|
|
// Note: agent_tasks.json and agent_jobs.json are handled by AgentJobService directly
|
|
// The service watches and reloads these files internally
|
|
return c
|
|
}
|
|
|
|
func (c *configFileHandler) Register(filename string, handler fileHandler, runNow bool) error {
|
|
_, ok := c.handlers[filename]
|
|
if ok {
|
|
return fmt.Errorf("handler already registered for file %s", filename)
|
|
}
|
|
c.handlers[filename] = handler
|
|
if runNow {
|
|
c.callHandler(filename, handler)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *configFileHandler) callHandler(filename string, handler fileHandler) {
|
|
rootedFilePath := filepath.Join(c.appConfig.DynamicConfigsDir, filepath.Clean(filename))
|
|
xlog.Debug("reading file for dynamic config update", "filename", rootedFilePath)
|
|
fileContent, err := os.ReadFile(rootedFilePath)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
xlog.Error("could not read file", "error", err, "filename", rootedFilePath)
|
|
}
|
|
|
|
if err = handler(fileContent, c.appConfig); err != nil {
|
|
xlog.Error("WatchConfigDirectory goroutine failed to update options", "error", err)
|
|
}
|
|
}
|
|
|
|
func (c *configFileHandler) Watch() error {
|
|
configWatcher, err := fsnotify.NewWatcher()
|
|
c.watcher = configWatcher
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if c.appConfig.DynamicConfigsDirPollInterval > 0 {
|
|
xlog.Debug("Poll interval set, falling back to polling for configuration changes")
|
|
ticker := time.NewTicker(c.appConfig.DynamicConfigsDirPollInterval)
|
|
go func() {
|
|
for {
|
|
<-ticker.C
|
|
for file, handler := range c.handlers {
|
|
xlog.Debug("polling config file", "file", file)
|
|
c.callHandler(file, handler)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Start listening for events.
|
|
go func() {
|
|
for {
|
|
select {
|
|
case event, ok := <-c.watcher.Events:
|
|
if !ok {
|
|
return
|
|
}
|
|
if event.Has(fsnotify.Write | fsnotify.Create | fsnotify.Remove) {
|
|
handler, ok := c.handlers[path.Base(event.Name)]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
c.callHandler(filepath.Base(event.Name), handler)
|
|
}
|
|
case err, ok := <-c.watcher.Errors:
|
|
xlog.Error("config watcher error received", "error", err)
|
|
if !ok {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Add a path.
|
|
err = c.watcher.Add(c.appConfig.DynamicConfigsDir)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to create a watcher on the configuration directory: %+v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// TODO: When we institute graceful shutdown, this should be called
|
|
func (c *configFileHandler) Stop() error {
|
|
return c.watcher.Close()
|
|
}
|
|
|
|
func readApiKeysJson(startupAppConfig config.ApplicationConfig) fileHandler {
|
|
handler := func(fileContent []byte, appConfig *config.ApplicationConfig) error {
|
|
xlog.Debug("processing api keys runtime update", "numKeys", len(startupAppConfig.ApiKeys))
|
|
|
|
if len(fileContent) > 0 {
|
|
// Parse JSON content from the file
|
|
var fileKeys []string
|
|
err := json.Unmarshal(fileContent, &fileKeys)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
xlog.Debug("discovered API keys from api keys dynamic config file", "numKeys", len(fileKeys))
|
|
|
|
appConfig.ApiKeys = append(startupAppConfig.ApiKeys, fileKeys...)
|
|
} else {
|
|
xlog.Debug("no API keys discovered from dynamic config file")
|
|
appConfig.ApiKeys = startupAppConfig.ApiKeys
|
|
}
|
|
xlog.Debug("total api keys after processing", "numKeys", len(appConfig.ApiKeys))
|
|
return nil
|
|
}
|
|
|
|
return handler
|
|
}
|
|
|
|
func readExternalBackendsJson(startupAppConfig config.ApplicationConfig) fileHandler {
|
|
handler := func(fileContent []byte, appConfig *config.ApplicationConfig) error {
|
|
xlog.Debug("processing external_backends.json")
|
|
|
|
if len(fileContent) > 0 {
|
|
// Parse JSON content from the file
|
|
var fileBackends map[string]string
|
|
err := json.Unmarshal(fileContent, &fileBackends)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
appConfig.ExternalGRPCBackends = startupAppConfig.ExternalGRPCBackends
|
|
err = mergo.Merge(&appConfig.ExternalGRPCBackends, &fileBackends)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
appConfig.ExternalGRPCBackends = startupAppConfig.ExternalGRPCBackends
|
|
}
|
|
xlog.Debug("external backends loaded from external_backends.json")
|
|
return nil
|
|
}
|
|
return handler
|
|
}
|
|
|
|
func readRuntimeSettingsJson(startupAppConfig config.ApplicationConfig) fileHandler {
|
|
handler := func(fileContent []byte, appConfig *config.ApplicationConfig) error {
|
|
xlog.Debug("processing runtime_settings.json")
|
|
|
|
// Determine if settings came from env vars by comparing with startup config
|
|
// startupAppConfig contains the original values set from env vars at startup.
|
|
// If current values match startup values, they came from env vars (or defaults).
|
|
// We apply file settings only if current values match startup values (meaning not from env vars).
|
|
envWatchdogIdle := appConfig.WatchDogIdle == startupAppConfig.WatchDogIdle
|
|
envWatchdogBusy := appConfig.WatchDogBusy == startupAppConfig.WatchDogBusy
|
|
envWatchdogIdleTimeout := appConfig.WatchDogIdleTimeout == startupAppConfig.WatchDogIdleTimeout
|
|
envWatchdogBusyTimeout := appConfig.WatchDogBusyTimeout == startupAppConfig.WatchDogBusyTimeout
|
|
envWatchdogInterval := appConfig.WatchDogInterval == startupAppConfig.WatchDogInterval
|
|
envSingleBackend := appConfig.SingleBackend == startupAppConfig.SingleBackend
|
|
envMaxActiveBackends := appConfig.MaxActiveBackends == startupAppConfig.MaxActiveBackends
|
|
envMemoryReclaimerEnabled := appConfig.MemoryReclaimerEnabled == startupAppConfig.MemoryReclaimerEnabled
|
|
envMemoryReclaimerThreshold := appConfig.MemoryReclaimerThreshold == startupAppConfig.MemoryReclaimerThreshold
|
|
envThreads := appConfig.Threads == startupAppConfig.Threads
|
|
envContextSize := appConfig.ContextSize == startupAppConfig.ContextSize
|
|
envF16 := appConfig.F16 == startupAppConfig.F16
|
|
envDebug := appConfig.Debug == startupAppConfig.Debug
|
|
envCORS := appConfig.CORS == startupAppConfig.CORS
|
|
envCSRF := appConfig.DisableCSRF == startupAppConfig.DisableCSRF
|
|
envCORSAllowOrigins := appConfig.CORSAllowOrigins == startupAppConfig.CORSAllowOrigins
|
|
envP2PToken := appConfig.P2PToken == startupAppConfig.P2PToken
|
|
envP2PNetworkID := appConfig.P2PNetworkID == startupAppConfig.P2PNetworkID
|
|
envFederated := appConfig.Federated == startupAppConfig.Federated
|
|
envGalleries := slices.Equal(appConfig.Galleries, startupAppConfig.Galleries)
|
|
envBackendGalleries := slices.Equal(appConfig.BackendGalleries, startupAppConfig.BackendGalleries)
|
|
envAutoloadGalleries := appConfig.AutoloadGalleries == startupAppConfig.AutoloadGalleries
|
|
envAutoloadBackendGalleries := appConfig.AutoloadBackendGalleries == startupAppConfig.AutoloadBackendGalleries
|
|
envPIIDefaultDetectors := slices.Equal(appConfig.PIIDefaultDetectors, startupAppConfig.PIIDefaultDetectors)
|
|
envAgentJobRetentionDays := appConfig.AgentJobRetentionDays == startupAppConfig.AgentJobRetentionDays
|
|
envForceEvictionWhenBusy := appConfig.ForceEvictionWhenBusy == startupAppConfig.ForceEvictionWhenBusy
|
|
envLRUEvictionMaxRetries := appConfig.LRUEvictionMaxRetries == startupAppConfig.LRUEvictionMaxRetries
|
|
envLRUEvictionRetryInterval := appConfig.LRUEvictionRetryInterval == startupAppConfig.LRUEvictionRetryInterval
|
|
|
|
if len(fileContent) > 0 {
|
|
var settings config.RuntimeSettings
|
|
err := json.Unmarshal(fileContent, &settings)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Apply file settings only if they don't match startup values (i.e., not from env vars)
|
|
if settings.WatchdogIdleEnabled != nil && !envWatchdogIdle {
|
|
appConfig.WatchDogIdle = *settings.WatchdogIdleEnabled
|
|
if appConfig.WatchDogIdle {
|
|
appConfig.WatchDog = true
|
|
}
|
|
}
|
|
if settings.WatchdogBusyEnabled != nil && !envWatchdogBusy {
|
|
appConfig.WatchDogBusy = *settings.WatchdogBusyEnabled
|
|
if appConfig.WatchDogBusy {
|
|
appConfig.WatchDog = true
|
|
}
|
|
}
|
|
if settings.WatchdogIdleTimeout != nil && !envWatchdogIdleTimeout {
|
|
dur, err := time.ParseDuration(*settings.WatchdogIdleTimeout)
|
|
if err == nil {
|
|
appConfig.WatchDogIdleTimeout = dur
|
|
} else {
|
|
xlog.Warn("invalid watchdog idle timeout in runtime_settings.json", "error", err, "timeout", *settings.WatchdogIdleTimeout)
|
|
}
|
|
}
|
|
if settings.WatchdogBusyTimeout != nil && !envWatchdogBusyTimeout {
|
|
dur, err := time.ParseDuration(*settings.WatchdogBusyTimeout)
|
|
if err == nil {
|
|
appConfig.WatchDogBusyTimeout = dur
|
|
} else {
|
|
xlog.Warn("invalid watchdog busy timeout in runtime_settings.json", "error", err, "timeout", *settings.WatchdogBusyTimeout)
|
|
}
|
|
}
|
|
if settings.WatchdogInterval != nil && !envWatchdogInterval {
|
|
dur, err := time.ParseDuration(*settings.WatchdogInterval)
|
|
if err == nil {
|
|
appConfig.WatchDogInterval = dur
|
|
} else {
|
|
xlog.Warn("invalid watchdog interval in runtime_settings.json", "error", err, "interval", *settings.WatchdogInterval)
|
|
}
|
|
}
|
|
// Handle MaxActiveBackends (new) and SingleBackend (deprecated)
|
|
if settings.MaxActiveBackends != nil && !envMaxActiveBackends {
|
|
appConfig.MaxActiveBackends = *settings.MaxActiveBackends
|
|
// For backward compatibility, also set SingleBackend if MaxActiveBackends == 1
|
|
appConfig.SingleBackend = (*settings.MaxActiveBackends == 1)
|
|
} else if settings.SingleBackend != nil && !envSingleBackend {
|
|
// Legacy: SingleBackend maps to MaxActiveBackends = 1
|
|
appConfig.SingleBackend = *settings.SingleBackend
|
|
if *settings.SingleBackend {
|
|
appConfig.MaxActiveBackends = 1
|
|
} else {
|
|
appConfig.MaxActiveBackends = 0
|
|
}
|
|
}
|
|
if settings.MemoryReclaimerEnabled != nil && !envMemoryReclaimerEnabled {
|
|
appConfig.MemoryReclaimerEnabled = *settings.MemoryReclaimerEnabled
|
|
if appConfig.MemoryReclaimerEnabled {
|
|
appConfig.WatchDog = true // Memory reclaimer requires watchdog
|
|
}
|
|
}
|
|
if settings.MemoryReclaimerThreshold != nil && !envMemoryReclaimerThreshold {
|
|
appConfig.MemoryReclaimerThreshold = *settings.MemoryReclaimerThreshold
|
|
}
|
|
if settings.ForceEvictionWhenBusy != nil && !envForceEvictionWhenBusy {
|
|
appConfig.ForceEvictionWhenBusy = *settings.ForceEvictionWhenBusy
|
|
}
|
|
if settings.LRUEvictionMaxRetries != nil && !envLRUEvictionMaxRetries {
|
|
appConfig.LRUEvictionMaxRetries = *settings.LRUEvictionMaxRetries
|
|
}
|
|
if settings.LRUEvictionRetryInterval != nil && !envLRUEvictionRetryInterval {
|
|
dur, err := time.ParseDuration(*settings.LRUEvictionRetryInterval)
|
|
if err == nil {
|
|
appConfig.LRUEvictionRetryInterval = dur
|
|
} else {
|
|
xlog.Warn("invalid LRU eviction retry interval in runtime_settings.json", "error", err, "interval", *settings.LRUEvictionRetryInterval)
|
|
}
|
|
}
|
|
if settings.Threads != nil && !envThreads {
|
|
appConfig.Threads = *settings.Threads
|
|
}
|
|
if settings.ContextSize != nil && !envContextSize {
|
|
appConfig.ContextSize = *settings.ContextSize
|
|
}
|
|
if settings.F16 != nil && !envF16 {
|
|
appConfig.F16 = *settings.F16
|
|
}
|
|
if settings.Debug != nil && !envDebug {
|
|
appConfig.Debug = *settings.Debug
|
|
}
|
|
if settings.CORS != nil && !envCORS {
|
|
appConfig.CORS = *settings.CORS
|
|
}
|
|
if settings.CSRF != nil && !envCSRF {
|
|
appConfig.DisableCSRF = *settings.CSRF
|
|
}
|
|
if settings.CORSAllowOrigins != nil && !envCORSAllowOrigins {
|
|
appConfig.CORSAllowOrigins = *settings.CORSAllowOrigins
|
|
}
|
|
if settings.P2PToken != nil && !envP2PToken {
|
|
appConfig.P2PToken = *settings.P2PToken
|
|
}
|
|
if settings.P2PNetworkID != nil && !envP2PNetworkID {
|
|
appConfig.P2PNetworkID = *settings.P2PNetworkID
|
|
}
|
|
if settings.Federated != nil && !envFederated {
|
|
appConfig.Federated = *settings.Federated
|
|
}
|
|
if settings.Galleries != nil && !envGalleries {
|
|
appConfig.Galleries = *settings.Galleries
|
|
}
|
|
if settings.BackendGalleries != nil && !envBackendGalleries {
|
|
appConfig.BackendGalleries = *settings.BackendGalleries
|
|
}
|
|
if settings.AutoloadGalleries != nil && !envAutoloadGalleries {
|
|
appConfig.AutoloadGalleries = *settings.AutoloadGalleries
|
|
}
|
|
if settings.AutoloadBackendGalleries != nil && !envAutoloadBackendGalleries {
|
|
appConfig.AutoloadBackendGalleries = *settings.AutoloadBackendGalleries
|
|
}
|
|
if settings.PIIDefaultDetectors != nil && !envPIIDefaultDetectors {
|
|
// Request-side default redaction reads this live via
|
|
// ResolvePIIPolicy, so a file edit takes effect on the next chat
|
|
// request. The MITM listener resolves its per-host detector map
|
|
// once at start, so a raw file edit reaches cloud-proxy traffic
|
|
// only after a restart or a POST /api/settings (which rebuilds
|
|
// the listener) — the admin UI uses the latter.
|
|
appConfig.PIIDefaultDetectors = append([]string(nil), (*settings.PIIDefaultDetectors)...)
|
|
}
|
|
if settings.AutoUpgradeBackends != nil {
|
|
appConfig.AutoUpgradeBackends = *settings.AutoUpgradeBackends
|
|
}
|
|
if settings.PreferDevelopmentBackends != nil {
|
|
appConfig.PreferDevelopmentBackends = *settings.PreferDevelopmentBackends
|
|
}
|
|
if settings.ApiKeys != nil {
|
|
// API keys from env vars (startup) should be kept, runtime settings keys replace all runtime keys
|
|
// If runtime_settings.json specifies ApiKeys (even if empty), it replaces all runtime keys
|
|
// Start with env keys, then add runtime_settings.json keys (which may be empty to clear them)
|
|
envKeys := startupAppConfig.ApiKeys
|
|
runtimeKeys := *settings.ApiKeys
|
|
// Replace all runtime keys with what's in runtime_settings.json
|
|
appConfig.ApiKeys = append(envKeys, runtimeKeys...)
|
|
}
|
|
if settings.AgentJobRetentionDays != nil && !envAgentJobRetentionDays {
|
|
appConfig.AgentJobRetentionDays = *settings.AgentJobRetentionDays
|
|
}
|
|
|
|
// If watchdog is enabled via file but not via env, ensure WatchDog flag is set
|
|
if !envWatchdogIdle && !envWatchdogBusy {
|
|
if settings.WatchdogEnabled != nil && *settings.WatchdogEnabled {
|
|
appConfig.WatchDog = true
|
|
}
|
|
}
|
|
}
|
|
xlog.Debug("runtime settings loaded from runtime_settings.json")
|
|
return nil
|
|
}
|
|
return handler
|
|
}
|