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,187 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("key not found")
|
||||
|
||||
type Observer func(string, Value)
|
||||
|
||||
type Config interface {
|
||||
Load() error
|
||||
Scan(any) error
|
||||
Value(string) Value
|
||||
Watch(string, Observer) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type config struct {
|
||||
opts options
|
||||
reader Reader
|
||||
cached sync.Map
|
||||
observers sync.Map
|
||||
watchers []Watcher
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func New(opts ...Option) Config {
|
||||
o := options{
|
||||
decoder: defaultDecoder,
|
||||
resolver: defaultResolver,
|
||||
merge: mergeMap,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&o)
|
||||
}
|
||||
return &config{
|
||||
opts: o,
|
||||
reader: newReader(o),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *config) Load() error {
|
||||
for _, src := range c.opts.sources {
|
||||
kvs, err := src.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = c.reader.Merge(kvs...); err != nil {
|
||||
return err
|
||||
}
|
||||
w, err := src.Watch()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.watchers = append(c.watchers, w)
|
||||
go c.watch(w)
|
||||
}
|
||||
return c.reader.Resolve()
|
||||
}
|
||||
|
||||
func (c *config) Value(key string) Value {
|
||||
if v, ok := c.cached.Load(key); ok {
|
||||
return v.(Value)
|
||||
}
|
||||
if v, ok := c.reader.Value(key); ok {
|
||||
c.cached.Store(key, v)
|
||||
return v
|
||||
}
|
||||
return &errValue{err: ErrNotFound}
|
||||
}
|
||||
|
||||
func (c *config) Scan(v any) error {
|
||||
data, err := c.reader.Source()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return unmarshalJSON(data, v)
|
||||
}
|
||||
|
||||
func (c *config) Watch(key string, o Observer) error {
|
||||
if v := c.Value(key); v.Load() == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
c.observers.Store(key, o)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *config) Close() error {
|
||||
var closeErr error
|
||||
c.closeOnce.Do(func() {
|
||||
for _, w := range c.watchers {
|
||||
if err := w.Stop(); err != nil && closeErr == nil {
|
||||
closeErr = err
|
||||
}
|
||||
}
|
||||
})
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func (c *config) watch(w Watcher) {
|
||||
for {
|
||||
kvs, err := w.Next()
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
if err := c.reader.Merge(kvs...); err != nil {
|
||||
c.notifyRootObserver()
|
||||
continue
|
||||
}
|
||||
if err := c.reader.Resolve(); err != nil {
|
||||
c.notifyRootObserver()
|
||||
continue
|
||||
}
|
||||
c.notifyObservers()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *config) notifyRootObserver() {
|
||||
if observer, ok := c.observers.Load(""); ok {
|
||||
observer.(Observer)("", c.Value(""))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *config) notifyObservers() {
|
||||
if observer, ok := c.observers.Load(""); ok {
|
||||
next, ok := c.reader.Value("")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
current := c.Value("")
|
||||
current.Store(next.Load())
|
||||
observer.(Observer)("", current)
|
||||
}
|
||||
|
||||
c.cached.Range(func(key, value any) bool {
|
||||
path := key.(string)
|
||||
if path == "" {
|
||||
return true
|
||||
}
|
||||
previous := value.(Value)
|
||||
next, ok := c.reader.Value(path)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if reflect.TypeOf(next.Load()) == reflect.TypeOf(previous.Load()) && !reflect.DeepEqual(next.Load(), previous.Load()) {
|
||||
previous.Store(next.Load())
|
||||
if observer, ok := c.observers.Load(path); ok {
|
||||
observer.(Observer)(path, previous)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func mergeMap(dst, src any) error {
|
||||
dstMap, ok := dst.(*map[string]any)
|
||||
if !ok {
|
||||
return errors.New("merge destination must be *map[string]any")
|
||||
}
|
||||
srcMap, ok := src.(map[string]any)
|
||||
if !ok {
|
||||
return errors.New("merge source must be map[string]any")
|
||||
}
|
||||
deepMerge(*dstMap, srcMap)
|
||||
return nil
|
||||
}
|
||||
|
||||
func deepMerge(dst, src map[string]any) {
|
||||
for key, srcValue := range src {
|
||||
if srcChild, ok := srcValue.(map[string]any); ok {
|
||||
if dstChild, ok := dst[key].(map[string]any); ok {
|
||||
deepMerge(dstChild, srcChild)
|
||||
continue
|
||||
}
|
||||
}
|
||||
dst[key] = srcValue
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user