feat(server): hot-reload config store and reloadable infra clients
- Replace single Load() with a watching Store that re-reads config files
(and config.local.yaml) and fans out a ReloadEvent with a per-field diff
so consumers can decide whether the change is hot-applicable or requires
a process restart.
- Wrap llm, retrieval, vector store, and object storage clients in
Reloadable* shells so the bootstrap can swap their underlying impls when
config changes without re-instantiating handlers.
- Make jwt.Manager and ops TokenIssuer mutable under a lock so secrets and
TTLs can be rotated live; thread default plan code through a setter on
the ops AdminUserService.
- Wire ConfigStore through bootstrap and every cmd/main.go, scheduler /
worker / tenant-api / ops-api start the watcher; services and handlers
take a config.Provider so they always read current values for things
like generation.stream_enabled, scheduler dispatch, retrieval, etc.
- Switch shared/config decoding off viper to a Kratos-derived runtime
package so env placeholders (\${VAR:default}) resolve consistently and
the same source machinery powers both the loader and the watcher.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config/runtime"
|
||||
runtimeenv "github.com/geo-platform/tenant-api/internal/shared/config/runtime/env"
|
||||
runtimefile "github.com/geo-platform/tenant-api/internal/shared/config/runtime/file"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
path string
|
||||
|
||||
mu sync.RWMutex
|
||||
cfg *Config
|
||||
files []string
|
||||
runtime runtime.Config
|
||||
watchedFiles map[string]fileSnapshot
|
||||
updates chan struct{}
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
type ReloadEvent struct {
|
||||
Previous *Config
|
||||
Current *Config
|
||||
Files []string
|
||||
Changes []FieldChange
|
||||
}
|
||||
|
||||
func NewStore(configPath string) (*Store, error) {
|
||||
cfg, files, err := loadWithFiles(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
store := &Store{
|
||||
path: configPath,
|
||||
cfg: cfg,
|
||||
files: normalizeConfigFiles(files),
|
||||
watchedFiles: snapshotFiles(nil),
|
||||
updates: make(chan struct{}, 1),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
store.watchedFiles = snapshotFiles(store.watchFiles())
|
||||
store.runtime = store.newRuntimeConfig()
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func NewStaticStore(cfg *Config) (*Store, error) {
|
||||
if cfg == nil {
|
||||
return nil, errors.New("config is nil")
|
||||
}
|
||||
return &Store{cfg: cfg}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Current() *Config {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.cfg
|
||||
}
|
||||
|
||||
func (s *Store) Watch(ctx context.Context, logger *zap.Logger, onReload func(ReloadEvent)) error {
|
||||
if s == nil {
|
||||
return errors.New("config store is nil")
|
||||
}
|
||||
if s.runtime == nil {
|
||||
s.runtime = s.newRuntimeConfig()
|
||||
}
|
||||
|
||||
if err := s.runtime.Load(); err != nil {
|
||||
return fmt.Errorf("load config runtime source: %w", err)
|
||||
}
|
||||
if err := s.runtime.Watch("", func(string, runtime.Value) {
|
||||
s.notifyReload()
|
||||
}); err != nil {
|
||||
_ = s.runtime.Close()
|
||||
return fmt.Errorf("watch config runtime source: %w", err)
|
||||
}
|
||||
|
||||
if logger != nil {
|
||||
logger.Info("config hot reload watcher started", zap.Strings("files", s.files))
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-s.ctx.Done():
|
||||
return nil
|
||||
case <-s.updates:
|
||||
s.reload(logger, onReload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
if s.runtime != nil {
|
||||
_ = s.runtime.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) reload(logger *zap.Logger, onReload func(ReloadEvent)) {
|
||||
next, files, err := loadWithFiles(s.path)
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("config reload rejected", zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
files = normalizeConfigFiles(files)
|
||||
s.mu.Lock()
|
||||
previous := s.cfg
|
||||
s.cfg = next
|
||||
s.files = files
|
||||
s.watchedFiles = snapshotFiles(s.watchFiles())
|
||||
s.mu.Unlock()
|
||||
|
||||
changes := Diff(previous, next)
|
||||
if logger != nil {
|
||||
logger.Info("config reloaded",
|
||||
zap.Strings("files", files),
|
||||
zap.Strings("hot_changes", ChangePaths(changes, true)),
|
||||
zap.Strings("restart_required_changes", ChangePaths(changes, false)),
|
||||
)
|
||||
}
|
||||
if onReload != nil {
|
||||
onReload(ReloadEvent{
|
||||
Previous: previous,
|
||||
Current: next,
|
||||
Files: files,
|
||||
Changes: changes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) newRuntimeConfig() runtime.Config {
|
||||
return runtime.New(runtime.WithSource(
|
||||
runtimefile.NewSource(s.watchRoot(), s.watchNames()...),
|
||||
runtimeenv.NewSource(),
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) watchRoot() string {
|
||||
for _, candidate := range candidateConfigPaths(s.path, false) {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
return filepath.Dir(candidate)
|
||||
}
|
||||
return filepath.Dir(s.path)
|
||||
}
|
||||
|
||||
func (s *Store) notifyReload() {
|
||||
if !s.consumeFileChange() {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case s.updates <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) consumeFileChange() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
next := snapshotFiles(s.watchFiles())
|
||||
if len(s.watchedFiles) == 0 {
|
||||
s.watchedFiles = next
|
||||
return true
|
||||
}
|
||||
changed := !fileSnapshotsEqual(s.watchedFiles, next)
|
||||
if changed {
|
||||
s.watchedFiles = next
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func (s *Store) watchNames() []string {
|
||||
names := make([]string, 0, 4)
|
||||
seen := make(map[string]struct{})
|
||||
for _, local := range []bool{false, true} {
|
||||
for _, candidate := range candidateConfigPaths(s.path, local) {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
name := filepath.Base(candidate)
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (s *Store) watchFiles() []string {
|
||||
files := make([]string, 0, 4)
|
||||
seen := make(map[string]struct{})
|
||||
for _, local := range []bool{false, true} {
|
||||
for _, candidate := range candidateConfigPaths(s.path, local) {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
abs, err := filepath.Abs(candidate)
|
||||
if err == nil {
|
||||
candidate = abs
|
||||
}
|
||||
candidate = filepath.Clean(candidate)
|
||||
if _, ok := seen[candidate]; ok {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = struct{}{}
|
||||
files = append(files, candidate)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func normalizeConfigFiles(files []string) []string {
|
||||
seen := make(map[string]struct{}, len(files))
|
||||
normalized := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
if file == "" {
|
||||
continue
|
||||
}
|
||||
abs, err := filepath.Abs(file)
|
||||
if err == nil {
|
||||
file = abs
|
||||
}
|
||||
file = filepath.Clean(file)
|
||||
if _, ok := seen[file]; ok {
|
||||
continue
|
||||
}
|
||||
seen[file] = struct{}{}
|
||||
normalized = append(normalized, file)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
type fileSnapshot struct {
|
||||
size int64
|
||||
modTime time.Time
|
||||
exists bool
|
||||
}
|
||||
|
||||
func snapshotFiles(files []string) map[string]fileSnapshot {
|
||||
snapshots := make(map[string]fileSnapshot, len(files))
|
||||
for _, file := range normalizeConfigFiles(files) {
|
||||
info, err := os.Stat(file)
|
||||
if err != nil {
|
||||
snapshots[file] = fileSnapshot{}
|
||||
continue
|
||||
}
|
||||
snapshots[file] = fileSnapshot{
|
||||
size: info.Size(),
|
||||
modTime: info.ModTime(),
|
||||
exists: true,
|
||||
}
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
|
||||
func fileSnapshotsEqual(a, b map[string]fileSnapshot) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for key, left := range a {
|
||||
right, ok := b[key]
|
||||
if !ok || left != right {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user