127 lines
3.4 KiB
Go
127 lines
3.4 KiB
Go
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")
|
|
)
|