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:
2026-05-01 16:01:23 +08:00
parent ce2d8a2907
commit 618399f86d
61 changed files with 3186 additions and 496 deletions
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 go-kratos
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -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
}
}
+62
View File
@@ -0,0 +1,62 @@
// Package env is adapted from go-kratos/kratos config/env.
//
// Upstream: https://github.com/go-kratos/kratos/tree/v2.9.2/config/env
// License: MIT, see ../LICENSE.kratos.
package env
import (
"os"
"strings"
"github.com/geo-platform/tenant-api/internal/shared/config/runtime"
)
type env struct {
prefixes []string
}
func NewSource(prefixes ...string) runtime.Source {
return &env{prefixes: prefixes}
}
func (e *env) Load() ([]*runtime.KeyValue, error) {
return e.load(os.Environ()), nil
}
func (e *env) load(envs []string) []*runtime.KeyValue {
kvs := make([]*runtime.KeyValue, 0, len(envs))
for _, item := range envs {
key, value, _ := strings.Cut(item, "=")
if key == "" {
continue
}
if len(e.prefixes) > 0 {
prefix, ok := matchPrefix(e.prefixes, key)
if !ok || key == prefix {
continue
}
key = strings.TrimPrefix(key, prefix)
key = strings.TrimPrefix(key, "_")
}
if key != "" {
kvs = append(kvs, &runtime.KeyValue{
Key: key,
Value: []byte(value),
})
}
}
return kvs
}
func (e *env) Watch() (runtime.Watcher, error) {
return NewWatcher()
}
func matchPrefix(prefixes []string, s string) (string, bool) {
for _, p := range prefixes {
if strings.HasPrefix(s, p) {
return p, true
}
}
return "", false
}
+29
View File
@@ -0,0 +1,29 @@
package env
import (
"context"
"github.com/geo-platform/tenant-api/internal/shared/config/runtime"
)
var _ runtime.Watcher = (*watcher)(nil)
type watcher struct {
ctx context.Context
cancel context.CancelFunc
}
func NewWatcher() (runtime.Watcher, error) {
ctx, cancel := context.WithCancel(context.Background())
return &watcher{ctx: ctx, cancel: cancel}, nil
}
func (w *watcher) Next() ([]*runtime.KeyValue, error) {
<-w.ctx.Done()
return nil, w.ctx.Err()
}
func (w *watcher) Stop() error {
w.cancel()
return nil
}
@@ -0,0 +1,110 @@
// Package file is adapted from go-kratos/kratos config/file.
//
// Upstream: https://github.com/go-kratos/kratos/tree/v2.9.2/config/file
// License: MIT, see ../LICENSE.kratos.
package file
import (
"io"
"os"
"path/filepath"
"strings"
"github.com/geo-platform/tenant-api/internal/shared/config/runtime"
)
type file struct {
path string
names map[string]struct{}
}
func NewSource(path string, names ...string) runtime.Source {
return &file{path: path, names: namesSet(names)}
}
func (f *file) loadFile(path string) (*runtime.KeyValue, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
return nil, err
}
info, err := file.Stat()
if err != nil {
return nil, err
}
return &runtime.KeyValue{
Key: info.Name(),
Format: format(info.Name()),
Value: data,
}, nil
}
func (f *file) loadDir(path string) ([]*runtime.KeyValue, error) {
files, err := os.ReadDir(path)
if err != nil {
return nil, err
}
kvs := make([]*runtime.KeyValue, 0, len(files))
for _, item := range files {
if item.IsDir() || strings.HasPrefix(item.Name(), ".") || !f.matchName(item.Name()) {
continue
}
kv, err := f.loadFile(filepath.Join(path, item.Name()))
if err != nil {
return nil, err
}
kvs = append(kvs, kv)
}
return kvs, nil
}
func (f *file) Load() ([]*runtime.KeyValue, error) {
info, err := os.Stat(f.path)
if err != nil {
return nil, err
}
if info.IsDir() {
return f.loadDir(f.path)
}
kv, err := f.loadFile(f.path)
if err != nil {
return nil, err
}
return []*runtime.KeyValue{kv}, nil
}
func (f *file) Watch() (runtime.Watcher, error) {
return newWatcher(f)
}
func (f *file) match(path string) bool {
return f.matchName(filepath.Base(path))
}
func (f *file) matchName(name string) bool {
if len(f.names) == 0 {
return true
}
_, ok := f.names[name]
return ok
}
func namesSet(names []string) map[string]struct{} {
if len(names) == 0 {
return nil
}
out := make(map[string]struct{}, len(names))
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" {
continue
}
out[filepath.Base(name)] = struct{}{}
}
return out
}
@@ -0,0 +1,10 @@
package file
import "strings"
func format(name string) string {
if idx := strings.LastIndexByte(name, '.'); idx >= 0 {
return name[idx+1:]
}
return ""
}
@@ -0,0 +1,78 @@
package file
import (
"context"
"os"
"path/filepath"
"sync"
"github.com/fsnotify/fsnotify"
"github.com/geo-platform/tenant-api/internal/shared/config/runtime"
)
type watcher struct {
f *file
fw *fsnotify.Watcher
ctx context.Context
cancel context.CancelFunc
stopOnce sync.Once
}
func newWatcher(f *file) (runtime.Watcher, error) {
fw, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
if err := fw.Add(f.path); err != nil {
_ = fw.Close()
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
return &watcher{f: f, fw: fw, ctx: ctx, cancel: cancel}, nil
}
func (w *watcher) Next() ([]*runtime.KeyValue, error) {
for {
select {
case <-w.ctx.Done():
return nil, w.ctx.Err()
case event := <-w.fw.Events:
if !w.f.match(event.Name) {
continue
}
if event.Op == fsnotify.Rename {
if _, err := os.Stat(event.Name); err == nil || os.IsExist(err) {
if err := w.fw.Add(event.Name); err != nil {
return nil, err
}
}
}
info, err := os.Stat(w.f.path)
if err != nil {
return nil, err
}
path := w.f.path
if info.IsDir() {
path = filepath.Join(w.f.path, filepath.Base(event.Name))
}
kv, err := w.f.loadFile(path)
if err != nil {
return nil, err
}
return []*runtime.KeyValue{kv}, nil
case err := <-w.fw.Errors:
return nil, err
}
}
}
func (w *watcher) Stop() error {
var err error
w.stopOnce.Do(func() {
w.cancel()
err = w.fw.Close()
})
return err
}
@@ -0,0 +1,167 @@
package runtime
import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
type Decoder func(*KeyValue, map[string]any) error
type Resolver func(map[string]any) error
type Merge func(dst, src any) error
type Option func(*options)
type options struct {
sources []Source
decoder Decoder
resolver Resolver
merge Merge
}
func WithSource(s ...Source) Option {
return func(o *options) {
o.sources = s
}
}
func WithDecoder(d Decoder) Option {
return func(o *options) {
o.decoder = d
}
}
func WithResolveActualTypes(enableConvertToType bool) Option {
return func(o *options) {
o.resolver = newActualTypesResolver(enableConvertToType)
}
}
func WithResolver(r Resolver) Option {
return func(o *options) {
o.resolver = r
}
}
func WithMergeFunc(m Merge) Option {
return func(o *options) {
o.merge = m
}
}
func defaultDecoder(src *KeyValue, target map[string]any) error {
if src.Format == "" {
keys := strings.Split(src.Key, ".")
for i, key := range keys {
if i == len(keys)-1 {
target[key] = src.Value
return nil
}
sub := make(map[string]any)
target[key] = sub
target = sub
}
return nil
}
switch strings.ToLower(src.Format) {
case "json":
return json.Unmarshal(src.Value, &target)
case "yaml", "yml":
return yaml.Unmarshal(src.Value, &target)
}
return fmt.Errorf("unsupported key: %s format: %s", src.Key, src.Format)
}
func newActualTypesResolver(enableConvertToType bool) func(map[string]any) error {
return func(input map[string]any) error {
mapper := mapper(input)
return resolver(input, mapper, enableConvertToType)
}
}
func defaultResolver(input map[string]any) error {
mapper := mapper(input)
return resolver(input, mapper, false)
}
func resolver(input map[string]any, mapper func(name string) string, toType bool) error {
var resolve func(map[string]any) error
resolve = func(sub map[string]any) error {
for key, value := range sub {
switch typed := value.(type) {
case string:
sub[key] = expand(typed, mapper, toType)
case map[string]any:
if err := resolve(typed); err != nil {
return err
}
case []any:
for i, item := range typed {
switch child := item.(type) {
case string:
typed[i] = expand(child, mapper, toType)
case map[string]any:
if err := resolve(child); err != nil {
return err
}
}
}
sub[key] = typed
}
}
return nil
}
return resolve(input)
}
func mapper(input map[string]any) func(name string) string {
return func(name string) string {
args := strings.SplitN(strings.TrimSpace(name), ":", 2)
if value, ok := readValue(input, args[0]); ok {
s, _ := value.String()
return s
}
if len(args) > 1 {
return args[1]
}
return ""
}
}
func convertToType(input string) any {
if strings.HasPrefix(input, "\"") && strings.HasSuffix(input, "\"") {
return strings.Trim(input, "\"")
}
if input == "true" || input == "false" {
b, _ := strconv.ParseBool(input)
return b
}
if strings.Contains(input, ".") {
if f, err := strconv.ParseFloat(input, 64); err == nil {
return f
}
}
if i, err := strconv.ParseInt(input, 10, 64); err == nil {
return i
}
return input
}
var placeholderRegexp = regexp.MustCompile(`\${(.*?)}`)
func expand(s string, mapping func(string) string, toType bool) any {
for _, match := range placeholderRegexp.FindAllStringSubmatch(s, -1) {
if len(match) != 2 {
continue
}
mapped := mapping(match[1])
if toType {
return convertToType(mapped)
}
s = strings.ReplaceAll(s, match[0], mapped)
}
return s
}
@@ -0,0 +1,154 @@
package runtime
import (
"bytes"
"encoding/gob"
"encoding/json"
"fmt"
"strings"
"sync"
)
type Reader interface {
Merge(...*KeyValue) error
Value(string) (Value, bool)
Source() ([]byte, error)
Resolve() error
}
type reader struct {
opts options
values map[string]any
lock sync.Mutex
}
func newReader(opts options) Reader {
return &reader{
opts: opts,
values: make(map[string]any),
lock: sync.Mutex{},
}
}
func (r *reader) Merge(kvs ...*KeyValue) error {
merged, err := r.cloneMap()
if err != nil {
return err
}
for _, kv := range kvs {
next := make(map[string]any)
if err := r.opts.decoder(kv, next); err != nil {
return fmt.Errorf("decode config key %s: %w", kv.Key, err)
}
if err := r.opts.merge(&merged, convertMap(next)); err != nil {
return fmt.Errorf("merge config key %s: %w", kv.Key, err)
}
}
r.lock.Lock()
r.values = merged
r.lock.Unlock()
return nil
}
func (r *reader) Value(path string) (Value, bool) {
r.lock.Lock()
defer r.lock.Unlock()
if strings.TrimSpace(path) == "" {
value := &atomicValue{}
value.Store(r.values)
return value, true
}
return readValue(r.values, path)
}
func (r *reader) Source() ([]byte, error) {
r.lock.Lock()
defer r.lock.Unlock()
return marshalJSON(convertMap(r.values))
}
func (r *reader) Resolve() error {
r.lock.Lock()
defer r.lock.Unlock()
return r.opts.resolver(r.values)
}
func (r *reader) cloneMap() (map[string]any, error) {
r.lock.Lock()
defer r.lock.Unlock()
return cloneMap(r.values)
}
func cloneMap(src map[string]any) (map[string]any, error) {
var buf bytes.Buffer
gob.Register(map[string]any{})
gob.Register([]any{})
enc := gob.NewEncoder(&buf)
dec := gob.NewDecoder(&buf)
if err := enc.Encode(src); err != nil {
return nil, err
}
var clone map[string]any
if err := dec.Decode(&clone); err != nil {
return nil, err
}
return clone, nil
}
func convertMap(src any) any {
switch value := src.(type) {
case map[string]any:
dst := make(map[string]any, len(value))
for k, v := range value {
dst[k] = convertMap(v)
}
return dst
case map[any]any:
dst := make(map[string]any, len(value))
for k, v := range value {
dst[fmt.Sprint(k)] = convertMap(v)
}
return dst
case []any:
dst := make([]any, len(value))
for k, v := range value {
dst[k] = convertMap(v)
}
return dst
case []byte:
return string(value)
default:
return src
}
}
func readValue(values map[string]any, path string) (Value, bool) {
next := values
keys := strings.Split(path, ".")
last := len(keys) - 1
for idx, key := range keys {
value, ok := next[key]
if !ok {
return nil, false
}
if idx == last {
atomic := &atomicValue{}
atomic.Store(value)
return atomic, true
}
child, ok := value.(map[string]any)
if !ok {
return nil, false
}
next = child
}
return nil, false
}
func marshalJSON(v any) ([]byte, error) {
return json.Marshal(v)
}
func unmarshalJSON(data []byte, v any) error {
return json.Unmarshal(data, v)
}
@@ -0,0 +1,21 @@
// Package runtime is adapted from go-kratos/kratos config.
//
// Upstream: https://github.com/go-kratos/kratos/tree/v2.9.2/config
// License: MIT, see LICENSE.kratos in this directory.
package runtime
type KeyValue struct {
Key string
Value []byte
Format string
}
type Source interface {
Load() ([]*KeyValue, error)
Watch() (Watcher, error)
}
type Watcher interface {
Next() ([]*KeyValue, error)
Stop() error
}
@@ -0,0 +1,158 @@
package runtime
import (
"encoding/json"
"fmt"
"reflect"
"strconv"
"sync/atomic"
"time"
)
type Value interface {
Bool() (bool, error)
Int() (int64, error)
Float() (float64, error)
String() (string, error)
Duration() (time.Duration, error)
Slice() ([]Value, error)
Map() (map[string]Value, error)
Scan(any) error
Load() any
Store(any)
}
type atomicValue struct {
atomic.Value
}
func (v *atomicValue) typeAssertError() error {
return fmt.Errorf("type assert to %v failed", reflect.TypeOf(v.Load()))
}
func (v *atomicValue) Bool() (bool, error) {
switch value := v.Load().(type) {
case bool:
return value, nil
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
return strconv.ParseBool(fmt.Sprint(value))
case string:
return strconv.ParseBool(value)
}
return false, v.typeAssertError()
}
func (v *atomicValue) Int() (int64, error) {
switch value := v.Load().(type) {
case int:
return int64(value), nil
case int8:
return int64(value), nil
case int16:
return int64(value), nil
case int32:
return int64(value), nil
case int64:
return value, nil
case uint:
return int64(value), nil
case uint8:
return int64(value), nil
case uint16:
return int64(value), nil
case uint32:
return int64(value), nil
case uint64:
return int64(value), nil
case float32:
return int64(value), nil
case float64:
return int64(value), nil
case string:
return strconv.ParseInt(value, 10, 64)
}
return 0, v.typeAssertError()
}
func (v *atomicValue) Float() (float64, error) {
switch value := v.Load().(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
return strconv.ParseFloat(fmt.Sprint(value), 64)
case string:
return strconv.ParseFloat(value, 64)
}
return 0, v.typeAssertError()
}
func (v *atomicValue) String() (string, error) {
switch value := v.Load().(type) {
case string:
return value, nil
case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
return fmt.Sprint(value), nil
case []byte:
return string(value), nil
case fmt.Stringer:
return value.String(), nil
}
return "", v.typeAssertError()
}
func (v *atomicValue) Duration() (time.Duration, error) {
value, err := v.Int()
if err != nil {
return 0, err
}
return time.Duration(value), nil
}
func (v *atomicValue) Slice() ([]Value, error) {
values, ok := v.Load().([]any)
if !ok {
return nil, v.typeAssertError()
}
out := make([]Value, 0, len(values))
for _, value := range values {
item := &atomicValue{}
item.Store(value)
out = append(out, item)
}
return out, nil
}
func (v *atomicValue) Map() (map[string]Value, error) {
values, ok := v.Load().(map[string]any)
if !ok {
return nil, v.typeAssertError()
}
out := make(map[string]Value, len(values))
for key, value := range values {
item := &atomicValue{}
item.Store(value)
out[key] = item
}
return out, nil
}
func (v *atomicValue) Scan(obj any) error {
data, err := json.Marshal(v.Load())
if err != nil {
return err
}
return json.Unmarshal(data, obj)
}
type errValue struct {
err error
}
func (v errValue) Bool() (bool, error) { return false, v.err }
func (v errValue) Int() (int64, error) { return 0, v.err }
func (v errValue) Float() (float64, error) { return 0, v.err }
func (v errValue) Duration() (time.Duration, error) { return 0, v.err }
func (v errValue) String() (string, error) { return "", v.err }
func (v errValue) Scan(any) error { return v.err }
func (v errValue) Load() any { return nil }
func (v errValue) Store(any) {}
func (v errValue) Slice() ([]Value, error) { return nil, v.err }
func (v errValue) Map() (map[string]Value, error) { return nil, v.err }