package config import ( "sync" "sync/atomic" ) // Store is the process-wide, hot-reloadable configuration holder. Readers take // a snapshot pointer (cheap, no lock) and writers swap a fully-built *Config // in atomically, so a request never observes a half-applied config. type Store struct { cur atomic.Pointer[Config] mu sync.Mutex // serialises writers so LastError/Version stay coherent // path is the file the config was loaded from, used by the API to // persist mutations. path string // lastError records why the most recent reload attempt failed, so the // health endpoint can report a stale-but-running state. lastErr atomic.Pointer[string] // generation increments on every successful swap. generation atomic.Uint64 // hooks run after a successful swap (server re-binds listeners etc). hooksMu sync.RWMutex hooks []func(*Config) } // NewStore wraps cfg. func NewStore(cfg *Config, path string) *Store { s := &Store{path: path} s.cur.Store(cfg) return s } // Get returns the current configuration snapshot. func (s *Store) Get() *Config { return s.cur.Load() } // Path is the file this config was loaded from ("" when running without one). func (s *Store) Path() string { return s.path } // Generation counts successful swaps; the cluster uses it to detect // divergence between instances. func (s *Store) Generation() uint64 { return s.generation.Load() } // LastError returns the error from the most recent failed reload, or nil. func (s *Store) LastError() string { if p := s.lastErr.Load(); p != nil { return *p } return "" } // Swap installs cfg as the current configuration. Callers are responsible for // validating first; Swap itself only guards structural invariants that would // otherwise panic subsystems. func (s *Store) Swap(cfg *Config) error { if cfg == nil { return errNilConfig } if cfg.Cache.Shards <= 0 { cfg.Cache.Shards = 32 } s.mu.Lock() s.cur.Store(cfg) s.generation.Add(1) s.lastErr.Store(nil) s.mu.Unlock() s.runHooks(cfg) return nil } // Reload re-reads the backing file and swaps it in on success. On failure the // previous configuration stays active and the error is recorded. func (s *Store) Reload() error { s.mu.Lock() defer s.mu.Unlock() if s.path == "" { return errNoPath } cfg, err := Load(s.path) if err != nil { msg := err.Error() s.lastErr.Store(&msg) return err } // Preserve the working dir of the running instance so relative paths keep // resolving the same way even if the file moved. cfg.Server.WorkingDir = s.cur.Load().Server.WorkingDir s.cur.Store(cfg) s.generation.Add(1) s.lastErr.Store(nil) s.runHooks(cfg) return nil } // OnSwap registers a callback invoked with the new config after each swap. // Hooks run outside the writer lock; a panicking hook is contained so one bad // subsystem cannot take down a reload. func (s *Store) OnSwap(fn func(*Config)) { if fn == nil { return } s.hooksMu.Lock() s.hooks = append(s.hooks, fn) s.hooksMu.Unlock() } func (s *Store) runHooks(cfg *Config) { s.hooksMu.RLock() hooks := s.hooks s.hooksMu.RUnlock() for _, h := range hooks { func() { defer func() { _ = recover() }() h(cfg) }() } } type storeError string func (e storeError) Error() string { return string(e) } const ( errNilConfig = storeError("config: refusing to swap a nil configuration") errNoPath = storeError("config: no config file path known, cannot reload") )