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
+154 -3
View File
@@ -1,6 +1,7 @@
package config
import (
"context"
"os"
"path/filepath"
"testing"
@@ -248,14 +249,164 @@ membership: {}
}
}
func TestLoadMergesLocalOverrideAndResolvesEnvPlaceholders(t *testing.T) {
t.Setenv("CONFIG_TEST_LLM_KEY", "placeholder-key")
t.Setenv("LLM_API_KEY", "")
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
localPath := filepath.Join(dir, "config.local.yaml")
writeFile(t, configPath, `
llm:
provider: ark
api_key: "${CONFIG_TEST_LLM_KEY:missing}"
max_output_tokens: 1000
generation:
stream_enabled: false
`)
writeFile(t, localPath, `
generation:
stream_enabled: true
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.LLM.APIKey != "placeholder-key" {
t.Fatalf("expected placeholder env api key, got %q", cfg.LLM.APIKey)
}
if !cfg.Generation.StreamEnabled {
t.Fatalf("expected local override to enable generation stream")
}
}
func TestStoreReloadsConfigAndKeepsPreviousOnInvalidYAML(t *testing.T) {
configPath := writeTestConfig(t, `
jwt:
secret: first
access_ttl: 15m
refresh_ttl: 720h
`)
store, err := NewStore(configPath)
if err != nil {
t.Fatalf("NewStore() error = %v", err)
}
defer store.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
events := make(chan ReloadEvent, 4)
go func() {
_ = store.Watch(ctx, nil, func(event ReloadEvent) {
events <- event
})
}()
waitForStoreWatch(t, store)
time.Sleep(100 * time.Millisecond)
writeFile(t, configPath, `
jwt:
secret: second
access_ttl: 20m
refresh_ttl: 720h
`)
event := waitForReloadEvent(t, events)
if event.Current.JWT.Secret != "second" {
t.Fatalf("expected reloaded jwt secret second, got %q", event.Current.JWT.Secret)
}
if store.Current().JWT.AccessTTL != 20*time.Minute {
t.Fatalf("expected store access ttl 20m, got %s", store.Current().JWT.AccessTTL)
}
writeFile(t, configPath, "jwt:\n secret: [broken\n")
time.Sleep(300 * time.Millisecond)
if store.Current().JWT.Secret != "second" {
t.Fatalf("expected invalid yaml to keep previous config, got %q", store.Current().JWT.Secret)
}
}
func TestStoreReloadsWhenLocalOverrideIsCreated(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
localPath := filepath.Join(dir, "config.local.yaml")
writeFile(t, configPath, `
generation:
stream_enabled: false
`)
store, err := NewStore(configPath)
if err != nil {
t.Fatalf("NewStore() error = %v", err)
}
defer store.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
events := make(chan ReloadEvent, 4)
go func() {
_ = store.Watch(ctx, nil, func(event ReloadEvent) {
events <- event
})
}()
waitForStoreWatch(t, store)
time.Sleep(100 * time.Millisecond)
writeFile(t, localPath, `
generation:
stream_enabled: true
`)
event := waitForReloadEvent(t, events)
if !event.Current.Generation.StreamEnabled {
t.Fatalf("expected created local override to enable stream")
}
}
func writeTestConfig(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
writeFile(t, path, body)
return path
}
func writeFile(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write file %s: %v", path, err)
}
}
func waitForStoreWatch(t *testing.T, store *Store) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if store.runtime != nil && store.runtime.Value("").Load() != nil {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("config store watcher did not start")
}
func waitForReloadEvent(t *testing.T, events <-chan ReloadEvent) ReloadEvent {
t.Helper()
timeout := time.After(3 * time.Second)
for {
select {
case event := <-events:
if event.Current != nil {
return event
}
case <-timeout:
t.Fatalf("timed out waiting for reload event")
}
}
}